webcake-landing-mcp 1.0.61 → 1.0.63

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
@@ -80,7 +80,8 @@ MCP (Model Context Protocol) server that teaches AI agents how to build a comple
80
80
  It exposes the element catalog, per-element usage hints + `specials`, the full page JSON Schema,
81
81
  valid element/page skeletons, a page validator, and tools to create or edit pages on the backend.
82
82
  The AI agent produces the full `{ page, popup, settings, options, cartConfigs }` JSON; `create_page`
83
- persists it (source-only the page opens in the editor where re-saving renders it).
83
+ persists it and auto-publishes (build + `publish_html`) so the preview renders immediately (the edit
84
+ tools save source-only — re-publish via `publish_page` after edits).
84
85
 
85
86
  | Method | Best for | Auth |
86
87
  |--------|----------|------|
@@ -152,10 +153,12 @@ npx -y webcake-landing-mcp login # opens the browser once, saves the token to
152
153
 
153
154
  …or set `WEBCAKE_ENV` (`local` | `staging` | `prod` — fills in all base URLs) + `WEBCAKE_JWT`.
154
155
 
155
- For `publish_page` to produce a **rendered** (non-blank) page, a build host is needed:
156
- - `prod` preset auto-configures `https://build.webcake.io` no extra setup.
156
+ For `publish_page` to actually put a page **live**, a build host is needed (it renders the
157
+ `app`/`app_css` that the live `publish_html` route requires):
158
+ - `prod` preset auto-configures `https://build.webcake.io` — no extra setup (the preset applies when the env resolves to `prod`: `WEBCAKE_ENV=prod`, `--env prod`, or `x-webcake-env: prod`).
157
159
  - For staging/local, set `WEBCAKE_BUILD_BASE=<url>` or send the `x-webcake-build-base` header per request.
158
- - Without it, `publish_page` falls back to source-only with `rendered:false` + a warning.
160
+ - Without it, `publish_page` falls back to a legacy source-only save with `rendered:false, live:false` + a warning — nothing goes live.
161
+ - A page is only **permanently** live with a `custom_domain`; without one the returned `/preview/<page_id>` link expires ~10 minutes after the publish.
159
162
 
160
163
  Everything else — the full env-var table, environment presets, per-request headers for the hosted
161
164
  server, the `login` browser flow (+ backend contract), and how to grab a JWT by hand — lives in
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.0.63",
4
+ "d": "11/06/2026",
5
+ "type": "Added",
6
+ "en": "create_page now auto-publishes after a successful create: builds the rendered app via the build host and calls the editor's publish_html route so…",
7
+ "vi": "create_page nay tự động publish sau khi tạo thành công: build rendered app qua build host rồi gọi route publish_html của editor để preview trang mới…"
8
+ },
9
+ {
10
+ "v": "1.0.62",
11
+ "d": "11/06/2026",
12
+ "type": "Added",
13
+ "en": "validate_page now warns when a text-block's estimated rendered height overflows onto a sibling element placed directly below its declared box; the…",
14
+ "vi": "validate_page nay cảnh báo khi chiều cao render ước tính của text-block tràn xuống phần tử anh em đặt ngay phía dưới khung khai báo; cảnh báo nêu…"
15
+ },
2
16
  {
3
17
  "v": "1.0.61",
4
18
  "d": "11/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Changed",
27
41
  "en": "html-box descriptor (get_element) has been rewritten to document COMPOSITE VISUALS as the primary use case: intricate non-interactive mockups such…",
28
42
  "vi": "Descriptor html-box (get_element) được viết lại để ghi lại COMPOSITE VISUALS là trường hợp sử dụng chính: các mockup phi tương tác phức tạp như…"
29
- },
30
- {
31
- "v": "1.0.57",
32
- "d": "11/06/2026",
33
- "type": "Added",
34
- "en": "New upload_images tool re-hosts up to 20 external image URLs or data: URIs as Webcake-hosted URLs (statics.pancake.vn) by downloading and uploading…",
35
- "vi": "Công cụ upload_images mới tải lại tối đa 20 URL ảnh ngoài hoặc data: URI thành URL do Webcake lưu trữ (statics.pancake.vn) bằng cách tải về và…"
36
- },
37
- {
38
- "v": "1.0.56",
39
- "d": "11/06/2026",
40
- "type": "Added",
41
- "en": "The expand pipeline now automatically derives styles.background from specials.src for every image-block node; the live published renderer reads only…",
42
- "vi": "Pipeline expand nay tự động tính styles.background từ specials.src cho mọi node image-block; renderer trên trang published chỉ đọc…"
43
43
  }
44
44
  ]
@@ -141,7 +141,7 @@ WORKFLOW (recommended)
141
141
  3. Optionally call new_element to get a correct skeleton, then fill specials + coordinates.
142
142
  3b. For every image the page needs (hero, product, about, feature, gallery): if the slot has a source image (user-supplied or from the reference HTML/URL), upload_images it and use the returned Webcake URL; otherwise call search_images and put a returned URL into specials.src / gallery item.link. Use placehold.co ONLY when search_images returns ok:false.
143
143
  4. Assemble { page, popup, settings, options, cartConfigs }.
144
- 5. Call validate_page and fix every error.
144
+ 5. Call validate_page and fix every error AND every warning — warnings are visible defects (text spilling onto the element below, off-canvas boxes, empty bands at a section's bottom, missing field_name, dead event targets), not advisory polish. Re-validate until the warning list is empty; only a demonstrably false positive may remain (tell the user which and why).
145
145
  6. To save: call list_organizations. If the account has EXACTLY ONE organization, create_page will auto-select it — no need to ask. If there are MULTIPLE organizations, show them to the user and ask which to use (highlight is_default as the suggested default); pass the chosen organization_id to create_page. If the user explicitly wants to save without any organization, pass organization_id:"personal". Then create_page (dry_run first, then dry_run:false). Note: create_page itself enforces this — it refuses to guess between multiple orgs and returns the org list asking you to pick.
146
146
 
147
147
  EDITING an existing page
@@ -171,6 +171,42 @@ function normalizeBackgrounds(node) {
171
171
  normalizeBackgrounds(child);
172
172
  }
173
173
  }
174
+ // ---------------------------------------------------------------------------
175
+ // misplaced-animation normalization: models regularly emit the animation object
176
+ // directly under responsive.<bp> instead of responsive.<bp>.config.animation —
177
+ // the single most common "must NOT have additional properties" schema error,
178
+ // and one a patch op:'update' can never fix (update merges; it cannot delete
179
+ // the stray key). The intent is unambiguous, so move it where the editor reads
180
+ // it: into config.animation (the author's explicit non-'none' config.animation
181
+ // wins if both are set), then drop the stray key. Deterministic + idempotent,
182
+ // so the expand(compact(x)) == expand(x) invariant holds.
183
+ // ---------------------------------------------------------------------------
184
+ /** Walk a tree node and relocate responsive.<bp>.animation → config.animation in-place (mutates). */
185
+ function normalizeMisplacedAnimation(node) {
186
+ if (!node || typeof node !== "object")
187
+ return;
188
+ for (const bp of ["desktop", "mobile"]) {
189
+ const rbp = node.responsive?.[bp];
190
+ if (!rbp || typeof rbp !== "object")
191
+ continue;
192
+ const stray = rbp.animation;
193
+ if (stray !== undefined) {
194
+ if (stray && typeof stray === "object") {
195
+ rbp.config = rbp.config && typeof rbp.config === "object" ? rbp.config : {};
196
+ const existing = rbp.config.animation;
197
+ const existingWins = existing && typeof existing === "object" && typeof existing.name === "string" && existing.name !== "none";
198
+ if (!existingWins) {
199
+ rbp.config.animation = { name: "none", delay: 0, duration: 3, repeat: null, ...stray };
200
+ }
201
+ }
202
+ delete rbp.animation;
203
+ }
204
+ }
205
+ if (Array.isArray(node.children)) {
206
+ for (const child of node.children)
207
+ normalizeMisplacedAnimation(child);
208
+ }
209
+ }
174
210
  /** Apply all post-expand normalizations to every node in a page source. */
175
211
  function normalizeSource(source) {
176
212
  if (!source || typeof source !== "object")
@@ -178,6 +214,7 @@ function normalizeSource(source) {
178
214
  for (const arr of ["page", "popup", "dynamic_pages"]) {
179
215
  if (Array.isArray(source[arr])) {
180
216
  for (const node of source[arr]) {
217
+ normalizeMisplacedAnimation(node);
181
218
  normalizeImageBlocks(node);
182
219
  normalizeBorderRadius(node);
183
220
  normalizeBackgrounds(node);
@@ -9,9 +9,9 @@ RULES (follow for every request):
9
9
  - INTAKE FIRST — do this EVERY time, even for a "quick"/"test" page. Do NOT jump straight to new_page_skeleton/create_page on the same turn as the request: ask the essentials, restate an outline, get a "yes", THEN build. Ask ONE short batch (3–6, with sensible defaults so the user answers fast) enough to understand the page's PURPOSE, name, look and layout: page purpose/goal, brand/page name, what they sell + price (sales/ads pages), primary color + logo/branding, sections & layout in order, primary CTA + destination, desktop+mobile or mobile-only, which organization. CONSULT, don't interrogate: SUGGEST so the user reacts to something concrete — propose a section flow (pick the archetype matching the page type) + a look (hero treatment + color/tone), and when the user is vague offer 2–3 directions to choose from; proactively suggest sections that fit their goal (social-proof, FAQ, countdown), but ask, don't silently add. Then restate the proposed design (section flow + CTA + color/tone) and WAIT for the user's confirmation, iterating until it matches their intent, before generating. Never assume or silently placeholder the page name, product, price, or colors — ask; only placeholder a core fact when the user explicitly declines to give it.
10
10
  - ASK for any real data the page will display — never invent it, and don't silently placeholder it. This includes: phone/hotline/Zalo, price (+ original price), address, shop/brand name, links/URLs, email, opening hours, and exact stats/social-proof numbers. If a value the page needs is missing, ASK the user for it (in intake, or pause and ask before generating). Use a clearly-labelled placeholder ONLY when the user explicitly says to skip it — then tell them exactly what to fill in.
11
11
  - LANGUAGE: write ALL page copy in the SAME language the user is chatting in, with FULL, CORRECT diacritics/accents. For Vietnamese, every word MUST carry its proper dấu (e.g. "Trân Trọng Kính Mời", "Ngày 15 Tháng 08 Năm 2025") — NEVER emit accent-stripped "không dấu" text. Never romanize or drop accent marks from any language.
12
- - ALWAYS call validate_page and fix every error before create_page / update_page.
12
+ - ALWAYS call validate_page and fix every error before create_page / update_page. WARNINGS ARE A FIX LIST, NOT NOISE: each warning is a visible defect the customer will see (text spilling onto the element below, off-canvas boxes, empty bands, missing field_name, dead event targets). Fix every warning too — before the first save when building new, or via patch_page right after saving an edit — and re-validate until the list is empty. Only a warning you can demonstrate is a false positive may remain (name it to the user and say why). Never tell the user the page is done while warnings stand.
13
13
  - BUILD THE SOURCE IN ONE PASS — gather everything you need BEFORE assembling the source, then build the FULL tree once. BATCH the reads: when a section needs several element types (section + text-block + image-block + button + form + input), call get_element({types:[…]}) ONCE instead of one call per type — same for images, call search_images({queries:[…]}) ONCE with one query per image slot (it dedups + parallelizes and returns one best photo per query). Across MULTIPLE pages in one conversation: reusing skeletons you already fetched is correct, but call get_element for any type you have NOT already fetched in this conversation — NEVER author an unfamiliar type from memory; and if the earlier skeletons are no longer in your context (long conversation, compacted history), re-fetch before building. Do NOT interleave get_element calls between create_page previews and rebuild. create_page/update_page take the entire source as input, so each call re-ships the whole page — re-previewing repeatedly wastes the request.
14
- - create_page and update_page DEFAULT to dry_run=true (a safety net for ambiguous requests). When the user's intent is clear AND validate_page already passed (no errors), SKIP the dry-run and call with dry_run=false directly — saves one round-trip. Use dry_run=true only when (a) the request is ambiguous about target/content, (b) the user explicitly asks to "preview" or "xem trước", (c) this is an update_page that overwrites significant existing content, or (d) you genuinely need to inspect the redacted payload. Never loop dry-runs to "check" the source — validate_page is the validator. Do not run dry-run then dry-run again before the real write.
14
+ - create_page and update_page DEFAULT to dry_run=true (a safety net for ambiguous requests). When the user's intent is clear AND validate_page already passed (no errors, warnings fixed), SKIP the dry-run and call with dry_run=false directly — saves one round-trip. Use dry_run=true only when (a) the request is ambiguous about target/content, (b) the user explicitly asks to "preview" or "xem trước", (c) this is an update_page that overwrites significant existing content, or (d) you genuinely need to inspect the redacted payload. Never loop dry-runs to "check" the source — validate_page is the validator. Do not run dry-run then dry-run again before the real write.
15
15
  - LARGE PAGES (4+ sections) — build INCREMENTALLY to avoid the giant single create_page payload that can drop the connection: create_page with a SMALL skeleton (empty/near-empty page) to get a page_id, then call add_section once per section (each call ships ONLY that section; the backend appends it server-side and rejects duplicate ids — no whole-source get+put). Small pages can still go in one create_page pass.
16
16
  - EDIT existing pages surgically: find_pages (locate the page by name/domain/id when you don't already have a page_id) → get_page → change ONLY what was asked → keep every other element, its id, and coordinates. For a SMALL edit, PREFER patch_page over update_page: send only the changed elements by id (ops: update/replace/remove/add) instead of re-shipping the whole tree — the MCP fetches the live source, merges, validates and saves server-side. Use update_page only when you're rewriting most of the page. Never regenerate the whole tree for a small change.
17
17
  - FIX-AFTER-ERROR / RETRY-AFTER-TIMEOUT (don't rebuild — this is the #1 time-waster): every mutating tool (create_page, update_page, add_section, patch_page) writes the payload to a draft cache BEFORE the network call and returns a draft_id. On validation failure, timeout, or any network error, the draft is kept — retry or fix without re-sending the full JSON:
@@ -21,7 +21,7 @@ RULES (follow for every request):
21
21
  · patch_page (live-page) timed out → response returns a draft_id (patched source cached). Retry patch_page({ draft_id, dry_run:false }) with no patches — the COMMIT-AS-IS path.
22
22
  · update_page validation failed → the page already has a page_id → patch_page({ page_id, patches:[…] }) the offending ids.
23
23
  COMMIT-AS-IS (retry path after timeout): patch_page({ draft_id, dry_run:false }) with NO patches — skips the apply step, re-validates, then saves. This is the universal retry for any timed-out write.
24
- A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). Drafts expire in ~30 min.
24
+ A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). Drafts expire in ~2 h.
25
25
  - DRY-RUN CACHE: create_page, update_page, and add_section dry_run=true all cache the validated payload and return a draft_id. Re-run the same tool with { draft_id, dry_run:false } — no need to re-send the source.
26
26
  - Organizations: call list_organizations before saving. If the account has exactly ONE org, create_page auto-selects it — no need to ask. If there are MULTIPLE orgs, show them and ask the user which to use (is_default is the suggested default); pass the chosen organization_id to create_page. Pass organization_id:"personal" only when the user explicitly wants no org. create_page enforces this itself (it refuses to guess between multiple orgs). Endpoints are owner-scoped (only the account's own pages).
27
27
  - REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements using role→element hints (hero → section with background image/overlay + heading + paragraph + button; features → group blocks with icon/title/text; stats bar → group with heading+text per stat; pricing → group with text list + button; footer → section with text + links). When the reference contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box (clone its HTML with all styles inlined), not as element soup. (2) HTML string → call ingest_html(html, detail:'full') to get the richer AST when cloning; use detail:'compact' (default) for a layout-only reference. (3) URL → call ingest_url(url, detail:'full') for the same richer AST. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts), CSS custom-property palette, background_images from stylesheets, and in full mode: per-section blocks (cards/tiles/steps with title/body/image/cta), li lists, and gradients — use it for LAYOUT + HIERARCHY, then generate FRESH content tailored to the user's brand (don't 1:1 copy text). IMAGES FROM THE REFERENCE ARE THE USER'S ASSETS — for BOTH intents (adapt AND clone), every real image URL found in the AST (images, background_images, og_image) MUST be re-hosted via upload_images and reused in the corresponding slot; do NOT replace them with search_images stock photos. Call search_images ONLY for slots that have no source image in the reference. intent='clone' only when the user explicitly asks to mirror the original; default intent='adapt' (adapt rewrites TEXT, not the imagery). The reference workflow PRESERVES craft rules above (centering, page margin, premium spacing, real images) — apply them on top of the reference layout, don't bypass them.
@@ -37,6 +37,6 @@ MODEL (essentials):
37
37
  - Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
38
38
  - IMAGES: include them (hero/product, feature icons, about photo). SOURCE PRIORITY: (1) images the user provided or that exist in the reference HTML/URL → re-host via upload_images and use those exact images; (2) only for slots with NO source image → call search_images with a short English subject (e.g. 'fresh coffee cup') and put a returned URL (src.large for a hero/banner, src.medium for a card/thumb) into the image-block specials.src; it works out of the box (a shared proxy supplies images). Only if search_images returns ok:false, FALL BACK to a PLACEHOLDER sized to the box: https://placehold.co/<width>x<height>. (gallery.media = array of OBJECTS {type:'image',link:'<url>',linkVideo:'',typeVideo:'youtube',imageCompression:true} — NOT plain strings, the gallery reads item.link; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
39
39
 
40
- - PREVIEW vs PUBLISH: the preview_url returned by create_page/update_page/add_section lives on the PREVIEW host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod NOT the builder subdomain) and renders the stored source immediatelyshare it as-is for review. When the user wants the page LIVE (public/published, optionally on their custom domain), call publish_page({ page_id, custom_domain?, custom_path?, dry_run:false }).
40
+ - 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 onlyafter 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.
41
41
 
42
42
  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, upload_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
@@ -61,6 +61,25 @@ function num(v) {
61
61
  }
62
62
  return undefined;
63
63
  }
64
+ /**
65
+ * Estimated rendered height (px) of a text-block's specials.text at a given
66
+ * fontSize/width — the guide's TEXT HEIGHT MATH (lines ≈ ceil(chars × fontSize
67
+ * × 0.55 / width), height ≈ lines × fontSize × 1.4; lines counted per explicit
68
+ * <br> segment). Returns undefined for empty text or template variables
69
+ * ({{…}}) whose rendered length is unknown.
70
+ */
71
+ function estTextHeight(rawText, fs, w) {
72
+ if (rawText.includes("{{") || !(fs > 0) || !(w > 0))
73
+ return undefined;
74
+ const segments = rawText
75
+ .split(/<br\s*\/?>/i)
76
+ .map((s) => s.replace(/<[^>]*>/g, "").replace(/&nbsp;|&#160;/g, " ").trim())
77
+ .filter((s) => s !== "");
78
+ if (segments.length === 0)
79
+ return undefined;
80
+ const lines = segments.reduce((acc, seg) => acc + Math.max(1, Math.ceil((seg.length * fs * 0.55) / w)), 0);
81
+ return Math.round(lines * fs * 1.4);
82
+ }
64
83
  /**
65
84
  * True when a CSS color string carries real hue — i.e. NOT white/black/grey/
66
85
  * transparent. Used to flag a page that ships with no color at all (every band
@@ -106,6 +125,57 @@ export function coercePage(input) {
106
125
  return JSON.parse(input);
107
126
  return input;
108
127
  }
128
+ /**
129
+ * Resolve an ajv instancePath (e.g. "/page/1/children/2/responsive/desktop") to
130
+ * the deepest ELEMENT (object with string id + type) along it, plus the final
131
+ * value the path lands on. The positional path alone is the #1 reason a model
132
+ * patches the WRONG element after a schema error — indices are easy to miscount;
133
+ * ids are not.
134
+ */
135
+ function describeInstancePath(page, instancePath) {
136
+ if (!instancePath || instancePath === "/")
137
+ return {};
138
+ let cur = page;
139
+ let el;
140
+ for (const rawSeg of instancePath.split("/").slice(1)) {
141
+ if (cur == null || typeof cur !== "object")
142
+ return { id: el?.id, type: el?.type };
143
+ const seg = rawSeg.replace(/~1/g, "/").replace(/~0/g, "~");
144
+ cur = Array.isArray(cur) ? cur[Number(seg)] : cur[seg];
145
+ if (cur && typeof cur === "object" && typeof cur.id === "string" && typeof cur.type === "string")
146
+ el = cur;
147
+ }
148
+ return { id: el?.id, type: el?.type, value: cur };
149
+ }
150
+ /**
151
+ * Format one ajv error as an ACTIONABLE message: positional path + ajv message,
152
+ * plus the offending property name (additionalProperties), the actual bad value
153
+ * (enum/type), and — crucially — the enclosing element's id/type so the fix can
154
+ * target the right element by id on the first try. For stray-key errors it also
155
+ * names the only op that can fix them: patch update MERGES, so deleting a key
156
+ * needs op:'replace' (or, on a rebuild, simply omitting the key).
157
+ */
158
+ function describeSchemaError(page, err) {
159
+ const path = err.instancePath || "/";
160
+ let msg = `schema ${path} ${err.message}`;
161
+ const at = describeInstancePath(page, path);
162
+ const extraKey = err.params?.additionalProperty;
163
+ if (extraKey)
164
+ msg += ` — offending key: "${extraKey}"`;
165
+ if ((err.keyword === "enum" || err.keyword === "type" || err.keyword === "const") &&
166
+ (typeof at.value === "string" || typeof at.value === "number" || typeof at.value === "boolean")) {
167
+ msg += ` — got: ${JSON.stringify(at.value)}`;
168
+ }
169
+ if (at.id)
170
+ msg += ` — element id="${at.id}"${at.type ? ` (type ${at.type})` : ""}`;
171
+ if (extraKey && at.id) {
172
+ msg +=
173
+ `. patch_page op:'update' MERGES and cannot delete this key — fix via ` +
174
+ `{op:'replace', id:'${at.id}', element:<the clean node without "${extraKey}">}` +
175
+ (extraKey === "animation" ? ` (animation belongs in responsive.<bp>.config.animation, not responsive.<bp>)` : "");
176
+ }
177
+ return msg;
178
+ }
109
179
  export function validatePage(input) {
110
180
  const errors = [];
111
181
  const warnings = [];
@@ -116,11 +186,13 @@ export function validatePage(input) {
116
186
  catch (e) {
117
187
  return { valid: false, errors: [`Invalid JSON: ${e.message}`], warnings: [], stats: { sections: 0, popups: 0, elements: 0, ids: 0 } };
118
188
  }
119
- // 1) Structural (JSON Schema)
189
+ // 1) Structural (JSON Schema) — each error names the enclosing ELEMENT (id +
190
+ // type) and the offending key/value, so a fix can target the right element
191
+ // by id instead of decoding positional indices.
120
192
  const ok = validateSchema(page);
121
193
  if (!ok && validateSchema.errors) {
122
194
  for (const err of validateSchema.errors) {
123
- errors.push(`schema ${err.instancePath || "/"} ${err.message}`);
195
+ errors.push(describeSchemaError(page, err));
124
196
  }
125
197
  }
126
198
  // 2) Semantic
@@ -325,31 +397,22 @@ export function validatePage(input) {
325
397
  // Wrapped-text overflow: live text height is AUTO — text that wraps to
326
398
  // more lines than the declared box spills DOWN and overlaps the element
327
399
  // below (the classic "2-line card title over the card body" defect).
328
- // Rough estimate: avg glyph width fontSize × 0.55, line height
329
- // fontSize × 1.4; lines counted per explicit <br> segment. Warn only when
330
- // the estimate exceeds the declared height by MORE than one full line
331
- // (keeps the heuristic from flagging well-sized paragraphs). Skip text
332
- // with template variables ({{…}}) rendered length is unknown.
333
- if (!rawText.includes("{{")) {
334
- const segments = rawText
335
- .split(/<br\s*\/?>/i)
336
- .map((s) => s.replace(/<[^>]*>/g, "").replace(/&nbsp;|&#160;/g, " ").trim())
337
- .filter((s) => s !== "");
338
- if (segments.length > 0) {
339
- for (const bp of ["desktop", "mobile"]) {
340
- const styles = node.responsive?.[bp]?.styles;
341
- const w = num(styles?.width);
342
- const h = num(styles?.height);
343
- const fs = num(styles?.fontSize) ?? 16;
344
- if (!w || !h || w <= 0 || fs <= 0)
345
- continue;
346
- const lines = segments.reduce((acc, seg) => acc + Math.max(1, Math.ceil((seg.length * fs * 0.55) / w)), 0);
347
- const lineH = fs * 1.4;
348
- const est = Math.round(lines * lineH);
349
- if (est > h + lineH) {
350
- warnings.push(`${path} (text-block) [${bp}]: text wraps to ~${lines} lines (~${est}px) but the box is only ${h}px tall — live text height is AUTO, so it will spill down and overlap the element below. Set height ≈ ${est}px and push the elements below down (estimate: lines ≈ ceil(chars × fontSize × 0.55 / width), height ≈ lines × fontSize × 1.4).`);
351
- }
352
- }
400
+ // Slack is capped at 24px: a full-line slack on a 40px heading (56px)
401
+ // is exactly what lets the most common defect a 2-line H2 on a
402
+ // 1-line-tall box slip through, while body text (16px 22px line)
403
+ // keeps its old tolerance against the rough estimate.
404
+ for (const bp of ["desktop", "mobile"]) {
405
+ const styles = node.responsive?.[bp]?.styles;
406
+ const w = num(styles?.width);
407
+ const h = num(styles?.height);
408
+ const fs = num(styles?.fontSize) ?? 16;
409
+ if (!w || !h)
410
+ continue;
411
+ const est = estTextHeight(rawText, fs, w);
412
+ if (est == null)
413
+ continue;
414
+ if (est > h + Math.min(fs * 1.4, 24)) {
415
+ warnings.push(`${path} (text-block) [${bp}]: text wraps to ~${est}px but the box is only ${h}px tall — live text height is AUTO, so it will spill down and overlap the element below. Set height ≈ ${est}px and push the elements below down (estimate: lines ≈ ceil(chars × fontSize × 0.55 / width), height ≈ lines × fontSize × 1.4).`);
353
416
  }
354
417
  }
355
418
  }
@@ -696,6 +759,95 @@ export function validatePage(input) {
696
759
  const ms = sec?.responsive?.mobile?.styles ?? {};
697
760
  checkBounds(sec, rootCanvasD, num(ds.height) ?? DEFAULT_SECTION_HEIGHT, rootCanvasM, num(ms.height) ?? DEFAULT_SECTION_HEIGHT, `page[${i}]`);
698
761
  });
762
+ // 3c) Wrapped-text collision — live text height is AUTO, so a text-block whose
763
+ // content wraps past its declared box spills DOWN. When the declared layout
764
+ // puts a sibling directly below (boxes NOT overlapping — overlapping boxes
765
+ // are intentional layering), the spill lands ON that sibling: the classic
766
+ // broken-looking page of a 2-line H2 over its subheading or a wrapped card
767
+ // title over the card body. The own-box check (section 1) flags the text
768
+ // block itself; this geometric pass names the VICTIM and the exact fix.
769
+ let overlapWarnings = 0;
770
+ const MAX_OVERLAP_WARNINGS = 12;
771
+ const checkTextOverlap = (container, path) => {
772
+ if (!container || !Array.isArray(container.children))
773
+ return;
774
+ const kids = container.children;
775
+ kids.forEach((child, idx) => {
776
+ if (!child || typeof child !== "object")
777
+ return;
778
+ const cpath = `${path}.children[${idx}]`;
779
+ const rawText = child.type === "text-block" ? child.specials?.text : undefined;
780
+ if (typeof rawText === "string") {
781
+ for (const bp of ["desktop", "mobile"]) {
782
+ if (overlapWarnings >= MAX_OVERLAP_WARNINGS)
783
+ break;
784
+ const s = child.responsive?.[bp]?.styles;
785
+ const top = num(s?.top);
786
+ const left = num(s?.left) ?? 0;
787
+ const w = num(s?.width);
788
+ const h = num(s?.height);
789
+ const fs = num(s?.fontSize) ?? 16;
790
+ if (top == null || h == null || !w)
791
+ continue;
792
+ const est = estTextHeight(rawText, fs, w);
793
+ if (est == null || est <= h)
794
+ continue;
795
+ const estBottom = top + est;
796
+ // nearest sibling the declared layout places below this text block
797
+ let hit;
798
+ kids.forEach((sib, j) => {
799
+ if (j === idx || !sib || typeof sib !== "object")
800
+ return;
801
+ const ss = sib.responsive?.[bp]?.styles;
802
+ const st = num(ss?.top);
803
+ const sl = num(ss?.left) ?? 0;
804
+ const sw = num(ss?.width);
805
+ if (st == null || sw == null)
806
+ return;
807
+ if (st < top + h)
808
+ return; // declared boxes overlap or sibling is above → layering, skip
809
+ if (sl + sw <= left || sl >= left + w)
810
+ return; // no horizontal intersection
811
+ if (estBottom > st + 4 && (!hit || st < hit.t))
812
+ hit = { p: `${path}.children[${j}]`, t: st, type: sib.type ?? "?" };
813
+ });
814
+ if (hit) {
815
+ warnings.push(`${cpath} (text-block) [${bp}]: wrapped text renders ~${est}px tall (declared ${h}px) and will spill onto ${hit.p} (${hit.type}, top=${hit.t}) below it. Set this block's height ≈ ${est} and move the elements below to top ≥ ${estBottom + 8}.`);
816
+ overlapWarnings++;
817
+ }
818
+ }
819
+ }
820
+ if (Array.isArray(child.children) && child.children.length > 0)
821
+ checkTextOverlap(child, cpath);
822
+ });
823
+ };
824
+ topList.forEach((sec, i) => checkTextOverlap(sec, `page[${i}]`));
825
+ // 3d) Trailing dead space — a section far taller than its lowest content
826
+ // renders as a big empty band, which reads as a broken/unfinished page.
827
+ // Threshold is generous (320px) since text auto-grow and bottom padding
828
+ // are legitimate; advisory only.
829
+ topList.forEach((sec, i) => {
830
+ if (!sec || !Array.isArray(sec.children) || sec.children.length === 0)
831
+ return;
832
+ for (const bp of ["desktop", "mobile"]) {
833
+ const sh = num(sec.responsive?.[bp]?.styles?.height);
834
+ if (sh == null)
835
+ continue;
836
+ let maxBottom;
837
+ for (const child of sec.children) {
838
+ const s = child?.responsive?.[bp]?.styles;
839
+ const t = num(s?.top);
840
+ const h = num(s?.height);
841
+ if (t == null || h == null)
842
+ continue;
843
+ if (maxBottom == null || t + h > maxBottom)
844
+ maxBottom = t + h;
845
+ }
846
+ if (maxBottom != null && sh > maxBottom + 320) {
847
+ warnings.push(`page[${i}] (section) [${bp}]: height=${sh} but the lowest child ends at ${maxBottom} — ~${sh - maxBottom}px of empty band at the bottom of the section. Reduce the section height to ≈ ${maxBottom + 80} (or move content down into the band).`);
848
+ }
849
+ }
850
+ });
699
851
  // 3b) Page margin axis — every band (header included) should put its
700
852
  // left-anchored content on ONE shared left margin. A header/section that
701
853
  // starts on a different left than the rest is the #1 "looks misaligned"
@@ -6,3 +6,15 @@ export function text(value) {
6
6
  const body = typeof value === "string" ? value : JSON.stringify(value, null, 2);
7
7
  return { content: [{ type: "text", text: body }] };
8
8
  }
9
+ /**
10
+ * Directive shipped alongside every non-empty validation-warnings list.
11
+ * Warnings are design defects the customer WILL see (text overlapping the
12
+ * element below, off-canvas boxes, empty bands, dead event targets, missing
13
+ * field_name…) — without this, models treat them as advisory noise and save
14
+ * anyway.
15
+ */
16
+ export const WARNINGS_NOTICE = "FIX THESE WARNINGS — each one is a visible defect the customer will see, not a suggestion. Apply the fix each warning prescribes (patch_page by element id is the cheap path), then re-validate until the list is empty. Only a warning you can demonstrate is a false positive may remain — name it to the user and say why. Do NOT report the page as done while warnings stand.";
17
+ /** Spread helper: {} when there are no warnings, else { warnings, warnings_notice }. */
18
+ export function warningsField(warnings) {
19
+ return warnings && warnings.length > 0 ? { warnings, warnings_notice: WARNINGS_NOTICE } : {};
20
+ }
@@ -29,7 +29,11 @@
29
29
  * CALLER's own creds, so a draft only ever yields a page in the caller's account.
30
30
  */
31
31
  import { randomUUID } from "node:crypto";
32
- const TTL_MS = 30 * 60 * 1000; // 30 minutes
32
+ /** Draft lifetime default 2 hours. Override via WEBCAKE_DRAFT_TTL_MS env. */
33
+ const TTL_MS = (() => {
34
+ const v = parseInt(process.env.WEBCAKE_DRAFT_TTL_MS ?? "", 10);
35
+ return Number.isFinite(v) && v > 0 ? v : 2 * 60 * 60 * 1000;
36
+ })();
33
37
  const MAX_ENTRIES = 50;
34
38
  const store = new Map();
35
39
  function sweep(now) {