webcake-landing-mcp 1.0.84 → 1.2.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.
package/README.md CHANGED
@@ -172,7 +172,7 @@ server, the `login` browser flow (+ backend contract), and how to grab a JWT by
172
172
  |-------|---------------|
173
173
  | **[Connect your IDE / claude.ai](docs/connect-mcp.md)** | Step-by-step connection for every client (npx & hosted URL), troubleshooting table. |
174
174
  | **[Configuration](docs/configuration.md)** | Env vars, `--env` presets, browser `login`, per-request headers, getting a JWT. |
175
- | **[Tools reference](docs/tools.md)** | All 22 tools in detail + the step-by-step workflow + model notes. |
175
+ | **[Tools reference](docs/tools.md)** | All 23 tools in detail + the step-by-step workflow + model notes. |
176
176
  | **[Usage examples](docs/usage-examples.md)** | Three end-to-end walkthroughs: build from a brief, surgical edit, inspect a type. |
177
177
  | **[Manual / advanced install](docs/manual-install.md)** | Shell installers, cloned builds, hand-written per-IDE config. |
178
178
  | **[Page-element schema](docs/page-element-schema.md)** | The full element-model reference (+ [every special/event](docs/element-specials-reference.md)). |
@@ -181,12 +181,12 @@ server, the `login` browser flow (+ backend contract), and how to grab a JWT by
181
181
 
182
182
  ## 🧰 The tools at a glance
183
183
 
184
- 22 tools in five groups — full descriptions in **[docs/tools.md](docs/tools.md)**:
184
+ 23 tools in five groups — full descriptions in **[docs/tools.md](docs/tools.md)**:
185
185
 
186
186
  | Group | Tools | Needs |
187
187
  |-------|-------|-------|
188
188
  | **Reference** | `get_generation_guide` · `list_elements` · `get_element` · `get_page_schema` | nothing |
189
- | **Generation** | `new_element` · `new_page_skeleton` · `validate_page` | nothing |
189
+ | **Generation** | `new_element` · `new_page_skeleton` · `validate_page` · `layout` (exact centering/row/grid/stack coordinates, both breakpoints) | nothing |
190
190
  | **Media** | `search_images` (real Pexels stock photos) · `get_icon_svg` (Material Symbols / Font Awesome icon names → inline SVG via Iconify) · `upload_images` (re-host external images, data: URIs, or local file paths from the user's machine) · `render_preview` (screenshot a page/URL so the model can see + compare it) | nothing |
191
191
  | **Ingest** | `ingest_html` · `ingest_url` (recreate an existing page) | nothing |
192
192
  | **Persistence** | `list_organizations` · `create_page` · `list_pages` · `find_pages` · `get_page` · `update_page` · `add_section` · `patch_page` · `publish_page` | `WEBCAKE_API_BASE` + `WEBCAKE_JWT` |
package/README.vi.md CHANGED
@@ -174,12 +174,12 @@ flow `login` qua browser (+ contract backend), và cách lấy JWT bằng tay
174
174
 
175
175
  ## 🧰 Tool nhìn nhanh
176
176
 
177
- 19 tool trong năm nhóm — mô tả đầy đủ ở **[docs/tools.vi.md](docs/tools.vi.md)**:
177
+ 20 tool trong năm nhóm — mô tả đầy đủ ở **[docs/tools.vi.md](docs/tools.vi.md)**:
178
178
 
179
179
  | Nhóm | Tools | Cần gì |
180
180
  |------|-------|--------|
181
181
  | **Tham chiếu** | `get_generation_guide` · `list_elements` · `get_element` · `get_page_schema` | không cần gì |
182
- | **Generation** | `new_element` · `new_page_skeleton` · `validate_page` | không cần gì |
182
+ | **Generation** | `new_element` · `new_page_skeleton` · `validate_page` · `layout` | không cần gì |
183
183
  | **Media** | `search_images` (ảnh stock Pexels thật) | không cần gì (key riêng tuỳ chọn) |
184
184
  | **Ingest** | `ingest_html` · `ingest_url` (tái tạo trang có sẵn) | không cần gì |
185
185
  | **Lưu trữ** | `list_organizations` · `create_page` · `list_pages` · `find_pages` · `get_page` · `update_page` · `add_section` · `patch_page` · `publish_page` | `WEBCAKE_API_BASE` + `WEBCAKE_JWT` |
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.2.0",
4
+ "d": "21/06/2026",
5
+ "type": "Added",
6
+ "en": "New layout tool computes exact top/left/width/height coordinates for both breakpoints so the model never hand-calculates left/top (the number-one…",
7
+ "vi": "Tool mới layout tính toán chính xác tọa độ top/left/width/height cho cả hai breakpoint để model không cần tự tính left/top bằng tay (nguyên nhân…"
8
+ },
9
+ {
10
+ "v": "1.1.0",
11
+ "d": "20/06/2026",
12
+ "type": "Added",
13
+ "en": "create_page, add_section, and validate_page now apply a deterministic layout auto-fix before validating or saving: off-canvas children are pulled…",
14
+ "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…"
15
+ },
2
16
  {
3
17
  "v": "1.0.84",
4
18
  "d": "17/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Changed",
27
41
  "en": "The Playwright screenshot engine that backs render_preview's GET /api/render/screenshot route now defaults to JPEG output instead of PNG, reducing…",
28
42
  "vi": "Engine chụp màn hình Playwright phục vụ route GET /api/render/screenshot của render_preview nay mặc định xuất ảnh JPEG thay vì PNG, giúp giảm kích…"
29
- },
30
- {
31
- "v": "1.0.80",
32
- "d": "16/06/2026",
33
- "type": "Added",
34
- "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
- "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,8 @@ 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";
9
+ import { computeLayout } from "./layout.js";
8
10
  import { expandSource } from "../../core/expand.js";
9
11
  import { compactSource } from "../../core/compact.js";
10
12
  /** The payload returned by the get_generation_guide tool. */
@@ -269,5 +271,18 @@ export const landingDomain = {
269
271
  }
270
272
  },
271
273
  schema: pageSchema,
274
+ // Applies the deterministic layout fixes IN PLACE to an already-expanded tree
275
+ // (off-canvas clamp + wrapped-text downward reflow) and returns the change log.
276
+ autofixLayout: (input) => {
277
+ try {
278
+ return autofixLayout(input);
279
+ }
280
+ catch {
281
+ return []; // never let a layout-fix corner case block the build
282
+ }
283
+ },
284
+ // Exact centering/row/grid/stack coordinates for both breakpoints (the math
285
+ // the guide prescribes), so the model never hand-computes `left`/`top`.
286
+ computeLayout: (opts) => computeLayout(opts),
272
287
  canvasToSource: (canvas, meta) => canvasToPageSource(canvas, meta),
273
288
  };
@@ -32,7 +32,7 @@ MODEL (essentials):
32
32
  - Element: { id, type, properties, responsive:{desktop,mobile:{config,styles}}, specials, children, runtime, events }. Absolute canvas: children carry numeric top/left/width/height (px) per breakpoint (canvas width desktop=960, mobile=420); sections own a height.
33
33
  - ELEMENT CATALOG (the FULL menu — all ${ELEMENT_TYPES.length} types; PICK FROM HERE, never rebuild a dedicated type's behavior out of text-blocks/rectangles, and never invent a type; fetch specials + a skeleton via get_element({types:[…]}) for every type you'll use): ${CATALOG_SUMMARY}.
34
34
  - COMPACT AUTHORING (emit FEWER tokens — faster, cheaper): the server HYDRATES every element from its type's factory defaults, so OMIT the boilerplate. Send only: id, type, the meaningful responsive.desktop.styles + responsive.mobile.styles (positions/sizes/colors/font — provide BOTH breakpoints), specials (text/src/field_name…), and events ONLY when the element actually has them. You may DROP: properties, runtime, empty events/children, and each breakpoint's config (animation). A full node still works (it's just overlaid on the seed). Applies to create_page, update_page, add_section, patch_page, validate_page — ~halves the JSON you emit per element. The whole loop is sparse: get_element skeletons/examples and new_element already come in this shape (copy them as-is), and get_page returns sources COMPACTED the same way — edit the compacted tree and send it back without re-adding boilerplate.
35
- - CENTERING (the #1 layout defect — do the math, don't eyeball): to center a box compute left = round((canvas - width)/2) — 960 desktop, 420 mobile. textAlign:center only centers text inside the box, not the box itself. For a row of N items, center the whole row block (startLeft = round((canvas - (N*item + (N-1)*gap))/2)). Keep 0 ≤ left and left+width ≤ canvas on each breakpoint.
35
+ - CENTERING (the #1 layout defect — do the math, don't eyeball): to center a box compute left = round((canvas - width)/2) — 960 desktop, 420 mobile. textAlign:center only centers text inside the box, not the box itself. For a row of N items, center the whole row block (startLeft = round((canvas - (N*item + (N-1)*gap))/2)). SHORTCUT — instead of computing this by hand (or with a script), call layout({ mode:'center'|'row'|'grid'|'stack', items|count+itemWidth+itemHeight, gap, top, … }): it returns the exact top/left/width/height for BOTH breakpoints (row/grid auto-stack into a single column on mobile), which you drop straight into each element's responsive.<bp>.styles. create_page also auto-fixes residual off-canvas/overlap, but layout gets the intended geometry right up front. Keep 0 ≤ left and left+width ≤ canvas on each breakpoint.
36
36
  - PAGE MARGIN (one shared axis — fixes the ragged/header-misaligned look): every section AND the header use the SAME column — left edge at 80 desktop / 20 mobile, right edge at 880 / 400 (content width 800 / 380). Header: logo at left=80, CTA right edge at 880 (its left = 880 − width). Never let one band start at left=80 and the next at left=140.
37
37
  - PREMIUM CRAFT (read "sang"): generous whitespace (don't cram; ~48–72px above each band's first element, ≥16–24px between elements); clear type scale (H1 40–56 / body 16–18, big jump); ONE accent used sparingly + neutrals; snap spacing to an 8px grid; reuse the same content width / margin / card+button radius across sections.
38
38
  - STICKY HEADER: a sticky/fixed header (config.sticky) OVERLAYS the page — it does NOT push sections below it down. Offset the first section's top content DOWN by the header height (~60–72px) so nothing hides behind it, and do NOT duplicate the shop name in both the header and the top of the hero. A non-sticky header stacks normally and needs no offset.
@@ -43,4 +43,4 @@ MODEL (essentials):
43
43
  - PREVIEW vs PUBLISH: for review share the EDITOR url (the builder renders the raw source). The editor_url SIGNS THE BROWSER IN automatically (it routes through the builder's /transport with the account token), so it works even when the user isn't logged in — but for the same reason it must go to the PAGE OWNER ONLY, never into anything public. create_page AUTO-PUBLISHES on success (builds the rendered app + publish_html), so a fresh page's preview_url renders right away — but the preview host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod) only serves it for ~10 MINUTES after the last publish, then shows "Preview page is expired". The EDIT routes (update_page/add_section/patch_page) store source only — after finishing a round of edits, run publish_page({ page_id, dry_run:false }) to rebuild the rendered app (else the preview shows the STALE pre-edit build). ONLY a custom_domain (publish_page({ page_id, custom_domain, dry_run:false })) gives a permanent public URL; without one the page has just the ephemeral preview link — say so and suggest attaching a domain the user already points at Webcake.
44
44
  - VISUAL CHECK after saving (do it for every clone/reference build, and whenever the look matters): don't trust the build blind — LOOK at the rendered page and fix what's off. SCREENSHOT IT WITH YOUR OWN CAPABILITY FIRST — if you have a browser/screenshot MCP (e.g. chrome-devtools) or any tool that captures a URL, screenshot the returned preview_url yourself (fresh + unlimited); ONLY if you have none, call render_preview(page_id), which returns a PNG (on a quota/429 it returns ok:false → skip the check that round, never fail the build). Compare your shot to the reference (screenshot the reference URL too when you only have HTML) — section order, colors, spacing, image placement, sizing, text — then patch_page each mismatch by id, re-publish (publish_page), and screenshot again; loop 2–3 rounds. Rebuilding once from a glance lands ~60% similar — the look-and-patch loop is what closes the gap. The no-domain preview only renders ~10 min after the last publish, so shoot promptly and re-publish before re-checking.
45
45
 
46
- Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, get_icon_svg, upload_images, render_preview, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
46
+ Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, layout, search_images, get_icon_svg, upload_images, render_preview, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Layout coordinate math — the exact arithmetic the generation guide tells the
3
+ * model to do by hand ("do the math, don't eyeball `left`; off-center is the #1
4
+ * defect"), exposed as a deterministic helper so the model gets perfect
5
+ * coordinates for BOTH breakpoints instead of computing them in its head (or
6
+ * spinning up an ad-hoc script). Pure functions, no I/O.
7
+ *
8
+ * Four patterns cover the vast majority of landing layouts:
9
+ * - center : center ONE box on the canvas.
10
+ * - row : N boxes in a horizontally-centered row (desktop) that STACK into a
11
+ * single column on mobile (the feature-card / stats / logo-strip case).
12
+ * - grid : N uniform cells in `cols` columns, the block centered; stacks on mobile.
13
+ * - stack : a vertical list down the shared content column, both breakpoints.
14
+ *
15
+ * Every result honours the page-margin axis (content column 80..880 desktop /
16
+ * 20..400 mobile by default) and returns boxes the model drops straight into an
17
+ * element's responsive.<bp>.styles.
18
+ */
19
+ import { CANVAS } from "./vocab.js";
20
+ const r = Math.round;
21
+ function resolveItems(opts) {
22
+ if (Array.isArray(opts.items) && opts.items.length) {
23
+ return opts.items.map((it) => ({ width: Math.max(0, it.width || 0), height: Math.max(0, it.height || 0) }));
24
+ }
25
+ const n = Math.max(1, opts.count ?? 1);
26
+ const w = Math.max(0, opts.itemWidth ?? 0);
27
+ const h = Math.max(0, opts.itemHeight ?? 0);
28
+ return Array.from({ length: n }, () => ({ width: w, height: h }));
29
+ }
30
+ /** Left edge of a `blockW`-wide block on a `canvas`-wide canvas for an alignment. */
31
+ function blockLeft(align, canvas, margin, blockW) {
32
+ if (align === "left")
33
+ return margin;
34
+ if (align === "right")
35
+ return canvas - margin - blockW;
36
+ return r((canvas - blockW) / 2);
37
+ }
38
+ /** Stack a list of sizes into a single mobile column; returns boxes + the bottom y. */
39
+ function stackMobile(items, startTop, itemW, left, rowGap) {
40
+ let y = startTop;
41
+ return items.map((it) => {
42
+ const box = { top: r(y), left: r(left), width: r(itemW), height: it.height };
43
+ y += it.height + rowGap;
44
+ return box;
45
+ });
46
+ }
47
+ export function computeLayout(opts) {
48
+ const notes = [];
49
+ const items = resolveItems(opts);
50
+ const gap = opts.gap ?? 24;
51
+ const rowGap = opts.rowGap ?? gap;
52
+ const top = opts.top ?? 0;
53
+ const mobileTop = opts.mobileTop ?? top;
54
+ const canvasD = opts.canvasDesktop ?? CANVAS.desktopWidth;
55
+ const canvasM = opts.canvasMobile ?? CANVAS.mobileWidth;
56
+ const marginD = opts.marginDesktop ?? 80;
57
+ const marginM = opts.marginMobile ?? 20;
58
+ const align = opts.align ?? "center";
59
+ const contentD = canvasD - 2 * marginD;
60
+ const contentM = canvasM - 2 * marginM;
61
+ const mobileItemW = Math.min(opts.mobileItemWidth ?? contentM, canvasM - 2 * marginM);
62
+ let desktop = [];
63
+ let mobile = [];
64
+ let summary = "";
65
+ if (opts.mode === "center") {
66
+ const it = items[0];
67
+ const wD = Math.min(it.width, canvasD);
68
+ desktop = [{ top, left: r((canvasD - wD) / 2), width: wD, height: it.height }];
69
+ const wM = Math.min(it.width, contentM);
70
+ mobile = [{ top: mobileTop, left: r((canvasM - wM) / 2), width: wM, height: it.height }];
71
+ if (wM !== it.width)
72
+ notes.push(`mobile: width ${it.width}→${wM} to fit the ${contentM}px content column.`);
73
+ summary = `center 1 box: desktop left ${desktop[0].left} (w ${wD}), mobile left ${mobile[0].left} (w ${wM}).`;
74
+ }
75
+ else if (opts.mode === "row") {
76
+ const blockW = items.reduce((s, it) => s + it.width, 0) + gap * (items.length - 1);
77
+ let x = blockLeft(align, canvasD, marginD, blockW);
78
+ desktop = items.map((it) => {
79
+ const box = { top, left: r(x), width: it.width, height: it.height };
80
+ x += it.width + gap;
81
+ return box;
82
+ });
83
+ const mLeft = blockLeft(align, canvasM, marginM, mobileItemW);
84
+ mobile = stackMobile(items, mobileTop, mobileItemW, mLeft, rowGap);
85
+ if (blockW > contentD)
86
+ notes.push(`desktop row width ${blockW} exceeds the ${contentD}px content column — shrink the items or gap, or split into fewer per row (use grid).`);
87
+ summary = `row of ${items.length}: desktop ${align}-aligned from left ${desktop[0].left} to ${desktop[desktop.length - 1].left + desktop[desktop.length - 1].width} at top ${top}; mobile stacked single-column (w ${mobileItemW}) from top ${mobileTop}.`;
88
+ }
89
+ else if (opts.mode === "grid") {
90
+ const n = items.length;
91
+ const cols = Math.max(1, opts.cols ?? Math.min(n, 3));
92
+ const cellW = Math.max(...items.map((it) => it.width));
93
+ const rows = [];
94
+ for (let i = 0; i < n; i += cols)
95
+ rows.push(items.slice(i, i + cols));
96
+ const blockW = cols * cellW + (cols - 1) * gap;
97
+ const startLeft = blockLeft(align, canvasD, marginD, blockW);
98
+ let y = top;
99
+ desktop = [];
100
+ for (const row of rows) {
101
+ const rowH = Math.max(...row.map((it) => it.height));
102
+ row.forEach((it, c) => {
103
+ desktop.push({ top: r(y), left: r(startLeft + c * (cellW + gap)), width: it.width, height: it.height });
104
+ });
105
+ y += rowH + rowGap;
106
+ }
107
+ const mLeft = blockLeft(align, canvasM, marginM, mobileItemW);
108
+ mobile = stackMobile(items, mobileTop, mobileItemW, mLeft, rowGap);
109
+ if (blockW > contentD)
110
+ notes.push(`desktop grid width ${blockW} exceeds the ${contentD}px content column — reduce cols, item width, or gap.`);
111
+ summary = `grid ${cols}×${rows.length} (${n} cells): desktop block from left ${startLeft}, top ${top}; mobile stacked single-column from top ${mobileTop}.`;
112
+ }
113
+ else {
114
+ // stack
115
+ let yD = top;
116
+ desktop = items.map((it) => {
117
+ const wD = Math.min(it.width, canvasD);
118
+ const left = blockLeft(align, canvasD, marginD, wD);
119
+ const box = { top: r(yD), left: r(left), width: wD, height: it.height };
120
+ yD += it.height + rowGap;
121
+ return box;
122
+ });
123
+ let yM = mobileTop;
124
+ mobile = items.map((it) => {
125
+ const wM = Math.min(it.width, contentM);
126
+ const left = blockLeft(align, canvasM, marginM, wM);
127
+ const box = { top: r(yM), left: r(left), width: wM, height: it.height };
128
+ yM += it.height + rowGap;
129
+ return box;
130
+ });
131
+ summary = `stack of ${items.length}: desktop ${align}-aligned from top ${top}; mobile from top ${mobileTop}.`;
132
+ }
133
+ // Guardrail: anything that lands off-canvas is a bad input — surface it (the
134
+ // model can adjust, and create_page's autofix will also pull it back).
135
+ const offEdge = (boxes, canvas) => boxes.some((b) => b.left < 0 || b.left + b.width > canvas);
136
+ if (offEdge(desktop, canvasD))
137
+ notes.push(`some desktop boxes fall outside 0..${canvasD} — reduce sizes/gap or change alignment.`);
138
+ if (offEdge(mobile, canvasM))
139
+ notes.push(`some mobile boxes fall outside 0..${canvasM}.`);
140
+ return { desktop, mobile, summary, notes };
141
+ }
@@ -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/server.js CHANGED
@@ -6,10 +6,29 @@
6
6
  * To add another domain later: import its `Domain` object and call
7
7
  * registerTools(server, otherDomain) — no changes to core or the tool layer.
8
8
  */
9
+ import { readFileSync } from "node:fs";
9
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
11
  import { landingDomain } from "./domains/landing/index.js";
11
12
  import { registerTools } from "./tools/index.js";
12
13
  import { ICON_DATA_URI, ICON_MIME, BRAND } from "./branding.js";
14
+ /**
15
+ * The published package version, read at runtime from package.json so the MCP
16
+ * serverInfo.version always matches what npm shipped (no hand-bumped constant to
17
+ * drift). package.json sits at the package root — one level above dist/ at
18
+ * runtime and above src/ in the tree — so the same relative URL resolves in both
19
+ * the compiled build and a checked-out source tree. Falls back to "0.0.0" if it
20
+ * can't be read (never block startup over a version string). Avoids a JSON import
21
+ * (the repo deliberately uses readFileSync for runtime JSON — see validate.ts).
22
+ */
23
+ export function pkgVersion() {
24
+ try {
25
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
26
+ return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
27
+ }
28
+ catch {
29
+ return "0.0.0";
30
+ }
31
+ }
13
32
  /**
14
33
  * Create the MCP server.
15
34
  * @param allowLocalFiles Set to false in remote HTTP (serve) mode to prevent
@@ -19,7 +38,7 @@ import { ICON_DATA_URI, ICON_MIME, BRAND } from "./branding.js";
19
38
  export function createServer({ allowLocalFiles = true } = {}) {
20
39
  const server = new McpServer({
21
40
  name: "webcake-landing",
22
- version: "1.0.0",
41
+ version: pkgVersion(),
23
42
  // Shown by MCP clients (e.g. the claude.ai connector) instead of a generic
24
43
  // globe. icons is per the MCP spec; the data URI keeps it self-contained.
25
44
  title: BRAND.title,
package/dist/smoke.js CHANGED
@@ -3,7 +3,10 @@
3
3
  * verify the server's building blocks without a client. Run: npm run smoke
4
4
  */
5
5
  import { createElement, CONTAINER_TYPES, FIELD_TYPES, LIBRARY, ELEMENT_TYPES, ELEMENTS, } from "./domains/landing/elements/index.js";
6
+ import { readFileSync } from "node:fs";
6
7
  import { landingDomain } from "./domains/landing/index.js";
8
+ import { computeLayout } from "./domains/landing/layout.js";
9
+ import { pkgVersion } from "./server.js";
7
10
  import { validatePage, pageSchema } from "./domains/landing/validate.js";
8
11
  import { expandSource } from "./core/expand.js";
9
12
  import { compactSource, deepEq, sparseTemplate } from "./core/compact.js";
@@ -833,7 +836,7 @@ check("clone: html-box passthrough renames builder classes (ladi-html-code → w
833
836
  console.log("== expand: image-block published background derives from specials.src (placeholder seed must not win) ==");
834
837
  {
835
838
  const mk = (src) => ({
836
- page: [{ id: "s", type: "section", responsive: { desktop: { styles: { height: 300 } }, mobile: { styles: { height: 300 } } }, children: [
839
+ page: [{ id: "lsec1", type: "section", responsive: { desktop: { styles: { height: 300 } }, mobile: { styles: { height: 300 } } }, children: [
837
840
  { 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
841
  ] }], popup: [], settings: {}, options: {}, cartConfigs: {},
839
842
  });
@@ -1636,14 +1639,14 @@ console.log("== validator: pill/badge label alignment ==");
1636
1639
  responsive: { desktop: { styles: { height: 400 } }, mobile: { styles: { height: 400 } } },
1637
1640
  children: [
1638
1641
  {
1639
- id: "pill", type: "rectangle",
1642
+ id: "pill1", type: "rectangle",
1640
1643
  responsive: {
1641
1644
  desktop: { styles: { top: 100, left: 330, width: 300, height: 36, borderRadius: "999px", background: "rgba(59,130,246,0.15)", ...pillOpts } },
1642
1645
  mobile: { styles: { top: 100, left: 60, width: 300, height: 36, borderRadius: "999px", background: "rgba(59,130,246,0.15)", ...pillOpts } },
1643
1646
  },
1644
1647
  },
1645
1648
  {
1646
- id: "label", type: "text-block",
1649
+ id: "label1", type: "text-block",
1647
1650
  responsive: {
1648
1651
  desktop: { styles: { top: textTop, left: textLeft, width: textW, height: 20, fontSize: 14, fontWeight: 600, textAlign: "center", ...textOpts } },
1649
1652
  mobile: { styles: { top: textTop, left: textLeft - 270, width: textW, height: 20, fontSize: 14, fontWeight: 600, textAlign: "center", ...textOpts } },
@@ -1836,5 +1839,116 @@ console.log("== upload_images: localContentType (ext + magic, pure offline) ==")
1836
1839
  check("isAllowedScreenshotUrl: localhost rejected", !isAllowedScreenshotUrl("http://localhost:5800/preview/1").ok);
1837
1840
  check("isAllowedScreenshotUrl: garbage rejected", !isAllowedScreenshotUrl("not a url").ok);
1838
1841
  }
1842
+ console.log("== autofix-layout: clamps off-canvas + reflows wrapped-text overlap (reported, idempotent) ==");
1843
+ {
1844
+ // Hero with: a too-short H1 box whose long text WRAPS (renderer height:auto, so
1845
+ // it spills onto the subheading right below), plus an off-canvas CTA. autofix
1846
+ // should push the subheading down and pull the CTA back on-canvas — clearing
1847
+ // the very warnings the validator would otherwise emit.
1848
+ const page = {
1849
+ page: [
1850
+ {
1851
+ id: "heroSec", type: "section",
1852
+ responsive: { desktop: { styles: { height: 400, background: "rgba(17,24,39,1)" } }, mobile: { styles: { height: 400, background: "rgba(17,24,39,1)" } } },
1853
+ children: [
1854
+ {
1855
+ id: "head1", type: "text-block",
1856
+ responsive: {
1857
+ desktop: { styles: { top: 40, left: 80, width: 300, height: 40, fontSize: 40, fontWeight: "bold", color: "rgba(255,255,255,1)" } },
1858
+ mobile: { styles: { top: 40, left: 20, width: 380, height: 40, fontSize: 30, fontWeight: "bold", color: "rgba(255,255,255,1)" } },
1859
+ },
1860
+ specials: { text: "This headline is intentionally long enough to wrap onto several lines inside a narrow box", tag: "h1" },
1861
+ },
1862
+ {
1863
+ id: "subh1", type: "text-block",
1864
+ responsive: {
1865
+ desktop: { styles: { top: 92, left: 80, width: 300, height: 24, fontSize: 16, color: "rgba(255,255,255,1)" } },
1866
+ mobile: { styles: { top: 92, left: 20, width: 380, height: 24, fontSize: 16, color: "rgba(255,255,255,1)" } },
1867
+ },
1868
+ specials: { text: "Short subheading", tag: "p" },
1869
+ },
1870
+ {
1871
+ id: "cta1", type: "button",
1872
+ responsive: {
1873
+ desktop: { styles: { top: 320, left: 900, width: 160, height: 44, background: "rgba(246,4,87,1)" } },
1874
+ mobile: { styles: { top: 320, left: 40, width: 160, height: 44, background: "rgba(246,4,87,1)" } },
1875
+ },
1876
+ specials: { text: "Go" },
1877
+ },
1878
+ ],
1879
+ },
1880
+ ],
1881
+ settings: { title: "t", description: "d", keywords: "k", lang: "vi", fontGeneral: "Roboto" },
1882
+ };
1883
+ const expanded = landingDomain.expand(page);
1884
+ const subTopBefore = expanded.page[0].children[1].responsive.desktop.styles.top;
1885
+ const fixes = landingDomain.autofixLayout(expanded);
1886
+ check("autofix: returns a non-empty change list", fixes.length > 0, fixes);
1887
+ const subTopAfter = expanded.page[0].children[1].responsive.desktop.styles.top;
1888
+ check("autofix: pushed the subheading below the wrapped headline", subTopAfter > subTopBefore, { subTopBefore, subTopAfter });
1889
+ const ctaLeftAfter = expanded.page[0].children[2].responsive.desktop.styles.left;
1890
+ check("autofix: pulled the off-canvas CTA on-canvas (left+width ≤ 960)", ctaLeftAfter + 160 <= 960, ctaLeftAfter);
1891
+ const post = validatePage(expanded);
1892
+ check("autofix: result still validates", post.valid, post.errors);
1893
+ check("autofix: cleared the wrapped-text spill + off-canvas warnings", !post.warnings.some((w) => /spill onto|exceeds canvas/.test(w)), post.warnings);
1894
+ // Idempotent: a second pass over the fixed tree changes nothing.
1895
+ const fixes2 = landingDomain.autofixLayout(expanded);
1896
+ check("autofix: idempotent (second pass is a no-op)", fixes2.length === 0, fixes2);
1897
+ // No-op on an already-correct page (the canonical `good` fixture).
1898
+ const cleanFixes = landingDomain.autofixLayout(landingDomain.expand(good));
1899
+ check("autofix: no changes on an already-valid page", cleanFixes.length === 0, cleanFixes);
1900
+ // Intentional layering (a label declared INSIDE a pill rectangle's box) is NOT
1901
+ // treated as an overlap to reflow.
1902
+ const layered = {
1903
+ page: [
1904
+ {
1905
+ id: "lsec1", type: "section",
1906
+ responsive: { desktop: { styles: { height: 300, background: "rgba(255,255,255,1)" } }, mobile: { styles: { height: 300, background: "rgba(255,255,255,1)" } } },
1907
+ children: [
1908
+ { 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: {} },
1909
+ { 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" } },
1910
+ ],
1911
+ },
1912
+ ],
1913
+ settings: { title: "t", description: "d", keywords: "k", lang: "vi", fontGeneral: "Roboto" },
1914
+ };
1915
+ const layExp = landingDomain.expand(layered);
1916
+ landingDomain.autofixLayout(layExp);
1917
+ 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);
1918
+ }
1919
+ console.log("== layout: exact centering/row/grid/stack coordinates (both breakpoints) ==");
1920
+ {
1921
+ // center one box: left = round((canvas - w)/2) on each breakpoint.
1922
+ const c = computeLayout({ mode: "center", itemWidth: 300, itemHeight: 80, count: 1 });
1923
+ check("layout center: desktop left = (960-300)/2 = 330", c.desktop[0].left === 330 && c.desktop[0].width === 300, c.desktop[0]);
1924
+ check("layout center: mobile left = (420-300)/2 = 60", c.mobile[0].left === 60 && c.mobile[0].width === 300, c.mobile[0]);
1925
+ // row of 3 (200×150, gap 24, top 100): block 648 centered → startLeft 156.
1926
+ const row = computeLayout({ mode: "row", count: 3, itemWidth: 200, itemHeight: 150, gap: 24, top: 100 });
1927
+ check("layout row: 3 desktop boxes equally spaced from centered start", row.desktop.map((b) => b.left).join(",") === "156,380,604", row.desktop.map((b) => b.left));
1928
+ check("layout row: all desktop tops = 100", row.desktop.every((b) => b.top === 100), row.desktop);
1929
+ check("layout row: mobile stacks single-column (full content width, left 20)", row.mobile.every((b) => b.left === 20 && b.width === 380), row.mobile);
1930
+ check("layout row: mobile tops accumulate height+gap (100, 274, 448)", row.mobile.map((b) => b.top).join(",") === "100,274,448", row.mobile.map((b) => b.top));
1931
+ // grid 2×2 (250×200, gap 24, cols 2): block 524 centered → startLeft 218.
1932
+ const grid = computeLayout({ mode: "grid", count: 4, itemWidth: 250, itemHeight: 200, gap: 24, cols: 2, top: 0 });
1933
+ check("layout grid: row 0 at top 0 (left 218, 492)", grid.desktop[0].top === 0 && grid.desktop[0].left === 218 && grid.desktop[1].left === 492, grid.desktop.slice(0, 2));
1934
+ check("layout grid: row 1 at top 224 (200 + 24 rowGap)", grid.desktop[2].top === 224 && grid.desktop[3].top === 224, grid.desktop.slice(2));
1935
+ // stack (left-aligned at margin 80, cumulative tops with rowGap 24).
1936
+ const stack = computeLayout({ mode: "stack", items: [{ width: 800, height: 60 }, { width: 800, height: 120 }], top: 40, align: "left" });
1937
+ check("layout stack: first at margin 80 / top 40", stack.desktop[0].left === 80 && stack.desktop[0].top === 40, stack.desktop[0]);
1938
+ check("layout stack: second top = 40 + 60 + 24 = 124", stack.desktop[1].top === 124, stack.desktop[1]);
1939
+ // guardrails: an over-wide row is flagged (and would be off-canvas left).
1940
+ const wide = computeLayout({ mode: "row", count: 5, itemWidth: 200, itemHeight: 100, gap: 24 });
1941
+ check("layout: over-wide row produces a note", wide.notes.some((n) => /exceeds|outside/.test(n)), wide.notes);
1942
+ // 1200-canvas + right alignment: block hugs the right margin.
1943
+ const rightWide = computeLayout({ mode: "row", count: 2, itemWidth: 300, itemHeight: 120, gap: 40, canvasDesktop: 1200, align: "right", marginDesktop: 80 });
1944
+ const lastRight = rightWide.desktop[1].left + rightWide.desktop[1].width;
1945
+ check("layout: align right ends at canvas − margin (1200 − 80 = 1120)", lastRight === 1120, { lastRight, desktop: rightWide.desktop });
1946
+ }
1947
+ console.log("== server: MCP serverInfo.version follows package.json (no hardcoded constant) ==");
1948
+ {
1949
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1950
+ check("server: pkgVersion() equals package.json version", pkgVersion() === pkg.version, { pkgVersion: pkgVersion(), pkg: pkg.version });
1951
+ check("server: version is semver-shaped", /^\d+\.\d+\.\d+/.test(pkgVersion()), pkgVersion());
1952
+ }
1839
1953
  console.log(`\n${failures === 0 ? "ALL GOOD" : failures + " FAILURE(S)"}`);
1840
1954
  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,50 @@ 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) });
48
+ });
49
+ // 7b) Layout coordinates -----------------------------------------------------
50
+ server.tool("layout", "Computes EXACT on-canvas coordinates (top/left/width/height) for a group of elements, for BOTH breakpoints, following the guide's layout math — so you NEVER hand-compute `left`/`top` (the #1 source of off-center defects) or write a script to do it. Drop the returned boxes straight into each element's responsive.<bp>.styles (results are in the same order you passed items). Four modes: 'center' (one box centered on the canvas); 'row' (N boxes in a horizontally-centered row on desktop that STACK into a single mobile column — feature cards / stats / logo strip); 'grid' (N uniform cells in `cols` columns, block centered; stacks on mobile); 'stack' (a vertical list down the shared content column on both breakpoints). Honours the page-margin axis (content column 80..880 desktop / 20..400 mobile by default). Pure math — no env, no network. `notes` flags off-canvas / over-wide inputs.", {
51
+ mode: z.enum(["center", "row", "grid", "stack"]).describe("Layout pattern. center=one box; row=horizontal row (stacks on mobile); grid=cols×rows (stacks on mobile); stack=vertical list."),
52
+ items: z
53
+ .array(z.object({ width: z.number(), height: z.number() }))
54
+ .optional()
55
+ .describe("Explicit per-item sizes in order (row/stack may vary sizes). Provide this OR count+itemWidth+itemHeight."),
56
+ count: z.number().int().positive().optional().describe("Uniform shortcut: number of identical boxes (use with itemWidth/itemHeight)."),
57
+ itemWidth: z.number().optional().describe("Uniform item width (with count)."),
58
+ itemHeight: z.number().optional().describe("Uniform item height (with count)."),
59
+ gap: z.number().optional().describe("Horizontal gap between row/grid items (px). Default 24."),
60
+ rowGap: z.number().optional().describe("Vertical gap between grid rows / stacked items (px). Default = gap."),
61
+ cols: z.number().int().positive().optional().describe("Grid columns. Default min(itemCount, 3)."),
62
+ top: z.number().optional().describe("Desktop start y inside the section (px). Default 0."),
63
+ mobileTop: z.number().optional().describe("Mobile start y (px). Default = top."),
64
+ canvasDesktop: z.number().optional().describe("Desktop canvas width. Default 960 (use 1200 for wide pages)."),
65
+ canvasMobile: z.number().optional().describe("Mobile canvas width. Default 420 (use 360 to match a narrow design)."),
66
+ marginDesktop: z.number().optional().describe("Desktop page margin / content inset. Default 80."),
67
+ marginMobile: z.number().optional().describe("Mobile page margin / content inset. Default 20."),
68
+ align: z.enum(["center", "left", "right"]).optional().describe("Horizontal alignment of the block within the canvas. Default center."),
69
+ mobileItemWidth: z.number().optional().describe("Stacked-mobile item width (row/grid). Default = mobile content width."),
70
+ }, { title: "Compute Layout Coordinates", readOnlyHint: true, openWorldHint: false }, async (opts) => {
71
+ if (!domain.computeLayout) {
72
+ return text({ error: "This domain does not provide layout coordinates." });
73
+ }
74
+ if (!opts.items?.length && !(opts.count && (opts.itemWidth != null || opts.itemHeight != null))) {
75
+ return text({
76
+ error: "Provide either `items` (an array of {width,height}) or `count` + `itemWidth` + `itemHeight`.",
77
+ });
78
+ }
79
+ return text(domain.computeLayout(opts));
44
80
  });
45
81
  }
@@ -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.2.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",