sbuilder-mcp 0.63.1 → 0.64.0

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/CHANGELOG.md CHANGED
@@ -6,6 +6,25 @@ All notable changes to this project are documented in this file.
6
6
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.64.0] - 2026-09-24
10
+
11
+ ### Added
12
+ - `sb_templates` / `sb_template_use` gained four built-in layout patterns — testimonials, numbered steps, image-and-text, and a pricing table — alongside the existing hero, feature, stats, CTA, gallery, and FAQ bands.
13
+ - `sb_add`'s node specs (and the import mapper's captured nodes) now accept `states`, so a caller can seed an interaction state such as hover directly on a new node instead of writing it in a second call.
14
+
15
+ ### Changed
16
+ - The built-in layout patterns are no longer flat: feature, testimonial, and pricing rows now render as cards on accent-tinted bands, the CTA band is an accent panel, headings carry eyebrow labels, and heroes gained a second (ghost) button and a narrower text measure.
17
+ - The built-in layout patterns now animate: first-screen text enters on load line by line, lower bands reveal on scroll staggered by position, cards lift on hover, and buttons get a matching hover treatment through their own `config.stateHover` slot.
18
+ - The built-in layout patterns now wear the target page's own accent and heading colors when composed onto an existing page, falling back to the site's theme only when the page has none of its own.
19
+
20
+ ### Fixed
21
+ - A page built or extended by this server on a site that had never saved a theme now gets the starter theme saved to the platform on the first write, so the published page's buttons and headings actually match what the editor's canvas showed instead of publishing unstyled (`var(--wb-color-primary)` referencing nothing declared).
22
+
23
+ ## [0.63.2] - 2026-09-24
24
+
25
+ ### Fixed
26
+ - The live-edit socket now holds frames sent before the server's welcome instead of dropping them, so a section added right after `sb_page_open` reaches an editor watching the page instead of leaving it showing "This page is empty" and risking that empty state being autosaved over the agent's work; the held queue is capped at 500 frames so a long reconnect loop cannot grow memory without bound.
27
+
9
28
  ## [0.63.1] - 2026-09-24
10
29
 
11
30
  ### Fixed
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,25 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
6
6
  Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.64.0] - 2026-09-24
10
+
11
+ ### Added
12
+ - `sb_templates` / `sb_template_use` có thêm bốn layout dựng sẵn — cảm nhận khách hàng, các bước đánh số, ảnh kèm nội dung, và bảng giá — bên cạnh các band hero, lợi ích, số liệu, kêu gọi hành động, thư viện ảnh và FAQ đã có.
13
+ - Node spec của `sb_add` (và node được capture bởi bộ mapper nhập trang) giờ nhận `states`, để có thể gieo sẵn một trạng thái tương tác như hover ngay trên node mới thay vì phải gọi thêm một lần nữa.
14
+
15
+ ### Changed
16
+ - Các layout dựng sẵn không còn phẳng: các hàng lợi ích, cảm nhận khách hàng và bảng giá giờ render thành thẻ (card) trên nền có tông màu nhấn nhạt, band CTA trở thành một khối màu nhấn, tiêu đề có nhãn nhỏ phía trên, và hero có thêm nút thứ hai (dạng viền) cùng bề rộng chữ hẹp hơn.
17
+ - Các layout dựng sẵn giờ có chuyển động: chữ ở màn hình đầu hiện dần khi tải trang theo từng dòng, các band phía dưới hiện dần khi cuộn tới theo thứ tự vị trí, thẻ nổi lên khi rê chuột, và nút bấm có hiệu ứng hover riêng qua đúng nơi nó thuộc về — `config.stateHover`.
18
+ - Các layout dựng sẵn giờ mang màu nhấn và màu tiêu đề của chính trang đích khi được ghép vào một trang đã có nội dung, chỉ dùng lại theme của site khi trang đó chưa có gì.
19
+
20
+ ### Fixed
21
+ - Một trang được server này tạo hoặc chỉnh sửa trên site chưa từng lưu theme giờ sẽ được lưu theme khởi đầu (starter theme) lên nền tảng ngay ở lần ghi đầu tiên, để trang đã publish có nút và tiêu đề đúng như những gì canvas của editor hiển thị, thay vì publish ra trang không có style (`var(--wb-color-primary)` trỏ tới một biến chưa được khai báo ở đâu cả).
22
+
23
+ ## [0.63.2] - 2026-09-24
24
+
25
+ ### Fixed
26
+ - Socket live-edit giờ giữ lại các frame được gửi trước khi server chào (welcome) thay vì loại bỏ chúng, để một section được thêm ngay sau `sb_page_open` đến được với editor đang xem trang thay vì để trang đó hiển thị "This page is empty" và có nguy cơ trạng thái rỗng đó bị autosave đè lên công việc của agent; hàng đợi giữ frame bị giới hạn ở 500 frame để một vòng lặp reconnect kéo dài không thể làm bộ nhớ tăng vô hạn.
27
+
9
28
  ## [0.63.1] - 2026-09-24
10
29
 
11
30
  ### Fixed
@@ -98,6 +98,7 @@ export function addSubtree(doc, parentId, spec, index, guard) {
98
98
  config: s.config,
99
99
  specials: s.specials,
100
100
  responsive: s.responsive,
101
+ states: s.states,
101
102
  });
102
103
  // Before the owner is handed to a patch: minting rewrites its `config`.
103
104
  const sats = mintSatellites(n);
@@ -178,6 +178,18 @@ function textScale(slug, fallback) {
178
178
  return { style, config: { textGlobalStyle: slug } };
179
179
  }
180
180
  function one(c, t) {
181
+ const n = shape(c, t);
182
+ const x = c.extra;
183
+ if (!n || !x)
184
+ return n;
185
+ return {
186
+ ...n,
187
+ ...(x.style ? { style: { ...n.style, ...x.style } } : {}),
188
+ ...(x.config ? { config: { ...n.config, ...x.config } } : {}),
189
+ ...(x.states ? { states: { ...n.states, ...x.states } } : {}),
190
+ };
191
+ }
192
+ function shape(c, t) {
181
193
  switch (c.kind) {
182
194
  case 'heading': {
183
195
  const text = c.text?.trim();
@@ -78,7 +78,7 @@ export function createNode(type, opts = {}) {
78
78
  'guessing a type produces a node nothing can render.');
79
79
  }
80
80
  const d = meta.defaults;
81
- const states = d.states;
81
+ const states = opts.states ? { ...copy(d.states ?? {}), ...copy(opts.states) } : d.states;
82
82
  const config = { ...copy(d.config ?? {}), ...(opts.config ?? {}) };
83
83
  return {
84
84
  id: genId(type),
@@ -57,24 +57,46 @@ export const THEME_TOKENS = {
57
57
  // commits to for a page that cannot answer for itself.
58
58
  sectionMaxWidth: '1200px',
59
59
  };
60
+ const ACCENT = 'var(--wb-color-primary)';
61
+ const HEADING = 'var(--wb-color-heading)';
62
+ /**
63
+ * THE ACCENT AND THE INK ARE THE PAGE'S, not the theme's, when the page has its
64
+ * own. The helpers below write the theme variables because that is the answer
65
+ * on a blank page; on a page whose tokens were read off its content, a band in
66
+ * the theme's accent reads as a different website (rule 0). One substitution
67
+ * here covers every helper rather than threading `t` through each of them.
68
+ */
69
+ function wear(spec, t) {
70
+ let json = JSON.stringify(spec);
71
+ const swap = (from, to) => {
72
+ if (to && to !== from)
73
+ json = json.split(from).join(JSON.stringify(to).slice(1, -1));
74
+ };
75
+ swap(ACCENT, t.buttonBg);
76
+ swap(HEADING, t.headingColor);
77
+ return JSON.parse(json);
78
+ }
60
79
  /** One section, through the same mapper an import goes through. */
61
- function section(children, t, style, innerStyle) {
80
+ function section(children, t, style, innerStyle, mobile) {
62
81
  const [spec] = toSpecs([{ kind: 'section', children }], t);
63
82
  if (!spec)
64
83
  return null;
65
- const outer = style ? { ...spec, style: { ...spec.style, ...style } } : spec;
84
+ const styled = style ? { ...spec, style: { ...spec.style, ...style } } : spec;
85
+ const outer = mobile
86
+ ? { ...styled, responsive: { ...styled.responsive, mobile: { ...styled.responsive?.mobile, style: { ...styled.responsive?.mobile?.style, ...mobile } } } }
87
+ : styled;
66
88
  if (!innerStyle)
67
- return outer;
89
+ return wear(outer, t);
68
90
  // THE SECTION CENTRES ITS BLOCK; THE BLOCK CENTRES ITS CHILDREN. `textAlign`
69
91
  // moves the words and leaves a `width: fit-content` button where it was, so a
70
92
  // centred band came out with its call to action against the left margin.
71
93
  const [inner, ...rest] = outer.children ?? [];
72
94
  if (!inner)
73
- return outer;
74
- return {
95
+ return wear(outer, t);
96
+ return wear({
75
97
  ...outer,
76
98
  children: [{ ...inner, style: { ...inner.style, ...innerStyle } }, ...rest],
77
- };
99
+ }, t);
78
100
  }
79
101
  const row = (children, wrap = false, align) => ({
80
102
  kind: 'group',
@@ -94,7 +116,100 @@ const p = (text, textAlign) => ({
94
116
  text,
95
117
  ...(textAlign ? { textAlign } : {}),
96
118
  });
97
- const cta = (text, href = '#') => ({ kind: 'button', variant: 'cta', text, href });
119
+ /** Lay extras over a capture, merging each namespace rather than replacing it. */
120
+ function dress(c, ...xs) {
121
+ const out = { ...c.extra };
122
+ for (const x of xs) {
123
+ if (x.style)
124
+ out.style = { ...out.style, ...x.style };
125
+ if (x.config)
126
+ out.config = { ...out.config, ...x.config };
127
+ if (x.states)
128
+ out.states = { ...out.states, ...x.states };
129
+ }
130
+ return { ...c, extra: out };
131
+ }
132
+ // SCHEME ROLES, with the light scheme's own values as fallbacks. `--wb-sc-*` is
133
+ // what the theme swaps for dark mode, so a card drawn from these follows it.
134
+ const BORDER = 'var(--wb-sc-border, #e5e7eb)';
135
+ const SHADOW = 'var(--wb-sc-shadow, rgba(17,24,39,0.08))';
136
+ const SURFACE = 'var(--wb-sc-background, #ffffff)';
137
+ /** A band set apart from its neighbours: the page's background, tinted by its accent. */
138
+ const TINT = { backgroundColor: `color-mix(in srgb, ${ACCENT} 5%, ${SURFACE})` };
139
+ /**
140
+ * REVEAL ON SCROLL, staggered by position.
141
+ *
142
+ * `trigger: "view"` ties the animation to the scroll timeline, where a time
143
+ * delay means nothing, so the stagger is carried by `range` (how far into the
144
+ * viewport the entrance completes): the first column lands first. `delay` is
145
+ * kept for browsers without scroll timelines, which play it at first paint.
146
+ * Reduced motion is honoured by the renderer, not here.
147
+ */
148
+ const reveal = (i = 0, type = 'fade_in_up') => ({
149
+ config: {
150
+ animation: { active: true, type, intensity: 'soft', trigger: 'view', range: 40 + i * 15, delay: Number((i * 0.1).toFixed(2)) },
151
+ },
152
+ });
153
+ /** The first screen: nothing to scroll to, so it plays at load, one line after another. */
154
+ const enter = (i = 0, type = 'fade_in_up') => ({
155
+ config: { animation: { active: true, type, intensity: 'soft', delay: Number((i * 0.12).toFixed(2)) } },
156
+ });
157
+ /**
158
+ * A button that answers the pointer. A button's hover lives in the flat,
159
+ * base-only `config.stateHover` (see `hover.ts`); `states.hover` paints nothing
160
+ * on one. `translate`, not `transform`: an entrance animation fills `transform`
161
+ * and would outrank a hover written there.
162
+ */
163
+ const cta = (text, href = '#') => dress({ kind: 'button', variant: 'cta', text, href }, {
164
+ style: { transition: 'translate 0.2s ease, box-shadow 0.2s ease' },
165
+ config: { stateHover: { translate: '0 -2px', boxShadow: `0 10px 24px ${SHADOW}` } },
166
+ });
167
+ /** The second choice beside a call to action: same shape, no fill. */
168
+ const ghost = (text, href = '#') => dress(cta(text, href), {
169
+ style: { backgroundColor: 'transparent', color: HEADING, border: `1px solid ${BORDER}` },
170
+ });
171
+ /** Two buttons side by side, packed rather than spread across the row. */
172
+ const actions = (kids, centred = false) => ({
173
+ kind: 'group',
174
+ direction: 'row',
175
+ pack: true,
176
+ ...(centred ? { align: 'center' } : {}),
177
+ children: kids,
178
+ });
179
+ /** The small label above a heading that says what the band is. */
180
+ const eyebrow = (text, textAlign) => dress(p(text, textAlign), {
181
+ style: {
182
+ fontSize: 'var(--wb-ts-text-2-size)',
183
+ fontWeight: '600',
184
+ letterSpacing: '0.12em',
185
+ textTransform: 'uppercase',
186
+ lineHeight: '1.4',
187
+ color: ACCENT,
188
+ },
189
+ });
190
+ const muted = (text, textAlign) => dress(p(text, textAlign), { style: { color: 'var(--wb-color-muted)' } });
191
+ /**
192
+ * A CARD: a surface, a hairline, a radius, and a lift under the pointer. It
193
+ * fills its cell (`flex`), so a row of cards shares one height when the row
194
+ * stretches.
195
+ */
196
+ const card = (children, i, highlight = false) => dress({ kind: 'group', direction: 'column', children }, {
197
+ style: {
198
+ flex: '1 1 auto',
199
+ padding: '28px',
200
+ borderRadius: '16px',
201
+ backgroundColor: SURFACE,
202
+ border: highlight ? `2px solid ${ACCENT}` : `1px solid ${BORDER}`,
203
+ ...(highlight ? { boxShadow: `0 16px 40px ${SHADOW}` } : {}),
204
+ },
205
+ states: { hover: { style: { translate: '0 -4px', boxShadow: `0 16px 40px ${SHADOW}` } } },
206
+ }, reveal(i));
207
+ /** The heading block every band opens with: label, title, and one line. */
208
+ const intro = (label, title, line, textAlign) => [
209
+ eyebrow(label, textAlign),
210
+ h(title, 2, textAlign),
211
+ ...(line ? [muted(line, textAlign)] : []),
212
+ ];
98
213
  /**
99
214
  * The picture slot: a real image when the site has one, and WORDS when it does
100
215
  * not.
@@ -179,7 +294,7 @@ export const LAYOUT_PATTERNS = [
179
294
  {
180
295
  id: 'sb_hero_split',
181
296
  name: 'Hero — hai cột',
182
- use: 'Mở đầu trang: tiêu đề, một câu, nút hành động bên trái; chỗ cho ảnh bên phải',
297
+ use: 'Mở đầu trang: nhãn, tiêu đề, một câu, hai nút bên trái; ảnh bo góc bên phải — hiện dần khi tải trang',
183
298
  images: 1,
184
299
  build: (t, pool) => {
185
300
  const used = new Set();
@@ -189,61 +304,87 @@ export const LAYOUT_PATTERNS = [
189
304
  // measured 154px beside 420px, and the band read as a caption that
190
305
  // had slipped off the picture.
191
306
  row([
192
- { kind: 'group', direction: 'column', children: [h('Tiêu đề chính', 1), p('Một câu nói rõ bạn bán gì và cho ai.'), cta('Mua ngay')] },
193
307
  {
194
308
  kind: 'group',
195
309
  direction: 'column',
196
310
  children: [
197
- img(pick(pool, used, 'landscape'), 'Ảnh mở đầu', 'Chưa có ảnh nào trong thư viện — sb_media_upload một URL, rồi sb_set src lên khối này.'),
311
+ dress(eyebrow('Bộ sưu tập mới'), enter(0)),
312
+ dress(h('Tiêu đề chính', 1), enter(1)),
313
+ dress(p('Một câu nói rõ bạn bán gì và cho ai.'), enter(2)),
314
+ dress(actions([cta('Mua ngay'), ghost('Tìm hiểu thêm')]), enter(3)),
315
+ ],
316
+ },
317
+ {
318
+ kind: 'group',
319
+ direction: 'column',
320
+ children: [
321
+ dress(img(pick(pool, used, 'landscape'), 'Ảnh mở đầu', 'Chưa có ảnh nào trong thư viện — sb_media_upload một URL, rồi sb_set src lên khối này.'), { style: { borderRadius: '20px', overflow: 'hidden' } }, enter(2, 'fade_in')),
198
322
  ],
199
323
  },
200
324
  ], false, 'center'),
201
- ], t);
325
+ ], t, { padding: '96px 24px' }, undefined, { padding: '56px 20px' });
202
326
  },
203
327
  },
204
328
  {
205
329
  id: 'sb_hero_centered',
206
330
  name: 'Hero — canh giữa',
207
- use: 'Mở đầu trang khi chưa có ảnh: tiêu đề lớn, một câu, một nút',
331
+ use: 'Mở đầu trang khi chưa có ảnh: nhãn, tiêu đề lớn, một câu, hai nút — canh giữa, hiện dần khi tải trang',
208
332
  build: (t) => section(
209
333
  // Centred on the NODES, not left to the section's inherited textAlign —
210
334
  // the theme's heading preset declares its own and wins.
211
- [h('Tiêu đề chính', 1, 'center'), p('Một câu nói rõ bạn bán gì và cho ai.', 'center'), cta('Mua ngay')], t, { alignItems: 'center', textAlign: 'center' }, { alignItems: 'center' }),
335
+ [
336
+ dress(eyebrow('Chào mừng', 'center'), enter(0)),
337
+ dress(h('Tiêu đề chính', 1, 'center'), enter(1)),
338
+ dress(p('Một câu nói rõ bạn bán gì và cho ai.', 'center'), enter(2)),
339
+ dress(actions([cta('Mua ngay'), ghost('Tìm hiểu thêm')], true), enter(3)),
340
+ ], t, { alignItems: 'center', textAlign: 'center', padding: '112px 24px' },
341
+ // A NARROWER MEASURE than the 1200px band: a centred headline over a
342
+ // full-width line reads as a banner, not a sentence.
343
+ { alignItems: 'center', maxWidth: '760px' }, { padding: '64px 20px' }),
212
344
  },
213
345
  {
214
346
  id: 'sb_feature_trio',
215
347
  name: 'Ba lợi ích',
216
- use: 'Ba cột ngang nhau: mỗi cột một tiêu đề nhỏ và một đoạn — lý do nên mua',
348
+ use: 'Nền nhạt, ba thẻ ngang nhau: icon, tiêu đề nhỏ, một đoạn — lý do nên mua; thẻ nổi lên khi rê chuột',
217
349
  build: (t) => section([
218
- h('Vì sao chọn chúng tôi'),
350
+ ...intro('Lợi ích', 'Vì sao chọn chúng tôi', 'Ba điều khách hàng nhắc đến nhiều nhất.'),
219
351
  row([
220
- { kind: 'group', direction: 'column', children: [h('Lợi ích một', 3), p('Một hoặc hai câu.')] },
221
- { kind: 'group', direction: 'column', children: [h('Lợi ích hai', 3), p('Một hoặc hai câu.')] },
222
- { kind: 'group', direction: 'column', children: [h('Lợi ích ba', 3), p('Một hoặc hai câu.')] },
223
- ]),
224
- ], t),
352
+ card([icon('SparklingLine'), h('Lợi ích một', 3), p('Một hoặc hai câu.')], 0),
353
+ card([icon('LeafLine'), h('Lợi ích hai', 3), p('Một hoặc hai câu.')], 1),
354
+ card([icon('CustomerService2Line'), h('Lợi ích ba', 3), p('Một hoặc hai câu.')], 2),
355
+ ], false, 'stretch'),
356
+ ], t, TINT),
225
357
  },
226
358
  {
227
359
  id: 'sb_stats_row',
228
360
  name: 'Dải số liệu',
229
- use: 'Ba đến bốn con số lớn với nhãn bên dưới — bằng chứng, không phải lời hứa',
361
+ use: 'Ba đến bốn con số lớn với nhãn bên dưới — bằng chứng, không phải lời hứa; hiện lần lượt khi cuộn tới',
230
362
  build: (t) => section([
231
363
  row([
232
- { kind: 'group', direction: 'column', children: [h('1.000+', 2, 'center'), p('Khách hàng', 'center')] },
233
- { kind: 'group', direction: 'column', children: [h('4,9/5', 2, 'center'), p('Đánh giá trung bình', 'center')] },
234
- { kind: 'group', direction: 'column', children: [h('24h', 2, 'center'), p('Giao trong nội thành', 'center')] },
364
+ dress({ kind: 'group', direction: 'column', children: [h('1.000+', 2, 'center'), muted('Khách hàng', 'center')] }, reveal(0)),
365
+ dress({ kind: 'group', direction: 'column', children: [h('4,9/5', 2, 'center'), muted('Đánh giá trung bình', 'center')] }, reveal(1)),
366
+ dress({ kind: 'group', direction: 'column', children: [h('24h', 2, 'center'), muted('Giao trong nội thành', 'center')] }, reveal(2)),
235
367
  ]),
236
368
  ], t, { alignItems: 'center', textAlign: 'center' }, { alignItems: 'center' }),
237
369
  },
238
370
  {
239
371
  id: 'sb_cta_band',
240
372
  name: 'Dải kêu gọi hành động',
241
- use: 'Một câu và một nút, đặt cuối trang hoặc giữa hai band nội dung',
373
+ use: 'Khối màu chủ đạo bo góc: một câu, một nút đảo màu — đặt cuối trang hoặc giữa hai band nội dung',
242
374
  build: (t) => section(
243
375
  // CENTRED ON THE NODES. The section's own `textAlign` is inherited, and
244
376
  // the theme's heading preset declares its own — so the band came out
245
377
  // with its words hard left and only the button in the middle.
246
- [h('Sẵn sàng bắt đầu?', 2, 'center'), p('Một câu nhắc lại lời hứa chính.', 'center'), cta('Mua ngay')], t, { alignItems: 'center', textAlign: 'center' }, { alignItems: 'center' }),
378
+ [
379
+ // ON THE ACCENT, the ink is the page's button text — or, on a page
380
+ // that has none, its background, which is the inversion that reads.
381
+ dress(h('Sẵn sàng bắt đầu?', 2, 'center'), { style: { color: t.buttonColor ?? SURFACE } }, reveal(0)),
382
+ dress(p('Một câu nhắc lại lời hứa chính.', 'center'), { style: { color: t.buttonColor ?? SURFACE, opacity: '0.85' } }, reveal(1)),
383
+ dress(cta('Mua ngay'), { style: { backgroundColor: t.buttonColor ?? SURFACE, color: ACCENT } }, reveal(2)),
384
+ ], t, { alignItems: 'center', textAlign: 'center' },
385
+ // THE PANEL IS THE INNER BLOCK, so the band keeps the page's gutters and
386
+ // the colour sits inside the same 1200px measure as everything else.
387
+ { alignItems: 'center', backgroundColor: ACCENT, borderRadius: '24px', padding: '56px 32px' }),
247
388
  },
248
389
  {
249
390
  id: 'sb_gallery',
@@ -281,7 +422,7 @@ export const LAYOUT_PATTERNS = [
281
422
  .sort((a, b) => a - b);
282
423
  const frame = ratios.length ? ratios[Math.floor(ratios.length / 2)].toFixed(2) : undefined;
283
424
  for (const m of picked) {
284
- shots.push({ kind: 'image', src: m.url, alt: m.name ?? 'Ảnh', ...(frame ? { ratio: frame } : {}) });
425
+ shots.push(dress({ kind: 'image', src: m.url, alt: m.name ?? 'Ảnh', ...(frame ? { ratio: frame } : {}) }, { style: { borderRadius: '12px', overflow: 'hidden' } }, reveal(shots.length % 3, 'zoom_in')));
285
426
  }
286
427
  if (shots.length === 0) {
287
428
  return section([
@@ -297,8 +438,9 @@ export const LAYOUT_PATTERNS = [
297
438
  name: 'Câu hỏi thường gặp',
298
439
  use: 'Ba câu hỏi mở/đóng — nơi trả lời những gì cản trở việc mua',
299
440
  build: (t) => section([
300
- h('Câu hỏi thường gặp'),
441
+ ...intro('Hỗ trợ', 'Câu hỏi thường gặp'),
301
442
  {
443
+ extra: reveal(0),
302
444
  kind: 'accordion',
303
445
  children: [
304
446
  { kind: 'accordion-item', text: 'Giao hàng mất bao lâu?', children: [p('Trả lời ngắn gọn.')] },
@@ -366,21 +508,25 @@ export const LAYOUT_PATTERNS = [
366
508
  h('Cam kết của chúng tôi'),
367
509
  row([
368
510
  {
511
+ extra: reveal(0),
369
512
  kind: 'group',
370
513
  direction: 'column',
371
514
  children: [icon('TruckLine'), h('Giao hàng nhanh', 3), p('Giao toàn quốc trong 24-48h.')],
372
515
  },
373
516
  {
517
+ extra: reveal(1),
374
518
  kind: 'group',
375
519
  direction: 'column',
376
520
  children: [icon('ShieldCheckLine'), h('Bảo hành chính hãng', 3), p('Đổi mới trong 7 ngày nếu lỗi.')],
377
521
  },
378
522
  {
523
+ extra: reveal(2),
379
524
  kind: 'group',
380
525
  direction: 'column',
381
526
  children: [icon('RefreshLine'), h('Đổi trả dễ dàng', 3), p('30 ngày đổi ý, hoàn tiền nhanh.')],
382
527
  },
383
528
  {
529
+ extra: reveal(3),
384
530
  kind: 'group',
385
531
  direction: 'column',
386
532
  children: [icon('BankCardLine'), h('Thanh toán an toàn', 3), p('Hỗ trợ nhiều hình thức thanh toán.')],
@@ -388,5 +534,102 @@ export const LAYOUT_PATTERNS = [
388
534
  ]),
389
535
  ], t),
390
536
  },
537
+ // ---------------------------------------------------------------------
538
+ // THE BANDS A LANDING PAGE REACHES FOR AFTER THE FIRST SCREEN: social proof,
539
+ // a process, a picture beside a claim, and a choice between offers.
540
+ // ---------------------------------------------------------------------
541
+ {
542
+ id: 'sb_testimonials',
543
+ name: 'Cảm nhận khách hàng',
544
+ use: 'Nền nhạt, ba thẻ trích dẫn: năm sao, lời khách, tên và nơi ở — bằng chứng xã hội',
545
+ build: (t) => section([
546
+ ...intro('Khách hàng nói gì', 'Được hàng nghìn người tin chọn', undefined, 'center'),
547
+ row([
548
+ ['Nguyễn Minh Anh', 'Hà Nội'],
549
+ ['Trần Quốc Bảo', 'TP. Hồ Chí Minh'],
550
+ ['Lê Thu Hà', 'Đà Nẵng'],
551
+ ].map(([name, place], i) => card([
552
+ dress(p('★★★★★'), { style: { color: ACCENT, letterSpacing: '0.1em' } }),
553
+ dress(p('“Một hoặc hai câu khách hàng thật sự đã nói — cụ thể hơn một lời khen.”'), {
554
+ style: { fontSize: 'var(--wb-ts-heading-5-size)', color: HEADING },
555
+ }),
556
+ h(name, 4),
557
+ muted(`Khách hàng tại ${place}`),
558
+ ], i)), false, 'stretch'),
559
+ ], t, TINT, { alignItems: 'center' }),
560
+ },
561
+ {
562
+ id: 'sb_steps',
563
+ name: 'Các bước',
564
+ use: 'Ba bước đánh số 01-02-03, mỗi bước một tiêu đề nhỏ và một câu — quy trình đặt hàng, sử dụng, bảo hành',
565
+ build: (t) => section([
566
+ ...intro('Cách hoạt động', 'Ba bước đơn giản'),
567
+ row([
568
+ ['01', 'Chọn sản phẩm', 'Một câu nói bước này làm gì.'],
569
+ ['02', 'Đặt hàng', 'Một câu nói bước này làm gì.'],
570
+ ['03', 'Nhận hàng', 'Một câu nói bước này làm gì.'],
571
+ ].map(([n, title, line], i) => dress({
572
+ kind: 'group',
573
+ direction: 'column',
574
+ children: [
575
+ dress(p(n), {
576
+ style: { fontSize: 'var(--wb-ts-heading-2-size)', fontWeight: '700', lineHeight: '1', color: ACCENT },
577
+ }),
578
+ h(title, 3),
579
+ p(line),
580
+ ],
581
+ }, { style: { borderTop: `2px solid ${ACCENT}`, paddingTop: '20px' } }, reveal(i)))),
582
+ ], t),
583
+ },
584
+ {
585
+ id: 'sb_image_text',
586
+ name: 'Ảnh và nội dung',
587
+ use: 'Ảnh bên trái, bên phải là nhãn, tiêu đề, một đoạn, ba ý có dấu và một link — đặt xen kẽ với hero hai cột',
588
+ images: 1,
589
+ build: (t, pool) => {
590
+ const used = new Set();
591
+ return section([
592
+ row([
593
+ dress({
594
+ kind: 'group',
595
+ direction: 'column',
596
+ children: [
597
+ img(pick(pool, used, 'landscape'), 'Ảnh minh hoạ', 'Chưa có ảnh nào trong thư viện — sb_media_upload một URL, rồi sb_set src lên khối này.'),
598
+ ],
599
+ }, { style: { borderRadius: '20px', overflow: 'hidden' } }, reveal(0, 'fade_in_left')),
600
+ dress({
601
+ kind: 'group',
602
+ direction: 'column',
603
+ children: [
604
+ ...intro('Câu chuyện', 'Một điều làm bạn khác biệt'),
605
+ p('Hai hoặc ba câu kể cụ thể: nguyên liệu, cách làm, người làm.'),
606
+ { kind: 'list', items: ['Ý thứ nhất', 'Ý thứ hai', 'Ý thứ ba'] },
607
+ linkTo('Tìm hiểu thêm →'),
608
+ ],
609
+ }, reveal(1, 'fade_in_right')),
610
+ ], false, 'center'),
611
+ ], t);
612
+ },
613
+ },
614
+ {
615
+ id: 'sb_pricing',
616
+ name: 'Bảng giá',
617
+ use: 'Ba thẻ gói: tên, giá, một câu, danh sách quyền lợi, nút — thẻ giữa được làm nổi bật',
618
+ build: (t) => section([
619
+ ...intro('Bảng giá', 'Chọn gói phù hợp', 'Đổi gói bất cứ lúc nào.', 'center'),
620
+ row([
621
+ ['Cơ bản', '99.000đ', false],
622
+ ['Tiêu chuẩn', '199.000đ', true],
623
+ ['Cao cấp', '399.000đ', false],
624
+ ].map(([name, price, best], i) => card([
625
+ ...(best ? [eyebrow('Phổ biến nhất')] : []),
626
+ h(String(name), 3),
627
+ h(String(price), 2),
628
+ muted('Một câu cho biết gói này dành cho ai.'),
629
+ { kind: 'list', items: ['Quyền lợi thứ nhất', 'Quyền lợi thứ hai', 'Quyền lợi thứ ba'] },
630
+ best ? cta('Chọn gói') : ghost('Chọn gói'),
631
+ ], i, Boolean(best))), false, 'stretch'),
632
+ ], t, undefined, { alignItems: 'center' }),
633
+ },
391
634
  ];
392
635
  export const PATTERN_BY_ID = new Map(LAYOUT_PATTERNS.map((p2) => [p2.id, p2]));
@@ -15,6 +15,7 @@ import { PAGE_TYPES } from '../catalog/storepages.generated.js';
15
15
  import { animationNote, deadKeyNote, unknownValueNote, unknownWriteNote, preconditionNotes, unsupportedSettingNote, } from '../domains/site/vocabulary.js';
16
16
  import { skinLevelNote } from '../domains/site/fieldskin.js';
17
17
  import { siteTheme } from '../domains/site/theme-fetch.js';
18
+ import { ensureSiteTheme } from './theme.js';
18
19
  import { request, redact } from '../transport/http.js';
19
20
  import { siteToken } from './credentialpick.js';
20
21
  import { validateForSave } from '../domains/site/validate.js';
@@ -475,6 +476,7 @@ export class PageSession {
475
476
  // "is the document now different from what the server holds", and the
476
477
  // re-stamp wrote the server's own answer back into it.
477
478
  this.savedRev = d.rev;
479
+ await ensureSiteTheme(this.ctx, this.siteId);
478
480
  }
479
481
  }
480
482
  const specSchema = z.lazy(() => z.object({
@@ -1563,6 +1565,7 @@ export function registerPageTools(server, ctx) {
1563
1565
  body: { document, schemaVersion: document.schema_version },
1564
1566
  fetchImpl: ctx.fetchImpl,
1565
1567
  });
1568
+ await ensureSiteTheme(ctx, site_id);
1566
1569
  seeded = layout
1567
1570
  ? { layout, nodes: Object.keys(document.nodes).length }
1568
1571
  : { type, nodes: Object.keys(document.nodes).length };
@@ -31,6 +31,44 @@ export async function readTheme(ctx, siteId) {
31
31
  return { theme: got.theme, origin: 'site' };
32
32
  return { theme: structuredClone(STARTER_THEME), origin: 'starter' };
33
33
  }
34
+ /**
35
+ * SAVE THE STARTER THEME FOR A SITE THAT HAS NONE, once per site per process.
36
+ *
37
+ * A new site stores no theme. The editor paints against `DEFAULT_THEME` anyway,
38
+ * and it saves that theme the first time a node references a token. Publish
39
+ * compiles only the STORED theme, so a page this server builds on a fresh site
40
+ * references `var(--wb-color-primary)` and the `-default` preset classes with
41
+ * nothing declaring them. The result: buttons with white text on no fill, and
42
+ * headings at body size. The canvas looks right and the published page does not.
43
+ * Measured on a fresh local site, 2026-09-24.
44
+ *
45
+ * This does the save the editor would have done. It never replaces a stored
46
+ * theme (`readTheme` answers 'site' for one), and it never fails the page
47
+ * write it rides on. A failed attempt is simply retried on the next save.
48
+ */
49
+ const themeSeeded = new Set();
50
+ export async function ensureSiteTheme(ctx, siteId) {
51
+ if (themeSeeded.has(siteId))
52
+ return;
53
+ try {
54
+ const { theme, origin } = await readTheme(ctx, siteId);
55
+ if (origin === 'starter') {
56
+ await request({
57
+ base: ctx.base,
58
+ method: 'PUT',
59
+ path: `/api/sites/${encodeURIComponent(siteId)}/theme`,
60
+ token: siteToken(ctx),
61
+ body: { theme },
62
+ fetchImpl: ctx.fetchImpl,
63
+ });
64
+ clearThemeCache();
65
+ }
66
+ themeSeeded.add(siteId);
67
+ }
68
+ catch {
69
+ // The page write already succeeded; the next save retries.
70
+ }
71
+ }
34
72
  export function registerThemeTools(server, ctx) {
35
73
  server.registerTool('sb_theme', {
36
74
  description: "The site's palette and type scale — the layer every element's style preset resolves " +
@@ -11,6 +11,8 @@ import { agentIdentity } from './identity.js';
11
11
  const OPEN = 1;
12
12
  const RETRY_BASE_MS = 500;
13
13
  const RETRY_MAX_MS = 15_000;
14
+ /** Frames held while not ready. A reconnect loop must not grow memory forever. */
15
+ const QUEUE_MAX = 500;
14
16
  export class RealtimeSocket {
15
17
  url;
16
18
  token;
@@ -20,6 +22,19 @@ export class RealtimeSocket {
20
22
  attempt = 0;
21
23
  handlers = [];
22
24
  timer = null;
25
+ /** The server has welcomed THIS connection — frames sent now are read. */
26
+ ready = false;
27
+ /**
28
+ * Frames sent while not ready, flushed on the welcome.
29
+ *
30
+ * `sb_page_open` starts the connection and returns; the `sb_add` right after
31
+ * it ran before the handshake finished, and a send that DROPPED on a
32
+ * not-open socket lost it without a trace. The editor watching the page never
33
+ * saw the section appear — it showed an empty page, and its next autosave
34
+ * could write that empty page over the agent's work. Same for every edit made
35
+ * during a reconnect.
36
+ */
37
+ queue = [];
23
38
  constructor(url,
24
39
  /**
25
40
  * A GETTER, read per attempt. The access token lives ~15 minutes and
@@ -44,6 +59,7 @@ export class RealtimeSocket {
44
59
  const ws = this.factory(this.url);
45
60
  this.ws = ws;
46
61
  this.attempt += 1;
62
+ this.ready = false;
47
63
  ws.onopen = () => {
48
64
  // Auth is the FIRST message, never a header (a browser cannot set one on a
49
65
  // WebSocket) and never a query parameter (a token in a URL lands in logs).
@@ -92,10 +108,17 @@ export class RealtimeSocket {
92
108
  catch {
93
109
  return; // a malformed frame must never throw into the socket
94
110
  }
111
+ // Ready BEFORE the handlers, flushed AFTER them: the page announcement
112
+ // the welcome triggers goes out directly, ahead of the ops held for it.
113
+ const held = this.ready ? [] : this.queue.splice(0);
114
+ this.ready = true;
95
115
  for (const h of this.handlers)
96
116
  h(parsed);
117
+ for (const f of held)
118
+ ws.send(f);
97
119
  };
98
120
  ws.onclose = () => {
121
+ this.ready = false;
99
122
  if (this.closed)
100
123
  return;
101
124
  const wait = Math.min(RETRY_BASE_MS * 2 ** this.attempt, RETRY_MAX_MS);
@@ -109,12 +132,21 @@ export class RealtimeSocket {
109
132
  send(e) {
110
133
  if (this.closed)
111
134
  return;
112
- if (this.ws?.readyState !== OPEN)
135
+ const frame = JSON.stringify(e);
136
+ if (this.ready && this.ws?.readyState === OPEN) {
137
+ this.ws.send(frame);
113
138
  return;
114
- this.ws.send(JSON.stringify(e));
139
+ }
140
+ if (this.queue.length >= QUEUE_MAX) {
141
+ // ponytail: drops the oldest past the cap; the peer re-pulls on the gap.
142
+ console.error('[sbuilder-mcp] live queue full, dropping the oldest frame');
143
+ this.queue.shift();
144
+ }
145
+ this.queue.push(frame);
115
146
  }
116
147
  close() {
117
148
  this.closed = true;
149
+ this.queue = [];
118
150
  if (this.timer)
119
151
  clearTimeout(this.timer);
120
152
  this.ws?.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.63.1",
3
+ "version": "0.64.0",
4
4
  "description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
5
5
  "mcpName": "io.github.vuluu2k/sbuilder-mcp",
6
6
  "type": "module",