sbuilder-mcp 0.63.2 → 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 +14 -0
- package/CHANGELOG.vi.md +14 -0
- package/dist/domains/site/builder.js +1 -0
- package/dist/domains/site/importmap.js +12 -0
- package/dist/domains/site/node.js +1 -1
- package/dist/domains/site/patterns.js +271 -28
- package/dist/tools/page.js +3 -0
- package/dist/tools/theme.js +38 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ 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
|
+
|
|
9
23
|
## [0.63.2] - 2026-09-24
|
|
10
24
|
|
|
11
25
|
### Fixed
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,20 @@ 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
|
+
|
|
9
23
|
## [0.63.2] - 2026-09-24
|
|
10
24
|
|
|
11
25
|
### 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
[
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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'),
|
|
233
|
-
{ kind: 'group', direction: 'column', children: [h('4,9/5', 2, 'center'),
|
|
234
|
-
{ kind: 'group', direction: 'column', children: [h('24h', 2, '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: '
|
|
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
|
-
[
|
|
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
|
-
|
|
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]));
|
package/dist/tools/page.js
CHANGED
|
@@ -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 };
|
package/dist/tools/theme.js
CHANGED
|
@@ -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 " +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
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",
|