sbuilder-mcp 0.76.2 → 0.76.4
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 +13 -0
- package/CHANGELOG.vi.md +13 -0
- package/dist/domains/site/findings.js +5 -0
- package/dist/domains/site/importmap.js +53 -0
- package/dist/domains/site/readiness.js +1 -1
- package/dist/tools/live.js +1 -1
- package/dist/vision/measure.js +86 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,19 @@ 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.76.4] - 2026-10-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `sb_look` reports two new layout findings, `title_bar_stranded` and `title_bar_offcenter`, when a hand-built heading-plus-action row leaves the button short of the right edge or off the heading's centre line, and skips rows that declare a centred layout.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- `sb_import` and `sb_import_site` now map a heading beside a single action such as "Xem tất cả" to a space-between row with centred alignment, so the button sits on the right edge, shares the heading's centre line, and stacks left-aligned on mobile.
|
|
16
|
+
|
|
17
|
+
## [0.76.3] - 2026-09-28
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- `sb_review`'s `catalogue` readiness gap now reads "1 product" instead of "1 products" when the store has exactly one inactive product and none active.
|
|
21
|
+
|
|
9
22
|
## [0.76.2] - 2026-09-28
|
|
10
23
|
|
|
11
24
|
### Fixed
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,19 @@ 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.76.4] - 2026-10-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `sb_look` báo thêm hai finding bố cục `title_bar_stranded` và `title_bar_offcenter` khi một hàng tiêu đề kèm nút được dựng tay để nút lệch khỏi mép phải hoặc lệch tâm dọc so với tiêu đề, và bỏ qua các hàng khai báo bố cục căn giữa.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- `sb_import` và `sb_import_site` giờ ánh xạ một tiêu đề đi kèm một nút duy nhất như "Xem tất cả" thành hàng space-between căn giữa theo chiều dọc, nên nút nằm sát mép phải, cùng tâm dọc với tiêu đề và xếp dọc căn trái trên mobile.
|
|
16
|
+
|
|
17
|
+
## [0.76.3] - 2026-09-28
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Gap readiness `catalogue` của `sb_review` giờ hiển thị "1 product" thay vì "1 products" khi store chỉ có đúng một sản phẩm inactive và không cái nào active.
|
|
21
|
+
|
|
9
22
|
## [0.76.2] - 2026-09-28
|
|
10
23
|
|
|
11
24
|
### Fixed
|
|
@@ -71,6 +71,11 @@ export const FIX = {
|
|
|
71
71
|
dead_binding_field: 'Rebind with sb_bind and a field of the form "specials.<key>".',
|
|
72
72
|
off_canvas: 'Give it a width that can shrink — sb_set id "<id>", namespace style, keys { "maxWidth": "100%" } at this breakpoint.',
|
|
73
73
|
text_too_small: 'Raise it for this breakpoint: sb_set id "<id>", namespace style, keys { "fontSize": "16px" }.',
|
|
74
|
+
title_bar_stranded: 'Put the action on the right edge: sb_set id "<id>", namespace style, keys ' +
|
|
75
|
+
'{ "justifyContent": "space-between", "alignItems": "center" }, and sb_set id "<cell>", ' +
|
|
76
|
+
'namespace style, keys { "flex": "0 0 auto", "width": "auto" }. On mobile give "<id>" ' +
|
|
77
|
+
'{ "flexDirection": "column", "alignItems": "flex-start" }.',
|
|
78
|
+
title_bar_offcenter: 'Centre the pair on one line: sb_set id "<id>", namespace style, keys { "alignItems": "center" } at this breakpoint.',
|
|
74
79
|
overlap: 'Check the two for a fixed height or a negative margin at this breakpoint; sb_look with node_id on each shows which one is out of place.',
|
|
75
80
|
// Both sticky findings name a node OTHER than the one at fault, because in
|
|
76
81
|
// both cases the node carrying the defect is not the node to edit.
|
|
@@ -184,6 +184,22 @@ function textScale(slug, fallback) {
|
|
|
184
184
|
}
|
|
185
185
|
return { style, config: { textGlobalStyle: slug } };
|
|
186
186
|
}
|
|
187
|
+
const holds = (c, kind) => c.kind === kind || (c.children ?? []).some((k) => holds(k, kind));
|
|
188
|
+
/** A button, or a packed run of nothing but buttons. */
|
|
189
|
+
const isAction = (c) => c.kind === 'button' || (c.kind === 'group' && !!c.children?.length && c.children.every((k) => k.kind === 'button'));
|
|
190
|
+
/**
|
|
191
|
+
* A row of a heading block and ONE action — a shelf's title and its "see all".
|
|
192
|
+
* `align: 'center'` is a caller asking for a centred band, and keeps the
|
|
193
|
+
* equal-share answer it asked for.
|
|
194
|
+
*/
|
|
195
|
+
export function isTitleBar(c) {
|
|
196
|
+
const kids = c.children ?? [];
|
|
197
|
+
if (c.kind !== 'group' || c.direction === 'column' || c.wrap || c.pack || c.align === 'center')
|
|
198
|
+
return false;
|
|
199
|
+
if (kids.length !== 2)
|
|
200
|
+
return false;
|
|
201
|
+
return holds(kids[0], 'heading') && !holds(kids[0], 'button') && isAction(kids[1]);
|
|
202
|
+
}
|
|
187
203
|
function one(c, t) {
|
|
188
204
|
const n = shape(c, t);
|
|
189
205
|
const x = c.extra;
|
|
@@ -510,6 +526,43 @@ function shape(c, t) {
|
|
|
510
526
|
})),
|
|
511
527
|
};
|
|
512
528
|
}
|
|
529
|
+
// A TITLE BAR IS NOT TWO EQUAL COLUMNS. A heading with one action beside
|
|
530
|
+
// it ("Xem tất cả") went down the equal-share path below: each side got
|
|
531
|
+
// half the row, the action sat at the LEFT of its half — the middle of
|
|
532
|
+
// the band — and `flex-start` hung it from the heading's top edge. The
|
|
533
|
+
// shape wanted is the opposite on both axes: the heading takes what it
|
|
534
|
+
// needs, the action sits on the right edge, and both share a centre line.
|
|
535
|
+
// A phone stacks them and keeps them flush left.
|
|
536
|
+
if (kids.length === 2 && isTitleBar(c)) {
|
|
537
|
+
return {
|
|
538
|
+
type: 'flex-block',
|
|
539
|
+
style: {
|
|
540
|
+
width: '100%',
|
|
541
|
+
display: 'flex',
|
|
542
|
+
flexDirection: 'row',
|
|
543
|
+
flexWrap: 'nowrap',
|
|
544
|
+
justifyContent: 'space-between',
|
|
545
|
+
alignItems: 'center',
|
|
546
|
+
gap: '24px',
|
|
547
|
+
},
|
|
548
|
+
responsive: { mobile: { style: { flexDirection: 'column', alignItems: 'flex-start', gap: '12px' } } },
|
|
549
|
+
// `width: auto` on both, for the reason the packed row records:
|
|
550
|
+
// `flex-block` seeds `width: 100%`, which would hand the heading the
|
|
551
|
+
// whole line and push the action onto nothing.
|
|
552
|
+
children: [
|
|
553
|
+
{
|
|
554
|
+
type: 'flex-block',
|
|
555
|
+
style: { flex: '1 1 auto', width: 'auto', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '8px' },
|
|
556
|
+
children: [kids[0]],
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
type: 'flex-block',
|
|
560
|
+
style: { flex: '0 0 auto', width: 'auto', display: 'flex', flexDirection: 'column' },
|
|
561
|
+
children: [kids[1]],
|
|
562
|
+
},
|
|
563
|
+
],
|
|
564
|
+
};
|
|
565
|
+
}
|
|
513
566
|
if (c.wrap) {
|
|
514
567
|
return {
|
|
515
568
|
type: 'flex-block',
|
|
@@ -553,7 +553,7 @@ export function readinessGaps(input) {
|
|
|
553
553
|
gaps.push({
|
|
554
554
|
id: 'catalogue',
|
|
555
555
|
draft: false,
|
|
556
|
-
problem: `The store has ${input.products.inactive} products and none is active — a product ` +
|
|
556
|
+
problem: `The store has ${input.products.inactive} product${input.products.inactive === 1 ? '' : 's'} and none is active — a product ` +
|
|
557
557
|
'created without status is stored as "draft", which the storefront never lists and ' +
|
|
558
558
|
'the checkout refuses. Every product list renders its empty state.',
|
|
559
559
|
fix: 'PUT /api/v1/products/{id} with the whole object read back and status: "active" ' +
|
package/dist/tools/live.js
CHANGED
|
@@ -366,7 +366,7 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
366
366
|
// and judging its geometry produced thirteen false off-canvas findings
|
|
367
367
|
// per page on a site whose pages were correct.
|
|
368
368
|
const skip = offscreenNodes(doc);
|
|
369
|
-
const visual = node_id ? [] : measure(shots, skip);
|
|
369
|
+
const visual = node_id ? [] : measure(shots, skip, doc);
|
|
370
370
|
const layout = compactFindings(visual);
|
|
371
371
|
const layoutNotice = visual.length > 0 ? ctx.notices.once('measure', MEASURE_NOTICE) : undefined;
|
|
372
372
|
// The legend rides with the first look only; the shape does not change after.
|
package/dist/vision/measure.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fill } from '../domains/site/findings.js';
|
|
2
|
+
import { childrenOf, subtreeIds } from '../core/tree.js';
|
|
2
3
|
/** Below this, body text is uncomfortable on a phone. */
|
|
3
4
|
const MIN_BODY_PX = 12;
|
|
4
5
|
/** A few pixels of overlap is a rounding artefact, not a defect. */
|
|
@@ -16,7 +17,7 @@ function overlaps(a, b) {
|
|
|
16
17
|
* 1440 and spills at 390 is the ordinary responsive failure, and saying which
|
|
17
18
|
* width it happened at is most of the fix.
|
|
18
19
|
*/
|
|
19
|
-
export function measureShot(shot, skip = new Set()) {
|
|
20
|
+
export function measureShot(shot, skip = new Set(), doc) {
|
|
20
21
|
const out = [];
|
|
21
22
|
const byId = new Map(shot.boxes.map((b) => [b.id, b]));
|
|
22
23
|
const seen = new Set();
|
|
@@ -109,7 +110,88 @@ export function measureShot(shot, skip = new Set()) {
|
|
|
109
110
|
});
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
|
-
|
|
113
|
+
if (doc)
|
|
114
|
+
out.push(...titleBars(shot, doc, byId, skip));
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
function union(bs) {
|
|
118
|
+
if (!bs.length)
|
|
119
|
+
return null;
|
|
120
|
+
const x = Math.min(...bs.map((b) => b.x));
|
|
121
|
+
const y = Math.min(...bs.map((b) => b.y));
|
|
122
|
+
return { x, y, w: Math.max(...bs.map((b) => b.x + b.w)) - x, h: Math.max(...bs.map((b) => b.y + b.h)) - y };
|
|
123
|
+
}
|
|
124
|
+
/** Every value a node declares for one style key, at any width. */
|
|
125
|
+
function declared(doc, id, key) {
|
|
126
|
+
const n = doc.nodes[id];
|
|
127
|
+
return [n?.style, ...Object.values(n?.responsive ?? {}).map((r) => r?.style)].map((s) => s?.[key]).filter((v) => v !== undefined);
|
|
128
|
+
}
|
|
129
|
+
/** Spacings that mean "not flush right" on purpose. */
|
|
130
|
+
const SPREAD = new Set(['center', 'space-around', 'space-evenly']);
|
|
131
|
+
/** Cross-axis answers that mean "not on the centre line" on purpose. */
|
|
132
|
+
const LINED = new Set(['flex-end', 'end', 'baseline']);
|
|
133
|
+
/** Containers an action may sit in, and nothing else. */
|
|
134
|
+
const WRAPS = new Set(['flex-block']);
|
|
135
|
+
const visible = (b) => !!b && b.w > 0 && b.h > 0;
|
|
136
|
+
/**
|
|
137
|
+
* A HEADING WITH ONE ACTION BESIDE IT, judged where the eye judges it.
|
|
138
|
+
*
|
|
139
|
+
* The row that went wrong split itself in two equal halves: the action sat at
|
|
140
|
+
* the left of its half — mid-band — and the top-aligned row hung it from the
|
|
141
|
+
* heading's top edge. Neither shows in the tree as a defect, because every
|
|
142
|
+
* style on it is a legitimate value. Only side-by-side pairs are judged, so a
|
|
143
|
+
* stacked phone layout is not reported; the action side must be buttons and
|
|
144
|
+
* their wrappers ONLY (the mapper's `isTitleBar`), so a text column beside a
|
|
145
|
+
* card or a grid holding buttons is a different shape; and a row that DECLARES
|
|
146
|
+
* a centred spacing or cross-axis line is left to its own decision.
|
|
147
|
+
*/
|
|
148
|
+
function titleBars(shot, doc, byId, skip) {
|
|
149
|
+
const out = [];
|
|
150
|
+
const typeOf = (id) => doc.nodes[id]?.data.type ?? '';
|
|
151
|
+
for (const id of Object.keys(doc.nodes)) {
|
|
152
|
+
const kids = childrenOf(doc, id);
|
|
153
|
+
const row = byId.get(id);
|
|
154
|
+
if (kids.length !== 2 || !visible(row) || skip.has(id))
|
|
155
|
+
continue;
|
|
156
|
+
const trees = kids.map((k) => subtreeIds(doc, k));
|
|
157
|
+
const isAction = (t) => {
|
|
158
|
+
const n = t.filter((x) => typeOf(x) === 'button').length;
|
|
159
|
+
return n >= 1 && n <= 3 && t.every((x) => typeOf(x) === 'button' || WRAPS.has(typeOf(x)));
|
|
160
|
+
};
|
|
161
|
+
const isHead = (t) => t.some((x) => typeOf(x) === 'heading') && !t.some((x) => typeOf(x) === 'button');
|
|
162
|
+
const h = isHead(trees[0]) && isAction(trees[1]) ? 0 : isHead(trees[1]) && isAction(trees[0]) ? 1 : -1;
|
|
163
|
+
if (h === -1)
|
|
164
|
+
continue;
|
|
165
|
+
const btnIds = trees[1 - h].filter((x) => typeOf(x) === 'button');
|
|
166
|
+
const head = union(trees[h].filter((x) => !childrenOf(doc, x).length).map((x) => byId.get(x)).filter(visible));
|
|
167
|
+
const btn = union(btnIds.map((x) => byId.get(x)).filter(visible));
|
|
168
|
+
if (!head || !btn)
|
|
169
|
+
continue;
|
|
170
|
+
// Stacked (a phone), or the action leads: not this shape.
|
|
171
|
+
if (btn.x < head.x + head.w - SLOP || btn.y >= head.y + head.h || head.y >= btn.y + btn.h)
|
|
172
|
+
continue;
|
|
173
|
+
const centredText = [id, kids[h], ...trees[h].filter((x) => typeOf(x) === 'heading')].some((x) => declared(doc, x, 'textAlign').includes('center'));
|
|
174
|
+
const rightGap = row.x + row.w - (btn.x + btn.w);
|
|
175
|
+
if (!centredText && !declared(doc, id, 'justifyContent').some((v) => SPREAD.has(String(v))) && rightGap > Math.max(24, row.w * 0.15)) {
|
|
176
|
+
out.push({
|
|
177
|
+
code: 'title_bar_stranded',
|
|
178
|
+
nodeId: btnIds[0],
|
|
179
|
+
width: shot.width,
|
|
180
|
+
problem: `Sits ${Math.round(rightGap)}px short of the right edge of its ${row.w}px row, beside a heading — it reads as floating mid-band.`,
|
|
181
|
+
fix: fill('title_bar_stranded', { id, cell: kids[1 - h] }),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const dy = Math.abs(btn.y + btn.h / 2 - (head.y + head.h / 2));
|
|
185
|
+
if (!declared(doc, id, 'alignItems').some((v) => LINED.has(String(v))) && dy > Math.max(6, head.h * 0.2)) {
|
|
186
|
+
out.push({
|
|
187
|
+
code: 'title_bar_offcenter',
|
|
188
|
+
nodeId: btnIds[0],
|
|
189
|
+
width: shot.width,
|
|
190
|
+
problem: `Its centre is ${Math.round(dy)}px off the heading's at ${shot.width}px wide — the pair does not share a line.`,
|
|
191
|
+
fix: fill('title_bar_offcenter', { id }),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
113
195
|
return out;
|
|
114
196
|
}
|
|
115
197
|
function contains(outer, inner) {
|
|
@@ -125,10 +207,10 @@ function contains(outer, inner) {
|
|
|
125
207
|
* widths it spills at is the useful part, so the widths are collected onto the
|
|
126
208
|
* single finding rather than repeated as separate ones.
|
|
127
209
|
*/
|
|
128
|
-
export function measure(shots, skip = new Set()) {
|
|
210
|
+
export function measure(shots, skip = new Set(), doc) {
|
|
129
211
|
const merged = new Map();
|
|
130
212
|
for (const shot of shots) {
|
|
131
|
-
for (const f of measureShot(shot, skip)) {
|
|
213
|
+
for (const f of measureShot(shot, skip, doc)) {
|
|
132
214
|
const key = `${f.code}|${f.nodeId}`;
|
|
133
215
|
const existing = merged.get(key);
|
|
134
216
|
if (existing)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.76.
|
|
3
|
+
"version": "0.76.4",
|
|
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",
|