webcake-landing-mcp 1.0.84 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.1.0",
4
+ "d": "20/06/2026",
5
+ "type": "Added",
6
+ "en": "create_page, add_section, and validate_page now apply a deterministic layout auto-fix before validating or saving: off-canvas children are pulled…",
7
+ "vi": "create_page, add_section và validate_page nay tự động áp dụng một lần auto-fix layout xác định trước khi validate hoặc lưu: các phần tử con nằm…"
8
+ },
2
9
  {
3
10
  "v": "1.0.84",
4
11
  "d": "17/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Added",
34
41
  "en": "New render_preview tool screenshots a page's /preview/<id> or any public URL and returns a PNG the model can see, enabling a…",
35
42
  "vi": "Tool mới render_preview chụp màn hình /preview/<id> của một trang hoặc bất kỳ URL công khai nào và trả về ảnh PNG để model có thể NHÌN THẤY kết quả,…"
36
- },
37
- {
38
- "v": "1.0.79",
39
- "d": "15/06/2026",
40
- "type": "Changed",
41
- "en": "The Privacy Policy served at /privacy is updated with GDPR-style data-category headings, per-category purpose statements, documentation of…",
42
- "vi": "Trang Privacy Policy tại /privacy được cập nhật với các tiêu đề phân loại dữ liệu theo chuẩn GDPR, ghi rõ mục đích xử lý từng loại dữ liệu, bổ sung…"
43
43
  }
44
44
  ]
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Deterministic LAYOUT auto-fix — APPLIES the corrections the validator already
3
+ * computes, so the dominant build loop "validate → read warnings → patch_page →
4
+ * re-validate → re-publish" collapses to zero round-trips on the two most common
5
+ * layout defects:
6
+ *
7
+ * 1. Off-canvas boxes — a child whose box runs past the canvas edge is pulled
8
+ * back on-canvas (left clamped to [0, canvasW − width]; negative top → 0).
9
+ * 2. Wrapped-text overlap — a text-block renders height:AUTO from `top` (the
10
+ * renderer IGNORES the declared height), so text that wraps to more lines
11
+ * than the author assumed spills DOWN onto the element below. The ONLY real
12
+ * fix is to MOVE the elements below down — which is exactly what the reflow
13
+ * does, measuring the real rendered height with the SAME font metrics
14
+ * (estTextHeightPx) the validator warns with. A container is grown to
15
+ * contain its reflowed content.
16
+ *
17
+ * Runs AFTER expand (on the full hydrated tree) and BEFORE validate/persist, in
18
+ * the build-a-new-page tools (create_page, add_section, validate_page). It
19
+ * MUTATES the tree in place and returns a human-readable list of every change so
20
+ * the correction is transparent — never a silent move. Conservative by design:
21
+ * - only ever ADDS vertical whitespace / pulls boxes inward, never removes;
22
+ * - skips intentional layering (declared-overlapping boxes — badges, card
23
+ * backdrops, image-behind-text) using the validator's own gate;
24
+ * - idempotent: a second pass over a fixed tree is a no-op.
25
+ *
26
+ * The validator still reports anything autofix can't safely resolve (e.g. a box
27
+ * wider than the canvas, cross-column card-height mismatches) as warnings.
28
+ */
29
+ import { estTextHeightPx } from "./text-metrics.js";
30
+ const CANVAS_DESKTOP = 960;
31
+ const CANVAS_MOBILE = 420;
32
+ const DEFAULT_SECTION_HEIGHT = 800;
33
+ const MIN_GAP = 8; // px breathing room kept between a wrapped block and the one below
34
+ const TOL = 1; // px rounding tolerance
35
+ const MAX_FIXES = 40; // cap the reported list so a pathological page can't flood the response
36
+ const BPS = ["desktop", "mobile"];
37
+ /** Coerce a style value (number or "300px"/"300") to a finite number, else undefined. */
38
+ function num(v) {
39
+ if (typeof v === "number")
40
+ return Number.isFinite(v) ? v : undefined;
41
+ if (typeof v === "string") {
42
+ const n = parseFloat(v);
43
+ return Number.isFinite(n) ? n : undefined;
44
+ }
45
+ return undefined;
46
+ }
47
+ /** Material Symbols / Font Awesome single glyph — one glyph, not wrapping text (skip measuring). */
48
+ function isIconGlyph(rawText) {
49
+ return (typeof rawText === "string" &&
50
+ (/\b(material-symbols|material-icons)\b/.test(rawText) || /<i\b[^>]*\bfa-/.test(rawText)));
51
+ }
52
+ function idOf(node) {
53
+ return typeof node?.id === "string" && node.id ? node.id : node?.type ?? "?";
54
+ }
55
+ /**
56
+ * Pull one child back on-canvas at a breakpoint (horizontal + negative-top only).
57
+ * A box WIDER than the canvas can't be clamped without resizing — left for the
58
+ * validator to warn about.
59
+ */
60
+ function clampChild(child, bp, canvasW, fixes) {
61
+ const styles = child?.responsive?.[bp]?.styles;
62
+ if (!styles || typeof styles !== "object")
63
+ return;
64
+ const left = num(styles.left);
65
+ const width = num(styles.width);
66
+ const top = num(styles.top);
67
+ if (left != null && left < -TOL) {
68
+ styles.left = 0;
69
+ pushFix(fixes, `"${idOf(child)}" [${bp}]: off-canvas left=${left} → pulled to 0.`);
70
+ }
71
+ else if (left != null && width != null && width <= canvasW + TOL && left + width > canvasW + TOL) {
72
+ const fixed = Math.round(canvasW - width);
73
+ styles.left = fixed;
74
+ pushFix(fixes, `"${idOf(child)}" [${bp}]: ran off the right edge (left+width=${left + width} > ${canvasW}) → moved left to ${fixed}.`);
75
+ }
76
+ if (top != null && top < -TOL) {
77
+ styles.top = 0;
78
+ pushFix(fixes, `"${idOf(child)}" [${bp}]: negative top=${top} → pulled to 0.`);
79
+ }
80
+ }
81
+ /**
82
+ * Push siblings DOWN so no element sits inside the spill of a wrapped text-block
83
+ * above it. Uses ORIGINAL declared boxes to decide intentional layering (skip)
84
+ * and CURRENT positions + effective heights to decide clearance. Single
85
+ * top-to-bottom pass: each element's top is resolved once against finalized
86
+ * priors, so it converges and only moves elements down.
87
+ */
88
+ function reflowChildren(kids, effH, origH, bp, fixes) {
89
+ const items = kids
90
+ .map((k) => {
91
+ const s = k?.responsive?.[bp]?.styles ?? {};
92
+ const top = num(s.top);
93
+ if (top == null)
94
+ return null;
95
+ return {
96
+ k,
97
+ origTop: top,
98
+ cur: top,
99
+ left: num(s.left) ?? 0,
100
+ w: num(s.width) ?? 0,
101
+ // The ORIGINAL declared height decides intentional layering — NOT the
102
+ // height a text-block leaf may have just been resized to (that would
103
+ // make a too-short box look like it "contains" the element below it).
104
+ declaredH: origH.get(k) ?? 0,
105
+ eff: effH.get(k) ?? num(s.height) ?? 0,
106
+ };
107
+ })
108
+ .filter((x) => x != null)
109
+ .sort((a, b) => a.origTop - b.origTop);
110
+ for (let i = 0; i < items.length; i++) {
111
+ const b = items[i];
112
+ let required = b.cur;
113
+ for (let j = 0; j < i; j++) {
114
+ const a = items[j];
115
+ // intentional layering (badge over rect, image behind text…): the author
116
+ // declared b inside a's declared box → leave it alone.
117
+ if (b.origTop < a.origTop + a.declaredH - TOL)
118
+ continue;
119
+ // only a sibling in the same horizontal column can be hit by the spill.
120
+ const intersects = a.left < b.left + b.w - TOL && b.left < a.left + a.w - TOL;
121
+ if (!intersects)
122
+ continue;
123
+ required = Math.max(required, a.cur + a.eff + MIN_GAP);
124
+ }
125
+ if (required > b.cur + TOL) {
126
+ const moved = Math.round(required - b.cur);
127
+ b.cur = Math.round(required);
128
+ b.k.responsive[bp].styles.top = b.cur;
129
+ pushFix(fixes, `"${idOf(b.k)}" [${bp}]: pushed down ${moved}px (top ${b.origTop}→${b.cur}) to clear wrapped text above it.`);
130
+ }
131
+ }
132
+ }
133
+ /**
134
+ * Settle one node's EFFECTIVE rendered height at a breakpoint (post-order):
135
+ * recurse so child boxes settle first, clamp + reflow this node's direct
136
+ * children, then grow this node's own height to contain them. Returns the
137
+ * effective height the PARENT should use for this node when it reflows.
138
+ */
139
+ function processNode(node, bp, canvasW, pageFont, fixes) {
140
+ if (!node || typeof node !== "object")
141
+ return 0;
142
+ const styles = node?.responsive?.[bp]?.styles ?? {};
143
+ const ownW = num(styles.width) ?? canvasW;
144
+ const ownH = num(styles.height);
145
+ const kids = Array.isArray(node.children) ? node.children.filter((k) => k && typeof k === "object") : [];
146
+ if (kids.length === 0) {
147
+ // Leaf: a text-block renders at its measured height regardless of declared
148
+ // height. Resize the declared box to match (clears the own-box warning) and
149
+ // report it as the effective height the parent reflows against.
150
+ if (node.type === "text-block" && !isIconGlyph(node.specials?.text)) {
151
+ const est = estTextHeightPx(node.specials?.text, styles, pageFont);
152
+ if (est != null) {
153
+ const h = num(styles.height);
154
+ const fs = num(styles.fontSize) ?? 16;
155
+ if (h != null && est > h + Math.min(fs * 1.4, 24)) {
156
+ styles.height = est;
157
+ pushFix(fixes, `"${idOf(node)}" [${bp}]: resized height ${h}→${est} to fit wrapped text (real font metrics).`);
158
+ }
159
+ return est;
160
+ }
161
+ }
162
+ return ownH ?? 0;
163
+ }
164
+ // Capture ORIGINAL declared heights before recursion mutates any (text leaves
165
+ // get resized to their measured height) — the reflow's layering test needs the
166
+ // author's intended box, not the corrected one.
167
+ const origH = new Map();
168
+ for (const k of kids)
169
+ origH.set(k, num(k?.responsive?.[bp]?.styles?.height));
170
+ // 1) post-order: settle each child's effective height first.
171
+ const eff = new Map();
172
+ for (const k of kids) {
173
+ const childCanvasW = num(k?.responsive?.[bp]?.styles?.width) ?? ownW;
174
+ eff.set(k, processNode(k, bp, childCanvasW, pageFont, fixes));
175
+ }
176
+ // 2) pull each child on-canvas (horizontal), then 3) reflow them downward.
177
+ for (const k of kids)
178
+ clampChild(k, bp, ownW, fixes);
179
+ reflowChildren(kids, eff, origH, bp, fixes);
180
+ // 4) grow this container to contain its (reflowed) children.
181
+ let maxBottom = 0;
182
+ for (const k of kids) {
183
+ const t = num(k?.responsive?.[bp]?.styles?.top) ?? 0;
184
+ maxBottom = Math.max(maxBottom, t + (eff.get(k) ?? 0));
185
+ }
186
+ if (ownH != null && Math.ceil(maxBottom) > ownH + TOL) {
187
+ const grown = Math.ceil(maxBottom);
188
+ node.responsive[bp].styles.height = grown;
189
+ pushFix(fixes, `"${idOf(node)}" [${bp}]: grew height ${ownH}→${grown} to contain its content.`);
190
+ return grown;
191
+ }
192
+ return ownH != null ? ownH : Math.ceil(maxBottom);
193
+ }
194
+ function pushFix(fixes, msg) {
195
+ if (fixes.length < MAX_FIXES)
196
+ fixes.push(msg);
197
+ else if (fixes.length === MAX_FIXES)
198
+ fixes.push("…(more layout fixes applied — re-fetch with get_page to see the final coordinates).");
199
+ }
200
+ /**
201
+ * Apply the deterministic layout fixes to a (already-expanded) page source IN
202
+ * PLACE and return the list of changes. Tolerant — a non-object or a tree with
203
+ * no fixable defects returns an empty list. The canvas width comes from
204
+ * settings.width_section (defaults 960/420), the page font from
205
+ * settings.fontGeneral.
206
+ */
207
+ export function autofixLayout(source) {
208
+ if (!source || typeof source !== "object")
209
+ return [];
210
+ const fixes = [];
211
+ const pageFont = source?.settings?.fontGeneral;
212
+ const ws = source?.settings?.width_section ?? {};
213
+ const canvasFor = {
214
+ desktop: num(ws.desktop) ?? CANVAS_DESKTOP,
215
+ mobile: num(ws.mobile) ?? CANVAS_MOBILE,
216
+ };
217
+ const roots = [];
218
+ for (const key of ["page", "popup"]) {
219
+ if (Array.isArray(source[key]))
220
+ roots.push(...source[key]);
221
+ }
222
+ for (const top of roots) {
223
+ if (!top || typeof top !== "object")
224
+ continue;
225
+ for (const bp of BPS) {
226
+ // A top-level section/popup has no top/left to clamp; process its subtree.
227
+ processNode(top, bp, canvasFor[bp], pageFont, fixes);
228
+ }
229
+ }
230
+ return fixes;
231
+ }
@@ -5,6 +5,7 @@ import { LIBRARY, ELEMENT_TYPES, CONTAINER_TYPES, FIELD_TYPES, createElement } f
5
5
  import { createPageSource } from "./page.js";
6
6
  import { canvasToPageSource } from "./canvas-to-source.js";
7
7
  import { validatePage, coercePage, pageSchema } from "./validate.js";
8
+ import { autofixLayout } from "./autofix-layout.js";
8
9
  import { expandSource } from "../../core/expand.js";
9
10
  import { compactSource } from "../../core/compact.js";
10
11
  /** The payload returned by the get_generation_guide tool. */
@@ -269,5 +270,15 @@ export const landingDomain = {
269
270
  }
270
271
  },
271
272
  schema: pageSchema,
273
+ // Applies the deterministic layout fixes IN PLACE to an already-expanded tree
274
+ // (off-canvas clamp + wrapped-text downward reflow) and returns the change log.
275
+ autofixLayout: (input) => {
276
+ try {
277
+ return autofixLayout(input);
278
+ }
279
+ catch {
280
+ return []; // never let a layout-fix corner case block the build
281
+ }
282
+ },
272
283
  canvasToSource: (canvas, meta) => canvasToPageSource(canvas, meta),
273
284
  };
@@ -42,3 +42,14 @@ export const WARNINGS_NOTICE = "FIX THESE WARNINGS — each one is a visible def
42
42
  export function warningsField(warnings) {
43
43
  return warnings && warnings.length > 0 ? { warnings, warnings_notice: WARNINGS_NOTICE } : {};
44
44
  }
45
+ /**
46
+ * Directive shipped alongside an auto-fix change list. Unlike warnings, these
47
+ * defects were ALREADY corrected deterministically on this call (positions /
48
+ * heights changed in the saved tree), so the model needs no action — just
49
+ * awareness that coordinates moved.
50
+ */
51
+ export const AUTO_FIXED_NOTICE = "These layout defects were auto-corrected on this call (off-canvas boxes pulled on-canvas; elements below wrapped text pushed down to clear the spill; containers grown to fit). The new coordinates/heights are what got validated and saved — no action needed. If you re-emit this source later, keep these positions (or re-fetch with get_page) rather than reverting to the originals.";
52
+ /** Spread helper: {} when nothing was auto-fixed, else { auto_fixed, auto_fixed_notice }. */
53
+ export function autoFixedField(autoFixed) {
54
+ return autoFixed && autoFixed.length > 0 ? { auto_fixed: autoFixed, auto_fixed_notice: AUTO_FIXED_NOTICE } : {};
55
+ }
package/dist/smoke.js CHANGED
@@ -833,7 +833,7 @@ check("clone: html-box passthrough renames builder classes (ladi-html-code → w
833
833
  console.log("== expand: image-block published background derives from specials.src (placeholder seed must not win) ==");
834
834
  {
835
835
  const mk = (src) => ({
836
- page: [{ id: "s", type: "section", responsive: { desktop: { styles: { height: 300 } }, mobile: { styles: { height: 300 } } }, children: [
836
+ page: [{ id: "lsec1", type: "section", responsive: { desktop: { styles: { height: 300 } }, mobile: { styles: { height: 300 } } }, children: [
837
837
  { id: "im", type: "image-block", responsive: { desktop: { styles: { top: 0, left: 0, width: 100, height: 80 } }, mobile: { styles: { top: 0, left: 0, width: 100, height: 80 } } }, specials: { src } },
838
838
  ] }], popup: [], settings: {}, options: {}, cartConfigs: {},
839
839
  });
@@ -1636,14 +1636,14 @@ console.log("== validator: pill/badge label alignment ==");
1636
1636
  responsive: { desktop: { styles: { height: 400 } }, mobile: { styles: { height: 400 } } },
1637
1637
  children: [
1638
1638
  {
1639
- id: "pill", type: "rectangle",
1639
+ id: "pill1", type: "rectangle",
1640
1640
  responsive: {
1641
1641
  desktop: { styles: { top: 100, left: 330, width: 300, height: 36, borderRadius: "999px", background: "rgba(59,130,246,0.15)", ...pillOpts } },
1642
1642
  mobile: { styles: { top: 100, left: 60, width: 300, height: 36, borderRadius: "999px", background: "rgba(59,130,246,0.15)", ...pillOpts } },
1643
1643
  },
1644
1644
  },
1645
1645
  {
1646
- id: "label", type: "text-block",
1646
+ id: "label1", type: "text-block",
1647
1647
  responsive: {
1648
1648
  desktop: { styles: { top: textTop, left: textLeft, width: textW, height: 20, fontSize: 14, fontWeight: 600, textAlign: "center", ...textOpts } },
1649
1649
  mobile: { styles: { top: textTop, left: textLeft - 270, width: textW, height: 20, fontSize: 14, fontWeight: 600, textAlign: "center", ...textOpts } },
@@ -1836,5 +1836,82 @@ console.log("== upload_images: localContentType (ext + magic, pure offline) ==")
1836
1836
  check("isAllowedScreenshotUrl: localhost rejected", !isAllowedScreenshotUrl("http://localhost:5800/preview/1").ok);
1837
1837
  check("isAllowedScreenshotUrl: garbage rejected", !isAllowedScreenshotUrl("not a url").ok);
1838
1838
  }
1839
+ console.log("== autofix-layout: clamps off-canvas + reflows wrapped-text overlap (reported, idempotent) ==");
1840
+ {
1841
+ // Hero with: a too-short H1 box whose long text WRAPS (renderer height:auto, so
1842
+ // it spills onto the subheading right below), plus an off-canvas CTA. autofix
1843
+ // should push the subheading down and pull the CTA back on-canvas — clearing
1844
+ // the very warnings the validator would otherwise emit.
1845
+ const page = {
1846
+ page: [
1847
+ {
1848
+ id: "heroSec", type: "section",
1849
+ responsive: { desktop: { styles: { height: 400, background: "rgba(17,24,39,1)" } }, mobile: { styles: { height: 400, background: "rgba(17,24,39,1)" } } },
1850
+ children: [
1851
+ {
1852
+ id: "head1", type: "text-block",
1853
+ responsive: {
1854
+ desktop: { styles: { top: 40, left: 80, width: 300, height: 40, fontSize: 40, fontWeight: "bold", color: "rgba(255,255,255,1)" } },
1855
+ mobile: { styles: { top: 40, left: 20, width: 380, height: 40, fontSize: 30, fontWeight: "bold", color: "rgba(255,255,255,1)" } },
1856
+ },
1857
+ specials: { text: "This headline is intentionally long enough to wrap onto several lines inside a narrow box", tag: "h1" },
1858
+ },
1859
+ {
1860
+ id: "subh1", type: "text-block",
1861
+ responsive: {
1862
+ desktop: { styles: { top: 92, left: 80, width: 300, height: 24, fontSize: 16, color: "rgba(255,255,255,1)" } },
1863
+ mobile: { styles: { top: 92, left: 20, width: 380, height: 24, fontSize: 16, color: "rgba(255,255,255,1)" } },
1864
+ },
1865
+ specials: { text: "Short subheading", tag: "p" },
1866
+ },
1867
+ {
1868
+ id: "cta1", type: "button",
1869
+ responsive: {
1870
+ desktop: { styles: { top: 320, left: 900, width: 160, height: 44, background: "rgba(246,4,87,1)" } },
1871
+ mobile: { styles: { top: 320, left: 40, width: 160, height: 44, background: "rgba(246,4,87,1)" } },
1872
+ },
1873
+ specials: { text: "Go" },
1874
+ },
1875
+ ],
1876
+ },
1877
+ ],
1878
+ settings: { title: "t", description: "d", keywords: "k", lang: "vi", fontGeneral: "Roboto" },
1879
+ };
1880
+ const expanded = landingDomain.expand(page);
1881
+ const subTopBefore = expanded.page[0].children[1].responsive.desktop.styles.top;
1882
+ const fixes = landingDomain.autofixLayout(expanded);
1883
+ check("autofix: returns a non-empty change list", fixes.length > 0, fixes);
1884
+ const subTopAfter = expanded.page[0].children[1].responsive.desktop.styles.top;
1885
+ check("autofix: pushed the subheading below the wrapped headline", subTopAfter > subTopBefore, { subTopBefore, subTopAfter });
1886
+ const ctaLeftAfter = expanded.page[0].children[2].responsive.desktop.styles.left;
1887
+ check("autofix: pulled the off-canvas CTA on-canvas (left+width ≤ 960)", ctaLeftAfter + 160 <= 960, ctaLeftAfter);
1888
+ const post = validatePage(expanded);
1889
+ check("autofix: result still validates", post.valid, post.errors);
1890
+ check("autofix: cleared the wrapped-text spill + off-canvas warnings", !post.warnings.some((w) => /spill onto|exceeds canvas/.test(w)), post.warnings);
1891
+ // Idempotent: a second pass over the fixed tree changes nothing.
1892
+ const fixes2 = landingDomain.autofixLayout(expanded);
1893
+ check("autofix: idempotent (second pass is a no-op)", fixes2.length === 0, fixes2);
1894
+ // No-op on an already-correct page (the canonical `good` fixture).
1895
+ const cleanFixes = landingDomain.autofixLayout(landingDomain.expand(good));
1896
+ check("autofix: no changes on an already-valid page", cleanFixes.length === 0, cleanFixes);
1897
+ // Intentional layering (a label declared INSIDE a pill rectangle's box) is NOT
1898
+ // treated as an overlap to reflow.
1899
+ const layered = {
1900
+ page: [
1901
+ {
1902
+ id: "lsec1", type: "section",
1903
+ responsive: { desktop: { styles: { height: 300, background: "rgba(255,255,255,1)" } }, mobile: { styles: { height: 300, background: "rgba(255,255,255,1)" } } },
1904
+ children: [
1905
+ { id: "pill1", type: "rectangle", responsive: { desktop: { styles: { top: 40, left: 80, width: 160, height: 36, background: "rgba(0,88,188,1)", borderRadius: "18px" } }, mobile: { styles: { top: 40, left: 20, width: 160, height: 36, background: "rgba(0,88,188,1)", borderRadius: "18px" } } }, specials: {} },
1906
+ { id: "label1", type: "text-block", responsive: { desktop: { styles: { top: 48, left: 96, width: 130, height: 20, fontSize: 14, color: "rgba(255,255,255,1)", textAlign: "center" } }, mobile: { styles: { top: 48, left: 36, width: 130, height: 20, fontSize: 14, color: "rgba(255,255,255,1)", textAlign: "center" } } }, specials: { text: "NEW", tag: "span" } },
1907
+ ],
1908
+ },
1909
+ ],
1910
+ settings: { title: "t", description: "d", keywords: "k", lang: "vi", fontGeneral: "Roboto" },
1911
+ };
1912
+ const layExp = landingDomain.expand(layered);
1913
+ landingDomain.autofixLayout(layExp);
1914
+ check("autofix: leaves intentional layering (label over pill) in place", layExp.page[0].children[1].responsive.desktop.styles.top === 48, layExp.page[0].children[1].responsive.desktop.styles);
1915
+ }
1839
1916
  console.log(`\n${failures === 0 ? "ALL GOOD" : failures + " FAILURE(S)"}`);
1840
1917
  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, warningsField } from "../mcp/response.js";
8
+ import { text, warningsField, autoFixedField } 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).", {
@@ -32,14 +32,18 @@ export function registerGenerationTools(server, domain) {
32
32
  : undefined,
33
33
  })));
34
34
  // 7) Validate page ----------------------------------------------------------
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).", {
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). FIRST auto-fixes the layout defects that can be resolved deterministically (off-canvas boxes pulled on-canvas; elements below wrapped text pushed down to clear the spill — the same corrections create_page/add_section apply on save) and reports them in auto_fixed. Then 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).", {
36
36
  page: z
37
37
  .any()
38
38
  .describe("The page source object { page:[...], settings:{} } OR a JSON string of it."),
39
39
  }, { title: "Validate Page Source", readOnlyHint: true, openWorldHint: false }, async ({ page }) => {
40
40
  // Hydrate sparse nodes (the model may omit boilerplate) before validating,
41
41
  // so what we check is the same full tree that create_page/add_section persist.
42
- const result = domain.validate(domain.expand(page));
43
- return text({ ...result, ...warningsField(result.warnings) });
42
+ const expanded = domain.expand(page);
43
+ // Apply the same deterministic layout auto-fix create_page/add_section run,
44
+ // so validate reflects (and reports) the tree that would actually be saved.
45
+ const autoFixed = domain.autofixLayout?.(expanded) ?? [];
46
+ const result = domain.validate(expanded);
47
+ return text({ ...result, ...autoFixedField(autoFixed), ...warningsField(result.warnings) });
44
48
  });
45
49
  }
@@ -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, warningsField } from "../mcp/response.js";
13
+ import { text, warningsField, autoFixedField } 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";
@@ -71,7 +71,7 @@ export function registerPersistenceTools(server, domain) {
71
71
  return text(await listOrganizations(config));
72
72
  });
73
73
  // 9) Create page (persist) --------------------------------------------------
74
- server.tool("create_page", "Persists a page source to the configured Webcake backend: creates a NEW page, saves the source, then AUTO-PUBLISHES it (builds the rendered app on the build host + publishes via the editor's publish_html route) so the preview renders immediately — set publish:false to skip, and note the no-domain preview link still expires ~10 minutes after each publish (publish_page with custom_domain gives a permanent URL). A failed auto-publish never fails the create (result.publish says how to retry). Validates first. DEFAULTS to dry_run=true (validates, caches the source as draft_id, returns the HTTP request it WOULD send, token masked); dry_run=false to actually create. Accepts draft_id from a previous call (validation failure, dry_run, or a timed-out create) — re-runs from the cached source without re-sending the full JSON. Organization resolution on the real run (dry_run=false): (1) explicit organization_id wins; pass the string 'personal' to save without any org. (2) WEBCAKE_ORG_ID env / x-webcake-org-id header wins. (3) Otherwise list_organizations is called: 0 orgs or lookup fails → personal (no org); exactly 1 org → used automatically (result includes organization_auto_selected:true); 2+ orgs → returns ok:false with the org list and asks the caller to re-call with organization_id. Real writes need WEBCAKE_API_BASE + WEBCAKE_JWT.", {
74
+ server.tool("create_page", "Persists a page source to the configured Webcake backend: creates a NEW page, saves the source, then AUTO-PUBLISHES it (builds the rendered app on the build host + publishes via the editor's publish_html route) so the preview renders immediately — set publish:false to skip, and note the no-domain preview link still expires ~10 minutes after each publish (publish_page with custom_domain gives a permanent URL). A failed auto-publish never fails the create (result.publish says how to retry). Auto-fixes the deterministically-resolvable layout defects first (off-canvas boxes pulled on-canvas; elements below wrapped text pushed down to clear the spill; containers grown to fit) and reports them in auto_fixed — so the saved tree is corrected without a patch round-trip. Then validates. DEFAULTS to dry_run=true (validates, caches the source as draft_id, returns the HTTP request it WOULD send, token masked); dry_run=false to actually create. Accepts draft_id from a previous call (validation failure, dry_run, or a timed-out create) — re-runs from the cached source without re-sending the full JSON. Organization resolution on the real run (dry_run=false): (1) explicit organization_id wins; pass the string 'personal' to save without any org. (2) WEBCAKE_ORG_ID env / x-webcake-org-id header wins. (3) Otherwise list_organizations is called: 0 orgs or lookup fails → personal (no org); exactly 1 org → used automatically (result includes organization_auto_selected:true); 2+ orgs → returns ok:false with the org list and asks the caller to re-call with organization_id. Real writes need WEBCAKE_API_BASE + WEBCAKE_JWT.", {
75
75
  source: z
76
76
  .any()
77
77
  .optional()
@@ -131,6 +131,11 @@ export function registerPersistenceTools(server, domain) {
131
131
  // On dry_run, use the explicit arg or the draft's stored org (if any).
132
132
  const draftOrgId = cachedDraft?.organization_id;
133
133
  const orgId = explicitOrgId ?? draftOrgId;
134
+ // Deterministic layout auto-fix (off-canvas clamp + wrapped-text reflow)
135
+ // BEFORE validate/persist, so the saved tree is the corrected one and the
136
+ // dominant validate→patch→re-validate loop is skipped for those defects.
137
+ // Mutates `expanded` in place — the draft cached below is the fixed tree.
138
+ const autoFixed = domain.autofixLayout?.(expanded) ?? [];
134
139
  const result = domain.validate(expanded);
135
140
  if (!result.valid) {
136
141
  // Cache the failed source so the model can fix ONLY the broken elements via
@@ -146,6 +151,7 @@ export function registerPersistenceTools(server, domain) {
146
151
  created: false,
147
152
  reason: "validation_failed",
148
153
  errors: result.errors,
154
+ ...autoFixedField(autoFixed),
149
155
  ...warningsField(result.warnings),
150
156
  draft_id: existingDraftId,
151
157
  hint: "Do NOT rebuild the whole source — it is cached as draft_id. Each error names the offending element id — fix ONLY those 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). A stray/extra key ('must NOT have additional properties') → { op:'replace', id, element:<clean node> } — op:'update' MERGES and cannot delete a key. The draft expires in ~2 h.",
@@ -188,6 +194,7 @@ export function registerPersistenceTools(server, domain) {
188
194
  return text({
189
195
  dry_run: true,
190
196
  validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
197
+ ...autoFixedField(autoFixed),
191
198
  ...(largePageAdvisory ? { large_page_advisory: largePageAdvisory } : {}),
192
199
  env_ready: missing.length === 0,
193
200
  missing_env: missing,
@@ -296,6 +303,7 @@ export function registerPersistenceTools(server, domain) {
296
303
  created: true,
297
304
  ...outcome,
298
305
  publish: publishOutcome,
306
+ ...autoFixedField(autoFixed),
299
307
  ...warningsField(result.warnings),
300
308
  ...(organizationAutoSelected ? { organization_auto_selected: true } : {}),
301
309
  ...(organizationNote ? { note: organizationNote } : {}),
@@ -610,6 +618,9 @@ export function registerPersistenceTools(server, domain) {
610
618
  };
611
619
  expandedShell = domain.expand(shell);
612
620
  }
621
+ // Same deterministic layout auto-fix as create_page, on the section shell
622
+ // (off-canvas clamp + wrapped-text reflow) before validate/append.
623
+ const autoFixed = domain.autofixLayout?.(expandedShell) ?? [];
613
624
  const newSections = Array.isArray(expandedShell?.page) ? expandedShell.page : [];
614
625
  const labels = newSections.map(sectionLabel);
615
626
  // Light validation: the append path does NOT fetch the live tree, so validate
@@ -633,6 +644,7 @@ export function registerPersistenceTools(server, domain) {
633
644
  added: false,
634
645
  reason: "validation_failed",
635
646
  errors: result.errors,
647
+ ...autoFixedField(autoFixed),
636
648
  ...warningsField(result.warnings),
637
649
  draft_id: existingDraftId,
638
650
  hint: "Do NOT rebuild the section batch — it is cached as draft_id. Each error names the offending element id — fix ONLY those 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>' }. A stray/extra key ('must NOT have additional properties') → { op:'replace', id, element:<clean node> } — op:'update' MERGES and cannot delete a key. The draft expires in ~2 h.",
@@ -662,6 +674,7 @@ export function registerPersistenceTools(server, domain) {
662
674
  sections_added: newSections.length,
663
675
  section_labels: labels,
664
676
  validation: { valid: true, ...warningsField(result.warnings), stats: result.stats },
677
+ ...autoFixedField(autoFixed),
665
678
  draft_id: existingDraftId,
666
679
  request: buildAppendRequestRedacted(config, page_id, newSections),
667
680
  note: "The backend appends these to the END of `page` and rejects duplicate element ids across the live tree.",
@@ -697,6 +710,7 @@ export function registerPersistenceTools(server, domain) {
697
710
  status: outcome.status,
698
711
  error: outcome.error,
699
712
  ...(outcome.rehost ? { rehost: outcome.rehost } : {}),
713
+ ...autoFixedField(autoFixed),
700
714
  ...warningsField(result.warnings),
701
715
  ...(outcome.ok ? {} : {
702
716
  draft_id: existingDraftId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.84",
3
+ "version": "1.1.0",
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",