webcake-landing-mcp 1.0.64 → 1.0.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.0.66",
4
+ "d": "12/06/2026",
5
+ "type": "Changed",
6
+ "en": "Generation guide (get_generation_guide) and server instructions now prescribe a four-step image sourcing priority: (1) re-host user-supplied or…",
7
+ "vi": "Hướng dẫn sinh trang (get_generation_guide) và instruction server nay quy định thứ tự ưu tiên bốn bước để lấy ảnh: (1) re-host ảnh do người dùng…"
8
+ },
9
+ {
10
+ "v": "1.0.65",
11
+ "d": "12/06/2026",
12
+ "type": "Added",
13
+ "en": "validate_page now warns when a single-line text-block label sitting on a rounded rectangle (the badge/pill pattern) is vertically or horizontally…",
14
+ "vi": "validate_page nay cảnh báo khi nhãn text-block một dòng đặt trên rectangle bo góc (kiểu badge/pill) bị lệch tâm theo chiều dọc hoặc ngang: sử dụng…"
15
+ },
2
16
  {
3
17
  "v": "1.0.64",
4
18
  "d": "12/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "ingest_html and ingest_url now return a size_hint field ({ height, basis, css? }) on every AST section, providing the desktop section height in px…",
28
42
  "vi": "ingest_html và ingest_url nay trả về trường size_hint ({ height, basis, css? }) trên mỗi section trong AST, cung cấp chiều cao desktop của section…"
29
- },
30
- {
31
- "v": "1.0.60",
32
- "d": "11/06/2026",
33
- "type": "Fixed",
34
- "en": "The expand pipeline (invoked by create_page, update_page, add_section, validate_page, and patch_page) now auto-canonicalizes every url() layer in…",
35
- "vi": "Pipeline expand (được gọi bởi create_page, update_page, add_section, validate_page, và patch_page) nay tự chuẩn hóa mọi layer url() trong…"
36
- },
37
- {
38
- "v": "1.0.59",
39
- "d": "11/06/2026",
40
- "type": "Changed",
41
- "en": "create_page now resolves the organization automatically on the real run (dry_run:false): if the account has exactly one org it is auto-selected and…",
42
- "vi": "create_page nay tự phân giải tổ chức trên lần chạy thực (dry_run:false): nếu tài khoản có đúng một org thì tự động chọn và kết quả có thêm…"
43
43
  }
44
44
  ]
@@ -54,6 +54,7 @@ CENTERING & ALIGNMENT (do the math — do NOT eyeball \`left\`; off-center layou
54
54
  TEXT HEIGHT MATH (the #2 layout defect — wrapped text overlapping the element below; do the math, don't eyeball)
55
55
  - On the live page a text-block's height is AUTO: text that wraps to more lines than you assumed spills DOWN and overlaps whatever you placed below it (classic symptom: a 2-line card title overlapping the card body, or a name line clipping the card's bottom edge).
56
56
  - Estimate BEFORE placing the next element: lines ≈ ceil(chars × fontSize × 0.55 / width) — count the longest run between explicit <br>s; bold/heading glyphs are wider (use 0.6), and ALL-CAPS/uppercase headings are wider still (use 0.7 — UPPERCASE wraps to more lines than it looks). Needed height ≈ lines × fontSize × 1.4. validate_page re-checks this with REAL per-character font metrics (fontWeight/letterSpacing/textTransform aware), so an under-sized box WILL be flagged — size it right the first time.
57
+ - BADGE/PILL ("background hugs a label" — do the math, the #1 misaligned-detail defect): build it as TWO elements — a rounded rectangle (the pill) + a single-line text-block layered on top. NEVER give the text-block itself a styles.background to fake the pill: on text-blocks background is the GRADIENT-TEXT-FILL mode (the renderer adds -webkit-text-fill-color:transparent), so the glyphs go invisible instead of gaining a backdrop. Size the pill to the text: pill.width ≈ textWidth + 32 (textWidth ≈ chars × fontSize × 0.6; caps 0.7) and pill.height ≈ fontSize×1.4 + 12–16. Then center the LINE BOX, not your declared height — the renderer draws text-blocks height:AUTO from \`top\` (declared height is IGNORED), so: text.top = pill.top + (pill.height − fontSize×1.4)/2, text box same center-x as the pill with textAlign "center". validate_page re-checks both axes with real font metrics and reports the exact corrected top/left when off.
57
58
  - Set the text-block's height to that estimate, and place the NEXT element's top ≥ this element's top + height + gap (≥8px).
58
59
  - EQUAL CARDS: if ANY card's title wraps to 2 lines, give EVERY card in the row the 2-line title height so all bodies start at the same top — never let one card's body ride up under its title.
59
60
  - COMPOSED VALUES: render "2.400+" / "94%" / a number with its suffix as ONE string in ONE text-block — never split the number and its suffix/icon into separately positioned elements (they drift apart and the suffix orphans onto its own line).
@@ -113,7 +114,7 @@ SECTION BUILD HINTS (apply to whichever sections the chosen archetype uses)
113
114
  RULES
114
115
  - Visible content goes in "specials" (text-block.specials.text, image-block.specials.src…), NEVER in "styles".
115
116
  - Colors as rgba(r,g,b,a). fontSize/borderWidth/top/left/width/height are NUMBERS (px). borderRadius is a STRING with CSS units ("8px", "50%", "16px 16px 0 0") — a bare number or unit-less string is auto-coerced to px by the server, but write the unit explicitly to avoid surprises.
116
- - IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL (ingest AST images/background_images/og_image) → re-host them via upload_images and use those EXACT images in their slots — never swap them for stock photos; (2) only for slots with NO source image → call search_images with a short English subject (e.g. 'fresh coffee cup', 'modern office team') and put a returned URL into image-block specials.src — use src.large for a hero/banner, src.medium for a card/thumb (avg_color helps pick a matching section background). ONLY if search_images returns ok:false (or is unreachable) FALL BACK to a PLACEHOLDER sized to the box: "https://placehold.co/<width>x<height>". NEVER leave src empty — it renders blank on the live page. The server automatically derives styles.background from specials.src on every expand (create/update/validate) using the editor's exact format: 'center center/ cover no-repeat scroll content-box url(<src>) border-box' — you do NOT need to set styles.background manually; if you do hand-write it, it must contain url(...). A SECTION background may layer a gradient overlay over an image: 'linear-gradient(...), center center/ cover no-repeat scroll content-box url(<src>) border-box' — the server canonicalises any url() layer into that exact editor shorthand on expand (other url() formats survive the first save but get mangled to 'undefined/ undefined/ …' the moment the page is edited in the Webcake editor). gallery.media = array of OBJECTS {type:'image', link:'<real-or-placeholder-url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} (NOT plain URL strings — the gallery reads item.link); video.specials.img = a poster image (real photo, else placeholder). Do NOT set a flat (no url()) styles.background on a video element — it suppresses the poster image.
117
+ - IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL (ingest AST images/background_images/og_image) → re-host them via upload_images and use those EXACT images in their slots — never swap them for stock photos; (2) only for slots with NO source image → call search_images with a short English subject (e.g. 'fresh coffee cup', 'modern office team') and put a returned URL into image-block specials.src — use src.large for a hero/banner, src.medium for a card/thumb (avg_color helps pick a matching section background); (3) if search_images returns ok:false, is unreachable, or has NO photo that fits the slot → find a real image YOURSELF using whatever web search/fetch capability you have (the brand's own site, the product page, a free-to-use source), then re-host it via upload_images and use the returned URL; (4) a PLACEHOLDER is the LAST resort, ONLY after (2) AND (3) both failed — sized to the box: "https://placehold.co/<width>x<height>". Never jump straight from a failed search to a placeholder without trying (3). NEVER leave src empty — it renders blank on the live page. The server automatically derives styles.background from specials.src on every expand (create/update/validate) using the editor's exact format: 'center center/ cover no-repeat scroll content-box url(<src>) border-box' — you do NOT need to set styles.background manually; if you do hand-write it, it must contain url(...). A SECTION background may layer a gradient overlay over an image: 'linear-gradient(...), center center/ cover no-repeat scroll content-box url(<src>) border-box' — the server canonicalises any url() layer into that exact editor shorthand on expand (other url() formats survive the first save but get mangled to 'undefined/ undefined/ …' the moment the page is edited in the Webcake editor). gallery.media = array of OBJECTS {type:'image', link:'<real-or-placeholder-url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} (NOT plain URL strings — the gallery reads item.link); video.specials.img = a poster image (real photo, else placeholder). Do NOT set a flat (no url()) styles.background on a video element — it suppresses the poster image.
117
118
  - CONTRAST (check EVERY text element against the band it sits on, especially SATURATED / mid-tone bands like yellow, orange, teal, pink — there "light vs dark text" is not obvious, so decide by the band's luminance): light bands → near-black text (e.g. rgba(20,30,25,1)); dark bands → near-white text; a saturated/mid-tone band → whichever of near-black or near-white actually reads (for a bright yellow/amber band that means DARK text, not white/grey). NEVER use muted-grey, low-alpha (alpha < ~0.85), or near-white text on a colored band — that is exactly what makes labels look faded/sunken. Muted-grey is ONLY for secondary text on a white/very-light band. Icons and their captions follow the SAME rule as the text beside them.
118
119
  - movable:false for section/slide/grid-item/popup; otherwise true. runtime is always {}.
119
120
  - Every form input MUST have a unique specials.field_name.
@@ -145,7 +146,7 @@ WORKFLOW (recommended)
145
146
  1. Call get_generation_guide (this) once, then new_page_skeleton for the top-level shape.
146
147
  2. For each element type you'll use, call get_element to learn its specials & see an example.
147
148
  3. Optionally call new_element to get a correct skeleton, then fill specials + coordinates.
148
- 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.
149
+ 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. If search_images fails or has no fitting photo, find a real image yourself (web search/fetch → upload_images). Use placehold.co ONLY as the last resort when both search_images AND your own search failed.
149
150
  4. Assemble { page, popup, settings, options, cartConfigs }.
150
151
  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).
151
152
  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.
@@ -37,7 +37,7 @@ MODEL (essentials):
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.
39
39
  - 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).
40
- - 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.
40
+ - 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); (3) if search_images returns ok:false / is unreachable / has no fitting photo → find a real image YOURSELF using whatever web search/fetch capability you have (brand site, product page, free-to-use source) and re-host it via upload_images; (4) a PLACEHOLDER sized to the box (https://placehold.co/<width>x<height>) is the LAST resort, only after (2) AND (3) both failed. (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.
41
41
 
42
42
  - 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.
43
43
 
@@ -64,38 +64,42 @@ function measurePx(text, fs, t, letterSpacing) {
64
64
  }
65
65
  return (units / 1000) * fs + letterSpacing * Math.max(0, count - 1);
66
66
  }
67
- /** Greedy word-wrap: number of rendered lines for one explicit-break segment. */
67
+ /** Greedy word-wrap of one explicit-break segment: line count + widest painted line. */
68
68
  function wrapLines(seg, width, fs, t, ls) {
69
69
  const spaceW = (charMille(t, " ") / 1000) * fs + ls;
70
70
  let lines = 1;
71
71
  let lineW = 0; // width consumed on the current line; 0 = fresh line
72
+ let maxW = 0;
72
73
  const placeOnFreshLine = (wordW) => {
73
74
  // a word wider than the box breaks across lines (≈ break-word; close
74
75
  // enough for estimation — it pushes content down either way).
75
76
  const extra = Math.max(0, Math.ceil(wordW / width) - 1);
76
77
  lines += extra;
77
78
  lineW = wordW - extra * width;
79
+ maxW = Math.max(maxW, Math.min(wordW, width));
78
80
  };
79
81
  for (const word of seg.split(/\s+/).filter(Boolean)) {
80
82
  const wordW = measurePx(word, fs, t, ls);
81
83
  if (lineW === 0)
82
84
  placeOnFreshLine(wordW);
83
- else if (lineW + spaceW + wordW <= width)
85
+ else if (lineW + spaceW + wordW <= width) {
84
86
  lineW += spaceW + wordW;
87
+ maxW = Math.max(maxW, lineW);
88
+ }
85
89
  else {
86
90
  lines++;
87
91
  placeOnFreshLine(wordW);
88
92
  }
89
93
  }
90
- return lines;
94
+ return { lines, maxW };
91
95
  }
92
96
  /**
93
- * Estimated rendered height (px) of a text-block's specials.text given its
94
- * breakpoint styles and the page font (settings.fontGeneral). Returns
95
- * undefined for empty text or template variables ({{…}}) whose rendered
96
- * length is unknown — same contract as the old heuristic.
97
+ * Measure a text-block's specials.text against its breakpoint styles using the
98
+ * real font tables: rendered line count, widest painted line, and the line box.
99
+ * Returns undefined for empty text or template variables ({{…}}) whose
100
+ * rendered length is unknown.
97
101
  */
98
- export function estTextHeightPx(rawText, styles, pageFont) {
102
+ export function measureTextBlock(rawText, styles, pageFont) {
99
103
  const fs = num(styles?.fontSize) ?? 16;
100
104
  const width = num(styles?.width);
101
105
  if (rawText.includes("{{") || !(fs > 0) || !width || !(width > 0))
@@ -121,8 +125,19 @@ export function estTextHeightPx(rawText, styles, pageFont) {
121
125
  : (typeof styles.lineHeight === "string" && /px/i.test(styles.lineHeight)) || lhRaw > 4 ? lhRaw
122
126
  : fs * lhRaw;
123
127
  let lines = 0;
128
+ let maxLineWidthPx = 0;
124
129
  for (const seg of segments) {
125
- lines += wrapLines(upper ? seg.toUpperCase() : seg, width, fs, table, ls);
130
+ const r = wrapLines(upper ? seg.toUpperCase() : seg, width, fs, table, ls);
131
+ lines += r.lines;
132
+ maxLineWidthPx = Math.max(maxLineWidthPx, r.maxW);
126
133
  }
127
- return Math.round(lines * lineHeightPx);
134
+ return { lines, maxLineWidthPx, lineHeightPx };
135
+ }
136
+ /**
137
+ * Estimated rendered height (px) of a text-block's specials.text — same
138
+ * contract as the old heuristic (undefined for empty/template text).
139
+ */
140
+ export function estTextHeightPx(rawText, styles, pageFont) {
141
+ const m = measureTextBlock(rawText, styles, pageFont);
142
+ return m ? Math.round(m.lines * m.lineHeightPx) : undefined;
128
143
  }
@@ -10,7 +10,7 @@ import { readFileSync } from "node:fs";
10
10
  import Ajv2020Module from "ajv/dist/2020.js";
11
11
  import { CONTAINER_TYPES, FIELD_TYPES } from "./elements/index.js";
12
12
  import { ANIMATABLE_TYPES, ANIMATION_NAMES } from "./vocab.js";
13
- import { estTextHeightPx } from "./text-metrics.js";
13
+ import { estTextHeightPx, measureTextBlock } from "./text-metrics.js";
14
14
  // ajv ships as CJS; under Node16 ESM the constructor is on `.default`.
15
15
  const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
16
16
  // Loaded at runtime (the build copies this JSON beside the compiled validator)
@@ -879,6 +879,92 @@ export function validatePage(input) {
879
879
  });
880
880
  };
881
881
  topList.forEach((sec, i) => checkTextOverlap(sec, `page[${i}]`));
882
+ // 3c2) Pill/badge alignment — the classic "background hugging a label"
883
+ // pattern is a rounded rectangle with a single-line text-block layered
884
+ // on top. The renderer draws text-blocks with height:AUTO from `top`
885
+ // (declared height is ignored), so the glyph row sits at top + lineBox/2
886
+ // — models that eyeball `top` against the pill leave the text visibly
887
+ // off-center. With real font metrics we can check both axes and name
888
+ // the exact corrected coordinates.
889
+ let pillWarnings = 0;
890
+ const MAX_PILL_WARNINGS = 12;
891
+ const isPillRect = (sib, bp) => {
892
+ if (sib?.type !== "rectangle")
893
+ return false;
894
+ if (sib.responsive?.[bp]?.config?.svgMask)
895
+ return false; // icon, not a pill
896
+ const ss = sib.responsive?.[bp]?.styles;
897
+ const br = ss?.borderRadius;
898
+ const hasRadius = br != null && String(br).trim() !== "" && parseFloat(String(br)) !== 0;
899
+ const h = num(ss?.height);
900
+ const w = num(ss?.width);
901
+ return hasRadius && h != null && h <= 88 && w != null && w <= 600;
902
+ };
903
+ const checkPillAlignment = (container, path) => {
904
+ if (!container || !Array.isArray(container.children))
905
+ return;
906
+ const kids = container.children;
907
+ kids.forEach((child, idx) => {
908
+ if (!child || typeof child !== "object")
909
+ return;
910
+ const cpath = `${path}.children[${idx}]`;
911
+ const rawText = child.type === "text-block" ? child.specials?.text : undefined;
912
+ if (typeof rawText === "string") {
913
+ for (const bp of ["desktop", "mobile"]) {
914
+ if (pillWarnings >= MAX_PILL_WARNINGS)
915
+ break;
916
+ const s = child.responsive?.[bp]?.styles;
917
+ const top = num(s?.top);
918
+ const left = num(s?.left);
919
+ const w = num(s?.width);
920
+ if (top == null || left == null || !w)
921
+ continue;
922
+ const m = measureTextBlock(rawText, s, pageFont);
923
+ if (!m || m.lines !== 1)
924
+ continue; // pill labels are single-line
925
+ // the pill: a rounded rectangle sibling whose box contains the text row
926
+ const pill = kids.find((sib, j) => {
927
+ if (j === idx || !isPillRect(sib, bp))
928
+ return false;
929
+ const ss = sib.responsive[bp].styles;
930
+ const rt = num(ss?.top), rl = num(ss?.left), rw = num(ss?.width), rh = num(ss?.height);
931
+ if (rt == null || rl == null || !rw || !rh)
932
+ return false;
933
+ return top >= rt - 2 && top < rt + rh && left >= rl - rw * 0.25 && left + w <= rl + rw * 1.25;
934
+ });
935
+ if (!pill)
936
+ continue;
937
+ const ps = pill.responsive[bp].styles;
938
+ const pTop = num(ps.top), pLeft = num(ps.left), pW = num(ps.width), pH = num(ps.height);
939
+ // vertical: glyph row center vs pill center
940
+ const dy = Math.round(top + m.lineHeightPx / 2 - (pTop + pH / 2));
941
+ if (Math.abs(dy) > 4) {
942
+ warnings.push(`${cpath} (text-block) [${bp}]: badge label sits ~${Math.abs(dy)}px ${dy > 0 ? "BELOW" : "ABOVE"} the center of its pill (${pill.id}) — text-blocks render with height:auto from \`top\` (declared height is ignored), so center the LINE BOX, not the styles.height: set top = ${Math.round(pTop + (pH - m.lineHeightPx) / 2)} (pill top ${pTop} + (pill height ${pH} − line box ${Math.round(m.lineHeightPx)})/2).`);
943
+ pillWarnings++;
944
+ }
945
+ // text wider than the pill → spills out both ends
946
+ if (m.maxLineWidthPx > pW - 8) {
947
+ warnings.push(`${cpath} (text-block) [${bp}]: badge label is ~${Math.round(m.maxLineWidthPx)}px wide but its pill (${pill.id}) is only ${pW}px — the text spills past the rounded background. Set the pill width ≈ ${Math.ceil(m.maxLineWidthPx + 32)} (text + 16px padding each side) and re-center it.`);
948
+ pillWarnings++;
949
+ }
950
+ else {
951
+ // horizontal: painted text center vs pill center
952
+ const centered = typeof s?.textAlign === "string" && /center/i.test(s.textAlign);
953
+ const tCx = centered ? left + w / 2 : left + m.maxLineWidthPx / 2;
954
+ const dx = Math.round(tCx - (pLeft + pW / 2));
955
+ if (Math.abs(dx) > 6) {
956
+ const fixLeft = centered ? Math.round(pLeft + pW / 2 - w / 2) : Math.round(pLeft + (pW - m.maxLineWidthPx) / 2);
957
+ warnings.push(`${cpath} (text-block) [${bp}]: badge label is ~${Math.abs(dx)}px ${dx > 0 ? "RIGHT" : "LEFT"} of its pill's center (${pill.id}) — set left = ${fixLeft}${centered ? "" : " (or add textAlign:'center' and center the box on the pill)"}.`);
958
+ pillWarnings++;
959
+ }
960
+ }
961
+ }
962
+ }
963
+ if (Array.isArray(child.children) && child.children.length > 0)
964
+ checkPillAlignment(child, cpath);
965
+ });
966
+ };
967
+ topList.forEach((sec, i) => checkPillAlignment(sec, `page[${i}]`));
882
968
  // 3d) Trailing dead space — a section far taller than its lowest content
883
969
  // renders as a big empty band, which reads as a broken/unfinished page.
884
970
  // Threshold is generous (320px) since text auto-grow and bottom padding
package/dist/smoke.js CHANGED
@@ -1135,5 +1135,44 @@ console.log("== validator: rectangle svgMask needs a visible background ==");
1135
1135
  const rStray = validatePage(expandSource(straySrc, createElement));
1136
1136
  check("svgMask: placed in specials → placement warning", rStray.warnings.some((w) => w.includes("ONLY reads responsive.<bp>.config.svgMask")), rStray.warnings);
1137
1137
  }
1138
+ console.log("== validator: pill/badge label alignment ==");
1139
+ {
1140
+ const badge = (textTop, textLeft, textW, textOpts = {}, pillOpts = {}) => ({
1141
+ page: [{
1142
+ id: "psec", type: "section",
1143
+ responsive: { desktop: { styles: { height: 400 } }, mobile: { styles: { height: 400 } } },
1144
+ children: [
1145
+ {
1146
+ id: "pill", type: "rectangle",
1147
+ responsive: {
1148
+ desktop: { styles: { top: 100, left: 330, width: 300, height: 36, borderRadius: "999px", background: "rgba(59,130,246,0.15)", ...pillOpts } },
1149
+ mobile: { styles: { top: 100, left: 60, width: 300, height: 36, borderRadius: "999px", background: "rgba(59,130,246,0.15)", ...pillOpts } },
1150
+ },
1151
+ },
1152
+ {
1153
+ id: "label", type: "text-block",
1154
+ responsive: {
1155
+ desktop: { styles: { top: textTop, left: textLeft, width: textW, height: 20, fontSize: 14, fontWeight: 600, textAlign: "center", ...textOpts } },
1156
+ mobile: { styles: { top: textTop, left: textLeft - 270, width: textW, height: 20, fontSize: 14, fontWeight: 600, textAlign: "center", ...textOpts } },
1157
+ },
1158
+ specials: { text: "ĐỐI TÁC VẬN CHUYỂN TOÀN QUỐC", tag: "p" },
1159
+ },
1160
+ ],
1161
+ }],
1162
+ settings: { title: "t", description: "d", keywords: "k", lang: "vi" },
1163
+ });
1164
+ // label top eyeballed too low → glyph row sits below the pill center
1165
+ const rLow = validatePage(expandSource(badge(115, 340, 280), createElement));
1166
+ check("pill: label below pill center → warned with exact top", rLow.warnings.some((w) => w.includes("BELOW") && w.includes("set top = 108")), rLow.warnings);
1167
+ // line-box-centered label → silent
1168
+ const rMid = validatePage(expandSource(badge(108, 340, 280), createElement));
1169
+ check("pill: centered label → no badge warnings", !rMid.warnings.some((w) => w.includes("badge label")), rMid.warnings);
1170
+ // label box center 30px right of the pill center
1171
+ const rOff = validatePage(expandSource(badge(108, 370, 280), createElement));
1172
+ check("pill: label off-center horizontally → warned", rOff.warnings.some((w) => w.includes("badge label") && w.includes("RIGHT")), rOff.warnings);
1173
+ // label painted wider than the pill → spills past the rounded ends
1174
+ const rWide = validatePage(expandSource(badge(108, 330, 300, { fontSize: 16, fontWeight: 700 }, { width: 220, left: 370 }), createElement));
1175
+ check("pill: label wider than pill → spill warning", rWide.warnings.some((w) => w.includes("spills past")), rWide.warnings);
1176
+ }
1138
1177
  console.log(`\n${failures === 0 ? "ALL GOOD" : failures + " FAILURE(S)"}`);
1139
1178
  process.exit(failures === 0 ? 0 : 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.64",
3
+ "version": "1.0.66",
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",