webcake-landing-mcp 1.5.1 → 1.5.2

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,11 @@
1
1
  [
2
+ {
3
+ "v": "1.5.2",
4
+ "d": "25/08/2026",
5
+ "type": "Fixed",
6
+ "en": "Colors written in any CSS notation other than the legacy rgba(r,g,b,a) form (hex, rgb(), hsl(), CSS named colors, and the modern space/slash rgba()…",
7
+ "vi": "Các giá trị màu được viết ở bất kỳ dạng CSS nào khác ngoài dạng rgba(r,g,b,a) kiểu cũ (hex, rgb(), hsl(), tên màu CSS, và cú pháp rgba() mới dùng…"
8
+ },
2
9
  {
3
10
  "v": "1.5.1",
4
11
  "d": "24/08/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Changed",
34
41
  "en": "Server instructions and get_generation_guide now restrict the visual-check step to three explicit triggers — cloning a reference to compare pixel…",
35
42
  "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 độ…"
36
- },
37
- {
38
- "v": "1.2.0",
39
- "d": "21/06/2026",
40
- "type": "Added",
41
- "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…",
42
- "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…"
43
43
  }
44
44
  ]
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Colour canonicalisation — pure helpers, no I/O, no domain imports.
3
+ *
4
+ * WHY THIS EXISTS. The live renderer emits colour values RAW into CSS, so a hex
5
+ * / `rgb()` / `hsl()` / named colour renders perfectly on the published page.
6
+ * The Webcake EDITOR does not: its colour traits re-parse the stored string with
7
+ * hand-rolled parsers that only understand the legacy comma `rgba(r,g,b,a)`
8
+ * form, and every one of them falls back to BLACK when the parse fails.
9
+ * Confirmed read paths in landing_page_backend/assets/editor:
10
+ *
11
+ * • common.js:128 `parseBackground` scans for `url|linear-gradient|
12
+ * radial-gradient|rgba` ONLY. Note the literal "rgba" — `rgb(...)` without
13
+ * the `a` does not match either.
14
+ * • traits/Background.vue:163 — for a text-block the background trait shows
15
+ * `styles.color` (that IS the text-colour swatch: traitGroup.js:2 gives
16
+ * text-block no 'color' trait). Background.vue:264-277 `makeBg()` runs the
17
+ * value through `parseBackground` and, when nothing parses, pushes a
18
+ * hard-coded `rgba(0, 0, 0, 1)` for a text-block.
19
+ * • traits/Colour.vue:62-67 (button / form / countdown / …) — falls back to
20
+ * `'#000000'` when `mixStyle.color` is falsy, and common.js:186
21
+ * `rgbaToArray` does a blind `rgba.substr(5)`, so anything not starting with
22
+ * `rgba(` decodes to NaN.
23
+ *
24
+ * So an MCP-authored page whose text colour is `#ffffff` renders white but shows
25
+ * BLACK in the editor's trait panel — and the moment a user touches that trait
26
+ * the black is written back and the page really does turn black.
27
+ *
28
+ * Fix: canonicalise every colour-bearing style/config value to `rgba(r,g,b,a)`
29
+ * during expand (see normalizeColors in ./index.ts). Values already in the
30
+ * legacy comma-`rgba()` form are returned BYTE-IDENTICAL so a save never
31
+ * rewrites colours the editor itself wrote.
32
+ */
33
+ /** Split a CSS value into top-level (paren-aware) comma-separated layers. */
34
+ export function splitCssLayers(value) {
35
+ const layers = [];
36
+ let depth = 0;
37
+ let cur = "";
38
+ for (const ch of value) {
39
+ if (ch === "(")
40
+ depth++;
41
+ else if (ch === ")")
42
+ depth--;
43
+ if (ch === "," && depth === 0) {
44
+ layers.push(cur);
45
+ cur = "";
46
+ }
47
+ else
48
+ cur += ch;
49
+ }
50
+ layers.push(cur);
51
+ return layers.map((l) => l.trim()).filter((l) => l !== "");
52
+ }
53
+ /**
54
+ * The exact shape the editor's parsers can read: `rgba(` + three comma-separated
55
+ * numbers + an alpha. A value matching this is left untouched.
56
+ * (`rgb(…)`, the modern `rgba(255 255 255 / .5)` space syntax, hex, hsl and
57
+ * named colours all FAIL the editor's parsers and must be converted.)
58
+ */
59
+ export const EDITOR_RGBA = /^rgba\(\s*-?[\d.]+\s*,\s*-?[\d.]+\s*,\s*-?[\d.]+\s*,\s*-?[\d.]+%?\s*\)$/i;
60
+ /**
61
+ * CSS-wide keywords and `transparent` — never rewritten. `transparent` in
62
+ * particular is load-bearing: the editor's Colour trait pairs it with
63
+ * `config.colorHidden` to implement its hide/show toggle (Colour.vue:126-133),
64
+ * so converting it to `rgba(0,0,0,0)` would break that toggle.
65
+ */
66
+ const PASS_THROUGH = new Set([
67
+ "transparent", "inherit", "currentcolor", "initial", "unset", "revert", "none", "auto",
68
+ ]);
69
+ /** The CSS named colours a model realistically writes. */
70
+ const NAMED_COLORS = {
71
+ black: [0, 0, 0], white: [255, 255, 255], red: [255, 0, 0], lime: [0, 255, 0],
72
+ blue: [0, 0, 255], yellow: [255, 255, 0], cyan: [0, 255, 255], aqua: [0, 255, 255],
73
+ magenta: [255, 0, 255], fuchsia: [255, 0, 255], silver: [192, 192, 192],
74
+ gray: [128, 128, 128], grey: [128, 128, 128], maroon: [128, 0, 0], olive: [128, 128, 0],
75
+ green: [0, 128, 0], purple: [128, 0, 128], teal: [0, 128, 128], navy: [0, 0, 128],
76
+ orange: [255, 165, 0], gold: [255, 215, 0], pink: [255, 192, 203],
77
+ hotpink: [255, 105, 180], crimson: [220, 20, 60], salmon: [250, 128, 114],
78
+ coral: [255, 127, 80], tomato: [255, 99, 71], orangered: [255, 69, 0],
79
+ brown: [165, 42, 42], chocolate: [210, 105, 30], tan: [210, 180, 140],
80
+ beige: [245, 245, 220], ivory: [255, 255, 240], khaki: [240, 230, 140],
81
+ indigo: [75, 0, 130], violet: [238, 130, 238], orchid: [218, 112, 214],
82
+ plum: [221, 160, 221], lavender: [230, 230, 250], turquoise: [64, 224, 208],
83
+ skyblue: [135, 206, 235], steelblue: [70, 130, 180], royalblue: [65, 105, 225],
84
+ dodgerblue: [30, 144, 255], midnightblue: [25, 25, 112], seagreen: [46, 139, 87],
85
+ forestgreen: [34, 139, 34], darkgreen: [0, 100, 0], darkred: [139, 0, 0],
86
+ darkblue: [0, 0, 139], darkgray: [169, 169, 169], darkgrey: [169, 169, 169],
87
+ lightgray: [211, 211, 211], lightgrey: [211, 211, 211], dimgray: [105, 105, 105],
88
+ dimgrey: [105, 105, 105], slategray: [112, 128, 144], slategrey: [112, 128, 144],
89
+ whitesmoke: [245, 245, 245], snow: [255, 250, 250], linen: [250, 240, 230],
90
+ wheat: [245, 222, 179], mintcream: [245, 255, 250], aliceblue: [240, 248, 255],
91
+ ghostwhite: [248, 248, 255],
92
+ };
93
+ const clampByte = (n) => Math.max(0, Math.min(255, Math.round(n)));
94
+ /** 1 → "1", 0.5 → "0.5", 0.88 → "0.88" (never "1.000" — matches the guide's shape). */
95
+ function fmtAlpha(a) {
96
+ if (!Number.isFinite(a))
97
+ return "1";
98
+ const clamped = Math.max(0, Math.min(1, a));
99
+ const s = clamped.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
100
+ return s === "" ? "0" : s;
101
+ }
102
+ const rgba = (r, g, b, a) => `rgba(${clampByte(r)},${clampByte(g)},${clampByte(b)},${fmtAlpha(a)})`;
103
+ /** One channel of an rgb() function: a 0–255 number or a percentage. */
104
+ function channel(tok) {
105
+ return tok.endsWith("%") ? (parseFloat(tok) / 100) * 255 : parseFloat(tok);
106
+ }
107
+ /** An rgb()/hsl() alpha argument: a 0–1 number or a percentage. */
108
+ function alphaArg(tok) {
109
+ if (tok === undefined)
110
+ return 1;
111
+ return tok.endsWith("%") ? parseFloat(tok) / 100 : parseFloat(tok);
112
+ }
113
+ /** hsl → rgb (h in degrees, s/l as 0–1 fractions). */
114
+ function hslToRgb(h, s, l) {
115
+ const hh = (((h % 360) + 360) % 360) / 60;
116
+ const c = (1 - Math.abs(2 * l - 1)) * s;
117
+ const x = c * (1 - Math.abs((hh % 2) - 1));
118
+ const m = l - c / 2;
119
+ const [r, g, b] = hh < 1 ? [c, x, 0] : hh < 2 ? [x, c, 0] : hh < 3 ? [0, c, x]
120
+ : hh < 4 ? [0, x, c] : hh < 5 ? [x, 0, c] : [c, 0, x];
121
+ return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
122
+ }
123
+ /**
124
+ * Canonicalise ONE colour token to the editor-readable `rgba(r,g,b,a)`.
125
+ * Returns null when the token needs no change (already editor-readable, a
126
+ * CSS-wide keyword) or cannot be understood (`var(--x)`, `color-mix(…)`, …) —
127
+ * so a caller can leave the original string exactly as it found it.
128
+ */
129
+ export function toRgba(input) {
130
+ const s = input.trim();
131
+ if (s === "")
132
+ return null;
133
+ if (EDITOR_RGBA.test(s))
134
+ return null; // already the shape every editor trait parses
135
+ const lower = s.toLowerCase();
136
+ if (PASS_THROUGH.has(lower))
137
+ return null;
138
+ const hex = lower.match(/^#([0-9a-f]+)$/);
139
+ if (hex) {
140
+ const h = hex[1];
141
+ const dup = (c) => parseInt(c + c, 16);
142
+ const pair = (i) => parseInt(h.slice(i, i + 2), 16);
143
+ if (h.length === 3)
144
+ return rgba(dup(h[0]), dup(h[1]), dup(h[2]), 1);
145
+ if (h.length === 4)
146
+ return rgba(dup(h[0]), dup(h[1]), dup(h[2]), dup(h[3]) / 255);
147
+ if (h.length === 6)
148
+ return rgba(pair(0), pair(2), pair(4), 1);
149
+ if (h.length === 8)
150
+ return rgba(pair(0), pair(2), pair(4), pair(6) / 255);
151
+ return null; // 5/7-digit hex is not valid CSS — leave it for the validator to flag
152
+ }
153
+ // rgb()/rgba() in ANY separator syntax (comma, space, slash alpha).
154
+ const rgbFn = lower.match(/^rgba?\(([^()]*)\)$/);
155
+ if (rgbFn) {
156
+ const parts = rgbFn[1].split(/[\s,/]+/).filter((p) => p !== "");
157
+ if (parts.length < 3)
158
+ return null;
159
+ const [r, g, b] = [channel(parts[0]), channel(parts[1]), channel(parts[2])];
160
+ const a = alphaArg(parts[3]);
161
+ if (![r, g, b, a].every(Number.isFinite))
162
+ return null;
163
+ return rgba(r, g, b, a);
164
+ }
165
+ const hslFn = lower.match(/^hsla?\(([^()]*)\)$/);
166
+ if (hslFn) {
167
+ const parts = hslFn[1].split(/[\s,/]+/).filter((p) => p !== "");
168
+ if (parts.length < 3)
169
+ return null;
170
+ const h = parseFloat(parts[0]); // deg/turn/rad suffixes: parseFloat takes the number, deg is the default
171
+ const sat = parseFloat(parts[1]) / 100;
172
+ const lig = parseFloat(parts[2]) / 100;
173
+ const a = alphaArg(parts[3]);
174
+ if (![h, sat, lig, a].every(Number.isFinite))
175
+ return null;
176
+ const [r, g, b] = hslToRgb(h, Math.max(0, Math.min(1, sat)), Math.max(0, Math.min(1, lig)));
177
+ return rgba(r, g, b, a);
178
+ }
179
+ const named = NAMED_COLORS[lower];
180
+ return named ? rgba(named[0], named[1], named[2], 1) : null;
181
+ }
182
+ /**
183
+ * Colour tokens INSIDE a gradient layer. The bare-word alternative catches named
184
+ * colours (`linear-gradient(180deg, white 0%, black 100%)`); gradient keywords
185
+ * (`to`, `deg`, `circle`, `closest-side`…) are not colour names, so toRgba
186
+ * returns null for them and they pass through untouched.
187
+ */
188
+ const COLOR_TOKEN = /#[0-9a-fA-F]+\b|\b(?:rgba?|hsla?)\([^()]*\)|\b[a-zA-Z]{3,20}\b/g;
189
+ /**
190
+ * Canonicalise every colour in a full style value — a bare colour, a
191
+ * comma-separated layer list, or the colour stops inside a gradient. `url()`
192
+ * layers are left alone (normalizeBackgrounds in ./index.ts owns those).
193
+ * Returns the ORIGINAL string when nothing needed changing, so the pass is
194
+ * byte-stable and idempotent.
195
+ */
196
+ export function normalizeColorValue(value) {
197
+ if (typeof value !== "string" || value.trim() === "")
198
+ return value;
199
+ let changed = false;
200
+ const out = splitCssLayers(value).map((layer) => {
201
+ if (layer.includes("url("))
202
+ return layer;
203
+ const next = /gradient\(/i.test(layer)
204
+ ? layer.replace(COLOR_TOKEN, (tok) => toRgba(tok) ?? tok)
205
+ : toRgba(layer) ?? layer;
206
+ if (next !== layer)
207
+ changed = true;
208
+ return next;
209
+ });
210
+ return changed ? out.join(", ") : value;
211
+ }
212
+ /**
213
+ * True for a style/config key whose value is a colour the editor's traits parse:
214
+ * anything named `*color*` (color, borderColor, colorBtn, iconColor, …) plus the
215
+ * three colour-bearing keys that are not spelled "color".
216
+ */
217
+ export function isColorKey(key) {
218
+ return /colou?r/i.test(key) || key === "background" || key === "backgroundTxt" || key === "overlay";
219
+ }
220
+ /**
221
+ * True when a colour value will render BLACK in the editor's trait panel: not a
222
+ * gradient/url, not a CSS-wide keyword, and not the legacy `rgba()` form the
223
+ * traits can parse. Used by the validator to warn about the values
224
+ * normalizeColorValue could not rescue (`var(--x)`, `color-mix(…)`, …).
225
+ */
226
+ export function isUnparseableByEditor(value) {
227
+ if (typeof value !== "string")
228
+ return false;
229
+ const s = value.trim();
230
+ if (s === "" || PASS_THROUGH.has(s.toLowerCase()))
231
+ return false;
232
+ if (s.includes("url(") || /gradient\(/i.test(s))
233
+ return false;
234
+ return splitCssLayers(s).some((layer) => !EDITOR_RGBA.test(layer));
235
+ }
@@ -113,7 +113,7 @@ SECTION BUILD HINTS (apply to whichever sections the chosen archetype uses)
113
113
 
114
114
  RULES
115
115
  - Visible content goes in "specials" (text-block.specials.text, image-block.specials.src…), NEVER in "styles".
116
- - Colors as rgba(r,g,b,a). fontSize/borderWidth/top/left/width/height are NUMBERS (px) — always EMIT numbers. (Reading an existing page you may still meet strings: the editor saves fontSize as "59"/"13px", height as "600", opacity as "50%". The server normalizes those back to numbers on both get_page and save, so a hand-edited page is never locked out of the MCP; only a value with a unit the renderer cannot use — e.g. width:"100%" — is reported, as a warning.) 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
+ - Colors as rgba(r,g,b,a) — ALWAYS, and on BOTH breakpoints. The published page renders any CSS color, but the EDITOR does not read them all back: its color traits only parse the rgba() form, so a hex / rgb() / hsl() / named color shows BLACK in the trait panel and that black is SAVED the first moment a user touches the trait; and because the traits read one breakpoint at a time with no desktop→mobile fallback, a color set on only one breakpoint shows black on the other (and renders black there, since that breakpoint's CSS carries no color at all). The server repairs both slips on every save — it converts hex/rgb()/hsl()/named to rgba() and mirrors color/backgroundTxt/borderColor onto the breakpoint that lacks it — but write rgba() on both breakpoints yourself; what it cannot convert (var(--x), color-mix(…)) comes back as a warning. fontSize/borderWidth/top/left/width/height are NUMBERS (px) — always EMIT numbers. (Reading an existing page you may still meet strings: the editor saves fontSize as "59"/"13px", height as "600", opacity as "50%". The server normalizes those back to numbers on both get_page and save, so a hand-edited page is never locked out of the MCP; only a value with a unit the renderer cannot use — e.g. width:"100%" — is reported, as a warning.) 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.
117
117
  - IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). The save AUTO-HOSTS external image URLs: any real http(s) URL you put in specials.src / a url(...) background / gallery item.link / video poster gets downloaded and re-hosted to the Webcake CDN automatically on create_page/update_page/add_section/patch_page (the response's rehost field reports the count) — so reference and web image URLs do NOT need a pre-call to upload_images. SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL (ingest AST images/background_images/og_image, canvas src/background) → put the REAL source URL straight into the slot and let the save host it; use those EXACT images, never swap them for stock photos, and carry the URL through un-altered (never drop it, never placeholder a slot that had a real source image). The ONE case you must call upload_images yourself FIRST: LOCAL FILE PATHS from the user's computer (pass the path directly in upload_images 'urls' — the save can't read local files; NEVER relay the user's local file through a third-party host like catbox or imgur), then use the returned URL. (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. MATCH THE VARIANT TO THE SLOT SIZE — search_images returns a per-photo \`sizes\` map (delivered WxH per variant) + a \`size_guide\`; pick the variant whose width ≈ the element's RENDERED width (×1–2 for retina), then stop: full-bleed hero/banner (~900–1200px) → src.large (~940px) or src.large2x (~1880px); feature card/thumb (~260–360px) → src.medium (~350px); avatar/logo (~64–120px) → src.tiny (280px). NEVER stretch a small variant across a big slot — it pixelates ('vỡ ảnh'); NEVER put src.original (full-res, often several MB) or an oversized variant in a small card — it makes the page slow/heavy ('nặng trang'). If a photo's NATIVE width (photo.width) is smaller than the slot, pick a higher-res photo instead of upscaling. (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.
118
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.
119
119
  - movable:false for section/slide/grid-item/popup; otherwise true. runtime is always {}.
@@ -9,6 +9,7 @@ import { autofixLayout } from "./autofix-layout.js";
9
9
  import { computeLayout } from "./layout.js";
10
10
  import { expandSource } from "../../core/expand.js";
11
11
  import { compactSource } from "../../core/compact.js";
12
+ import { splitCssLayers, normalizeColorValue, isColorKey } from "./color.js";
12
13
  /** The payload returned by the get_generation_guide tool. */
13
14
  export const guidePayload = {
14
15
  guide: GENERATION_GUIDE,
@@ -135,26 +136,6 @@ function normalizeBorderRadius(node) {
135
136
  // preserving the url. Deterministic + idempotent, so expand(compact(x)) ==
136
137
  // expand(x) still holds.
137
138
  // ---------------------------------------------------------------------------
138
- /** Split a CSS background value into top-level comma-separated layers. */
139
- function splitBackgroundLayers(bg) {
140
- const layers = [];
141
- let depth = 0;
142
- let cur = "";
143
- for (const ch of bg) {
144
- if (ch === "(")
145
- depth++;
146
- else if (ch === ")")
147
- depth--;
148
- if (ch === "," && depth === 0) {
149
- layers.push(cur);
150
- cur = "";
151
- }
152
- else
153
- cur += ch;
154
- }
155
- layers.push(cur);
156
- return layers.map((l) => l.trim()).filter((l) => l !== "");
157
- }
158
139
  /** The editor-canonical url() layer shape its splitBackground() can re-parse. */
159
140
  const CANONICAL_URL_LAYER = /^(left|center|right) (top|center|bottom)\/ (cover|contain|auto|[\d.]+(?:px|%)(?: [\d.]+(?:px|%))?) (no-repeat|repeat|repeat-x|repeat-y|space|round)(?: (scroll|fixed|local))? content-box url\(.+\)(?: border-box)?$/;
160
141
  /** Normalise one styles.background value (returns the input when no url layer). */
@@ -162,7 +143,7 @@ function normalizeBackgroundValue(bg) {
162
143
  if (typeof bg !== "string" || !bg.includes("url("))
163
144
  return bg;
164
145
  const out = [];
165
- for (const layer of splitBackgroundLayers(bg)) {
146
+ for (const layer of splitCssLayers(bg)) {
166
147
  const url = layer.match(/url\((['"]?)(.*?)\1\)/);
167
148
  if (url) {
168
149
  out.push(CANONICAL_URL_LAYER.test(layer) ? layer : imgBackground(url[2]));
@@ -191,6 +172,80 @@ function normalizeBackgrounds(node) {
191
172
  }
192
173
  }
193
174
  // ---------------------------------------------------------------------------
175
+ // colour normalization: the live renderer emits colours RAW into CSS, so a hex
176
+ // / rgb() / hsl() / named colour renders perfectly on the published page — but
177
+ // the EDITOR's colour traits re-parse the stored string and every one of them
178
+ // falls back to BLACK when the parse fails. `parseBackground` (common.js:128)
179
+ // scans for `url|linear-gradient|radial-gradient|rgba` only — note the literal
180
+ // "rgba", so even `rgb(...)` misses — and for a text-block the colour swatch IS
181
+ // that background trait (Background.vue:163 returns styles.color; :272-273
182
+ // pushes a hard-coded rgba(0,0,0,1) when nothing parsed). Colour.vue:67 does the
183
+ // same with '#000000' for button/form/countdown. So `color:"#ffffff"` renders
184
+ // white but shows BLACK in the trait panel — and one touch of that trait writes
185
+ // the black back and the page really does turn black.
186
+ //
187
+ // Second half of the same bug: the traits read `el.responsive[display].styles`
188
+ // (Trait.vue:377-380) with NO desktop→mobile fallback, so a colour set on one
189
+ // breakpoint only shows black on the other — and renders black there too, since
190
+ // the breakpoint's CSS simply carries no colour declaration.
191
+ //
192
+ // Fix: after every expand pass, (a) canonicalise every colour-bearing style/
193
+ // config value to the legacy `rgba(r,g,b,a)` form every editor parser reads, and
194
+ // (b) mirror a foreground colour set on exactly one breakpoint onto the other.
195
+ // Values already in that form are returned byte-identical, so a save never
196
+ // rewrites the colours the editor itself wrote. Deterministic + idempotent, so
197
+ // the expand(compact(x)) == expand(x) invariant holds.
198
+ // ---------------------------------------------------------------------------
199
+ /**
200
+ * Colour keys copied to the other breakpoint when only one carries them.
201
+ * Deliberately a short ALLOWLIST, not every `*color*` key: mirroring
202
+ * `-webkitTextFillColor:transparent` (the gradient-text-fill recipe) without the
203
+ * gradient `background` that goes with it would render the mobile text
204
+ * invisible, and a url()-bearing `background` can legitimately differ per
205
+ * breakpoint. These three are inert when the other breakpoint has no matching
206
+ * geometry (a borderColor with borderWidth:0 paints nothing).
207
+ */
208
+ const MIRRORED_COLOR_KEYS = ["color", "backgroundTxt", "borderColor"];
209
+ /** Walk a tree node and canonicalise + mirror every colour in-place (mutates). */
210
+ function normalizeColors(node) {
211
+ if (!node || typeof node !== "object")
212
+ return;
213
+ for (const bp of ["desktop", "mobile"]) {
214
+ const rbp = node.responsive?.[bp];
215
+ if (!rbp || typeof rbp !== "object")
216
+ continue;
217
+ for (const bag of [rbp.styles, rbp.config]) {
218
+ if (!bag || typeof bag !== "object")
219
+ continue;
220
+ for (const key of Object.keys(bag)) {
221
+ if (!isColorKey(key))
222
+ continue;
223
+ const fixed = normalizeColorValue(bag[key]);
224
+ if (fixed !== bag[key])
225
+ bag[key] = fixed;
226
+ }
227
+ }
228
+ }
229
+ const desktop = node.responsive?.desktop?.styles;
230
+ const mobile = node.responsive?.mobile?.styles;
231
+ if (desktop && typeof desktop === "object" && mobile && typeof mobile === "object") {
232
+ for (const key of MIRRORED_COLOR_KEYS) {
233
+ const dv = desktop[key];
234
+ const mv = mobile[key];
235
+ const dHas = typeof dv === "string" && dv.trim() !== "";
236
+ const mHas = typeof mv === "string" && mv.trim() !== "";
237
+ if (dHas && !mHas)
238
+ mobile[key] = dv;
239
+ else if (mHas && !dHas)
240
+ desktop[key] = mv;
241
+ }
242
+ }
243
+ if (Array.isArray(node.children)) {
244
+ for (const child of node.children)
245
+ normalizeColors(child);
246
+ }
247
+ }
248
+ // ---------------------------------------------------------------------------
194
249
  // misplaced-animation normalization: models regularly emit the animation object
195
250
  // directly under responsive.<bp> instead of responsive.<bp>.config.animation —
196
251
  // the single most common "must NOT have additional properties" schema error,
@@ -335,6 +390,10 @@ function normalizeSource(source) {
335
390
  normalizeMisplacedAnimation(node);
336
391
  normalizeImageBlocks(node);
337
392
  normalizeBorderRadius(node);
393
+ // Colours BEFORE backgrounds: the colour pass rewrites gradient stops
394
+ // and bare-colour layers, the background pass then canonicalises any
395
+ // url() layer in the same value.
396
+ normalizeColors(node);
338
397
  normalizeBackgrounds(node);
339
398
  }
340
399
  }
@@ -103,9 +103,9 @@
103
103
  "height": { "type": ["number", "string", "null"], "description": "px. Section uses this as canvas height. EMIT A NUMBER (the editor saves the string \"600\" when the height box loses focus — normalized on read)." },
104
104
  "position": { "type": "string", "enum": ["absolute", "relative", "fixed", "static"] },
105
105
  "zIndex": { "type": ["number", "string", "null"], "description": "Stacking order. EMIT A NUMBER; numeric strings are normalized." },
106
- "background": { "type": "string", "description": "rgba(...) or gradient." },
106
+ "background": { "type": "string", "description": "rgba(...) or gradient. MUST use the rgba() form for every color/gradient stop — the editor's background trait only parses url()/linear-gradient()/radial-gradient()/rgba(), and falls back to BLACK on anything else (hex, rgb(), hsl(), named). Auto-converted on save; unconvertible values are warned." },
107
107
  "backgroundImage": { "type": "string", "description": "url(...)." },
108
- "color": { "type": "string", "description": "rgba(r,g,b,a)." },
108
+ "color": { "type": "string", "description": "rgba(r,g,b,a). Set it on BOTH breakpoints: the editor trait reads one breakpoint at a time with no desktop\u2192mobile fallback, and a missing/unparseable color shows (and saves back) BLACK. hex/rgb()/hsl()/named are auto-converted to rgba() on save, and a color present on only one breakpoint is mirrored onto the other." },
109
109
  "fontSize": { "type": ["number", "string"], "description": "px. EMIT A NUMBER. Read-tolerance: the editor saves \"59\" (inline text-resize box) and \"13px\" (element pickers); the renderer parseInt()s it, and the MCP normalizes both back to a number." },
110
110
  "fontFamily": { "type": "string" },
111
111
  "fontWeight": { "type": ["string", "number"] },
@@ -117,7 +117,7 @@
117
117
  "textDecoration": { "type": "string" },
118
118
  "borderStyle": { "type": "string", "enum": ["none", "solid", "dashed", "dotted", "double", "groove", "ridge", "inset", "outset", "hidden"], "description": "The full set the editor's border-style dropdown offers (Border.vue)." },
119
119
  "borderWidth": { "type": ["number", "string"], "description": "px. EMIT A NUMBER. Read-tolerance: the editor saves \"1\" / \"0px\"; parseInt()ed by the renderer and normalized here." },
120
- "borderColor": { "type": "string" },
120
+ "borderColor": { "type": "string", "description": "rgba(r,g,b,a) \u2014 same editor-parse rule as styles.color (auto-converted + mirrored across breakpoints on save)." },
121
121
  "borderRadius": { "type": ["string", "number"] },
122
122
  "boxShadow": { "type": "string" },
123
123
  "opacity": { "type": ["number", "string"], "description": "0–1. EMIT A NUMBER. Read-tolerance: the editor Layer trait saves a percent string (\"50%\") — normalized to the 0–1 fraction (0.5) before validation." },
@@ -145,7 +145,7 @@
145
145
  }
146
146
  },
147
147
  "virtualHeight": { "type": ["number", "string", "null"] },
148
- "overlay": { "type": "string", "description": "Overlay color rgba(...)." },
148
+ "overlay": { "type": "string", "description": "Overlay color rgba(...) \u2014 the editor's overlay trait only parses the rgba() form; other notations are auto-converted on save." },
149
149
  "hiddenEffect": { "type": "object", "additionalProperties": true },
150
150
  "svgMask": { "type": "string" },
151
151
  "lock": { "type": "boolean" },
@@ -11,6 +11,7 @@ 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
13
  import { estTextHeightPx, measureTextBlock } from "./text-metrics.js";
14
+ import { isColorKey, isUnparseableByEditor } from "./color.js";
14
15
  // ajv ships as CJS; under Node16 ESM the constructor is on `.default`.
15
16
  const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
16
17
  // Loaded at runtime (the build copies this JSON beside the compiled validator)
@@ -395,6 +396,28 @@ export function validatePage(input) {
395
396
  `for a muted color use rgba() alpha on the color/background property instead.`);
396
397
  }
397
398
  }
399
+ // A colour the editor's traits cannot parse shows BLACK in the trait panel
400
+ // (Background.vue:272-273 / Colour.vue:67) even though the published page
401
+ // renders it correctly — and one touch of that trait writes the black back.
402
+ // landingDomain.expand already converts hex / rgb() / hsl() / named colours,
403
+ // so this fires only for values it could not rescue (var(--x), color-mix(…)).
404
+ for (const bp of ["desktop", "mobile"]) {
405
+ const rbp = node.responsive?.[bp];
406
+ if (!rbp || typeof rbp !== "object")
407
+ continue;
408
+ for (const [bag, label] of [[rbp.styles, "styles"], [rbp.config, "config"]]) {
409
+ if (!bag || typeof bag !== "object")
410
+ continue;
411
+ for (const key of Object.keys(bag)) {
412
+ if (!isColorKey(key) || !isUnparseableByEditor(bag[key]))
413
+ continue;
414
+ warnings.push(`${path} (${type ?? "?"}) [${bp}]: ${label}.${key}="${bag[key]}" is not an rgba() colour — ` +
415
+ `the editor's colour trait cannot parse it and shows BLACK, then writes that black back the first ` +
416
+ `time a user touches the trait. Use rgba(r,g,b,a): ` +
417
+ `patch_page({op:'update',id:'${node.id ?? "?"}',${label}:{${bp}:{${key}:'rgba(…)'}}}).`);
418
+ }
419
+ }
420
+ }
398
421
  // countdown.language must be a key the renderer's lang table knows (or 'custom');
399
422
  // anything else (e.g. a locale code "vi"/"en") crashes the renderer with
400
423
  // "is not iterable" when it destructures lang[language].
package/dist/smoke.js CHANGED
@@ -1535,6 +1535,87 @@ console.log("== background normalization: url() layers canonicalised to the edit
1535
1535
  const reexpanded = landingDomain.expand(landingDomain.compact(expanded));
1536
1536
  check("background round-trip: expand(compact(expand(x))) deep-equals expand(x)", deepEq(reexpanded, expanded));
1537
1537
  }
1538
+ console.log("== colour normalization: editor-readable rgba() + breakpoint mirroring ==");
1539
+ {
1540
+ // A text-block is the sharpest case: traitGroup.js gives it no 'color' trait,
1541
+ // so its colour swatch IS the background trait, which falls back to a
1542
+ // hard-coded rgba(0,0,0,1) whenever parseBackground() finds nothing.
1543
+ const mkColorPage = (desktop, mobile = desktop) => ({
1544
+ page: [
1545
+ {
1546
+ id: "c_sec", type: "section",
1547
+ responsive: {
1548
+ desktop: { styles: { height: 400 } },
1549
+ mobile: { styles: { height: 400 } },
1550
+ },
1551
+ children: [
1552
+ {
1553
+ id: "c_txt", type: "text-block",
1554
+ responsive: {
1555
+ desktop: { styles: { top: 10, left: 10, width: 200, height: 40, ...desktop } },
1556
+ mobile: { styles: { top: 10, left: 10, width: 200, height: 40, ...mobile } },
1557
+ },
1558
+ specials: { text: "hello", tag: "p" },
1559
+ },
1560
+ ],
1561
+ },
1562
+ ],
1563
+ settings: { title: "t", description: "d", keywords: "k", lang: "vi" },
1564
+ });
1565
+ const txtOf = (src, bp = "desktop") => src.page[0].children[0].responsive[bp].styles;
1566
+ // 1) every notation a model realistically writes → the one form the editor parses
1567
+ const cases = [
1568
+ ["hex #ffffff", "#ffffff", "rgba(255,255,255,1)"],
1569
+ ["short hex #fff", "#fff", "rgba(255,255,255,1)"],
1570
+ ["hex+alpha #ffffff80", "#ffffff80", "rgba(255,255,255,0.502)"],
1571
+ ["rgb() (no 'a' → parseBackground misses it)", "rgb(246,4,87)", "rgba(246,4,87,1)"],
1572
+ ["modern slash syntax", "rgba(255 255 255 / 0.5)", "rgba(255,255,255,0.5)"],
1573
+ ["hsl()", "hsl(340,96%,49%)", "rgba(245,5,85,1)"],
1574
+ ["named colour", "white", "rgba(255,255,255,1)"],
1575
+ ];
1576
+ for (const [label, input, want] of cases) {
1577
+ const got = txtOf(landingDomain.expand(mkColorPage({ color: input }))).color;
1578
+ check(`colour: ${label} → ${want}`, got === want, got);
1579
+ }
1580
+ // 2) values the editor ALREADY reads are returned byte-identical (no save churn)
1581
+ for (const untouched of ["rgba(26,32,44,1)", "rgba(0, 0, 0, 1)", "rgba(13,45,58,0.88)", "transparent"]) {
1582
+ const got = txtOf(landingDomain.expand(mkColorPage({ color: untouched }))).color;
1583
+ check(`colour: "${untouched}" left byte-identical`, got === untouched, got);
1584
+ }
1585
+ // 3) idempotent — a second expand changes nothing
1586
+ const once = landingDomain.expand(mkColorPage({ color: "#1a202c" }));
1587
+ const twice = landingDomain.expand(structuredClone(once));
1588
+ check("colour: normalization is idempotent", deepEq(twice, once));
1589
+ // 4) gradient stops are rewritten in place; an all-rgba gradient is untouched
1590
+ const hexGrad = landingDomain.expand(mkColorPage({ background: "linear-gradient(180deg, #fff 0%, #000 100%)" }));
1591
+ check("colour: hex gradient stops → rgba()", txtOf(hexGrad).background === "linear-gradient(180deg, rgba(255,255,255,1) 0%, rgba(0,0,0,1) 100%)", txtOf(hexGrad).background);
1592
+ const rgbaGrad = "linear-gradient(160deg, rgba(13,45,58,0.88) 0%, rgba(10,124,110,0.75) 100%)";
1593
+ check("colour: all-rgba gradient untouched", txtOf(landingDomain.expand(mkColorPage({ background: rgbaGrad }))).background === rgbaGrad);
1594
+ // 5) a url() background layer is left for normalizeBackgrounds (no colour rewrite)
1595
+ const urlBg = landingDomain.expand(mkColorPage({ background: "url(https://x.test/a.jpg) center/cover no-repeat" }));
1596
+ check("colour: url() layer untouched by the colour pass", txtOf(urlBg).background.includes("url(https://x.test/a.jpg)"), txtOf(urlBg).background);
1597
+ // 6) a colour on ONE breakpoint is mirrored onto the other — the trait panel
1598
+ // reads one breakpoint at a time and shows black for the missing one.
1599
+ const dOnly = landingDomain.expand(mkColorPage({ color: "rgba(26,32,44,1)" }, {}));
1600
+ check("colour: desktop-only color mirrored to mobile", txtOf(dOnly, "mobile").color === "rgba(26,32,44,1)", txtOf(dOnly, "mobile"));
1601
+ const mOnly = landingDomain.expand(mkColorPage({}, { color: "#ffffff" }));
1602
+ check("colour: mobile-only color mirrored to desktop (converted)", txtOf(mOnly, "desktop").color === "rgba(255,255,255,1)", txtOf(mOnly, "desktop"));
1603
+ const bothSet = landingDomain.expand(mkColorPage({ color: "rgba(1,2,3,1)" }, { color: "rgba(4,5,6,1)" }));
1604
+ check("colour: an explicit per-breakpoint colour is never overwritten", txtOf(bothSet, "desktop").color === "rgba(1,2,3,1)" && txtOf(bothSet, "mobile").color === "rgba(4,5,6,1)");
1605
+ // 7) the gradient-text-fill key is NOT mirrored — copying it without the
1606
+ // gradient background that goes with it would blank the mobile text.
1607
+ const fill = landingDomain.expand(mkColorPage({ background: "linear-gradient(90deg, rgba(1,2,3,1) 0%, rgba(4,5,6,1) 100%)", "-webkitTextFillColor": "transparent" }, {}));
1608
+ check("colour: -webkitTextFillColor NOT mirrored", txtOf(fill, "mobile")["-webkitTextFillColor"] === undefined, txtOf(fill, "mobile"));
1609
+ // 8) round-trip invariant survives the pass
1610
+ const expandedC = landingDomain.expand(mkColorPage({ color: "#1a202c", borderColor: "rgb(1,2,3)" }, {}));
1611
+ check("colour round-trip: expand(compact(expand(x))) deep-equals expand(x)", deepEq(landingDomain.expand(landingDomain.compact(expandedC)), expandedC));
1612
+ // 9) what the pass cannot convert is reported as a warning, not silently shipped
1613
+ const varColor = landingDomain.expand(mkColorPage({ color: "var(--brand)" }));
1614
+ const varWarn = validatePage(varColor).warnings.filter((w) => /not an rgba\(\) colour/.test(w));
1615
+ check("colour: unconvertible var(--x) warned", varWarn.length > 0, validatePage(varColor).warnings);
1616
+ const okColor = landingDomain.expand(mkColorPage({ color: "#1a202c" }));
1617
+ check("colour: no warning once the value is canonical rgba()", !validatePage(okColor).warnings.some((w) => /not an rgba\(\) colour/.test(w)), validatePage(okColor).warnings);
1618
+ }
1538
1619
  console.log("== text-block styles.background warning (gradient-text-fill mode) ==");
1539
1620
  {
1540
1621
  const mkTbBgPage = (bgValue, withClip) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
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",