webcake-landing-mcp 1.0.60 → 1.0.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.0.62",
4
+ "d": "11/06/2026",
5
+ "type": "Added",
6
+ "en": "validate_page now warns when a text-block's estimated rendered height overflows onto a sibling element placed directly below its declared box; the…",
7
+ "vi": "validate_page nay cảnh báo khi chiều cao render ước tính của text-block tràn xuống phần tử anh em đặt ngay phía dưới khung khai báo; cảnh báo nêu…"
8
+ },
9
+ {
10
+ "v": "1.0.61",
11
+ "d": "11/06/2026",
12
+ "type": "Added",
13
+ "en": "ingest_html and ingest_url now return a size_hint field ({ height, basis, css? }) on every AST section, providing the desktop section height in px…",
14
+ "vi": "ingest_html và ingest_url nay trả về trường size_hint ({ height, basis, css? }) trên mỗi section trong AST, cung cấp chiều cao desktop của section…"
15
+ },
2
16
  {
3
17
  "v": "1.0.60",
4
18
  "d": "11/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "New upload_images tool re-hosts up to 20 external image URLs or data: URIs as Webcake-hosted URLs (statics.pancake.vn) by downloading and uploading…",
28
42
  "vi": "Công cụ upload_images mới tải lại tối đa 20 URL ảnh ngoài hoặc data: URI thành URL do Webcake lưu trữ (statics.pancake.vn) bằng cách tải về và…"
29
- },
30
- {
31
- "v": "1.0.56",
32
- "d": "11/06/2026",
33
- "type": "Added",
34
- "en": "The expand pipeline now automatically derives styles.background from specials.src for every image-block node; the live published renderer reads only…",
35
- "vi": "Pipeline expand nay tự động tính styles.background từ specials.src cho mọi node image-block; renderer trên trang published chỉ đọc…"
36
- },
37
- {
38
- "v": "1.0.55",
39
- "d": "10/06/2026",
40
- "type": "Fixed",
41
- "en": "The install command now correctly locates claude_desktop_config.json on Windows when Claude Desktop was installed from the Microsoft Store: the…",
42
- "vi": "Lệnh install nay xác định đúng đường dẫn claude_desktop_config.json trên Windows khi Claude Desktop được cài từ Microsoft Store: bản Store bị…"
43
43
  }
44
44
  ]
@@ -141,7 +141,7 @@ WORKFLOW (recommended)
141
141
  3. Optionally call new_element to get a correct skeleton, then fill specials + coordinates.
142
142
  3b. For every image the page needs (hero, product, about, feature, gallery): if the slot has a source image (user-supplied or from the reference HTML/URL), upload_images it and use the returned Webcake URL; otherwise call search_images and put a returned URL into specials.src / gallery item.link. Use placehold.co ONLY when search_images returns ok:false.
143
143
  4. Assemble { page, popup, settings, options, cartConfigs }.
144
- 5. Call validate_page and fix every error.
144
+ 5. Call validate_page and fix every error AND every warning — warnings are visible defects (text spilling onto the element below, off-canvas boxes, empty bands at a section's bottom, missing field_name, dead event targets), not advisory polish. Re-validate until the warning list is empty; only a demonstrably false positive may remain (tell the user which and why).
145
145
  6. To save: call list_organizations. If the account has EXACTLY ONE organization, create_page will auto-select it — no need to ask. If there are MULTIPLE organizations, show them to the user and ask which to use (highlight is_default as the suggested default); pass the chosen organization_id to create_page. If the user explicitly wants to save without any organization, pass organization_id:"personal". Then create_page (dry_run first, then dry_run:false). Note: create_page itself enforces this — it refuses to guess between multiple orgs and returns the org list asking you to pick.
146
146
 
147
147
  EDITING an existing page
@@ -154,5 +154,6 @@ EDITING an existing page
154
154
 
155
155
  REFERENCE INPUT (HTML page to clone or adapt)
156
156
  - ingest_html(html, detail:'full') / ingest_url(url, detail:'full') returns a richer AST for clone-quality rebuilds: CSS custom-property palette (design tokens by name), background_images from stylesheets (hero/CTA bg images invisible to a plain img scan), per-section blocks (repeating card/tile/step structures with title/body/image/cta), li lists, gradients, and images as { src, alt } objects. Use detail:'compact' (default) for a quick layout-only reference.
157
- - Map AST section roles to Webcake elements: hero section (background image/overlay) + text-block H1 + text-block subheading + button; features group per card (icon rectangle + text-block title + text-block body); stats bar group with text-block per stat; pricing group with text-block list + button; footer section (dark bg) + text-block + links. When the ingested page contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) rebuild it as ONE html-box (clone its HTML with all styles inlined), NOT as element soup.
157
+ - SECTION HEIGHTS: every AST section carries size_hint = { height, basis, css? } the section's desktop height on the 960px canvas (basis:'css' = an explicit height/min-height found in the source, css holds the raw value e.g. "100vh"; basis:'estimate' = content-volume math). Set each rebuilt section's desktop height FROM its size_hint (±15% to fit your actual element placement) instead of the 800px default, so the page's vertical rhythm tracks the source: a 72px header stays a slim bar, a 1200px pricing band stays tall. Mobile is NOT hinted redo the height per the mobile text math (stacked content is taller).
158
+ - Map AST section roles to Webcake elements: hero → section (background image/overlay) + text-block H1 + text-block subheading + button; features → group per card (icon rectangle + text-block title + text-block body); stats bar → group with text-block per stat; pricing → group with text-block list + button; footer → section (dark bg) + text-block + links. When the ingested page contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box, NOT as element soup. The full AST hands you the SOURCE for this: section.widgets = [{ hint, html, css? }] — the widget's cleaned outerHTML plus the stylesheet rules that style it. Build the html-box FROM that html verbatim: inline each css rule into the matching elements' style="" attributes (an html-box has no <style> scope), wrap in a root div with width:100%;height:100%;box-sizing:border-box;overflow:hidden, and size the html-box to the widget's box — do NOT re-imagine the widget's markup from the summary fields.
158
159
  - Reference images are the user's assets — for BOTH intents (adapt AND clone), re-host every real image URL found in the AST (images, background_images, og_image) via upload_images and reuse it in the matching slot; never hotlink and never replace it with a search_images stock photo. intent='adapt' rewrites TEXT for the user's brand, not the imagery; search_images only fills slots that have no source image.`;
@@ -9,9 +9,9 @@ RULES (follow for every request):
9
9
  - INTAKE FIRST — do this EVERY time, even for a "quick"/"test" page. Do NOT jump straight to new_page_skeleton/create_page on the same turn as the request: ask the essentials, restate an outline, get a "yes", THEN build. Ask ONE short batch (3–6, with sensible defaults so the user answers fast) enough to understand the page's PURPOSE, name, look and layout: page purpose/goal, brand/page name, what they sell + price (sales/ads pages), primary color + logo/branding, sections & layout in order, primary CTA + destination, desktop+mobile or mobile-only, which organization. CONSULT, don't interrogate: SUGGEST so the user reacts to something concrete — propose a section flow (pick the archetype matching the page type) + a look (hero treatment + color/tone), and when the user is vague offer 2–3 directions to choose from; proactively suggest sections that fit their goal (social-proof, FAQ, countdown), but ask, don't silently add. Then restate the proposed design (section flow + CTA + color/tone) and WAIT for the user's confirmation, iterating until it matches their intent, before generating. Never assume or silently placeholder the page name, product, price, or colors — ask; only placeholder a core fact when the user explicitly declines to give it.
10
10
  - ASK for any real data the page will display — never invent it, and don't silently placeholder it. This includes: phone/hotline/Zalo, price (+ original price), address, shop/brand name, links/URLs, email, opening hours, and exact stats/social-proof numbers. If a value the page needs is missing, ASK the user for it (in intake, or pause and ask before generating). Use a clearly-labelled placeholder ONLY when the user explicitly says to skip it — then tell them exactly what to fill in.
11
11
  - LANGUAGE: write ALL page copy in the SAME language the user is chatting in, with FULL, CORRECT diacritics/accents. For Vietnamese, every word MUST carry its proper dấu (e.g. "Trân Trọng Kính Mời", "Ngày 15 Tháng 08 Năm 2025") — NEVER emit accent-stripped "không dấu" text. Never romanize or drop accent marks from any language.
12
- - ALWAYS call validate_page and fix every error before create_page / update_page.
12
+ - ALWAYS call validate_page and fix every error before create_page / update_page. WARNINGS ARE A FIX LIST, NOT NOISE: each warning is a visible defect the customer will see (text spilling onto the element below, off-canvas boxes, empty bands, missing field_name, dead event targets). Fix every warning too — before the first save when building new, or via patch_page right after saving an edit — and re-validate until the list is empty. Only a warning you can demonstrate is a false positive may remain (name it to the user and say why). Never tell the user the page is done while warnings stand.
13
13
  - BUILD THE SOURCE IN ONE PASS — gather everything you need BEFORE assembling the source, then build the FULL tree once. BATCH the reads: when a section needs several element types (section + text-block + image-block + button + form + input), call get_element({types:[…]}) ONCE instead of one call per type — same for images, call search_images({queries:[…]}) ONCE with one query per image slot (it dedups + parallelizes and returns one best photo per query). Across MULTIPLE pages in one conversation: reusing skeletons you already fetched is correct, but call get_element for any type you have NOT already fetched in this conversation — NEVER author an unfamiliar type from memory; and if the earlier skeletons are no longer in your context (long conversation, compacted history), re-fetch before building. Do NOT interleave get_element calls between create_page previews and rebuild. create_page/update_page take the entire source as input, so each call re-ships the whole page — re-previewing repeatedly wastes the request.
14
- - create_page and update_page DEFAULT to dry_run=true (a safety net for ambiguous requests). When the user's intent is clear AND validate_page already passed (no errors), SKIP the dry-run and call with dry_run=false directly — saves one round-trip. Use dry_run=true only when (a) the request is ambiguous about target/content, (b) the user explicitly asks to "preview" or "xem trước", (c) this is an update_page that overwrites significant existing content, or (d) you genuinely need to inspect the redacted payload. Never loop dry-runs to "check" the source — validate_page is the validator. Do not run dry-run then dry-run again before the real write.
14
+ - create_page and update_page DEFAULT to dry_run=true (a safety net for ambiguous requests). When the user's intent is clear AND validate_page already passed (no errors, warnings fixed), SKIP the dry-run and call with dry_run=false directly — saves one round-trip. Use dry_run=true only when (a) the request is ambiguous about target/content, (b) the user explicitly asks to "preview" or "xem trước", (c) this is an update_page that overwrites significant existing content, or (d) you genuinely need to inspect the redacted payload. Never loop dry-runs to "check" the source — validate_page is the validator. Do not run dry-run then dry-run again before the real write.
15
15
  - LARGE PAGES (4+ sections) — build INCREMENTALLY to avoid the giant single create_page payload that can drop the connection: create_page with a SMALL skeleton (empty/near-empty page) to get a page_id, then call add_section once per section (each call ships ONLY that section; the backend appends it server-side and rejects duplicate ids — no whole-source get+put). Small pages can still go in one create_page pass.
16
16
  - EDIT existing pages surgically: find_pages (locate the page by name/domain/id when you don't already have a page_id) → get_page → change ONLY what was asked → keep every other element, its id, and coordinates. For a SMALL edit, PREFER patch_page over update_page: send only the changed elements by id (ops: update/replace/remove/add) instead of re-shipping the whole tree — the MCP fetches the live source, merges, validates and saves server-side. Use update_page only when you're rewriting most of the page. Never regenerate the whole tree for a small change.
17
17
  - FIX-AFTER-ERROR / RETRY-AFTER-TIMEOUT (don't rebuild — this is the #1 time-waster): every mutating tool (create_page, update_page, add_section, patch_page) writes the payload to a draft cache BEFORE the network call and returns a draft_id. On validation failure, timeout, or any network error, the draft is kept — retry or fix without re-sending the full JSON:
@@ -61,6 +61,25 @@ function num(v) {
61
61
  }
62
62
  return undefined;
63
63
  }
64
+ /**
65
+ * Estimated rendered height (px) of a text-block's specials.text at a given
66
+ * fontSize/width — the guide's TEXT HEIGHT MATH (lines ≈ ceil(chars × fontSize
67
+ * × 0.55 / width), height ≈ lines × fontSize × 1.4; lines counted per explicit
68
+ * <br> segment). Returns undefined for empty text or template variables
69
+ * ({{…}}) whose rendered length is unknown.
70
+ */
71
+ function estTextHeight(rawText, fs, w) {
72
+ if (rawText.includes("{{") || !(fs > 0) || !(w > 0))
73
+ return undefined;
74
+ const segments = rawText
75
+ .split(/<br\s*\/?>/i)
76
+ .map((s) => s.replace(/<[^>]*>/g, "").replace(/&nbsp;|&#160;/g, " ").trim())
77
+ .filter((s) => s !== "");
78
+ if (segments.length === 0)
79
+ return undefined;
80
+ const lines = segments.reduce((acc, seg) => acc + Math.max(1, Math.ceil((seg.length * fs * 0.55) / w)), 0);
81
+ return Math.round(lines * fs * 1.4);
82
+ }
64
83
  /**
65
84
  * True when a CSS color string carries real hue — i.e. NOT white/black/grey/
66
85
  * transparent. Used to flag a page that ships with no color at all (every band
@@ -325,31 +344,22 @@ export function validatePage(input) {
325
344
  // Wrapped-text overflow: live text height is AUTO — text that wraps to
326
345
  // more lines than the declared box spills DOWN and overlaps the element
327
346
  // below (the classic "2-line card title over the card body" defect).
328
- // Rough estimate: avg glyph width fontSize × 0.55, line height
329
- // fontSize × 1.4; lines counted per explicit <br> segment. Warn only when
330
- // the estimate exceeds the declared height by MORE than one full line
331
- // (keeps the heuristic from flagging well-sized paragraphs). Skip text
332
- // with template variables ({{…}}) rendered length is unknown.
333
- if (!rawText.includes("{{")) {
334
- const segments = rawText
335
- .split(/<br\s*\/?>/i)
336
- .map((s) => s.replace(/<[^>]*>/g, "").replace(/&nbsp;|&#160;/g, " ").trim())
337
- .filter((s) => s !== "");
338
- if (segments.length > 0) {
339
- for (const bp of ["desktop", "mobile"]) {
340
- const styles = node.responsive?.[bp]?.styles;
341
- const w = num(styles?.width);
342
- const h = num(styles?.height);
343
- const fs = num(styles?.fontSize) ?? 16;
344
- if (!w || !h || w <= 0 || fs <= 0)
345
- continue;
346
- const lines = segments.reduce((acc, seg) => acc + Math.max(1, Math.ceil((seg.length * fs * 0.55) / w)), 0);
347
- const lineH = fs * 1.4;
348
- const est = Math.round(lines * lineH);
349
- if (est > h + lineH) {
350
- warnings.push(`${path} (text-block) [${bp}]: text wraps to ~${lines} lines (~${est}px) but the box is only ${h}px tall — live text height is AUTO, so it will spill down and overlap the element below. Set height ≈ ${est}px and push the elements below down (estimate: lines ≈ ceil(chars × fontSize × 0.55 / width), height ≈ lines × fontSize × 1.4).`);
351
- }
352
- }
347
+ // Slack is capped at 24px: a full-line slack on a 40px heading (56px)
348
+ // is exactly what lets the most common defect a 2-line H2 on a
349
+ // 1-line-tall box slip through, while body text (16px 22px line)
350
+ // keeps its old tolerance against the rough estimate.
351
+ for (const bp of ["desktop", "mobile"]) {
352
+ const styles = node.responsive?.[bp]?.styles;
353
+ const w = num(styles?.width);
354
+ const h = num(styles?.height);
355
+ const fs = num(styles?.fontSize) ?? 16;
356
+ if (!w || !h)
357
+ continue;
358
+ const est = estTextHeight(rawText, fs, w);
359
+ if (est == null)
360
+ continue;
361
+ if (est > h + Math.min(fs * 1.4, 24)) {
362
+ warnings.push(`${path} (text-block) [${bp}]: text wraps to ~${est}px but the box is only ${h}px tall — live text height is AUTO, so it will spill down and overlap the element below. Set height ≈ ${est}px and push the elements below down (estimate: lines ≈ ceil(chars × fontSize × 0.55 / width), height ≈ lines × fontSize × 1.4).`);
353
363
  }
354
364
  }
355
365
  }
@@ -696,6 +706,95 @@ export function validatePage(input) {
696
706
  const ms = sec?.responsive?.mobile?.styles ?? {};
697
707
  checkBounds(sec, rootCanvasD, num(ds.height) ?? DEFAULT_SECTION_HEIGHT, rootCanvasM, num(ms.height) ?? DEFAULT_SECTION_HEIGHT, `page[${i}]`);
698
708
  });
709
+ // 3c) Wrapped-text collision — live text height is AUTO, so a text-block whose
710
+ // content wraps past its declared box spills DOWN. When the declared layout
711
+ // puts a sibling directly below (boxes NOT overlapping — overlapping boxes
712
+ // are intentional layering), the spill lands ON that sibling: the classic
713
+ // broken-looking page of a 2-line H2 over its subheading or a wrapped card
714
+ // title over the card body. The own-box check (section 1) flags the text
715
+ // block itself; this geometric pass names the VICTIM and the exact fix.
716
+ let overlapWarnings = 0;
717
+ const MAX_OVERLAP_WARNINGS = 12;
718
+ const checkTextOverlap = (container, path) => {
719
+ if (!container || !Array.isArray(container.children))
720
+ return;
721
+ const kids = container.children;
722
+ kids.forEach((child, idx) => {
723
+ if (!child || typeof child !== "object")
724
+ return;
725
+ const cpath = `${path}.children[${idx}]`;
726
+ const rawText = child.type === "text-block" ? child.specials?.text : undefined;
727
+ if (typeof rawText === "string") {
728
+ for (const bp of ["desktop", "mobile"]) {
729
+ if (overlapWarnings >= MAX_OVERLAP_WARNINGS)
730
+ break;
731
+ const s = child.responsive?.[bp]?.styles;
732
+ const top = num(s?.top);
733
+ const left = num(s?.left) ?? 0;
734
+ const w = num(s?.width);
735
+ const h = num(s?.height);
736
+ const fs = num(s?.fontSize) ?? 16;
737
+ if (top == null || h == null || !w)
738
+ continue;
739
+ const est = estTextHeight(rawText, fs, w);
740
+ if (est == null || est <= h)
741
+ continue;
742
+ const estBottom = top + est;
743
+ // nearest sibling the declared layout places below this text block
744
+ let hit;
745
+ kids.forEach((sib, j) => {
746
+ if (j === idx || !sib || typeof sib !== "object")
747
+ return;
748
+ const ss = sib.responsive?.[bp]?.styles;
749
+ const st = num(ss?.top);
750
+ const sl = num(ss?.left) ?? 0;
751
+ const sw = num(ss?.width);
752
+ if (st == null || sw == null)
753
+ return;
754
+ if (st < top + h)
755
+ return; // declared boxes overlap or sibling is above → layering, skip
756
+ if (sl + sw <= left || sl >= left + w)
757
+ return; // no horizontal intersection
758
+ if (estBottom > st + 4 && (!hit || st < hit.t))
759
+ hit = { p: `${path}.children[${j}]`, t: st, type: sib.type ?? "?" };
760
+ });
761
+ if (hit) {
762
+ warnings.push(`${cpath} (text-block) [${bp}]: wrapped text renders ~${est}px tall (declared ${h}px) and will spill onto ${hit.p} (${hit.type}, top=${hit.t}) below it. Set this block's height ≈ ${est} and move the elements below to top ≥ ${estBottom + 8}.`);
763
+ overlapWarnings++;
764
+ }
765
+ }
766
+ }
767
+ if (Array.isArray(child.children) && child.children.length > 0)
768
+ checkTextOverlap(child, cpath);
769
+ });
770
+ };
771
+ topList.forEach((sec, i) => checkTextOverlap(sec, `page[${i}]`));
772
+ // 3d) Trailing dead space — a section far taller than its lowest content
773
+ // renders as a big empty band, which reads as a broken/unfinished page.
774
+ // Threshold is generous (320px) since text auto-grow and bottom padding
775
+ // are legitimate; advisory only.
776
+ topList.forEach((sec, i) => {
777
+ if (!sec || !Array.isArray(sec.children) || sec.children.length === 0)
778
+ return;
779
+ for (const bp of ["desktop", "mobile"]) {
780
+ const sh = num(sec.responsive?.[bp]?.styles?.height);
781
+ if (sh == null)
782
+ continue;
783
+ let maxBottom;
784
+ for (const child of sec.children) {
785
+ const s = child?.responsive?.[bp]?.styles;
786
+ const t = num(s?.top);
787
+ const h = num(s?.height);
788
+ if (t == null || h == null)
789
+ continue;
790
+ if (maxBottom == null || t + h > maxBottom)
791
+ maxBottom = t + h;
792
+ }
793
+ if (maxBottom != null && sh > maxBottom + 320) {
794
+ warnings.push(`page[${i}] (section) [${bp}]: height=${sh} but the lowest child ends at ${maxBottom} — ~${sh - maxBottom}px of empty band at the bottom of the section. Reduce the section height to ≈ ${maxBottom + 80} (or move content down into the band).`);
795
+ }
796
+ }
797
+ });
699
798
  // 3b) Page margin axis — every band (header included) should put its
700
799
  // left-anchored content on ONE shared left margin. A header/section that
701
800
  // starts on a different left than the rest is the #1 "looks misaligned"
@@ -6,3 +6,15 @@ export function text(value) {
6
6
  const body = typeof value === "string" ? value : JSON.stringify(value, null, 2);
7
7
  return { content: [{ type: "text", text: body }] };
8
8
  }
9
+ /**
10
+ * Directive shipped alongside every non-empty validation-warnings list.
11
+ * Warnings are design defects the customer WILL see (text overlapping the
12
+ * element below, off-canvas boxes, empty bands, dead event targets, missing
13
+ * field_name…) — without this, models treat them as advisory noise and save
14
+ * anyway.
15
+ */
16
+ export const WARNINGS_NOTICE = "FIX THESE WARNINGS — each one is a visible defect the customer will see, not a suggestion. Apply the fix each warning prescribes (patch_page by element id is the cheap path), then re-validate until the list is empty. Only a warning you can demonstrate is a false positive may remain — name it to the user and say why. Do NOT report the page as done while warnings stand.";
17
+ /** Spread helper: {} when there are no warnings, else { warnings, warnings_notice }. */
18
+ export function warningsField(warnings) {
19
+ return warnings && warnings.length > 0 ? { warnings, warnings_notice: WARNINGS_NOTICE } : {};
20
+ }
@@ -165,7 +165,16 @@ export function parseHtml(html, detail = "compact") {
165
165
  };
166
166
  }
167
167
  const sectionEls = findSections(body);
168
- const sections = sectionEls.map((el) => classifySection(el, detail));
168
+ const sections = sectionEls.map((el) => {
169
+ const sec = classifySection(el, detail);
170
+ sec.size_hint = computeSizeHint(el, sec, styleBlocks);
171
+ if (detail === "full") {
172
+ const widgets = detectWidgets(el, styleBlocks);
173
+ if (widgets.length)
174
+ sec.widgets = widgets;
175
+ }
176
+ return sec;
177
+ });
169
178
  // Brand hints from inline styles (both modes).
170
179
  const styleAttrs = [];
171
180
  body.querySelectorAll("[style]").forEach((el) => {
@@ -204,25 +213,33 @@ export function parseHtml(html, detail = "compact") {
204
213
  ...base,
205
214
  gradients: gradients.length ? gradients : undefined,
206
215
  };
207
- // Size-cap: drop blocks[].body first, then truncate lists.
208
- const serialized = JSON.stringify(result);
209
- if (serialized.length > FULL_SIZE_CAP) {
210
- // Strip block bodies.
216
+ // Size-cap shedding order: blocks[].body widgets[].css lists → widgets
217
+ // (widget html goes last — it's the clone-fidelity payload of full mode).
218
+ if (JSON.stringify(result).length > FULL_SIZE_CAP) {
211
219
  for (const sec of result.sections) {
212
- if (sec.blocks) {
220
+ if (sec.blocks)
213
221
  for (const blk of sec.blocks)
214
222
  delete blk.body;
223
+ }
224
+ if (JSON.stringify(result).length > FULL_SIZE_CAP) {
225
+ for (const sec of result.sections) {
226
+ if (sec.widgets)
227
+ for (const w of sec.widgets)
228
+ delete w.css;
215
229
  }
216
230
  }
217
- const s2 = JSON.stringify(result);
218
- if (s2.length > FULL_SIZE_CAP) {
219
- // Truncate lists.
231
+ if (JSON.stringify(result).length > FULL_SIZE_CAP) {
220
232
  for (const sec of result.sections) {
221
233
  if (sec.lists && sec.lists.length > 5)
222
234
  sec.lists = sec.lists.slice(0, 5);
223
235
  }
224
236
  result.truncated = true;
225
237
  }
238
+ if (JSON.stringify(result).length > FULL_SIZE_CAP) {
239
+ for (const sec of result.sections)
240
+ delete sec.widgets;
241
+ result.truncated = true;
242
+ }
226
243
  }
227
244
  return result;
228
245
  }
@@ -616,6 +633,202 @@ function pickLists(el, _blocks) {
616
633
  .filter((t) => t.length > 3 && t.length < 200)
617
634
  .slice(0, 15);
618
635
  }
636
+ // ─── size hint (desktop section height) ──────────────────────────────────────
637
+ const SIZE_DECL_RE = /(?:^|[;{\s])(min-height|height)\s*:\s*([\d.]+)(px|vh|rem|em)\b/gi;
638
+ const VH_PX = 8; // 1vh ≈ 8px (~800px viewport), so a 100vh hero lands near the editor's 800px default band
639
+ function clamp(v, lo, hi) {
640
+ return Math.min(hi, Math.max(lo, v));
641
+ }
642
+ /** lines ≈ ceil(chars × fontSize × glyphFactor / width) — same model as the generation guide's text math. */
643
+ function textLines(chars, fontSize, width, factor = 0.55) {
644
+ if (chars <= 0)
645
+ return 0;
646
+ return Math.max(1, Math.ceil((chars * fontSize * factor) / width));
647
+ }
648
+ /** Parse height/min-height declarations out of a CSS declaration string. */
649
+ function sizeDecls(decl) {
650
+ const out = [];
651
+ SIZE_DECL_RE.lastIndex = 0;
652
+ let m;
653
+ while ((m = SIZE_DECL_RE.exec(decl)) !== null) {
654
+ const kind = m[1].toLowerCase();
655
+ const v = parseFloat(m[2]);
656
+ const unit = m[3].toLowerCase();
657
+ const px = unit === "px" ? v : unit === "vh" ? v * VH_PX : v * 16; // rem/em ≈ 16px
658
+ if (px >= 40 && px <= 2400)
659
+ out.push({ kind, px: Math.round(px), raw: `${m[2]}${unit}` });
660
+ }
661
+ return out;
662
+ }
663
+ function escapeRe(s) {
664
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
665
+ }
666
+ /**
667
+ * Explicit height/min-height for a section element: its inline style, plus any
668
+ * stylesheet rule whose selector mentions the element's #id or one of its
669
+ * classes (whole-token match — `.hero` doesn't match `.hero-card`). Regex-level
670
+ * on purpose; full selector matching needs a renderer.
671
+ */
672
+ function cssSizeDecls(el, styleBlocks) {
673
+ const out = [];
674
+ const inline = el.getAttribute("style");
675
+ if (inline)
676
+ out.push(...sizeDecls(inline));
677
+ const id = el.getAttribute("id");
678
+ const classes = (el.getAttribute("class") ?? "").trim().split(/\s+/).filter(Boolean).slice(0, 4);
679
+ const tokens = [...(id ? ["#" + id] : []), ...classes.map((c) => "." + c)];
680
+ if (tokens.length) {
681
+ const matchers = tokens.map((t) => new RegExp(escapeRe(t) + "(?![\\w-])"));
682
+ const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
683
+ for (const css of styleBlocks) {
684
+ ruleRe.lastIndex = 0;
685
+ let m;
686
+ while ((m = ruleRe.exec(css)) !== null) {
687
+ if (matchers.some((re) => re.test(m[1])))
688
+ out.push(...sizeDecls(m[2]));
689
+ }
690
+ }
691
+ }
692
+ return out;
693
+ }
694
+ /**
695
+ * Content-volume estimate of the section's desktop height (960px canvas,
696
+ * ~800px content column) so the rebuilt band is proportional to the source
697
+ * instead of a flat 800px default.
698
+ */
699
+ function estimateSectionHeight(el, sec) {
700
+ const role = sec.role;
701
+ if (role === "header")
702
+ return 72;
703
+ if (role === "footer") {
704
+ const linkRows = Math.ceil((sec.links?.length ?? 0) / 4);
705
+ return clamp(120 + linkRows * 32 + (sec.paragraphs?.length ?? 0) * 24, 140, 480);
706
+ }
707
+ let h = 140; // band padding (top + bottom)
708
+ if (sec.heading) {
709
+ const fs = role === "hero" ? 48 : 36;
710
+ h += textLines(sec.heading.length, fs, 800, 0.6) * Math.round(fs * 1.2) + 20;
711
+ }
712
+ if (sec.subheading)
713
+ h += textLines(sec.subheading.length, 18, 640) * 27 + 16;
714
+ for (const p of sec.paragraphs ?? [])
715
+ h += textLines(p.length, 16, 640) * 24 + 12;
716
+ if (sec.ctas?.length)
717
+ h += 76;
718
+ if (sec.form_fields?.length)
719
+ h += sec.form_fields.length * 64 + 24;
720
+ // Card/tile rows: full mode carries blocks; compact recounts from the DOM.
721
+ let cards = sec.blocks?.length ?? 0;
722
+ if (!cards && (role === "features" || role === "pricing" || role === "testimonials")) {
723
+ cards = Math.min(countFeatureBlocks(el), 12);
724
+ }
725
+ if (cards)
726
+ h += Math.ceil(cards / 3) * (role === "pricing" ? 420 : 260) + 24;
727
+ else if (sec.lists?.length)
728
+ h += sec.lists.length * 30;
729
+ const imgCount = sec.images?.length ?? 0;
730
+ if (role === "gallery")
731
+ h += Math.ceil(imgCount / 3) * 260;
732
+ else if (role === "hero")
733
+ h = Math.max(h, imgCount ? 560 : 480);
734
+ else if (imgCount)
735
+ h += 320; // a content image alongside/below the text
736
+ return clamp(Math.round(h / 10) * 10, 160, 1600);
737
+ }
738
+ function computeSizeHint(el, sec, styleBlocks) {
739
+ const estimate = estimateSectionHeight(el, sec);
740
+ const decls = cssSizeDecls(el, styleBlocks);
741
+ const fixed = decls.filter((d) => d.kind === "height").sort((a, b) => b.px - a.px)[0];
742
+ // An explicit height pins the band; min-height grows with content, so take the larger.
743
+ if (fixed)
744
+ return { height: fixed.px, basis: "css", css: fixed.raw };
745
+ const min = decls.filter((d) => d.kind === "min-height").sort((a, b) => b.px - a.px)[0];
746
+ if (min)
747
+ return { height: Math.max(min.px, estimate), basis: "css", css: min.raw };
748
+ return { height: estimate, basis: "estimate" };
749
+ }
750
+ // ─── full-mode: composite-widget extraction (html-box source) ────────────────
751
+ // Class/id keywords that mark a composite visual the guide rebuilds as ONE
752
+ // html-box. Conservative on purpose — generic words (card, window, slider)
753
+ // over-match ordinary content.
754
+ const WIDGET_HINT_RE = /\b(mockup|phone|device|browser|terminal|console|dashboard|chat|inbox|player)\b/i;
755
+ const WIDGET_HTML_CAP = 8000;
756
+ const WIDGET_CSS_CAP = 4000;
757
+ const MAX_WIDGETS_PER_SECTION = 2;
758
+ /** outerHTML cleaned for html-box reuse: scripts/styles stripped, whitespace collapsed. */
759
+ function cleanWidgetHtml(el) {
760
+ // Re-parse a copy so removals don't mutate the tree other pickers read.
761
+ const frag = parse(el.toString(), { lowerCaseTagName: true });
762
+ frag.querySelectorAll("script, style, noscript").forEach((n) => n.remove());
763
+ return frag
764
+ .toString()
765
+ .replace(/<!--[\s\S]*?-->/g, "")
766
+ .replace(/>\s+</g, "><")
767
+ .replace(/\s{2,}/g, " ")
768
+ .trim();
769
+ }
770
+ /** Stylesheet rules whose selector mentions a class/id used inside the widget HTML. */
771
+ function widgetCss(html, styleBlocks) {
772
+ const tokens = new Set();
773
+ for (const m of html.matchAll(/class="([^"]+)"/g)) {
774
+ for (const c of m[1].trim().split(/\s+/))
775
+ if (c)
776
+ tokens.add("." + c);
777
+ }
778
+ for (const m of html.matchAll(/id="([^"]+)"/g)) {
779
+ if (m[1].trim())
780
+ tokens.add("#" + m[1].trim());
781
+ }
782
+ if (!tokens.size)
783
+ return undefined;
784
+ const matchers = [...tokens].map((t) => new RegExp(escapeRe(t) + "(?![\\w-])"));
785
+ const parts = [];
786
+ let total = 0;
787
+ const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
788
+ for (const css of styleBlocks) {
789
+ ruleRe.lastIndex = 0;
790
+ let m;
791
+ while ((m = ruleRe.exec(css)) !== null) {
792
+ const sel = m[1].trim();
793
+ if (!matchers.some((re) => re.test(sel)))
794
+ continue;
795
+ const rule = `${sel.replace(/\s+/g, " ")}{${m[2].trim().replace(/\s+/g, " ")}}`;
796
+ if (total + rule.length > WIDGET_CSS_CAP)
797
+ return parts.join("");
798
+ parts.push(rule);
799
+ total += rule.length;
800
+ }
801
+ }
802
+ return parts.length ? parts.join("") : undefined;
803
+ }
804
+ /**
805
+ * Find composite-widget candidates inside a section: OUTERMOST descendants
806
+ * whose class/id matches WIDGET_HINT_RE and that have real internal structure
807
+ * (≥3 descendant elements). Emits the cleaned HTML + matching CSS so the
808
+ * model's html-box reproduces the source instead of approximating it.
809
+ */
810
+ function detectWidgets(el, styleBlocks) {
811
+ const out = [];
812
+ const walk = (node) => {
813
+ for (const k of elementChildren(node)) {
814
+ if (out.length >= MAX_WIDGETS_PER_SECTION)
815
+ return;
816
+ const idCls = (k.getAttribute("id") ?? "") + " " + (k.getAttribute("class") ?? "");
817
+ const m = idCls.match(WIDGET_HINT_RE);
818
+ if (m && k.querySelectorAll("*").length >= 3) {
819
+ const html = cleanWidgetHtml(k);
820
+ if (html && html.length <= WIDGET_HTML_CAP) {
821
+ const css = widgetCss(html, styleBlocks);
822
+ out.push(css ? { hint: m[1].toLowerCase(), html, css } : { hint: m[1].toLowerCase(), html });
823
+ continue; // outermost only — don't descend into an emitted widget
824
+ }
825
+ }
826
+ walk(k);
827
+ }
828
+ };
829
+ walk(el);
830
+ return out;
831
+ }
619
832
  // ─── color / font helpers ────────────────────────────────────────────────────
620
833
  function elText(el) {
621
834
  const t = el?.text?.trim();
package/dist/smoke.js CHANGED
@@ -8,6 +8,7 @@ import { validatePage, pageSchema } from "./domains/landing/validate.js";
8
8
  import { expandSource } from "./core/expand.js";
9
9
  import { compactSource, deepEq, sparseTemplate } from "./core/compact.js";
10
10
  import { parseHtml } from "./persistence/html-ingest.js";
11
+ import { warningsField } from "./mcp/response.js";
11
12
  import { readConfig, resolveEnv, ENV_NAMES, configFromHeaders } from "./persistence/config.js";
12
13
  import { toEditorUrl, toPreviewUrl, buildPublishRequestRedacted } from "./persistence/webcake-client.js";
13
14
  import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsProxyBase, buildSearchQuery, PEXELS_PROXY_DEFAULT } from "./persistence/pexels-client.js";
@@ -236,6 +237,28 @@ check("ingest: hero image captured", (hero?.images?.length ?? 0) > 0, hero?.imag
236
237
  const form = ast.sections.find((s) => s.role === "form");
237
238
  check("ingest: form fields captured", (form?.form_fields?.length ?? 0) >= 2, form?.form_fields);
238
239
  check("ingest: form submit CTA captured", !!form?.ctas?.[0]?.text?.includes("Place"), form?.ctas);
240
+ console.log("== ingest: size_hint (desktop section heights) ==");
241
+ 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));
242
+ const headerHint = ast.sections.find((s) => s.role === "header")?.size_hint;
243
+ check("ingest: header size_hint is a slim bar", !!headerHint && headerHint.height <= 120, headerHint);
244
+ const heroHint = ast.sections.find((s) => s.role === "hero")?.size_hint;
245
+ check("ingest: hero size_hint is a tall band", !!heroHint && heroHint.height >= 400, heroHint);
246
+ const footerHint = ast.sections.find((s) => s.role === "footer")?.size_hint;
247
+ check("ingest: footer size_hint shorter than hero", !!footerHint && !!heroHint && footerHint.height < heroHint.height, { footerHint, heroHint });
248
+ const cssHintHtml = `<!DOCTYPE html><html><head><style>
249
+ .hero { min-height: 100vh; }
250
+ #promo { height: 560px; }
251
+ </style></head><body>
252
+ <section class="hero"><h1>Big hero</h1><p>Tagline goes here for the hero band.</p><button>Go</button></section>
253
+ <section id="promo"><h2>Promo</h2><p>Limited time offer on all plans this week.</p><button>Claim</button></section>
254
+ <section style="min-height: 75vh"><h2>Inline</h2><p>Inline-styled band with its own height.</p></section>
255
+ </body></html>`;
256
+ const cssAst = parseHtml(cssHintHtml);
257
+ const cssHints = cssAst.sections.map((s) => s.size_hint);
258
+ check("ingest: 100vh class rule → css basis ~800px", cssHints[0]?.basis === "css" && cssHints[0]?.height === 800 && cssHints[0]?.css === "100vh", cssHints[0]);
259
+ check("ingest: explicit px height by #id → css basis exact", cssHints[1]?.basis === "css" && cssHints[1]?.height === 560, cssHints[1]);
260
+ check("ingest: inline min-height vh → css basis", cssHints[2]?.basis === "css" && cssHints[2]?.height === 600, cssHints[2]);
261
+ check("ingest: .hero rule does not leak into #promo", cssHints[1]?.css !== "100vh", cssHints[1]);
239
262
  console.log("== ingest: tolerates empty/CSR-shell HTML ==");
240
263
  const empty = parseHtml("");
241
264
  check("ingest: empty input → warning", (empty.warnings?.length ?? 0) > 0, empty.warnings);
@@ -296,6 +319,27 @@ check("ingest: compact has no blocks", compactAst.sections.every((s) => s.blocks
296
319
  check("ingest: compact has no gradients", compactAst.gradients === undefined, compactAst.gradients);
297
320
  check("ingest: compact images are plain strings", compactAst.sections.every((s) => !s.images || s.images.every((i) => typeof i === "string")), compactAst.sections.map((s) => s.images));
298
321
  check("ingest: default (no detail arg) is compact-compatible", parseHtml(sampleHtml).sections.every((s) => s.blocks === undefined));
322
+ console.log("== ingest: full mode — composite widget extraction (html-box source) ==");
323
+ const widgetHtml = `<!DOCTYPE html><html><head><style>
324
+ .phone-mockup { width: 320px; border-radius: 24px; background: #111; }
325
+ .chat-bubble { padding: 8px 12px; border-radius: 12px; background: #f1f1f1; }
326
+ .chat-bubble.right { background: #0A7C6E; color: #fff; }
327
+ </style></head><body>
328
+ <section><h1>Talk to us</h1><p>Our assistant answers around the clock for you.</p><button>Start chat</button>
329
+ <div class="phone-mockup"><div class="screen"><div class="chat-bubble left">Xin chào!</div><div class="chat-bubble right">Chào bạn</div><div class="chat-input">Type a message…</div></div><script>track()</script></div>
330
+ </section>
331
+ <section><h2>About</h2><p>Plain prose section with no composite widget at all.</p></section>
332
+ </body></html>`;
333
+ const widgetAst = parseHtml(widgetHtml, "full");
334
+ const wSec = widgetAst.sections[0];
335
+ check("widgets: detected on the mockup section", (wSec?.widgets?.length ?? 0) === 1, wSec?.widgets?.map((w) => w.hint));
336
+ const w0 = wSec?.widgets?.[0];
337
+ check("widgets: hint from class keyword", w0?.hint === "phone" || w0?.hint === "mockup", w0?.hint);
338
+ check("widgets: html keeps inner structure", !!w0?.html.includes("chat-bubble"), w0?.html);
339
+ check("widgets: scripts stripped from html", !(w0?.html ?? "").includes("<script"), w0?.html);
340
+ check("widgets: matching css rules attached", !!w0?.css?.includes(".phone-mockup") && !!w0?.css?.includes(".chat-bubble"), w0?.css);
341
+ check("widgets: none on plain sections", widgetAst.sections[1]?.widgets === undefined, widgetAst.sections[1]);
342
+ check("widgets: compact mode emits none", parseHtml(widgetHtml, "compact").sections.every((s) => s.widgets === undefined));
299
343
  console.log("== ingest: nested-grid block detection (depth > 1) ==");
300
344
  // section > .grid-wrapper > .card — blocks must be found even though cards are not direct children
301
345
  const nestedGridHtml = `<!DOCTYPE html><html lang="en"><head><title>T</title></head><body>
@@ -921,5 +965,50 @@ console.log("== text-block styles.background warning (gradient-text-fill mode) =
921
965
  });
922
966
  check("text-block: no styles.background → no gradient-fill warning", !rNoBg2.warnings.some((w) => w.includes("gradient text-fill")), rNoBg2.warnings);
923
967
  }
968
+ console.log("== warningsField: warnings ship with the fix-list directive ==");
969
+ {
970
+ const withW = warningsField(["page[0]: something"]);
971
+ check("warningsField: non-empty warnings carry warnings_notice", Array.isArray(withW.warnings) && typeof withW.warnings_notice === "string" && withW.warnings_notice.includes("FIX THESE WARNINGS"), withW);
972
+ check("warningsField: empty list adds nothing", Object.keys(warningsField([])).length === 0 && Object.keys(warningsField(undefined)).length === 0);
973
+ }
974
+ console.log("== validator: wrapped-text collision + trailing dead space ==");
975
+ {
976
+ const tb = (id, top, height, text, fontSize, extra = {}) => ({
977
+ id, type: "text-block",
978
+ responsive: {
979
+ desktop: { styles: { top, left: 80, width: 560, height, fontSize, ...extra } },
980
+ mobile: { styles: { top, left: 20, width: 380, height, fontSize: Math.round(fontSize * 0.7), ...extra } },
981
+ },
982
+ specials: { text, tag: "p" },
983
+ });
984
+ const sect = (children, height = 800) => ({
985
+ page: [{ id: "csec", type: "section", responsive: { desktop: { styles: { height } }, mobile: { styles: { height } } }, children }],
986
+ settings: { title: "t", description: "d", keywords: "k", lang: "vi" },
987
+ });
988
+ const headline = "The challenges every hotel faces today"; // wraps to 2 lines at 40px/560w
989
+ // 2-line H2 on a 1-line box with the subheading right under the declared box → both checks fire
990
+ const rClash = validatePage(expandSource(sect([tb("h2", 120, 50, headline, 40), tb("sub", 180, 60, "Guests reach out from everywhere and teams are stretched thin.", 16)]), createElement));
991
+ check("collision: 2-line H2 over subheading warned (names the victim)", rClash.warnings.some((w) => w.includes("spill onto") && w.includes("children[1]")), rClash.warnings);
992
+ check("own-box: 2-line H2 on 1-line box no longer slips the one-line slack", rClash.warnings.some((w) => w.includes("children[0]") && w.includes("spill down")), rClash.warnings);
993
+ // properly sized heading + subheading pushed below the estimated bottom → silent
994
+ const rOk = validatePage(expandSource(sect([tb("h2", 120, 112, headline, 40), tb("sub", 260, 60, "Guests reach out from everywhere and teams are stretched thin.", 16)]), createElement));
995
+ check("collision: sized heading + pushed-down subheading → no overlap warning", !rOk.warnings.some((w) => w.includes("spill")), rOk.warnings);
996
+ // layered background rectangle (declared boxes overlap) must NOT count as a victim
997
+ const card = {
998
+ id: "card", type: "group",
999
+ responsive: { desktop: { styles: { top: 100, left: 80, width: 280, height: 300 } }, mobile: { styles: { top: 100, left: 20, width: 280, height: 300 } } },
1000
+ children: [
1001
+ { id: "bg", type: "rectangle", responsive: { desktop: { styles: { top: 0, left: 0, width: 280, height: 300 } }, mobile: { styles: { top: 0, left: 0, width: 280, height: 300 } } } },
1002
+ tb("title", 24, 30, "Guests who book once and disappear forever", 22, { left: 24, width: 232 }),
1003
+ ],
1004
+ };
1005
+ const rCard = validatePage(expandSource(sect([card]), createElement));
1006
+ check("collision: layered card background is not a victim", !rCard.warnings.some((w) => w.includes("spill onto") && w.includes("rectangle")), rCard.warnings);
1007
+ // trailing dead space: section 900 tall, content ends at 300
1008
+ const rDead = validatePage(expandSource(sect([tb("h2", 200, 100, "Short", 40)], 900), createElement));
1009
+ check("dead space: 600px empty band at section bottom warned", rDead.warnings.some((w) => w.includes("empty band")), rDead.warnings);
1010
+ const rTight = validatePage(expandSource(sect([tb("h2", 200, 100, "Short", 40)], 500), createElement));
1011
+ check("dead space: 200px bottom padding not flagged", !rTight.warnings.some((w) => w.includes("empty band")), rTight.warnings);
1012
+ }
924
1013
  console.log(`\n${failures === 0 ? "ALL GOOD" : failures + " FAILURE(S)"}`);
925
1014
  process.exit(failures === 0 ? 0 : 1);
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { z } from "zod";
7
7
  import { sparseTemplate } from "../core/compact.js";
8
- import { text } from "../mcp/response.js";
8
+ import { text, warningsField } from "../mcp/response.js";
9
9
  export function registerGenerationTools(server, domain) {
10
10
  // 5) New element ------------------------------------------------------------
11
11
  server.tool("new_element", "Returns a default element node for a type in the SPARSE authoring shape (fresh id, both breakpoints' seeded styles, seeded specials). Emit elements exactly like this — fill in specials + top/left coordinates; OMIT properties/runtime/empty events/config (the server hydrates them from factory defaults on validate/persist).", {
@@ -23,7 +23,7 @@ export function registerGenerationTools(server, domain) {
23
23
  // 6) New page skeleton ------------------------------------------------------
24
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 })));
25
25
  // 7) Validate page ----------------------------------------------------------
26
- 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) and warnings (advisory).", {
26
+ 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
27
  page: z
28
28
  .any()
29
29
  .describe("The page source object { page:[...], settings:{} } OR a JSON string of it."),
@@ -31,6 +31,6 @@ export function registerGenerationTools(server, domain) {
31
31
  // Hydrate sparse nodes (the model may omit boilerplate) before validating,
32
32
  // so what we check is the same full tree that create_page/add_section persist.
33
33
  const result = domain.validate(domain.expand(page));
34
- return text(result);
34
+ return text({ ...result, ...warningsField(result.warnings) });
35
35
  });
36
36
  }
@@ -24,9 +24,9 @@ import { parseHtml, fetchHtml } from "../persistence/html-ingest.js";
24
24
  const detailParam = z
25
25
  .enum(["compact", "full"])
26
26
  .optional()
27
- .describe("Level of detail in the returned AST. 'compact' (default) — backward-compatible ~2-5 KB shape with top colors/fonts from inline styles. 'full' — richer AST: CSS custom-property palette (design tokens by name), background_images from stylesheets, gradients, per-section blocks (repeating card/tile/step structures with title/body/image/cta), li lists, extended paragraphs, and images as { src, alt } objects. Use 'full' for clone-faithful rebuilds. Image URLs found in the result (images, background_images, og_image) are the user's assets: re-host them via upload_images and reuse them in the generated page for BOTH intents (never hotlink, never replace them with search_images stock photos).");
27
+ .describe("Level of detail in the returned AST. 'compact' (default) — backward-compatible ~2-5 KB shape with top colors/fonts from inline styles. 'full' — richer AST: CSS custom-property palette (design tokens by name), background_images from stylesheets, gradients, per-section blocks (repeating card/tile/step structures with title/body/image/cta), li lists, extended paragraphs, images as { src, alt } objects, and per-section widgets = { hint, html, css? } — the cleaned source HTML + matching CSS of composite visuals (phone/device mockup, chat thread, dashboard, browser frame) to rebuild VERBATIM as ONE html-box (inline the css; don't re-imagine the markup). Use 'full' for clone-faithful rebuilds. Image URLs found in the result (images, background_images, og_image) are the user's assets: re-host them via upload_images and reuse them in the generated page for BOTH intents (never hotlink, never replace them with search_images stock photos).");
28
28
  export function registerIngestTools(server) {
29
- server.tool("ingest_html", "Parses an HTML string into a reference AST: title, description, og_image, language, and sections classified by role (header, hero, features, about, form, cta, gallery, testimonials, pricing, faq, footer, unknown) with headings, subheadings, paragraphs, images, ctas, links, form fields — plus top colors, fonts, CSS custom-property palette, and background_images pulled from both inline styles and <style> blocks. Returns ~2-5KB (compact) or up to ~25KB (full). Use detail:'full' for clone-faithful rebuilds — it adds per-section blocks (cards/tiles/steps), li lists, gradients, and images as { src, alt } objects. Image URLs in the result (images, background_images, og_image) are the user's assets — re-host them via upload_images and reuse them for BOTH intents; use search_images only for slots with no source image.", {
29
+ server.tool("ingest_html", "Parses an HTML string into a reference AST: title, description, og_image, language, and sections classified by role (header, hero, features, about, form, cta, gallery, testimonials, pricing, faq, footer, unknown) with headings, subheadings, paragraphs, images, ctas, links, form fields, and a size_hint (desktop section height in px from the source CSS when explicit, else a content-volume estimate; set the rebuilt section's desktop height from it) — plus top colors, fonts, CSS custom-property palette, and background_images pulled from both inline styles and <style> blocks. Returns ~2-5KB (compact) or up to ~25KB (full). Use detail:'full' for clone-faithful rebuilds — it adds per-section blocks (cards/tiles/steps), li lists, gradients, images as { src, alt } objects, and widgets (the source HTML + CSS of composite mockups, to paste into ONE html-box). Image URLs in the result (images, background_images, og_image) are the user's assets — re-host them via upload_images and reuse them for BOTH intents; use search_images only for slots with no source image.", {
30
30
  html: z.string().describe("Raw HTML of a page or a section."),
31
31
  intent: z
32
32
  .enum(["adapt", "clone"])
@@ -34,7 +34,7 @@ export function registerIngestTools(server) {
34
34
  .describe("How the caller intends to use the result. 'adapt' (default) — use as a layout reference and rewrite the TEXT for the user's brand (images from the reference are still re-hosted via upload_images and reused). 'clone' — keep text and images close to the original."),
35
35
  detail: detailParam,
36
36
  }, { title: "Ingest HTML Reference", readOnlyHint: true, openWorldHint: false }, async ({ html, intent, detail }) => text({ intent: intent ?? "adapt", ...parseHtml(html, detail ?? "compact") }));
37
- server.tool("ingest_url", "Fetches a public webpage (GET, 10s timeout, 2MB cap) and parses it into the same reference AST as ingest_html. Returns a warning when the page appears client-rendered (empty <body>) so the caller can fall back to a screenshot — Claude can analyze a screenshot natively without this tool. Does not execute JavaScript; sites built with React/Vue/Next.js may return little content. Use detail:'full' for clone-faithful rebuilds — adds CSS palette, background_images, per-section blocks, lists, and images as { src, alt } objects. Image URLs in the result are the user's assets — re-host them via upload_images and reuse them for BOTH intents; use search_images only for slots with no source image.", {
37
+ server.tool("ingest_url", "Fetches a public webpage (GET, 10s timeout, 2MB cap) and parses it into the same reference AST as ingest_html (including per-section size_hint desktop heights). Returns a warning when the page appears client-rendered (empty <body>) so the caller can fall back to a screenshot — Claude can analyze a screenshot natively without this tool. Does not execute JavaScript; sites built with React/Vue/Next.js may return little content. Use detail:'full' for clone-faithful rebuilds — adds CSS palette, background_images, per-section blocks, lists, images as { src, alt } objects, and widgets (source HTML + CSS of composite mockups for html-box rebuilds). Image URLs in the result are the user's assets — re-host them via upload_images and reuse them for BOTH intents; use search_images only for slots with no source image.", {
38
38
  url: z.string().describe("Public HTTP(S) URL of the page to fetch."),
39
39
  intent: z
40
40
  .enum(["adapt", "clone"])
@@ -10,7 +10,7 @@
10
10
  * hosted server is multi-user; in stdio/single-user mode they come from env.
11
11
  */
12
12
  import { z } from "zod";
13
- import { text } from "../mcp/response.js";
13
+ import { text, warningsField } from "../mcp/response.js";
14
14
  import { readConfig, configFromHeaders } from "../persistence/config.js";
15
15
  import { buildRequestRedacted, buildUpdateRequestRedacted, buildAppendRequestRedacted, buildPublishRequestRedacted, buildPageApp, createPage, listOrganizations, listPages, searchPages, getPageSource, updatePageSource, appendSection, publishPage, toPreviewUrl, } from "../persistence/webcake-client.js";
16
16
  import { putDraft, getDraft, updateDraft, deleteDraft } from "../persistence/draft-cache.js";
@@ -102,7 +102,7 @@ export function registerPersistenceTools(server, domain) {
102
102
  created: false,
103
103
  reason: "validation_failed",
104
104
  errors: result.errors,
105
- warnings: result.warnings,
105
+ ...warningsField(result.warnings),
106
106
  draft_id: existingDraftId,
107
107
  hint: "Do NOT rebuild the whole source — it is cached as draft_id. Fix ONLY the listed elements with patch_page({ draft_id, patches:[…], dry_run:false }); it re-validates the merged tree and creates the page. A wrong element type → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements/get_element if unsure of the exact type name). The draft expires in ~30 min.",
108
108
  });
@@ -143,7 +143,7 @@ export function registerPersistenceTools(server, domain) {
143
143
  }
144
144
  return text({
145
145
  dry_run: true,
146
- validation: { valid: true, warnings: result.warnings, stats: result.stats },
146
+ validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
147
147
  ...(largePageAdvisory ? { large_page_advisory: largePageAdvisory } : {}),
148
148
  env_ready: missing.length === 0,
149
149
  missing_env: missing,
@@ -241,7 +241,7 @@ export function registerPersistenceTools(server, domain) {
241
241
  return text({
242
242
  created: true,
243
243
  ...outcome,
244
- warnings: result.warnings,
244
+ ...warningsField(result.warnings),
245
245
  ...(organizationAutoSelected ? { organization_auto_selected: true } : {}),
246
246
  ...(organizationNote ? { note: organizationNote } : {}),
247
247
  });
@@ -251,7 +251,7 @@ export function registerPersistenceTools(server, domain) {
251
251
  return text({
252
252
  created: false,
253
253
  ...outcome,
254
- warnings: result.warnings,
254
+ ...warningsField(result.warnings),
255
255
  draft_id: existingDraftId,
256
256
  hint: `Create failed — source is cached. Retry via create_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ draft_id: "${existingDraftId}", patches:[…], dry_run:false }). The draft expires in ~30 min.`,
257
257
  });
@@ -392,7 +392,7 @@ export function registerPersistenceTools(server, domain) {
392
392
  updated: false,
393
393
  reason: "validation_failed",
394
394
  errors: result.errors,
395
- warnings: result.warnings,
395
+ ...warningsField(result.warnings),
396
396
  draft_id: existingDraftId,
397
397
  hint: `Fix the errors, then retry update_page({ draft_id: "${existingDraftId}", dry_run:false }) — no need to re-send source. Or use patch_page({ page_id: "${resolvedPageId}", patches:[…] }) for surgical fixes.`,
398
398
  });
@@ -411,7 +411,7 @@ export function registerPersistenceTools(server, domain) {
411
411
  return text({
412
412
  dry_run: true,
413
413
  page_id: resolvedPageId,
414
- validation: { valid: true, warnings: result.warnings, stats: result.stats },
414
+ validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
415
415
  env_ready: missing.length === 0,
416
416
  missing_env: missing,
417
417
  draft_id: existingDraftId,
@@ -433,13 +433,13 @@ export function registerPersistenceTools(server, domain) {
433
433
  const outcome = await updatePageSource(config, resolvedPageId, parsed);
434
434
  if (outcome.ok) {
435
435
  deleteDraft(existingDraftId);
436
- return text({ updated: true, ...outcome, warnings: result.warnings });
436
+ return text({ updated: true, ...outcome, ...warningsField(result.warnings) });
437
437
  }
438
438
  updateDraft(existingDraftId, expanded);
439
439
  return text({
440
440
  updated: false,
441
441
  ...outcome,
442
- warnings: result.warnings,
442
+ ...warningsField(result.warnings),
443
443
  draft_id: existingDraftId,
444
444
  hint: `Update failed — source is cached. Retry via update_page({ draft_id: "${existingDraftId}", dry_run: false }) or fix elements via patch_page({ page_id: "${resolvedPageId}", patches:[…] }). The draft expires in ~30 min.`,
445
445
  });
@@ -569,7 +569,7 @@ export function registerPersistenceTools(server, domain) {
569
569
  added: false,
570
570
  reason: "validation_failed",
571
571
  errors: result.errors,
572
- warnings: result.warnings,
572
+ ...warningsField(result.warnings),
573
573
  draft_id: existingDraftId,
574
574
  hint: "Do NOT rebuild the section batch — it is cached as draft_id. Fix ONLY the listed elements with patch_page({ draft_id, patches:[…], dry_run:false }); it re-validates the merged shell and appends the sections. A wrong element type → { op:'update', id:'<element id>', type:'<allowed type>' }. The draft expires in ~30 min.",
575
575
  });
@@ -597,7 +597,7 @@ export function registerPersistenceTools(server, domain) {
597
597
  page_id,
598
598
  sections_added: newSections.length,
599
599
  section_labels: labels,
600
- validation: { valid: true, warnings: result.warnings, stats: result.stats },
600
+ validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
601
601
  draft_id: existingDraftId,
602
602
  request: buildAppendRequestRedacted(config, page_id, newSections),
603
603
  note: "The backend appends these to the END of `page` and rejects duplicate element ids across the live tree.",
@@ -632,7 +632,7 @@ export function registerPersistenceTools(server, domain) {
632
632
  preview_url: outcome.preview_url,
633
633
  status: outcome.status,
634
634
  error: outcome.error,
635
- warnings: result.warnings,
635
+ ...warningsField(result.warnings),
636
636
  ...(outcome.ok ? {} : {
637
637
  draft_id: existingDraftId,
638
638
  hint: "Append failed — the section batch is still cached. Fix the listed error (e.g. a duplicate id vs the live tree can be changed via patch_page({ draft_id, patches:[{op:'replace',id:'<old-id>',element:{…,id:'<new-id>'}}] })) then retry add_section({ page_id, draft_id, dry_run:false }).",
@@ -669,7 +669,7 @@ export function registerPersistenceTools(server, domain) {
669
669
  added: false,
670
670
  reason: "validation_failed",
671
671
  errors: mergedResult.errors,
672
- warnings: mergedResult.warnings,
672
+ ...warningsField(mergedResult.warnings),
673
673
  page_section_count: counts,
674
674
  hint: "Fix the section(s) — duplicate ids vs existing sections are a common cause — then retry.",
675
675
  });
@@ -689,7 +689,7 @@ export function registerPersistenceTools(server, domain) {
689
689
  preview_url: fbOutcome.preview_url,
690
690
  status: fbOutcome.status,
691
691
  error: fbOutcome.error,
692
- warnings: mergedResult.warnings,
692
+ ...warningsField(mergedResult.warnings),
693
693
  });
694
694
  });
695
695
  // 14) Patch page (surgical element edit / fix-after-error) -------------------
@@ -928,7 +928,7 @@ export function registerPersistenceTools(server, domain) {
928
928
  patched: false,
929
929
  reason: "validation_failed",
930
930
  errors: result.errors,
931
- warnings: result.warnings,
931
+ ...warningsField(result.warnings),
932
932
  patches_applied: applied,
933
933
  draft_id,
934
934
  hint: "Still invalid — fix the remaining errors with another patch_page({ draft_id, patches:[…] }). Your applied fixes are kept in the draft.",
@@ -954,7 +954,7 @@ export function registerPersistenceTools(server, domain) {
954
954
  draft_id,
955
955
  page_id: targetPageId,
956
956
  patches_applied: applied,
957
- validation: { valid: true, warnings: result.warnings, stats: result.stats },
957
+ validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
958
958
  env_ready: missing.length === 0,
959
959
  missing_env: missing,
960
960
  request: config
@@ -985,7 +985,7 @@ export function registerPersistenceTools(server, domain) {
985
985
  preview_url: outcome.preview_url,
986
986
  status: outcome.status,
987
987
  error: outcome.error,
988
- warnings: result.warnings,
988
+ ...warningsField(result.warnings),
989
989
  ...(outcome.ok ? {} : { draft_id, hint: `Append failed — fixes kept in draft. Retry patch_page({ draft_id: "${draft_id}", dry_run:false }) after resolving the error.` }),
990
990
  });
991
991
  }
@@ -1008,7 +1008,7 @@ export function registerPersistenceTools(server, domain) {
1008
1008
  draft_id,
1009
1009
  page_id: targetPageId,
1010
1010
  patches_applied: applied,
1011
- validation: { valid: true, warnings: result.warnings, stats: result.stats },
1011
+ validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
1012
1012
  env_ready: missing.length === 0,
1013
1013
  missing_env: missing,
1014
1014
  request: config
@@ -1038,7 +1038,7 @@ export function registerPersistenceTools(server, domain) {
1038
1038
  preview_url: outcome.preview_url,
1039
1039
  status: outcome.status,
1040
1040
  error: outcome.error,
1041
- warnings: result.warnings,
1041
+ ...warningsField(result.warnings),
1042
1042
  ...(outcome.ok ? {} : { draft_id, hint: `Update failed — fixes kept in draft. Retry patch_page({ draft_id: "${draft_id}", dry_run:false }) after resolving the error.` }),
1043
1043
  });
1044
1044
  }
@@ -1049,7 +1049,7 @@ export function registerPersistenceTools(server, domain) {
1049
1049
  dry_run: true,
1050
1050
  draft_id,
1051
1051
  patches_applied: applied,
1052
- validation: { valid: true, warnings: result.warnings, stats: result.stats },
1052
+ validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
1053
1053
  env_ready: missing.length === 0,
1054
1054
  missing_env: missing,
1055
1055
  request: config
@@ -1081,7 +1081,7 @@ export function registerPersistenceTools(server, domain) {
1081
1081
  preview_url: outcome.preview_url,
1082
1082
  status: outcome.status,
1083
1083
  error: outcome.error,
1084
- warnings: result.warnings,
1084
+ ...warningsField(result.warnings),
1085
1085
  ...(outcome.ok ? {} : { draft_id, hint: `Create failed — fixes kept in draft. Retry patch_page({ draft_id: "${draft_id}", dry_run:false }) or resolve the error first.` }),
1086
1086
  });
1087
1087
  }
@@ -1091,7 +1091,7 @@ export function registerPersistenceTools(server, domain) {
1091
1091
  patched: false,
1092
1092
  reason: "validation_failed",
1093
1093
  errors: result.errors,
1094
- warnings: result.warnings,
1094
+ ...warningsField(result.warnings),
1095
1095
  patches_applied: applied,
1096
1096
  hint: "The edit produced an invalid tree — fix the listed errors in your ops, then retry.",
1097
1097
  });
@@ -1105,7 +1105,7 @@ export function registerPersistenceTools(server, domain) {
1105
1105
  dry_run: true,
1106
1106
  page_id,
1107
1107
  patches_applied: applied,
1108
- validation: { valid: true, warnings: result.warnings, stats: result.stats },
1108
+ validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
1109
1109
  draft_id: liveDraftId,
1110
1110
  request: buildUpdateRequestRedacted(config, page_id, parsed),
1111
1111
  hint: `Re-run patch_page({ draft_id: "${liveDraftId}", dry_run: false }) — no need to re-send patches. Or re-run with page_id + dry_run:false.`,
@@ -1121,7 +1121,7 @@ export function registerPersistenceTools(server, domain) {
1121
1121
  editor_url: outcome.editor_url,
1122
1122
  preview_url: outcome.preview_url,
1123
1123
  status: outcome.status,
1124
- warnings: result.warnings,
1124
+ ...warningsField(result.warnings),
1125
1125
  });
1126
1126
  }
1127
1127
  // Network failure / timeout: keep the update draft for retry.
@@ -1132,7 +1132,7 @@ export function registerPersistenceTools(server, domain) {
1132
1132
  page_id: outcome.page_id ?? page_id,
1133
1133
  status: outcome.status,
1134
1134
  error: outcome.error,
1135
- warnings: result.warnings,
1135
+ ...warningsField(result.warnings),
1136
1136
  draft_id: liveDraftId,
1137
1137
  hint: `Save failed — the patched source is cached. Retry via patch_page({ draft_id: "${liveDraftId}", dry_run: false }) with no patches. The draft expires in ~30 min.`,
1138
1138
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.60",
3
+ "version": "1.0.62",
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",