webcake-landing-mcp 1.0.64 → 1.0.65
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/dist/changelog.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.0.65",
|
|
4
|
+
"d": "12/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"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…",
|
|
7
|
+
"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…"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"v": "1.0.64",
|
|
4
11
|
"d": "12/06/2026",
|
|
@@ -33,12 +40,5 @@
|
|
|
33
40
|
"type": "Fixed",
|
|
34
41
|
"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
42
|
"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).
|
|
@@ -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
|
|
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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* undefined for empty text or template variables ({{…}}) whose
|
|
96
|
-
* length is unknown
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.0.65",
|
|
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",
|