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 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" ' +
@@ -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.
@@ -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
- void byId;
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.2",
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",