sbuilder-mcp 0.4.4 → 0.5.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,23 @@ 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.5.0] - 2026-09-08
10
+
11
+ ### Added
12
+ - sb_review reports default_seed_copy when a satellite's empty state still carries the platform's own English seed text, such as a repeater's empty state saying "No products yet", which previously matched no rule and reviewed clean.
13
+ - sb_review reports form_fields_flush when a form, form-segment or form-step-nav stacks its fields with no gap between them, so each label reads as belonging to the control above it.
14
+ - The install CLI accepts --site-name, matching the flag the platform's own Agent app install line already appends; it rides in as SB_SITE_NAME alongside the site id, and sb_connect reports it back so a session can display the store's name instead of its id.
15
+ - A dry run of the install CLI now reports its own preview outcome and exit code instead of reusing the failure marker and exit code of a real install.
16
+ - sb_api_call now falls back {siteId} and {siteID} path parameters to SB_SITE across all 289 operations that name the site, matching the fallback every other tool already applies through siteFor(); an explicit argument still wins.
17
+
18
+ ### Fixed
19
+ - sb_set's state parameter now writes to the location the platform's cascade actually reads: node.states[state] at base, node.responsive[breakpoint].states[state] per breakpoint. Previously it wrote to a path nothing reads, and base:true combined with state:"hover" wrote the hover value straight into the plain style, leaving a node permanently styled as if hovered with no hover state at all.
20
+ - sb_set now refuses a state argument on specials instead of silently dropping it, since content and identity do not vary by interaction state.
21
+ - sb_review now walks into satellite nodes (a repeater's empty state, a variant option's skin, a quantity stepper's buttons, a menu or tab item's skin), so findings inside them are reported instead of being invisible to every check.
22
+ - sb_review and sb_set now report a site-wide edit as such: a change to a node inside a global section's interior, or inside a site overlay like the cart drawer, is flagged rather than reading as an ordinary page-local edit.
23
+ - sb_look now opens a closed overlay (such as the cart drawer) before measuring it, so a node_id resolving inside one can be photographed at all instead of failing with a clipping error that named neither the overlay nor the reason.
24
+ - sb_look's preview_note no longer claims the draft preview renders every repeater's empty state; the draft preview threads real store data just like a published page, and the note now describes the real caveat, which is that an entity template previews with nothing bound.
25
+
9
26
  ## [0.4.4] - 2026-09-08
10
27
 
11
28
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,23 @@ 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.5.0] - 2026-09-08
10
+
11
+ ### Added
12
+ - sb_review giờ báo cáo default_seed_copy khi một node vệ tinh ở trạng thái rỗng vẫn còn mang seed text tiếng Anh của nền tảng, chẳng hạn trạng thái rỗng của một repeater ghi "No products yet" — trước đây không khớp với rule nào nên review vẫn báo sạch.
13
+ - sb_review giờ báo cáo form_fields_flush khi một form, form-segment hoặc form-step-nav xếp các field mà không có gap, khiến mỗi label đọc như thể thuộc về control phía trên nó.
14
+ - CLI cài đặt giờ nhận --site-name, khớp với flag mà dòng lệnh cài đặt của chính Agent app trên nền tảng đã thêm vào; giá trị này đi kèm dưới dạng SB_SITE_NAME cùng với site id, và sb_connect báo cáo lại giá trị này để một session có thể hiển thị tên cửa hàng thay vì id.
15
+ - Một dry run của CLI cài đặt giờ báo cáo kết quả xem trước và exit code riêng, thay vì dùng chung dấu hiệu thất bại và exit code của một lần cài đặt thật.
16
+ - sb_api_call giờ fallback các path parameter {siteId} và {siteID} về SB_SITE trên toàn bộ 289 operation có tên site, khớp với cách fallback mà mọi tool khác đã áp dụng qua siteFor(); một tham số được truyền rõ ràng vẫn được ưu tiên.
17
+
18
+ ### Fixed
19
+ - Tham số state của sb_set giờ ghi đúng vào vị trí mà cascade của nền tảng thực sự đọc: node.states[state] ở base, node.responsive[breakpoint].states[state] theo từng breakpoint. Trước đây nó ghi vào một đường dẫn không ai đọc, và base:true kết hợp với state:"hover" ghi thẳng giá trị hover vào style thường, khiến node mang màu hover vĩnh viễn mà không hề có trạng thái hover.
20
+ - sb_set giờ từ chối tham số state trên specials thay vì âm thầm bỏ qua, vì nội dung và identity không thay đổi theo trạng thái tương tác.
21
+ - sb_review giờ đi vào cả các node vệ tinh (trạng thái rỗng của repeater, skin của một variant option, các nút của quantity stepper, skin của một mục menu hoặc tab), nên các finding bên trong chúng được báo cáo thay vì vô hình với mọi check.
22
+ - sb_review và sb_set giờ báo cáo một chỉnh sửa có phạm vi toàn site đúng như bản chất của nó: thay đổi một node bên trong phần thân của một global section, hoặc bên trong một overlay của site như cart drawer, giờ được gắn cờ thay vì đọc như một chỉnh sửa cục bộ trên trang.
23
+ - sb_look giờ mở một overlay đang đóng (như cart drawer) trước khi đo, nên một node_id nằm bên trong overlay đó giờ có thể được chụp ảnh, thay vì thất bại với lỗi clip không nêu rõ overlay hay lý do.
24
+ - preview_note của sb_look không còn khẳng định rằng bản xem trước bản nháp render trạng thái rỗng của mọi repeater; bản xem trước bản nháp truyền dữ liệu cửa hàng thật giống như một trang đã publish, và ghi chú giờ mô tả đúng lưu ý thực sự — đó là một entity template xem trước sẽ không có gì được bind.
25
+
9
26
  ## [0.4.4] - 2026-09-08
10
27
 
11
28
  ### Added
package/README.md CHANGED
@@ -23,6 +23,11 @@ worse than one that does not exist.
23
23
  as `SB_SITE` and every tool then defaults to it. Without it the model has to carry the id
24
24
  through the session, which it can only get by listing pages and reading one back.
25
25
 
26
+ `--site-name "Your Store"` rides alongside it as `SB_SITE_NAME`. It is a label, never an
27
+ address — nothing resolves by it — but it lets the agent say the store's name back to you
28
+ instead of a 32-character id you did not choose. The **Apps → AI agent** screen appends it
29
+ whenever the store has a name.
30
+
26
31
  It **merges**: the servers already in those files stay, whatever it replaces is copied to
27
32
  `<file>.sbuilder-backup`, and a config it cannot parse is refused rather than overwritten —
28
33
  a file with a trailing comma is far likelier than one worth discarding, and it is what you
package/README.vi.md CHANGED
@@ -23,6 +23,11 @@ còn tệ hơn một cờ không tồn tại.
23
23
  thành `SB_SITE` và mọi tool sau đó mặc định dùng site đó. Không có nó thì model phải mang
24
24
  theo id suốt phiên, mà cách duy nhất để lấy là liệt kê trang rồi đọc ngược ra.
25
25
 
26
+ `--site-name "Tên Cửa Hàng"` đi kèm, được ghi thành `SB_SITE_NAME`. Nó là một nhãn, không phải
27
+ địa chỉ — không có gì phân giải theo nó — nhưng nhờ nó agent gọi được tên cửa hàng thay vì
28
+ đọc lại một id 32 ký tự bạn không đặt. Màn hình **Apps → AI agent** tự thêm nó khi cửa hàng
29
+ có tên.
30
+
26
31
  Nó **gộp**: các server đã có trong file được giữ nguyên, thứ nó thay thế được chép sang
27
32
  `<file>.sbuilder-backup`, và một config nó không đọc được thì bị từ chối chứ không ghi đè —
28
33
  một file thừa dấu phẩy khả dĩ hơn nhiều một file đáng vứt, và đó chính là thứ bạn cần để sửa.
@@ -759,7 +759,8 @@ export const CHECKOUT_PAGE_DOCUMENT = {
759
759
  "display": "flex",
760
760
  "flexDirection": "column",
761
761
  "width": "100%",
762
- "height": "fit-content"
762
+ "height": "fit-content",
763
+ "gap": "12px"
763
764
  },
764
765
  "config": {},
765
766
  "specials": {
@@ -5834,7 +5834,8 @@ export const ELEMENTS = {
5834
5834
  "display": "flex",
5835
5835
  "flexDirection": "column",
5836
5836
  "width": "100%",
5837
- "height": "fit-content"
5837
+ "height": "fit-content",
5838
+ "gap": "12px"
5838
5839
  }
5839
5840
  },
5840
5841
  "inspector": [
package/dist/core/tree.js CHANGED
@@ -87,6 +87,32 @@ export function walk(doc, id, visit) {
87
87
  };
88
88
  go(id);
89
89
  }
90
+ /**
91
+ * One level of `walk`: `data.nodes` followed by the satellites on `config[key]`.
92
+ *
93
+ * For a caller that needs its OWN recursion — carrying scope down as it goes,
94
+ * the way `reviewDesign` carries "am I inside a repeater / an overlay" — and so
95
+ * cannot hand the traversal to `walk`. Such a caller reaching for `childrenOf`
96
+ * is the mistake `walk`'s comment warns about, and `sb_review` made it: it never
97
+ * saw a satellite, so every empty state, every variant-option skin and every
98
+ * quantity stepper was outside the review entirely.
99
+ *
100
+ * Satellites come AFTER the real children and are not deduplicated against
101
+ * them — a `configKey` pointing at a node that is also a child would be a
102
+ * malformed document, and a walk is not a validator.
103
+ */
104
+ export function childrenWithSatellites(doc, id) {
105
+ const n = doc.nodes[id];
106
+ if (!n)
107
+ return [];
108
+ const out = [...n.data.nodes];
109
+ for (const rule of SATELLITE_RULES[n.data.type] ?? []) {
110
+ const sat = n.config?.[rule.configKey];
111
+ if (typeof sat === 'string' && sat && doc.nodes[sat])
112
+ out.push(sat);
113
+ }
114
+ return out;
115
+ }
90
116
  export function subtreeIds(doc, id) {
91
117
  const out = [];
92
118
  walk(doc, id, (n) => out.push(n.id));
@@ -114,6 +140,26 @@ export function ancestors(doc, id) {
114
140
  * therefore stored nowhere and reported nowhere — trap 5. Nearest stamp wins:
115
141
  * the block root answers with itself.
116
142
  */
143
+ /**
144
+ * The site overlay this node sits in, or null. Nearest stamp wins, so the
145
+ * overlay root answers with itself.
146
+ *
147
+ * `isOverlay` answers only for the ROOT of one, which is right for the rules
148
+ * that decide whether a node may BE an overlay. It is the wrong question for a
149
+ * WRITE: an edit to a node inside the cart drawer is an edit to the drawer, and
150
+ * the drawer is one master shared by every page on the site. Without this, a
151
+ * `sb_set` on a drawer node reported a plain page-local success while changing
152
+ * ten pages — the same asymmetry `sb_review` closed when it started walking
153
+ * overlays and flagging their findings `overlay: true`.
154
+ */
155
+ export function overlayRoot(doc, id) {
156
+ if (isOverlay(doc, id))
157
+ return id;
158
+ for (const a of ancestors(doc, id))
159
+ if (isOverlay(doc, a))
160
+ return a;
161
+ return null;
162
+ }
117
163
  export function appBlockRoot(doc, id) {
118
164
  const stamped = (n) => n !== undefined &&
119
165
  (n.specials?.[SPEC_APP_BLOCK_ID] !== undefined || n.specials?.[SPEC_APP_BLOCK_REF] !== undefined);
@@ -182,12 +182,42 @@ export function setKeys(doc, id, keys, opts) {
182
182
  const rebind = namespace === 'config' ? rebindPatch(doc, id, keys) : null;
183
183
  if (namespace === 'specials') {
184
184
  refuseComposedStamp(keys);
185
+ // `specials` is content and identity, base-only by definition, and states
186
+ // carry style. Saying so is the point: this used to drop the state and write
187
+ // the value as if it had been asked for plainly.
188
+ if (opts.state) {
189
+ throw new Error(`sbuilder: specials takes no interaction state — "${opts.state}" would be dropped. ` +
190
+ 'specials is content and identity (text, htmlTag, bound…), which do not vary by ' +
191
+ 'state. Style is what has states.');
192
+ }
185
193
  return Object.entries(keys).map(([k, v]) => ({
186
194
  op: 'set',
187
195
  path: ['nodes', id, 'specials', k],
188
196
  value: v,
189
197
  }));
190
198
  }
199
+ // A STATE IS ANSWERED BEFORE `base`, because `base` used to be tested first
200
+ // and swallowed it: `base:true state:"hover"` wrote the hover value straight
201
+ // into the plain style, so the node wore its hover colour permanently and had
202
+ // no hover at all. The tool reported success, and this repo's own design skill
203
+ // documents that exact call — `sb_set pr_option style base:true state:"active"`
204
+ // — as the way to style a selected option.
205
+ if (opts.state) {
206
+ // Base state and per-breakpoint state are DIFFERENT PLACES in the document,
207
+ // and the old path (`states[state][bp][ns]`) was neither of them: it buried
208
+ // a breakpoint inside the base-state cluster, where nothing reads it.
209
+ const prefix = opts.base
210
+ ? ['nodes', id, 'states', opts.state]
211
+ : ['nodes', id, 'responsive', opts.breakpoint ?? 'desktop', 'states', opts.state];
212
+ return [
213
+ ...Object.entries(keys).map(([k, v]) => ({
214
+ op: 'set',
215
+ path: [...prefix, namespace, k],
216
+ value: v,
217
+ })),
218
+ ...(rebind ? [rebind] : []),
219
+ ];
220
+ }
191
221
  if (opts.base) {
192
222
  // Base IS legitimate, and this used to throw for anything that was not an
193
223
  // identity key — a refusal built on a misread of the platform's responsive
@@ -217,16 +247,6 @@ export function setKeys(doc, id, keys, opts) {
217
247
  ];
218
248
  }
219
249
  const bp = opts.breakpoint ?? 'desktop';
220
- if (opts.state) {
221
- return [
222
- ...Object.entries(keys).map(([k, v]) => ({
223
- op: 'set',
224
- path: ['nodes', id, 'states', opts.state, bp, namespace, k],
225
- value: v,
226
- })),
227
- ...(rebind ? [rebind] : []),
228
- ];
229
- }
230
250
  return [
231
251
  ...Object.entries(keys).map(([k, v]) => ({
232
252
  op: 'set',
@@ -18,6 +18,15 @@ export const FIX = {
18
18
  empty_text: 'Set it: sb_set id "<id>", namespace specials, keys { "<key>": … }.',
19
19
  missing_media: 'Set it: sb_set id "<id>", namespace specials, keys { "<key>": … }.',
20
20
  placeholder_content: 'Write the real copy: sb_set id "<id>", namespace specials, keys { "<key>": … }.',
21
+ // Named apart from placeholder_content because the reader needs telling that
22
+ // this surface EXISTS. An empty state is authored out of sight — it only shows
23
+ // when the list is empty — so an agent that never saw it does not know there is
24
+ // anything to write.
25
+ default_seed_copy: 'Write it in the shop\'s own language and ink: sb_set id "<id>", namespace specials, ' +
26
+ 'keys { "<key>": … }. It is a seeded satellite (an empty state, an element\'s chrome), ' +
27
+ 'so it only appears when the list is empty or the control is idle — sb_outline lists it ' +
28
+ 'under its owner as satellite:"<config key>". Style it too: the seed ships #171717 ink ' +
29
+ 'and #d4d4d4 icons, which are the platform\'s, not the site\'s.',
21
30
  // The fix is a DIFFERENT ELEMENT, not a value: setting "<key>" here would put
22
31
  // one authored value on every row of the repeater.
23
32
  static_in_dataset: `Swap the element: sb_add one of ${RECORD_ELEMENTS} in the same parent, sb_bind it ` +
@@ -25,6 +34,12 @@ export const FIX = {
25
34
  'sb_remove id "<id>". Setting "<key>" on this one would show the same value in every row.',
26
35
  unbound_dataset_element: 'Bind it: sb_bind id "<id>", field "specials.<key>", and the source that names the record ' +
27
36
  'field you want (sb_bind refuses an unknown source and lists every valid one).',
37
+ // The value the platform now seeds on these elements, so a repair matches what
38
+ // a freshly created form would have.
39
+ form_fields_flush: 'Give the stack room: sb_set id "<id>", namespace style, base true, keys { "gap": "12px" } ' +
40
+ '— the value the platform seeds on form / form-segment / form-step-nav. Raise it if the ' +
41
+ 'design wants more air; it must stay clear of config.fieldStackGap, which is the smaller ' +
42
+ 'label-to-control gap INSIDE one field.',
28
43
  unlinked_form: 'Point it at a real form: sb_set id "<id>", namespace specials, keys ' +
29
44
  '{ "formId": "<a form id from sb_api_find \'list forms\'>" }.',
30
45
  dead_menu_link: 'Write the entries the renderer actually reads: sb_set id "<id>", namespace specials, keys ' +
@@ -1,5 +1,5 @@
1
- import { childrenOf, isOverlay, pageChildren, appBlockRoot } from '../../core/tree.js';
2
- import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY } from '../../catalog/elements.generated.js';
1
+ import { childrenOf, childrenWithSatellites, isOverlay, pageChildren, appBlockRoot } from '../../core/tree.js';
2
+ import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
3
3
  import { fill } from './findings.js';
4
4
  /**
5
5
  * Shipped with every non-empty finding list.
@@ -23,6 +23,57 @@ function contentKeys(type) {
23
23
  function seededValue(type, key) {
24
24
  return (ELEMENTS[type]?.defaults.specials ?? {})[key];
25
25
  }
26
+ /**
27
+ * Every line of copy the PLATFORM writes into a seeded subtree.
28
+ *
29
+ * `placeholder_content` above compares against `ELEMENTS[type].defaults.specials`
30
+ * — the element's OWN default — and that is exactly why it could never see an
31
+ * empty state. A heading's own default is `"Heading"`; the heading inside a
32
+ * repeater's empty state is minted from `SATELLITE_RULES`' seed tree and says
33
+ * `"No products yet"`. Different source, so the check walked straight past it.
34
+ *
35
+ * The consequence was general, not incidental: EVERY store built with these
36
+ * tools ships the platform's English empty states and reviews clean. Measured on
37
+ * a Vietnamese storefront — home, category, product and search all reported
38
+ * "nothing a visitor would notice" while four repeaters said "No products yet"
39
+ * and "New arrivals will show up here. Check back soon." in `#171717` on a page
40
+ * that is `#2E2A3B` throughout. It is the most-visited copy on a store: an empty
41
+ * cart is the empty state a shopper meets first.
42
+ *
43
+ * Collected from BOTH generated seed tables, so a new empty state the platform
44
+ * ships is covered by the next `npm run codegen` rather than by an edit here.
45
+ */
46
+ const SEEDED_SUBTREE_TEXT = (() => {
47
+ const out = new Set();
48
+ const visit = (v) => {
49
+ if (Array.isArray(v)) {
50
+ for (const x of v)
51
+ visit(x);
52
+ return;
53
+ }
54
+ if (v === null || typeof v !== 'object')
55
+ return;
56
+ const o = v;
57
+ const t = o.specials?.text;
58
+ if (typeof t === 'string' && t.trim() !== '')
59
+ out.add(t);
60
+ for (const x of Object.values(v))
61
+ visit(x);
62
+ };
63
+ visit(SATELLITE_RULES);
64
+ visit(ELEMENT_SEEDS);
65
+ return out;
66
+ })();
67
+ /**
68
+ * The containers whose children are form FIELDS.
69
+ *
70
+ * Named rather than derived: "offers a Gap control" is true of 33 elements and
71
+ * four of them legitimately seed none (`flex-section` and `flex-block` are the
72
+ * layout primitives, where the author composes the spacing). What makes these
73
+ * three different is that their children are LABELLED CONTROLS, and a label with
74
+ * no space above it attaches itself to the wrong one.
75
+ */
76
+ const FIELD_STACKS = new Set(['form', 'form-segment', 'form-step-nav']);
26
77
  /**
27
78
  * A container that renders its subtree ONCE PER RECORD.
28
79
  *
@@ -111,7 +162,13 @@ export function reviewDesign(doc) {
111
162
  if (appBlockRoot(d, id) === id)
112
163
  return;
113
164
  const inner = repeats(d.nodes[id]?.data.type ?? '') ? (repeater ?? id) : repeater;
114
- for (const k of childrenOf(d, id))
165
+ // SATELLITES INCLUDED. `childrenOf` here meant the review never entered one,
166
+ // so an element's whole chrome — every repeater's empty state, the variant
167
+ // option skin, the quantity stepper, the menu and tab item skins — sat
168
+ // outside the check that exists to say what a visitor meets. `walk`'s own
169
+ // comment names this as the mistake a caller makes by reaching for the
170
+ // child-only list; this had made it.
171
+ for (const k of childrenWithSatellites(d, id))
115
172
  go(k, inner, overlay || overlayIds.has(k));
116
173
  };
117
174
  go(d.root_node_id);
@@ -212,6 +269,22 @@ export function reviewDesign(doc) {
212
269
  fix: fill('placeholder_content', { id, key }),
213
270
  });
214
271
  }
272
+ // The same defect, one seed table over — see SEEDED_SUBTREE_TEXT. Reported
273
+ // separately because the FIX reads differently: this copy is not a
274
+ // "<Heading>" nobody noticed, it is a real English sentence that renders
275
+ // as if somebody meant it.
276
+ else if (key === 'text' && typeof value === 'string' && SEEDED_SUBTREE_TEXT.has(value)) {
277
+ out.push({
278
+ code: 'default_seed_copy',
279
+ nodeId: id,
280
+ type,
281
+ problem: `Still the platform's own seed copy (${JSON.stringify(value)}), in English. This ` +
282
+ 'surface was never authored — it is minted with the element and reads as if ' +
283
+ 'somebody wrote it.',
284
+ key,
285
+ fix: fill('default_seed_copy', { id, key }),
286
+ });
287
+ }
215
288
  }
216
289
  // A FORM NOBODY LINKED publishes as an empty box, and the platform stays
217
290
  // deliberately quiet about it: form.go:221 skips an empty formId with the
@@ -233,6 +306,42 @@ export function reviewDesign(doc) {
233
306
  });
234
307
  }
235
308
  }
309
+ // A FORM WHOSE FIELDS TOUCH. The three field stacks lay out `display:flex`
310
+ // + `flexDirection:column`, so with no `gap` every field sits flush against
311
+ // the one above it and each label ends up nearer the previous control than
312
+ // its own — the one thing a form's spacing has to get right.
313
+ //
314
+ // Measured on a published checkout at 1440px: six consecutive fields, every
315
+ // gap between them EXACTLY 0. The platform now seeds `gap: 12px` on these
316
+ // elements, but `defaults` seeds at CREATION, so every form authored before
317
+ // that keeps the spacing it was given and nothing says so.
318
+ //
319
+ // NOT a child check: a form's fields live in the FORM DOCUMENT and are
320
+ // composed on the render path, so the page's own node has `nodes: []` and
321
+ // counting children would report every form as empty.
322
+ if (FIELD_STACKS.has(type)) {
323
+ const slots = [
324
+ n.style,
325
+ ...Object.values(n.responsive ?? {}).map((s) => s?.style),
326
+ ];
327
+ const anyGap = slots.some((s) => {
328
+ const g = s?.gap;
329
+ return g !== undefined && g !== null && `${g}`.trim() !== '' && parseFloat(`${g}`) > 0;
330
+ });
331
+ if (!anyGap) {
332
+ out.push({
333
+ code: 'form_fields_flush',
334
+ nodeId: id,
335
+ type,
336
+ problem: 'This form stacks its fields with no gap, so each one touches the one above it and ' +
337
+ "every label reads as belonging to the control above rather than its own. The " +
338
+ 'field\'s own label-to-control spacing (config.fieldStackGap) is a different, ' +
339
+ 'smaller quantity and does not close this.',
340
+ key: 'gap',
341
+ fix: fill('form_fields_flush', { id, key: 'gap' }),
342
+ });
343
+ }
344
+ }
236
345
  // A MENU ENTRY WITH NO HREF is a link that goes nowhere. The Go renderer
237
346
  // reads specials.menuItems and never menuId, so picking a menu by id
238
347
  // publishes an empty nav, and the element's own seed ships one entry
@@ -1,5 +1,5 @@
1
1
  import { FIRST_CHILD_ONLY } from '../../catalog/elements.generated.js';
2
- import { pageChildren, isOverlay, SPEC_GLOBAL_ID, SPEC_GLOBAL_KIND, SPEC_GLOBAL_REV, SPEC_OVERLAY_ID, SPEC_OVERLAY_REV, } from '../../core/tree.js';
2
+ import { pageChildren, isOverlay, overlayRoot, ancestors, SPEC_GLOBAL_ID, SPEC_GLOBAL_KIND, SPEC_GLOBAL_REV, SPEC_OVERLAY_ID, SPEC_OVERLAY_REV, } from '../../core/tree.js';
3
3
  /**
4
4
  * Which band a direct child of ROOT belongs to.
5
5
  *
@@ -60,6 +60,22 @@ export function checkBandOrder(doc) {
60
60
  export function isGlobal(doc, id) {
61
61
  return doc.nodes[id]?.specials?.[SPEC_GLOBAL_ID] !== undefined;
62
62
  }
63
+ /**
64
+ * The global section this node sits in, or null. Nearest stamp wins.
65
+ *
66
+ * `isGlobal` answers for the stamped ROOT alone, which is the right question for
67
+ * "may this node be moved out of the band it is in". It is the wrong one for a
68
+ * WRITE: nobody restyles the header section, they restyle a button inside it,
69
+ * and that edit is just as site-wide.
70
+ */
71
+ export function globalRoot(doc, id) {
72
+ if (isGlobal(doc, id))
73
+ return id;
74
+ for (const a of ancestors(doc, id))
75
+ if (isGlobal(doc, a))
76
+ return a;
77
+ return null;
78
+ }
63
79
  /**
64
80
  * The sentence to attach to any result that touched a global.
65
81
  *
@@ -69,10 +85,32 @@ export function isGlobal(doc, id) {
69
85
  * not know this reports "updated the header" having changed the whole site.
70
86
  */
71
87
  export function globalWarning(doc, id) {
72
- if (!isGlobal(doc, id))
73
- return null;
74
- const gid = doc.nodes[id].specials[SPEC_GLOBAL_ID];
75
- return `Node ${id} is the shared global section ${JSON.stringify(gid)}. Editing it changes EVERY page that carries it, and publishing cascades to all of them. Say so when reporting this change.`;
88
+ // THE STAMP IS ON THE SECTION ROOT, and asking only `isGlobal(id)` meant the
89
+ // warning fired for the one node nobody edits. The work happens INSIDE: a nav
90
+ // button in the global header, a line of the global footer. Restyling
91
+ // `bu_…` changed all ten pages of a site and the result said nothing, while
92
+ // the same edit one level up would have warned. Same asymmetry as the overlay
93
+ // one below, one construct over.
94
+ const g = globalRoot(doc, id);
95
+ if (g !== null) {
96
+ const gid = doc.nodes[g].specials?.[SPEC_GLOBAL_ID];
97
+ const kind = doc.nodes[g].specials?.[SPEC_GLOBAL_KIND];
98
+ const self = g === id ? '' : ` (inside global ${g})`;
99
+ return `Node ${id}${self} belongs to the shared global section ${JSON.stringify(gid)}${kind ? ` (${kind})` : ''}. Editing it changes EVERY page that carries it, and publishing cascades to all of them. Say so when reporting this change.`;
100
+ }
101
+ // AN OVERLAY IS SHARED TOO, and this used to answer only for globals. The cart
102
+ // drawer and the pop-ups are ONE master composed onto every page, so an edit
103
+ // to a node inside one is site-wide — measured: restyling the drawer's
104
+ // quantity stepper touched ten pages and the result read as a page-local
105
+ // success. It is the write-side of the reason `sb_review` flags overlay
106
+ // findings `overlay: true` instead of reporting them once per page.
107
+ const ov = overlayRoot(doc, id);
108
+ if (ov !== null) {
109
+ const oid = doc.nodes[ov].specials?.[SPEC_OVERLAY_ID];
110
+ const self = ov === id ? '' : ` (inside overlay ${ov})`;
111
+ return `Node ${id}${self} belongs to the site overlay ${JSON.stringify(oid)} — the cart drawer or a pop-up. It is ONE master composed onto every page, so this change is site-wide, not page-local. Say so when reporting it.`;
112
+ }
113
+ return null;
76
114
  }
77
115
  /**
78
116
  * Keys that are identity or content rather than a visual quantity.
@@ -9,11 +9,21 @@ export const SERVER_NAME = 'sbuilder';
9
9
  * at a real install, read by nothing, and reported as success — the caller then
10
10
  * spent the session wondering why the site was not selected. A flag that is
11
11
  * silently dropped is worse than one that does not exist.
12
+ *
13
+ * BUT REFUSING ONE THE PLATFORM ITSELF EMITS IS WORSE STILL, and that is what
14
+ * `--site-name` became. The editor's Agent app builds the whole install line for
15
+ * the merchant to paste — `AgentAppPanel.vue`, which appends
16
+ * `--site-name "<store>"` whenever it knows the name, with a test pinning it —
17
+ * so the ONE documented install path exited 1 and installed nothing. The flag
18
+ * was never noise; it was added on the platform side after this list was
19
+ * written. It is a label, not a credential: it rides in as SB_SITE_NAME so the
20
+ * agent can say "Áo Thun" instead of reading back site_14675b5a570b248d.
12
21
  */
13
22
  const FLAGS = [
14
23
  '--token',
15
24
  '--api',
16
25
  '--site',
26
+ '--site-name',
17
27
  '--email',
18
28
  '--password',
19
29
  '--client',
@@ -30,6 +40,11 @@ export function buildEntry(opts, pkg = 'sbuilder-mcp') {
30
40
  // model the guess it otherwise makes from a page list.
31
41
  if (opts.site)
32
42
  env.SB_SITE = opts.site;
43
+ // The name the merchant calls the store, so the agent can too. Carried only
44
+ // alongside an id — a name with nothing to resolve to is decoration, and a
45
+ // second install would file it under a site it does not name.
46
+ if (opts.site && opts.siteName)
47
+ env.SB_SITE_NAME = opts.siteName;
33
48
  // Only when a key is absent: a key opens everything the agent does day to day,
34
49
  // and writing an account password into six config files to buy the handful of
35
50
  // account-level calls it adds is a bad trade the installer should not make for
@@ -109,6 +124,7 @@ export function runInstallCli(argv) {
109
124
  token: get('--token') ?? process.env.SB_TOKEN,
110
125
  api: get('--api') ?? process.env.SB_API,
111
126
  site: get('--site') ?? process.env.SB_SITE,
127
+ siteName: get('--site-name') ?? process.env.SB_SITE_NAME,
112
128
  email: get('--email') ?? process.env.SB_EMAIL,
113
129
  password: get('--password') ?? process.env.SB_PASSWORD,
114
130
  clients: get('--client')?.split(','),
@@ -134,7 +150,17 @@ export function runInstallCli(argv) {
134
150
  return 1;
135
151
  }
136
152
  for (const r of results) {
137
- const mark = r.status === 'installed' ? '✔' : r.status === 'unchanged' ? '·' : '✖';
153
+ // A DRY RUN THAT WROTE NOTHING DID ITS JOB. Every line used to be marked
154
+ // `✖` and the command exited 1, so the one way to inspect an install before
155
+ // making it reported total failure — and a CI step wrapping it would stop
156
+ // there. The preview gets its own mark and its own exit code.
157
+ const mark = opts.dryRun
158
+ ? '→'
159
+ : r.status === 'installed'
160
+ ? '✔'
161
+ : r.status === 'unchanged'
162
+ ? '·'
163
+ : '✖';
138
164
  console.error(`${mark} ${r.client} — ${r.status}${r.reason ? ` (${r.reason})` : ''}`);
139
165
  console.error(` ${r.path}`);
140
166
  // The undo, named. A config writer that changes a file without saying where
@@ -144,5 +170,7 @@ export function runInstallCli(argv) {
144
170
  if (r.note)
145
171
  console.error(` ${r.note}`);
146
172
  }
173
+ if (opts.dryRun)
174
+ return 0;
147
175
  return results.some((r) => r.status === 'installed' || r.status === 'unchanged') ? 0 : 1;
148
176
  }
package/dist/server.js CHANGED
@@ -49,6 +49,7 @@ export function buildContext() {
49
49
  session: new Session(base),
50
50
  apiKey: process.env.SB_TOKEN,
51
51
  siteId: process.env.SB_SITE,
52
+ siteName: process.env.SB_SITE_NAME,
52
53
  notices: new Notices(), undo: new UndoLog(),
53
54
  };
54
55
  }
package/dist/tools/api.js CHANGED
@@ -163,6 +163,14 @@ export function tokenFor(ctx, credential) {
163
163
  }
164
164
  return undefined;
165
165
  }
166
+ /**
167
+ * The path parameters that mean "THIS site" — lowercased, because the platform
168
+ * writes `{siteId}` in 281 operations and `{siteID}` in 8.
169
+ *
170
+ * Deliberately only the site. `{productId}`, `{id}` and the rest name a record
171
+ * the caller chose; the site is the one id an install already holds.
172
+ */
173
+ const SITE_PARAMS = new Set(['siteid']);
166
174
  export async function callOperation(ctx, args) {
167
175
  const op = API_OPERATIONS.find((o) => o.id === args.id);
168
176
  if (!op)
@@ -185,6 +193,16 @@ export async function callOperation(ctx, args) {
185
193
  if (folded !== undefined)
186
194
  value = given[folded];
187
195
  }
196
+ // SB_SITE ANSWERS FOR {siteId}, exactly as it does for every tool's
197
+ // `site_id` argument through `siteFor()`. It did not here, and this is the
198
+ // surface where it costs most: `sb_api_call` reaches 484 operations and 289
199
+ // of them name the site, so a key-only install — where the id is a constant
200
+ // the environment already holds — made the model carry a 32-character
201
+ // string through every raw call it made. An explicit argument still wins,
202
+ // so a session spanning two sites works by naming each.
203
+ if (value === undefined && SITE_PARAMS.has(name.toLowerCase()) && ctx.siteId) {
204
+ value = ctx.siteId;
205
+ }
188
206
  if (value === undefined) {
189
207
  // NAME THE ARGUMENT, not just the parameter. The call sheet lists these
190
208
  // under `params` while the call takes them in `path_params`, and a caller
@@ -24,7 +24,7 @@ const DATASET_TYPES = new Set([
24
24
  import { RealtimeSocket } from '../transport/socket.js';
25
25
  import { LiveSession } from '../live/session.js';
26
26
  import { refuseAppBlockInterior } from '../domains/site/builder.js';
27
- import { childrenOf, isOverlay, subtreeIds } from '../core/tree.js';
27
+ import { childrenOf, isOverlay, overlayRoot, subtreeIds } from '../core/tree.js';
28
28
  import { siteToken } from './credentialpick.js';
29
29
  import { siteFor } from './context.js';
30
30
  import { projectList, MEDIA_FIELDS } from './project.js';
@@ -275,7 +275,21 @@ export function registerLiveTools(server, ctx, session) {
275
275
  // the process; `shoot` keeps them in `widths` order. The format changes
276
276
  // bytes and latency only — the client prices an image by its pixel size,
277
277
  // so jpeg and png cost the agent the same tokens.
278
- const shots = await shoot(target, { widths: widths ?? DEFAULT_WIDTHS, node: node_id, format });
278
+ // FRAMING A NODE IN THE CART DRAWER MEANS OPENING THE DRAWER. A closed
279
+ // overlay is translated off-screen, so the clip lands outside the image
280
+ // and the shot fails with a Playwright error naming neither the overlay
281
+ // nor the reason. `overlayRoot` answers for a node ANYWHERE inside one,
282
+ // which is the case that matters: the caller frames the stepper or the
283
+ // empty state, not the drawer root.
284
+ const openOverlay = node_id
285
+ ? (overlayRoot(session.current().doc, node_id) ?? undefined)
286
+ : undefined;
287
+ const shots = await shoot(target, {
288
+ widths: widths ?? DEFAULT_WIDTHS,
289
+ node: node_id,
290
+ format,
291
+ ...(openOverlay ? { open: openOverlay } : {}),
292
+ });
279
293
  // The boxes feed the presence cursor as well as the agent's own reading.
280
294
  session.noteBoxes(shots[0]?.boxes ?? []);
281
295
  // The findings ride WITH the picture. Judging a page by eye and judging it
@@ -301,10 +315,30 @@ export function registerLiveTools(server, ctx, session) {
301
315
  // Say it ONCE, and only when it can actually mislead: a page with no
302
316
  // store-driven element has nothing to be missing from the preview.
303
317
  const dataDriven = Object.values(session.current().doc.nodes).some((n) => DATASET_TYPES.has(n.data.type));
318
+ // THIS NOTE USED TO SAY THE OPPOSITE, and it sent readers to fix a page
319
+ // that was right. It claimed the draft preview "threads no store data:
320
+ // every repeater renders its empty state there" — measured false: a home
321
+ // page previewed four real products at their real prices, matching the
322
+ // catalogue exactly. `ServePreview` runs RenderDraft → gather → assemble,
323
+ // the SAME path as a published page, and the platform's own comment on it
324
+ // says the result is "byte-identical to what publishing this source would
325
+ // serve" (storefront.go:1260). The shoot path's own comment had already
326
+ // recorded the observation — "identical content on screen (images, prices,
327
+ // no empty states)" — while this note contradicted it.
328
+ //
329
+ // What the preview genuinely cannot do is resolve ONE RECORD from the URL:
330
+ // ServePreview never runs entity routing (that lives in ServeHost), so an
331
+ // entity TEMPLATE previews with nothing bound. That is the real caveat,
332
+ // and it is the opposite population of pages from the one the old note
333
+ // warned about.
304
334
  const previewNote = !url && dataDriven
305
- ? ctx.notices.once('preview-scope', 'This is the DRAFT PREVIEW, which threads no store data: every repeater renders its ' +
306
- 'empty state there, however correct the page is. Publish and pass the storefront ' +
307
- 'URL as `url` to see real products.')
335
+ ? ctx.notices.once('preview-scope', 'This is the DRAFT PREVIEW. It renders through the same path as a published page, ' +
336
+ 'so repeaters DO show real store records judge a list page from it. What it ' +
337
+ 'cannot do is resolve a single record from the address: on an ENTITY TEMPLATE ' +
338
+ '(the product or category detail page) nothing is bound, so the title is blank, ' +
339
+ 'the price reads zero and a variant picker shows the element\'s seed options ' +
340
+ '("Color / Size", "Red / S") rather than the product\'s own. That is the preview, ' +
341
+ 'not the page. Pass a published storefront URL as `url` to judge a template.')
308
342
  : undefined;
309
343
  return images(shots.map((s) => ({ dataBase64: s.imageBase64, mimeType: s.mimeType })), {
310
344
  widths: shots.map((s) => s.width),
@@ -24,9 +24,10 @@ export async function connect(ctx, args) {
24
24
  sites: [],
25
25
  api_key: 'present',
26
26
  ...(ctx.siteId ? { site: ctx.siteId } : {}),
27
+ ...(ctx.siteId && ctx.siteName ? { site_name: ctx.siteName } : {}),
27
28
  operations: API_OPERATIONS.length,
28
29
  note: ctx.siteId
29
- ? `Connected with an API key alone, on site ${ctx.siteId} (SB_SITE). Every tool ` +
30
+ ? `Connected with an API key alone, on site ${ctx.siteId} (SB_SITE)${ctx.siteName ? `, the store called ${JSON.stringify(ctx.siteName)}` : ''}. Every tool ` +
30
31
  'defaults to it, so site_id is optional. Set SB_EMAIL and SB_PASSWORD as well if ' +
31
32
  'you want account-level calls (listing sites, members, roles), which a key cannot make.'
32
33
  : 'Connected with an API key alone. It is bound to one site, so there is no site list — ' +
@@ -243,6 +243,30 @@ async function shootOne(page, url, width, format, opts) {
243
243
  // storefront. The settle is best-effort: if the page does go quiet, the shot
244
244
  // waits for it; if it never does, the shot happens anyway.
245
245
  await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
246
+ // OPEN THE OVERLAY BEFORE MEASURING, or it cannot be photographed at all.
247
+ //
248
+ // A closed drawer is `visibility:hidden` and translated 105% off-screen
249
+ // (`render/nodes/cart-drawer/css.go`), so it measures at x=1461 on a 1440
250
+ // viewport and `page.screenshot({clip})` fails outright: "Clipped area is
251
+ // either empty or outside the resulting image". That made the ONE surface this
252
+ // repo's own guidance insists you look at — "open the cart drawer and look,
253
+ // before calling a site done" — the one surface `sb_look` could not show.
254
+ //
255
+ // `is-open` is the platform's OWN mechanism, not a hack around it: the same
256
+ // class the storefront's cart button toggles, whose rule is `transform:none`
257
+ // plus `visibility:visible`. The scrim takes it too, so the shot matches what
258
+ // a shopper sees rather than a panel floating over bare page.
259
+ if (opts.open) {
260
+ await page.evaluate((id) => {
261
+ // The narrow `document` shim this file declares is for the MEASUREMENT
262
+ // pass; here the real DOM is what runs, so reach it through the cast
263
+ // rather than widening a shim that exists to keep that pass honest.
264
+ const d = document;
265
+ d.getElementById(id)?.classList.add('is-open');
266
+ for (const s of d.querySelectorAll('.wb-cart-scrim'))
267
+ s.classList.add('is-open');
268
+ }, opts.open);
269
+ }
246
270
  await settleDom(page);
247
271
  await settleLazyImages(page);
248
272
  // A RENDERED page carries its node ids as the HTML `id` attribute — not as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.4.4",
3
+ "version": "0.5.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",