webcake-landing-mcp 1.1.0 → 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,11 @@
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
+ },
2
9
  {
3
10
  "v": "1.1.0",
4
11
  "d": "20/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Changed",
34
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…",
35
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…"
36
- },
37
- {
38
- "v": "1.0.80",
39
- "d": "16/06/2026",
40
- "type": "Added",
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…",
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ả,…"
43
43
  }
44
44
  ]
@@ -6,6 +6,7 @@ import { createPageSource } from "./page.js";
6
6
  import { canvasToPageSource } from "./canvas-to-source.js";
7
7
  import { validatePage, coercePage, pageSchema } from "./validate.js";
8
8
  import { autofixLayout } from "./autofix-layout.js";
9
+ import { computeLayout } from "./layout.js";
9
10
  import { expandSource } from "../../core/expand.js";
10
11
  import { compactSource } from "../../core/compact.js";
11
12
  /** The payload returned by the get_generation_guide tool. */
@@ -280,5 +281,8 @@ export const landingDomain = {
280
281
  return []; // never let a layout-fix corner case block the build
281
282
  }
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),
283
287
  canvasToSource: (canvas, meta) => canvasToPageSource(canvas, meta),
284
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
+ }
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";
@@ -1913,5 +1916,39 @@ console.log("== autofix-layout: clamps off-canvas + reflows wrapped-text overlap
1913
1916
  landingDomain.autofixLayout(layExp);
1914
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);
1915
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
+ }
1916
1953
  console.log(`\n${failures === 0 ? "ALL GOOD" : failures + " FAILURE(S)"}`);
1917
1954
  process.exit(failures === 0 ? 0 : 1);
@@ -46,4 +46,36 @@ export function registerGenerationTools(server, domain) {
46
46
  const result = domain.validate(expanded);
47
47
  return text({ ...result, ...autoFixedField(autoFixed), ...warningsField(result.warnings) });
48
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));
80
+ });
49
81
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.1.0",
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",