webcake-landing-mcp 1.1.0 → 1.2.1
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 +3 -3
- package/README.vi.md +2 -2
- package/dist/changelog.json +14 -14
- package/dist/domains/landing/guide.js +2 -2
- package/dist/domains/landing/index.js +4 -0
- package/dist/domains/landing/instructions.js +3 -3
- package/dist/domains/landing/layout.js +141 -0
- package/dist/server.js +20 -1
- package/dist/smoke.js +37 -0
- package/dist/tools/generation.js +32 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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` |
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.2.1",
|
|
4
|
+
"d": "21/06/2026",
|
|
5
|
+
"type": "Changed",
|
|
6
|
+
"en": "Server instructions and get_generation_guide now restrict the visual-check step to three explicit triggers — cloning a reference to compare pixel…",
|
|
7
|
+
"vi": "Instruction của server và get_generation_guide nay giới hạn bước visual-check chỉ trong ba trường hợp cụ thể — clone trang tham chiếu để so sánh độ…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.2.0",
|
|
11
|
+
"d": "21/06/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"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…",
|
|
14
|
+
"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…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.1.0",
|
|
4
18
|
"d": "20/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Added",
|
|
27
41
|
"en": "render_preview now accepts a tiles parameter: pass tiles:true to receive the page as a stack of top-to-bottom horizontal band images instead of one…",
|
|
28
42
|
"vi": "render_preview nay nhận tham số tiles: truyền tiles:true để nhận trang dưới dạng stack các ảnh dải ngang từ trên xuống dưới thay vì một ảnh…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.0.81",
|
|
32
|
-
"d": "16/06/2026",
|
|
33
|
-
"type": "Changed",
|
|
34
|
-
"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
|
-
"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
|
]
|
|
@@ -152,7 +152,7 @@ WORKFLOW (recommended)
|
|
|
152
152
|
4. Assemble { page, popup, settings, options, cartConfigs }.
|
|
153
153
|
5. Call validate_page and fix every error AND every warning — warnings are visible defects (text spilling onto the element below, off-canvas boxes, empty bands at a section's bottom, missing field_name, dead event targets), not advisory polish. Re-validate until the warning list is empty; only a demonstrably false positive may remain (tell the user which and why).
|
|
154
154
|
6. To save: call list_organizations. If the account has EXACTLY ONE organization, create_page will auto-select it — no need to ask. If there are MULTIPLE organizations, show them to the user and ask which to use (highlight is_default as the suggested default); pass the chosen organization_id to create_page. If the user explicitly wants to save without any organization, pass organization_id:"personal". Then create_page (dry_run first, then dry_run:false). Note: create_page itself enforces this — it refuses to guess between multiple orgs and returns the org list asking you to pick.
|
|
155
|
-
7. VISUAL CHECK —
|
|
155
|
+
7. VISUAL CHECK (only when it earns its cost) — a screenshot is token-heavy and adds a round-trip, so do NOT screenshot by default: validate_page (errors + warnings) and the save-time auto-fix already catch the mechanical defects (off-canvas, wrapped-text overlap, contrast, missing data) for free. Screenshot ONLY when validation can't judge what you need: (a) you built from a REFERENCE and need to compare fidelity, (b) the USER asks to see/verify the look, or (c) a specific AESTHETIC doubt (color/imagery/composition). When you do: screenshot with YOUR OWN capability first — a browser/screenshot MCP (e.g. chrome-devtools) or any URL-capture tool (fresh + unlimited); only if you have none, call render_preview(page_id) (PNG; on a quota/rate-limit it returns ok:false → SKIP that round, don't fail the build). Take ONE shot, patch_page only the clear mismatches by id, re-publish (publish_page), and re-shoot ONLY if you changed something material — don't reflexively loop 2–3 rounds. For a clone, put your shot next to the reference (screenshot the reference URL too when you only have HTML) and compare section order, colors, spacing, image placement, sizing and text. The no-domain preview only renders for ~10 min after the last publish, so screenshot promptly and re-publish before re-checking a stale page.
|
|
156
156
|
|
|
157
157
|
EDITING an existing page
|
|
158
158
|
- find_pages / list_pages → let the user pick (or take a page_id from a URL).
|
|
@@ -167,4 +167,4 @@ REFERENCE INPUT (HTML page to clone or adapt)
|
|
|
167
167
|
- SECTION HEIGHTS: every AST section carries size_hint = { height, basis, css? } — the section's desktop height on the 960px canvas (basis:'css' = an explicit height/min-height found in the source, css holds the raw value e.g. "100vh"; basis:'estimate' = content-volume math). Set each rebuilt section's desktop height FROM its size_hint (±15% to fit your actual element placement) instead of the 800px default, so the page's vertical rhythm tracks the source: a 72px header stays a slim bar, a 1200px pricing band stays tall. Mobile is NOT hinted — redo the height per the mobile text math (stacked content is taller).
|
|
168
168
|
- Map AST section roles to Webcake elements: hero → section (background image/overlay) + text-block H1 + text-block subheading + button; features → group per card (icon rectangle + text-block title + text-block body); stats bar → group with text-block per stat; pricing → group with text-block list + button; footer → section (dark bg) + text-block + links. When the ingested page contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box, NOT as element soup. The full AST hands you the SOURCE for this: section.widgets = [{ hint, html, css? }] — the widget's cleaned outerHTML plus the stylesheet rules that style it. Build the html-box FROM that html verbatim: inline each css rule into the matching elements' style="" attributes (an html-box has no <style> scope), wrap in a root div with width:100%;height:100%;box-sizing:border-box;overflow:hidden, and size the html-box to the widget's box — do NOT re-imagine the widget's markup from the summary fields.
|
|
169
169
|
- Reference images are the user's assets — for BOTH intents (adapt AND clone), carry every real image URL found in the AST (images, background_images, og_image, canvas src/background) straight into the matching slot; the save auto-hosts it to the Webcake CDN (no upload_images needed, no hotlinking), so never replace it with a search_images stock photo or a placeholder. intent='adapt' rewrites TEXT for the user's brand, not the imagery; search_images only fills slots that have no source image.
|
|
170
|
-
- VERIFY THE CLONE VISUALLY (
|
|
170
|
+
- VERIFY THE CLONE VISUALLY (clones only — where fidelity is the whole point): for a clone/adapt build, do the VISUAL CHECK from BUILDING step 7 ONCE — screenshot the rendered preview with your own browser/screenshot capability (or render_preview as fallback), compare it side-by-side with the reference (screenshot the reference URL too if you only have HTML), patch_page the clear mismatches, and re-check ONLY if you changed something material (don't loop reflexively). For a NON-clone page, validate_page + the save-time auto-fix are the gate — skip the screenshot unless the user asks or you have a specific aesthetic doubt. Eyeballing a reference and rebuilding once typically lands ~60% similar; a targeted look-and-patch pass closes the gap when fidelity matters.`;
|
|
@@ -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.
|
|
@@ -41,6 +41,6 @@ MODEL (essentials):
|
|
|
41
41
|
|
|
42
42
|
- BEYOND ELEMENT CAPABILITY: when a reference effect can't be expressed with an element's built-in specials (hover scale/lift/zoom & transitions, gradients, glassmorphism/backdrop-blur, custom shadows, gradient/clipped text, keyframe animations outside the 9 entrance types) DON'T drop it — use the escape hatches so the page is as complete as possible: element specials.custom_css (extra DECLARATIONS in #w-<id>{…}; declarations-only) + specials.custom_class (both need specials.customAdvance:true) for per-element styling, and page settings.extra_css (a full raw stylesheet in <head> — where :hover/@keyframes/media-queries live, target #w-<element id>) + settings.extra_script (raw JS) + settings.bhet/bbet (raw HTML blocks at end of <head>/<body> — for webfont <link>s, <meta>/verification, analytics/pixels, chat widgets, third-party embeds). get_generation_guide has the recipes. SAFETY (custom is raw + global → sloppy custom BREAKS the whole UI): SCOPE every extra_css rule to "#w-<id>" or a specials.custom_class — NEVER a bare tag / "*" / a Webcake-internal class (.section-container/.rectangle-css/.text-block-css/.group-*…); keep custom_css to VISUAL props only (no position/top/left/width/height/display/float); close every bhet/bbet tag; don't restructure "#w-…" elements in extra_script. validate_page flags unscoped selectors, layout props in custom_css, unbalanced braces/tags, missing customAdvance, and CSS/JS in the wrong field — fix every such warning.
|
|
43
43
|
- PREVIEW vs PUBLISH: for review share the EDITOR url (the builder renders the raw source). The editor_url SIGNS THE BROWSER IN automatically (it routes through the builder's /transport with the account token), so it works even when the user isn't logged in — but for the same reason it must go to the PAGE OWNER ONLY, never into anything public. create_page AUTO-PUBLISHES on success (builds the rendered app + publish_html), so a fresh page's preview_url renders right away — but the preview host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod) only serves it for ~10 MINUTES after the last publish, then shows "Preview page is expired". The EDIT routes (update_page/add_section/patch_page) store source only — after finishing a round of edits, run publish_page({ page_id, dry_run:false }) to rebuild the rendered app (else the preview shows the STALE pre-edit build). ONLY a custom_domain (publish_page({ page_id, custom_domain, dry_run:false })) gives a permanent public URL; without one the page has just the ephemeral preview link — say so and suggest attaching a domain the user already points at Webcake.
|
|
44
|
-
- VISUAL CHECK
|
|
44
|
+
- VISUAL CHECK — a screenshot costs tokens + a round-trip, so by DEFAULT do NOT screenshot. validate_page (errors + warnings) and the save-time auto-fix already catch the mechanical defects (off-canvas, wrapped-text overlap, contrast, missing data) token-free — trust them. Screenshot ONLY when it adds something validation can't: (a) you're CLONING a reference and need to compare pixel fidelity, (b) the USER asks to see/verify the look, or (c) you have a specific AESTHETIC doubt (color/imagery/composition) text-validation can't judge. When you do: SCREENSHOT WITH YOUR OWN CAPABILITY FIRST — a browser/screenshot MCP (e.g. chrome-devtools) or any URL-capture tool (fresh + unlimited); only if you have none, call render_preview(page_id) (PNG; on a quota/429 it returns ok:false → skip that round, never fail the build). Take ONE shot, patch_page only the clear mismatches by id, re-publish (publish_page), and re-shoot ONLY if you changed something material — do NOT reflexively loop 2–3 times. For a clone, compare to the reference (screenshot the reference URL too when you only have HTML) — section order, colors, spacing, image placement, sizing, text. 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:
|
|
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);
|
package/dist/tools/generation.js
CHANGED
|
@@ -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
|
|
3
|
+
"version": "1.2.1",
|
|
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",
|