webcake-landing-mcp 1.0.5 → 1.0.6

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
@@ -190,7 +190,7 @@ The MCP config is the same as the local one, but `command`/`args` point at `npx`
190
190
  git clone https://github.com/vuluu2k/webcake-landing-mcp.git
191
191
  cd webcake-landing-mcp
192
192
  npm install # postinstall `prepare` builds dist/ automatically
193
- npm run build # (re)build: tsc -> dist/ + copies page-schema.json
193
+ npm run build # (re)build: tsc -> dist/ + copies src/**/*.json (page-schema.json) into dist/
194
194
  npm run smoke # offline self-test of factory + validator (prints "ALL GOOD")
195
195
  ```
196
196
 
@@ -603,5 +603,5 @@ Both `create_page` and `update_page` **default to `dry_run=true`** (validate and
603
603
  - Colors are `rgba()`; `top/left/width/height/fontSize` are numbers (px); form inputs need a unique `specials.field_name`.
604
604
 
605
605
  Reference: [docs/page-element-schema.md](docs/page-element-schema.md) and
606
- [src/page-schema.json](src/page-schema.json) (the bundled JSON Schema, Draft 2020-12). The schema mirrors
606
+ [src/domains/landing/page-schema.json](src/domains/landing/page-schema.json) (the bundled JSON Schema, Draft 2020-12). The schema mirrors
607
607
  the real editor `page_source` shape.
package/README.vi.md CHANGED
@@ -190,7 +190,7 @@ Cấu hình MCP giống bản local, chỉ khác `command`/`args` trỏ tới `n
190
190
  git clone https://github.com/vuluu2k/webcake-landing-mcp.git
191
191
  cd webcake-landing-mcp
192
192
  npm install # postinstall `prepare` tự build dist/
193
- npm run build # (re)build: tsc -> dist/ + copy page-schema.json
193
+ npm run build # (re)build: tsc -> dist/ + copy src/**/*.json (page-schema.json) vào dist/
194
194
  npm run smoke # self-test offline của factory + validator (in "ALL GOOD")
195
195
  ```
196
196
 
@@ -604,5 +604,5 @@ gửi, JWT được che); đặt `dry_run=false` để ghi thật. Kết quả t
604
604
 
605
605
  Tham khảo: [docs/page-element-schema.md](docs/page-element-schema.md),
606
606
  [docs/element-specials-reference.md](docs/element-specials-reference.md) (tham chiếu đầy đủ mọi specials/event),
607
- và [src/page-schema.json](src/page-schema.json) (JSON Schema, Draft 2020-12). Schema phản ánh đúng
607
+ và [src/domains/landing/page-schema.json](src/domains/landing/page-schema.json) (JSON Schema, Draft 2020-12). Schema phản ánh đúng
608
608
  hình dạng `page_source` thật của editor.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The single-descriptor element model.
3
+ *
4
+ * Every element type is declared ONCE as an `ElementDescriptor`: its category,
5
+ * its container/field flags, its default layer name, its AI usage docs, an
6
+ * optional filled example, and a `seed` that stamps the factory's visual
7
+ * defaults onto a fresh node. From a list of descriptors we DERIVE the catalog
8
+ * (docs), the container-type set, and the field-type set — so adding an element
9
+ * is a one-file change instead of editing four files that must stay in sync.
10
+ */
11
+ import { base } from "./element.js";
12
+ /** Build a default node for a descriptor (replaces the old `createElement` switch). */
13
+ export function createElementFrom(d, overrides = {}) {
14
+ const el = base();
15
+ el.type = d.type;
16
+ el.properties.name = overrides.name ?? d.defaultName;
17
+ if (d.container)
18
+ el.children = [];
19
+ d.seed?.(el);
20
+ return el;
21
+ }
22
+ /** Catalog (docs) derived from descriptors — the LIBRARY the reference tools read. */
23
+ export function buildCatalog(elements) {
24
+ const catalog = {};
25
+ for (const d of elements) {
26
+ catalog[d.type] = {
27
+ type: d.type,
28
+ category: d.category,
29
+ container: d.container,
30
+ summary: d.summary,
31
+ useWhen: d.useWhen,
32
+ keySpecials: d.keySpecials,
33
+ example: d.example,
34
+ };
35
+ }
36
+ return catalog;
37
+ }
38
+ export function deriveContainerTypes(elements) {
39
+ return new Set(elements.filter((d) => d.container).map((d) => d.type));
40
+ }
41
+ export function deriveFieldTypes(elements) {
42
+ return new Set(elements.filter((d) => d.field).map((d) => d.type));
43
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Domain-agnostic element primitives shared by every domain that builds an
3
+ * absolute-positioned, per-breakpoint element tree (the Webcake editor model).
4
+ *
5
+ * Holds the node shape, the empty-node factory (`base`), the small style
6
+ * helpers, and the id/placeholder/animation utilities. A domain's per-element
7
+ * `seed` (see ../core/descriptor.ts) builds on these to produce a structurally
8
+ * valid default node. Nothing here knows about "landing pages" specifically.
9
+ */
10
+ const ALNUM = "abcdefghijklmnopqrstuvwxyz0123456789";
11
+ export function randomId(len = 8) {
12
+ let s = "";
13
+ for (let i = 0; i < len; i++)
14
+ s += ALNUM[Math.floor(Math.random() * ALNUM.length)];
15
+ return s;
16
+ }
17
+ /**
18
+ * Placeholder image URL. There is no image API yet, so generated image elements
19
+ * get a visible placeholder (sized to the element) instead of an empty src —
20
+ * otherwise the page renders blank where images should be. Users swap these later.
21
+ */
22
+ export function imgPlaceholder(w = 600, h = 400, label = "Image") {
23
+ return `https://placehold.co/${Math.round(w)}x${Math.round(h)}?text=${encodeURIComponent(label)}`;
24
+ }
25
+ /** Default per-breakpoint animation block (matches real page_source). */
26
+ export function defaultAnimation() {
27
+ return { name: "none", delay: 0, duration: 3, repeat: null };
28
+ }
29
+ /** An empty, structurally-valid element node (no type yet). */
30
+ export function base() {
31
+ return {
32
+ id: randomId(),
33
+ type: "",
34
+ properties: { movable: true, sync: true },
35
+ responsive: {
36
+ desktop: { config: { notloaded: false, animation: defaultAnimation() }, styles: {} },
37
+ mobile: { config: { notloaded: false, animation: defaultAnimation() }, styles: {} },
38
+ },
39
+ specials: {},
40
+ runtime: {},
41
+ events: [],
42
+ };
43
+ }
44
+ /** Set the same style key on both breakpoints. */
45
+ export function setStyle(el, key, value) {
46
+ el.responsive.desktop.styles[key] = value;
47
+ el.responsive.mobile.styles[key] = value;
48
+ }
49
+ /** Set width+height on both breakpoints. */
50
+ export function setBox(el, w, h) {
51
+ if (w != null)
52
+ setStyle(el, "width", w);
53
+ if (h != null)
54
+ setStyle(el, "height", h);
55
+ }
56
+ /** Seed top/left = 0 on both breakpoints (absolute-positioned leaf inside a container). */
57
+ export function seedPosition(el) {
58
+ setStyle(el, "top", 0);
59
+ setStyle(el, "left", 0);
60
+ }
@@ -0,0 +1,92 @@
1
+ import { seedPosition, setStyle, setBox } from "../../../core/element.js";
2
+ export const COMMERCE = [
3
+ {
4
+ type: "list-product", category: "commerce", container: false, defaultName: "ListProduct",
5
+ summary: "Product list bound to the page store; clicking a card opens the popup-checkout overlay.",
6
+ useWhen: "Show purchasable products.",
7
+ keySpecials: {
8
+ select: "'product' | 'tag' | 'category' — which dimension to filter products by.",
9
+ type: "'expect' | 'except' — treat the expect*/except* arrays as an allowlist (expect) or denylist (except).",
10
+ expect: "array of product id strings to include (select='product', type='expect').",
11
+ except: "array of product id strings to exclude (select='product', type='except').",
12
+ expectCategory: "array of category ids to include (select='category').",
13
+ exceptCategory: "array of category ids to exclude (select='category').",
14
+ expectTags: "array of tag slugs to include (select='tag').",
15
+ exceptTags: "array of tag slugs to exclude (select='tag').",
16
+ format_title: "'default' | 'sku' | 'sku-name' | 'name-category' — product title composition.",
17
+ format_price: "'range' | 'discount' — 'discount' shows the % off badge when original > retail.",
18
+ direction: "'column' | (other) — 'column' = whole card is one click target; otherwise thumbnail + cart button each get handlers (and remain-quantity shows).",
19
+ numerical_order: "boolean — show numbered labels (01, 02 …) on thumbnails.",
20
+ remain_quantity_text: "string — low-stock label; {{value}} is replaced with the actual remaining count.",
21
+ },
22
+ seed: (el) => {
23
+ seedPosition(el);
24
+ setBox(el, 400, 162);
25
+ el.specials.format_title = "sku";
26
+ el.specials.numerical_order = true;
27
+ },
28
+ },
29
+ {
30
+ type: "search-list-product", category: "commerce", container: false, defaultName: "SearchListProduct",
31
+ summary: "Search box + product results. Reads NO specials (all DOM-driven) and REQUIRES a co-existing list-product element on the page — openProduct() delegates to a list-product vm, so without one nothing opens.",
32
+ useWhen: "Searchable catalog (pair it with a list-product element).",
33
+ keySpecials: {},
34
+ seed: (el) => {
35
+ seedPosition(el);
36
+ setBox(el, 400, 40);
37
+ setStyle(el, "background", "rgba(246, 4, 87, 1)");
38
+ setStyle(el, "color", "rgba(255,255,255,1)");
39
+ },
40
+ },
41
+ {
42
+ type: "cart-items", category: "commerce", container: false, defaultName: "CartItems",
43
+ summary: "Items currently in the cart. Has NO element specials — the WCart system writes its inner HTML at runtime and it needs the cart active (is_cart_active). Item name/price/quantity font sizes come from the page-level cartConfigs.checkoutElements['CART-ITEM'] (itemNameSize/itemPriceSize/inputQuantitySize), not from this node.",
44
+ useWhen: "Cart/checkout area (requires WCart active).",
45
+ keySpecials: {},
46
+ seed: (el) => {
47
+ seedPosition(el);
48
+ setBox(el, 233, 80);
49
+ },
50
+ },
51
+ {
52
+ type: "cart-quantity", category: "commerce", container: false, defaultName: "Cart Quantity",
53
+ summary: "Quantity stepper (+/-) that controls a field in the parent variation group; publishes <id>__quantity-change on each click.",
54
+ useWhen: "Per-variation quantity inside a cart/form group.",
55
+ keySpecials: {
56
+ field_name: "REQUIRED — identifies which field in the parent variation group this stepper controls.",
57
+ ignoreOnHidden: "boolean — when hidden, suppress this element's quantity contribution (calls _addIgnoreField/_removeIgnoreField on the parent vm).",
58
+ },
59
+ // NOT a FIELD_TYPE (field flag omitted) but the renderer still requires a
60
+ // field_name, so the seed sets one explicitly.
61
+ seed: (el) => {
62
+ seedPosition(el);
63
+ setBox(el, 150, 36);
64
+ el.specials.field_name = `cart_quantity_${el.id}`;
65
+ },
66
+ },
67
+ {
68
+ type: "product-select", category: "commerce", container: false, defaultName: "product-select",
69
+ summary: "STUB — no runtime renderer exists in render_v4, so placing this type produces a non-functional element that does nothing on the page. Use list-product (catalog) or form (order capture) instead.",
70
+ useWhen: "Do NOT use — it is a reserved/legacy stub. Prefer list-product or form.",
71
+ keySpecials: {},
72
+ seed: (el) => {
73
+ seedPosition(el);
74
+ setBox(el, 200, 100);
75
+ },
76
+ },
77
+ {
78
+ type: "table", category: "commerce", container: false, defaultName: "Table",
79
+ summary: "Data table rendered from a pre-fetched Google Sheets 2D array.",
80
+ useWhen: "Pricing/comparison/spec tables (data must be pre-loaded into specials).",
81
+ keySpecials: {
82
+ dataType: "0 | 1 — MUST be 1 to render anything; the renderer returns early when dataType != 1.",
83
+ source: "string — data source label (metadata only).",
84
+ sheetID: "string — Google Sheet document id (metadata only).",
85
+ google_sheet_data: "string[][] — the 2D table data; row 0 = headers as 'Title|type' where type ∈ image|video|link|time (absent type = plain text); rows 1+ are data cells.",
86
+ },
87
+ seed: (el) => {
88
+ seedPosition(el);
89
+ setBox(el, 400, 210);
90
+ },
91
+ },
92
+ ];
@@ -0,0 +1,202 @@
1
+ import { seedPosition, setStyle, setBox, imgPlaceholder } from "../../../core/element.js";
2
+ export const CONTENT = [
3
+ {
4
+ type: "text-block", category: "content", container: false, defaultName: "Text",
5
+ summary: "Text. specials.text holds the content (may contain inline HTML); specials.tag sets the semantic tag. Supports template variables ({{key}}), formula mode, URL-param injection, and date formatting.",
6
+ useWhen: "Any headline, paragraph, label. Use tag h1/h2 for headings, p for body. Style via responsive.styles (fontSize, color, fontWeight, textAlign).",
7
+ keySpecials: {
8
+ text: "string — the visible text; may include inline HTML (<b>, <br>, <span style>…). Also supports template variables: {{today}}, {{yesterday}}, {{tomorrow}} (formatted dates), {{coupon_text}}, {{coupon_code}}, {{coupon_codes}}, {{spin_turn_left}}, {{cart_total_price}}, {{cart_subtotal}}, {{cart_shipping_fee}}, {{cart_discount_code}}, {{voucher_price_cart}}, {{cart_item}}, {{cart_bonus_item}}, {{form_error_log}}, {{total_cart}}. Dynamic form field binding: {{formId__fieldName}} substitutes a field value from a sibling form.",
9
+ tag: "p | h1 | h2 | h3 | h4 | h5 | h6 | span | div.",
10
+ isFormula: "boolean — enable formula/computed numeric mode.",
11
+ formula: "string — formula expression evaluated to produce a numeric value (used when isFormula=true).",
12
+ fixed: "number — decimal places to display when isFormula=true.",
13
+ isTextParams: "boolean — populate text from a URL query parameter instead of specials.text.",
14
+ textParams: "string — URL query parameter name to read (used when isTextParams=true).",
15
+ isFormat: "boolean — apply a date format to template date variables.",
16
+ format: "string — dayjs format string (e.g. 'D/MM/YYYY') used when isFormat=true.",
17
+ formParamSeparator: "string — separator between items when rendering {{formId__form_items}} lists.",
18
+ },
19
+ seed: (el) => {
20
+ seedPosition(el);
21
+ setBox(el, 200);
22
+ el.specials.text = "hello world";
23
+ el.specials.tag = "p";
24
+ },
25
+ example: {
26
+ id: "headline1", type: "text-block",
27
+ properties: { name: "Headline", movable: true, sync: true },
28
+ responsive: {
29
+ desktop: { config: {}, styles: { top: 80, left: 180, width: 600, fontSize: 44, fontWeight: "bold", color: "rgba(255,255,255,1)", textAlign: "center" } },
30
+ mobile: { config: {}, styles: { top: 60, left: 20, width: 380, fontSize: 28, fontWeight: "bold", color: "rgba(255,255,255,1)", textAlign: "center" } },
31
+ },
32
+ specials: { text: "Bán hàng dễ hơn với Webcake", tag: "h1" },
33
+ runtime: {}, events: [],
34
+ },
35
+ },
36
+ {
37
+ type: "list-paragraph", category: "content", container: false, defaultName: "ListParagraph",
38
+ summary: "Bulleted list. specials.text is a string of <li>…</li> items.",
39
+ useWhen: "Feature checklists, benefit lists. One <li> per bullet.",
40
+ keySpecials: {
41
+ text: "string of <li>item</li><li>item</li>… (no <ul> wrapper). Bullet/icon styling lives in the per-breakpoint config, not specials: iconType ('shape'|'image'|'disc'|'circle'|'square'|'decimal'|'none'…), iconImage (SVG/URL), iconColor (rgba), iconFontSize, iconSize, iconTop, linePaddingLeft (text indent), linePaddingBottom (line spacing).",
42
+ iconSize: "(config) bullet icon size.", linePaddingLeft: "(config) text indent.",
43
+ },
44
+ seed: (el) => {
45
+ setBox(el, 400);
46
+ el.responsive.desktop.config = { ...el.responsive.desktop.config, iconSize: 12, iconTop: 5, linePaddingLeft: 23 };
47
+ el.responsive.mobile.config = { ...el.responsive.mobile.config, iconSize: 12, iconTop: 5, linePaddingLeft: 23 };
48
+ el.specials.text = "<li>List Paragraph.</li><li>List Paragraph.</li><li>List Paragraph.</li>";
49
+ },
50
+ },
51
+ {
52
+ type: "image-block", category: "content", container: false, defaultName: "Image Block",
53
+ summary: "Image. The editor renders the image from specials.src. config.overlay tints it.",
54
+ useWhen: "Add images where a landing page would have them: hero/product shot, feature icons, about photo, logos. There is NO image API yet — set specials.src to a PLACEHOLDER URL sized to the box: https://placehold.co/<width>x<height> (or https://picsum.photos/<w>/<h> for a photo). NEVER leave src empty (it renders blank). The user replaces placeholders later.",
55
+ keySpecials: {
56
+ src: "image URL — REQUIRED. Use https://placehold.co/WxH (matching width×height) if you don't have a real image.",
57
+ resize: "number — image crop behavior on resize; a value other than 300 triggers keep_solution (no-crop) mode.",
58
+ enable_background_compare: "boolean — show a before/after image-comparison slider (companion config.backgroundCompare holds the second image).",
59
+ overlay: "(config) overlay color rgba(...).",
60
+ },
61
+ seed: (el) => {
62
+ seedPosition(el);
63
+ setBox(el, 110, 80);
64
+ setStyle(el, "position", "absolute");
65
+ el.specials.imageCompression = true;
66
+ el.specials.src = imgPlaceholder(600, 400);
67
+ },
68
+ example: {
69
+ id: "hero_img", type: "image-block",
70
+ properties: { name: "Image Block", movable: true, sync: true },
71
+ responsive: {
72
+ desktop: { config: {}, styles: { top: 40, left: 540, width: 360, height: 300, position: "absolute" } },
73
+ mobile: { config: {}, styles: { top: 260, left: 60, width: 300, height: 240, position: "absolute" } },
74
+ },
75
+ specials: { src: "https://placehold.co/360x300?text=Product", imageCompression: true },
76
+ runtime: {}, events: [],
77
+ },
78
+ },
79
+ {
80
+ type: "rectangle", category: "content", container: false, defaultName: "Rectangle",
81
+ summary: "Colored block — divider, badge background, color band, card backdrop.",
82
+ useWhen: "Backgrounds behind text/groups, dividers, decorative shapes. Style via background/borderRadius/boxShadow.",
83
+ keySpecials: {},
84
+ seed: (el) => {
85
+ seedPosition(el);
86
+ setBox(el, 100, 100);
87
+ },
88
+ },
89
+ {
90
+ type: "line", category: "content", container: false, defaultName: "Line",
91
+ summary: "Horizontal rule / divider line.",
92
+ useWhen: "Separating content rows.",
93
+ keySpecials: {},
94
+ seed: (el) => {
95
+ setBox(el, 236);
96
+ },
97
+ },
98
+ {
99
+ type: "button", category: "content", container: false, defaultName: "Button",
100
+ summary: "Clickable button. Label in specials.text; behavior in the events array. Supports the same template variable system as text-block ({{today}}, {{cart_total_price}}, {{formId__fieldName}}, etc.).",
101
+ useWhen: "Every CTA. Add a click event: open_link (to URL), open_popup (lead modal), scroll_to (anchor). Inside a form, a button submits the form.",
102
+ keySpecials: {
103
+ text: "button label — supports template variables (same set as text-block: {{today}}, {{cart_total_price}}, {{formId__fieldName}}, etc.).",
104
+ required: "boolean — gate by form validity.",
105
+ isTextParams: "boolean — populate label from a URL query parameter instead of specials.text.",
106
+ textParams: "string — URL query parameter name to read (used when isTextParams=true).",
107
+ isFormat: "boolean — apply a date format to template date variables.",
108
+ format: "string — dayjs format string (e.g. 'D/MM/YYYY') used when isFormat=true.",
109
+ formParamSeparator: "string — separator between items when rendering {{formId__form_items}} lists.",
110
+ isConnectSurvey: "boolean — link this button to a survey element for required-field validation before submit.",
111
+ connectedSurvey: "string — id of the survey element to validate when isConnectSurvey=true.",
112
+ },
113
+ seed: (el) => {
114
+ seedPosition(el);
115
+ setBox(el, 150, 36);
116
+ el.specials.text = "Button";
117
+ },
118
+ example: {
119
+ id: "cta_main", type: "button",
120
+ properties: { name: "CTA", movable: true, sync: true },
121
+ responsive: {
122
+ desktop: { config: {}, styles: { top: 300, left: 405, width: 150, height: 44, background: "rgba(246,4,87,1)", color: "rgba(255,255,255,1)", borderRadius: "8px", textAlign: "center", fontWeight: "bold" } },
123
+ mobile: { config: {}, styles: { top: 200, left: 135, width: 150, height: 44, background: "rgba(246,4,87,1)", color: "rgba(255,255,255,1)", borderRadius: "8px", textAlign: "center", fontWeight: "bold" } },
124
+ },
125
+ specials: { text: "Đăng ký ngay" }, runtime: {},
126
+ events: [{ id: "ev_cta", type: "click", action: "scroll_to", target: "form_section" }],
127
+ },
128
+ },
129
+ {
130
+ type: "video", category: "content", container: false, defaultName: "Video",
131
+ summary: "Video player (YouTube/Vimeo/upload/CDN). Use specials.typeVideo to select the source type.",
132
+ useWhen: "Demo or promo videos. Set specials.img to a poster placeholder (https://placehold.co/640x360) when there's no real thumbnail.",
133
+ keySpecials: {
134
+ typeVideo: "'youtube' | 'vimeo' | 'webcake' | 'upload' — video source type.",
135
+ video: "string — raw video URL (for upload/webcake/CDN types).",
136
+ id: "string — YouTube video ID (for typeVideo='youtube').",
137
+ video_cdn: "string — CDN video URL or identifier.",
138
+ img: "string — poster/thumbnail image URL — use a placeholder if none (https://placehold.co/640x360).",
139
+ autoReplay: "boolean — loop the video.",
140
+ showControl: "boolean — show player controls.",
141
+ hideRelated: "boolean — hide YouTube related videos at end.",
142
+ muteOnPlay: "boolean — mute audio when video plays.",
143
+ autoPlay: "boolean — autoplay on load.",
144
+ },
145
+ seed: (el) => {
146
+ seedPosition(el);
147
+ setBox(el, 350, 200);
148
+ el.specials.imageCompression = true;
149
+ el.specials.img = imgPlaceholder(640, 360, "Video");
150
+ },
151
+ },
152
+ {
153
+ type: "gallery", category: "content", container: false, defaultName: "Gallery",
154
+ summary: "Multi-image/video gallery with thumbnail strip. Content comes entirely from specials.media — this is NOT a container and has no children.",
155
+ useWhen: "Photo grids/galleries with several images or videos. No image API — fill specials.media with placeholder URLs (https://placehold.co/600x400).",
156
+ keySpecials: {
157
+ media: "array of image URLs or video objects {type:'video', linkVideo:'<url>', typeVideo:'youtube'|'upload'} — use placeholder URLs if no real images.",
158
+ allowZoom: "'off' | 'carousel' | 'lightbox' — (config) zoom/lightbox mode when clicking an image.",
159
+ showNavigation: "boolean — (config) show prev/next navigation arrows.",
160
+ thumbnailAutoplay: "number (ms) | 'off' — (config) auto-advance thumbnails every N ms, or 'off' to disable.",
161
+ thumbnailAutoplayRepeat: "boolean — (config) loop thumbnail autoplay.",
162
+ thumbnailPosition: "'top' | 'bottom' | 'left' | 'right' — (config) position of the thumbnail strip relative to the main image.",
163
+ thumbnailWidth: "number — (config) width of each thumbnail in px.",
164
+ thumbnailHeight: "number — (config) height of each thumbnail in px.",
165
+ distanceAmong: "number — (config) gap between thumbnails in px.",
166
+ },
167
+ seed: (el) => {
168
+ seedPosition(el);
169
+ setBox(el, 350, 400);
170
+ el.specials.media = [imgPlaceholder(600, 400, "1"), imgPlaceholder(600, 400, "2"), imgPlaceholder(600, 400, "3")];
171
+ // gallery has NO children — content comes entirely from specials.media (gallery.js never reads vm.children)
172
+ },
173
+ },
174
+ {
175
+ type: "html-box", category: "content", container: false, defaultName: "HTML Box",
176
+ summary: "Raw HTML embed. specials.html holds the markup (rendered via v-html).",
177
+ useWhen: "Embedding third-party widgets or custom markup the standard elements can't express.",
178
+ keySpecials: { html: "string — raw HTML content (the only content key; stored HTML-escaped, unescaped at render)." },
179
+ seed: (el) => {
180
+ seedPosition(el);
181
+ setBox(el, 280, 310);
182
+ el.specials.html = "";
183
+ },
184
+ },
185
+ {
186
+ type: "editor-blog", category: "content", container: false, defaultName: "Editor blog",
187
+ summary: "Long-form rich text / article body. specials.html holds the rich-text markup.",
188
+ useWhen: "Blog/article content blocks.",
189
+ keySpecials: { html: "string — rich-text HTML content (stored HTML-escaped, unescaped at render)." },
190
+ seed: (el) => {
191
+ el.responsive.mobile.styles.width = 340;
192
+ el.responsive.mobile.styles.height = 303;
193
+ el.responsive.desktop.styles.width = 800;
194
+ el.responsive.desktop.styles.height = 124;
195
+ el.responsive.mobile.styles.top = 0;
196
+ el.responsive.mobile.styles.left = 0;
197
+ el.responsive.desktop.styles.top = 0;
198
+ el.responsive.desktop.styles.left = 0;
199
+ el.specials.html = "";
200
+ },
201
+ },
202
+ ];