sbuilder-mcp 0.9.2 → 0.11.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.11.0] - 2026-09-09
10
+
11
+ ### Added
12
+ - sb_set's `state:"hover"` now routes each write to the home its element's own renderer actually reads, since the platform's universal hover compiler deliberately stands aside for twelve element types that declare their own Hover variant; a `button` write now lands in `config.stateHover` (flat, base-only) instead of the unread `states.hover` slot, and the response reports where it went so a caller reading the node back is not surprised.
13
+ - sb_set refuses a `state:"parentHover"` write on a node with no box to key off — a satellite, a direct child of the page root, or an orphan — naming the reason instead of storing a rule the platform would never match.
14
+ - sb_set refuses `config.revealOnHover` on a node with no such box for the same reason, since the platform emits neither half of the reveal without one and the element would simply stay visible.
15
+ - sb_set now translates `hidden: true` under a hover state into `display: none`, and refuses any other config key or a `false` value, matching the same contract already enforced for the `stuck` state.
16
+ - sb_set warns when a write targets `product-image-list`'s hover state: its meta promises `states.hover`, but measured on 2026-09-09 nothing in the platform compiles it yet, so the override is stored where it belongs and will start painting once the platform closes the gap.
17
+
18
+ ## [0.10.0] - 2026-09-09
19
+
20
+ ### Added
21
+ - sb_set now takes `unset`, an array of key names removed from the same slot a write would target — base, a breakpoint, or either home of a state — giving every finding that names "remove the override" as its fix a tool that can actually perform it. `keys` is now optional when `unset` carries the work, and a pure removal is exempt from the sticky-host guard so it can repair a `stuck_no_host` finding rather than being refused by it.
22
+
23
+ ### Fixed
24
+ - sb_review's `stuck_no_host` check now counts the keys inside a stuck state slot instead of only checking whether the slot exists, matching the platform's own `HasStuckOverrides`; an empty slot (the state a repair with `unset` leaves behind) no longer keeps reporting the finding it was used to fix.
25
+
9
26
  ## [0.9.2] - 2026-09-09
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.11.0] - 2026-09-09
10
+
11
+ ### Added
12
+ - `state:"hover"` của sb_set giờ định tuyến mỗi lần ghi tới đúng nơi mà renderer của element đó thực sự đọc, vì bộ biên dịch hover phổ quát của nền tảng chủ động đứng ngoài đối với mười hai loại element tự khai báo biến thể Hover riêng; một lần ghi trên `button` giờ vào `config.stateHover` (dạng phẳng, chỉ ở base) thay vì slot `states.hover` không ai đọc, và kết quả trả về cho biết giá trị đã đi vào đâu để người gọi đọc lại node không bị bất ngờ.
13
+ - sb_set từ chối ghi `state:"parentHover"` trên một node không có "hộp" nào để dựa vào — một satellite, một con trực tiếp của root trang, hoặc một node mồ côi — nêu rõ lý do thay vì lưu một rule mà nền tảng sẽ không bao giờ khớp.
14
+ - sb_set từ chối `config.revealOnHover` trên một node không có hộp như vậy vì cùng lý do, vì nền tảng sẽ không phát ra cả hai nửa của hiệu ứng reveal nếu thiếu nó, khiến phần tử đơn giản là luôn hiển thị.
15
+ - sb_set giờ dịch `hidden: true` dưới một hover state thành `display: none`, và từ chối mọi config key khác hoặc giá trị `false`, khớp với đúng quy tắc đã áp dụng cho trạng thái `stuck`.
16
+ - sb_set cảnh báo khi một lần ghi nhắm vào hover state của `product-image-list`: meta của nó hứa hẹn `states.hover`, nhưng đo được vào ngày 2026-09-09 thì chưa có gì trong nền tảng biên dịch nó cả, nên override vẫn được lưu đúng chỗ meta quy định và sẽ bắt đầu hiển thị khi nền tảng khắc phục xong khoảng trống này.
17
+
18
+ ## [0.10.0] - 2026-09-09
19
+
20
+ ### Added
21
+ - sb_set giờ nhận `unset`, một mảng tên key cần xoá khỏi đúng slot mà một lần ghi sẽ nhắm tới — base, một breakpoint, hoặc một trong hai nơi lưu của một state — nhờ đó mọi finding nêu "xoá override đi" làm cách sửa giờ có công cụ để thực sự làm điều đó. `keys` giờ có thể bỏ qua khi `unset` đã làm phần việc, và một lần xoá thuần tuý được miễn khỏi kiểm tra sticky-host để nó có thể sửa một finding `stuck_no_host` thay vì bị chính kiểm tra đó từ chối.
22
+
23
+ ### Fixed
24
+ - Kiểm tra `stuck_no_host` của sb_review giờ đếm số key bên trong slot trạng thái stuck thay vì chỉ kiểm tra slot có tồn tại hay không, khớp với `HasStuckOverrides` của chính nền tảng; một slot rỗng (trạng thái mà một lần sửa bằng `unset` để lại) không còn tiếp tục báo cáo finding mà nó vừa được dùng để khắc phục.
25
+
9
26
  ## [0.9.2] - 2026-09-09
10
27
 
11
28
  ### Added
@@ -1,5 +1,5 @@
1
1
  export const ELEMENT_SOURCE = {
2
- "count": 108,
2
+ "count": 109,
3
3
  "docSchemaVersion": 2
4
4
  };
5
5
  export const ELEMENTS = {
@@ -1803,7 +1803,8 @@ export const ELEMENTS = {
1803
1803
  "key": "action",
1804
1804
  "label": "Action",
1805
1805
  "controls": [
1806
- "action"
1806
+ "action",
1807
+ "icon_cart_badge"
1807
1808
  ]
1808
1809
  },
1809
1810
  {
@@ -1876,6 +1877,7 @@ export const ELEMENTS = {
1876
1877
  "icon_color",
1877
1878
  "icon_size",
1878
1879
  "action",
1880
+ "icon_cart_badge",
1879
1881
  "padding",
1880
1882
  "margin",
1881
1883
  "align_self",
@@ -13842,6 +13844,166 @@ export const ELEMENTS = {
13842
13844
  "dynamic"
13843
13845
  ]
13844
13846
  },
13847
+ "cart-count": {
13848
+ "type": "cart-count",
13849
+ "label": "Cart count",
13850
+ "category": "store",
13851
+ "isContainer": false,
13852
+ "isRootOnly": false,
13853
+ "locked": false,
13854
+ "hideInLayer": false,
13855
+ "childAllows": [],
13856
+ "defaults": {
13857
+ "style": {
13858
+ "display": "inline-flex",
13859
+ "alignItems": "center",
13860
+ "justifyContent": "center",
13861
+ "width": "fit-content",
13862
+ "minWidth": "20px",
13863
+ "height": "20px",
13864
+ "paddingLeft": "6px",
13865
+ "paddingRight": "6px",
13866
+ "borderRadius": "999px",
13867
+ "backgroundColor": "#171717",
13868
+ "color": "#ffffff",
13869
+ "fontSize": "12px",
13870
+ "fontWeight": "600",
13871
+ "lineHeight": "1"
13872
+ },
13873
+ "specials": {
13874
+ "placeholder": "0",
13875
+ "hideWhenEmpty": true
13876
+ }
13877
+ },
13878
+ "inspector": [
13879
+ {
13880
+ "tab": "general",
13881
+ "groups": [
13882
+ {
13883
+ "key": "content",
13884
+ "label": "Content",
13885
+ "controls": [
13886
+ "cart_count_placeholder",
13887
+ "cart_count_hide_empty"
13888
+ ]
13889
+ },
13890
+ {
13891
+ "key": "size",
13892
+ "label": "Size",
13893
+ "controls": [
13894
+ "width_select",
13895
+ "height_select",
13896
+ "size_bounds"
13897
+ ]
13898
+ },
13899
+ {
13900
+ "key": "layout",
13901
+ "label": "Layout",
13902
+ "controls": [
13903
+ "padding",
13904
+ "margin",
13905
+ "vertical",
13906
+ "horizontal"
13907
+ ]
13908
+ },
13909
+ {
13910
+ "key": "typography",
13911
+ "label": "Typography",
13912
+ "controls": [
13913
+ "font_family",
13914
+ "font_size",
13915
+ "text_style",
13916
+ "text_color"
13917
+ ]
13918
+ },
13919
+ {
13920
+ "key": "background",
13921
+ "label": "Background",
13922
+ "controls": [
13923
+ "bg_color"
13924
+ ]
13925
+ },
13926
+ {
13927
+ "key": "shape",
13928
+ "label": "Shape",
13929
+ "controls": [
13930
+ "border",
13931
+ "corner"
13932
+ ]
13933
+ }
13934
+ ]
13935
+ },
13936
+ {
13937
+ "tab": "advanced",
13938
+ "groups": [
13939
+ {
13940
+ "key": "spacing",
13941
+ "label": "Spacing",
13942
+ "controls": [
13943
+ "padding_margin"
13944
+ ]
13945
+ },
13946
+ {
13947
+ "key": "display",
13948
+ "label": "Display",
13949
+ "controls": [
13950
+ "display"
13951
+ ]
13952
+ },
13953
+ {
13954
+ "key": "class_css",
13955
+ "label": "Class",
13956
+ "controls": [
13957
+ "class_css"
13958
+ ]
13959
+ }
13960
+ ]
13961
+ }
13962
+ ],
13963
+ "controls": [
13964
+ "cart_count_placeholder",
13965
+ "cart_count_hide_empty",
13966
+ "width_select",
13967
+ "height_select",
13968
+ "size_bounds",
13969
+ "padding",
13970
+ "margin",
13971
+ "vertical",
13972
+ "horizontal",
13973
+ "font_family",
13974
+ "font_size",
13975
+ "text_style",
13976
+ "text_color",
13977
+ "bg_color",
13978
+ "border",
13979
+ "corner",
13980
+ "padding_margin",
13981
+ "display",
13982
+ "class_css"
13983
+ ],
13984
+ "description": "The number of items in the shopper's cart, as a small badge. The figure comes from the shopper's own cart in their browser, so it changes the moment they add, remove or re-count a line. Its usual home is the cart icon in the site header, where it is the only evidence on the page that the basket holds anything.",
13985
+ "useWhen": [
13986
+ "a header or navigation bar carries a cart icon and a shopper needs to see the basket is not empty",
13987
+ "building a sticky mobile bar or a mini-cart trigger anywhere outside the cart drawer"
13988
+ ],
13989
+ "avoidWhen": [
13990
+ "showing what the cart COSTS — that is a cart-total, which reads a money figure",
13991
+ "showing how many of ONE product a shopper picked — that is the quantity stepper on the product",
13992
+ "showing a past order’s item count; an order is a record, not the live cart"
13993
+ ],
13994
+ "contentTips": [
13995
+ "the placeholder is what the badge reads before the shopper’s cart is known, so write it as a real empty-cart count",
13996
+ "leave “hide when empty” on unless the header’s layout must never shift when the first item lands",
13997
+ "to put it on a cart icon, turn the badge on from the icon’s own inspector rather than placing this element and positioning it by hand"
13998
+ ],
13999
+ "semantics": [
14000
+ "cart",
14001
+ "count",
14002
+ "badge",
14003
+ "header",
14004
+ "commerce"
14005
+ ]
14006
+ },
13845
14007
  "cart-total": {
13846
14008
  "type": "cart-total",
13847
14009
  "label": "Cart total",
@@ -33564,6 +33726,11 @@ export const TRAIT_WRITES = {
33564
33726
  "target": "config",
33565
33727
  "writeKey": "hidden",
33566
33728
  "type": "boolean"
33729
+ },
33730
+ {
33731
+ "target": "config",
33732
+ "writeKey": "revealOnHover",
33733
+ "type": "boolean"
33567
33734
  }
33568
33735
  ]
33569
33736
  },
@@ -33672,6 +33839,28 @@ export const TRAIT_WRITES = {
33672
33839
  }
33673
33840
  ]
33674
33841
  },
33842
+ "cart_count_placeholder": {
33843
+ "key": "cart_count_placeholder",
33844
+ "label": "Empty count",
33845
+ "writes": [
33846
+ {
33847
+ "target": "specials",
33848
+ "writeKey": "placeholder",
33849
+ "type": "string"
33850
+ }
33851
+ ]
33852
+ },
33853
+ "cart_count_hide_empty": {
33854
+ "key": "cart_count_hide_empty",
33855
+ "label": "Hide when empty",
33856
+ "writes": [
33857
+ {
33858
+ "target": "specials",
33859
+ "writeKey": "hideWhenEmpty",
33860
+ "type": "boolean"
33861
+ }
33862
+ ]
33863
+ },
33675
33864
  "cart_total_placeholder": {
33676
33865
  "key": "cart_total_placeholder",
33677
33866
  "label": "Empty amount",
@@ -34032,6 +34221,13 @@ export const TRAIT_WRITES = {
34032
34221
  }
34033
34222
  };
34034
34223
  export const SATELLITE_RULES = {
34224
+ "icon": [
34225
+ {
34226
+ "type": "cart-count",
34227
+ "configKey": "cartCountId",
34228
+ "optional": true
34229
+ }
34230
+ ],
34035
34231
  "tab": [
34036
34232
  {
34037
34233
  "type": "tab-item",
@@ -34600,3 +34796,41 @@ export const ELEMENT_SEEDS = {
34600
34796
  export const FIRST_CHILD_ONLY = [
34601
34797
  "list-dataset"
34602
34798
  ];
34799
+ export const HOVER_HOMES = {
34800
+ "button": {
34801
+ "home": "legacy"
34802
+ },
34803
+ "filter-checkbox": {
34804
+ "home": "state"
34805
+ },
34806
+ "filter-color": {
34807
+ "home": "state"
34808
+ },
34809
+ "filter-radio": {
34810
+ "home": "state"
34811
+ },
34812
+ "filter-tag": {
34813
+ "home": "state"
34814
+ },
34815
+ "tab-item": {
34816
+ "home": "state"
34817
+ },
34818
+ "accordion-item": {
34819
+ "home": "state"
34820
+ },
34821
+ "menu-item": {
34822
+ "home": "state"
34823
+ },
34824
+ "product-image-list": {
34825
+ "home": "state"
34826
+ },
34827
+ "quantity-button": {
34828
+ "home": "state"
34829
+ },
34830
+ "product-variant-option": {
34831
+ "home": "state"
34832
+ },
34833
+ "text-dataset": {
34834
+ "home": "state"
34835
+ }
34836
+ };
@@ -3,6 +3,7 @@ import { ELEMENTS, ELEMENT_SEEDS, SATELLITE_RULES } from '../../catalog/elements
3
3
  import { bindingsForConfig, createNode, mintSatellites } from './node.js';
4
4
  import { refuseSecondTemplate } from './traps.js';
5
5
  import { STUCK_STATE, refuseStuckAfter, refuseStuckConfig, requireStuckHost, stickySeeds, } from './sticky.js';
6
+ import { HOVER_STATE, PARENT_HOVER_STATE, hoverHome, legacyHoverPath, refuseHoverConfig, refuseReveal, requireHoverHost, } from './hover.js';
6
7
  import { genId } from './ids.js';
7
8
  /** Append sentinel: splice clamps a too-large index, and `isSyncablePatch`
8
9
  * deliberately allows one — an append is a legitimate thing to describe. */
@@ -186,8 +187,27 @@ export function setKeys(doc, id, keys, opts) {
186
187
  // different ways without a word — see sticky.ts. Checked here rather than in
187
188
  // the state branch because it is written on the node that PINS, at base or at
188
189
  // a breakpoint, never inside a stuck slot.
189
- if (namespace === 'config')
190
+ if (namespace === 'config') {
190
191
  refuseStuckAfter(doc.doc, id, keys);
192
+ // The reveal is a BASE-level config key with a host precondition, so it is
193
+ // checked here rather than in the state branch — it is not a state at all.
194
+ refuseReveal(doc.doc, id, keys);
195
+ }
196
+ // The paths `keys` and `unset` share. Computed once so a removal can never
197
+ // land somewhere a write would not have.
198
+ const slot = () => {
199
+ if (namespace === 'specials')
200
+ return ['nodes', id, 'specials'];
201
+ if (opts.state) {
202
+ return opts.base
203
+ ? ['nodes', id, 'states', opts.state, namespace]
204
+ : ['nodes', id, 'responsive', opts.breakpoint ?? 'desktop', 'states', opts.state, namespace];
205
+ }
206
+ if (opts.base)
207
+ return ['nodes', id, namespace];
208
+ return ['nodes', id, 'responsive', opts.breakpoint ?? 'desktop', namespace];
209
+ };
210
+ const removals = (opts.unset ?? []).map((k) => ({ op: 'unset', path: [...slot(), k] }));
191
211
  if (namespace === 'specials') {
192
212
  refuseComposedStamp(keys);
193
213
  // `specials` is content and identity, base-only by definition, and states
@@ -198,11 +218,14 @@ export function setKeys(doc, id, keys, opts) {
198
218
  'specials is content and identity (text, htmlTag, bound…), which do not vary by ' +
199
219
  'state. Style is what has states.');
200
220
  }
201
- return Object.entries(keys).map(([k, v]) => ({
202
- op: 'set',
203
- path: ['nodes', id, 'specials', k],
204
- value: v,
205
- }));
221
+ return [
222
+ ...Object.entries(keys).map(([k, v]) => ({
223
+ op: 'set',
224
+ path: ['nodes', id, 'specials', k],
225
+ value: v,
226
+ })),
227
+ ...removals,
228
+ ];
206
229
  }
207
230
  // A STATE IS ANSWERED BEFORE `base`, because `base` used to be tested first
208
231
  // and swallowed it: `base:true state:"hover"` wrote the hover value straight
@@ -216,11 +239,40 @@ export function setKeys(doc, id, keys, opts) {
216
239
  // class a runtime island toggles on the PINNED element, and the renderer
217
240
  // emits no rule at all when there is nothing pinned to hang it off. See
218
241
  // sticky.ts — this is the whole reason that module exists.
219
- if (opts.state === STUCK_STATE) {
242
+ // A pure REMOVAL is exempt: unsetting a hostless stuck override is exactly
243
+ // the repair `sb_review` asks for, and refusing it would leave the caller
244
+ // holding a finding they cannot act on.
245
+ if (opts.state === STUCK_STATE && Object.keys(keys).length) {
220
246
  requireStuckHost(doc.doc, id);
221
247
  if (namespace === 'config')
222
248
  refuseStuckConfig(keys);
223
249
  }
250
+ // THE PARENT-HOVER STATE has a host precondition of its own, and a different
251
+ // one: not "something pins above me" but "I am a real child of a real box".
252
+ if (opts.state === PARENT_HOVER_STATE && Object.keys(keys).length) {
253
+ requireHoverHost(doc.doc, id);
254
+ }
255
+ if ((opts.state === HOVER_STATE || opts.state === PARENT_HOVER_STATE) &&
256
+ namespace === 'config' &&
257
+ Object.keys(keys).length) {
258
+ refuseHoverConfig(keys, opts.state);
259
+ }
260
+ // HOVER HAS THREE HOMES and only one of them is `states.hover`. An element
261
+ // that declares a Hover variant of its own is served by its own renderer
262
+ // from a flat, base-only `config.stateHover` map, and the universal compiler
263
+ // stands aside for it — so writing the state slot here would store keys no
264
+ // compiler reads. Routed rather than refused: the caller asked for a hover
265
+ // and there is a home that works; `hoverRoutingNote` says where it went.
266
+ if (opts.state === HOVER_STATE && namespace === 'style' && hoverHome(doc.node(id).data.type) === 'legacy') {
267
+ return [
268
+ ...Object.entries(keys).map(([k, v]) => ({
269
+ op: 'set',
270
+ path: legacyHoverPath(id, k),
271
+ value: v,
272
+ })),
273
+ ...(opts.unset ?? []).map((k) => ({ op: 'unset', path: legacyHoverPath(id, k) })),
274
+ ];
275
+ }
224
276
  // Base state and per-breakpoint state are DIFFERENT PLACES in the document,
225
277
  // and the old path (`states[state][bp][ns]`) was neither of them: it buried
226
278
  // a breakpoint inside the base-state cluster, where nothing reads it.
@@ -233,6 +285,7 @@ export function setKeys(doc, id, keys, opts) {
233
285
  path: [...prefix, namespace, k],
234
286
  value: v,
235
287
  })),
288
+ ...removals,
236
289
  ...(rebind ? [rebind] : []),
237
290
  ];
238
291
  }
@@ -270,6 +323,7 @@ export function setKeys(doc, id, keys, opts) {
270
323
  path: ['nodes', id, namespace, k],
271
324
  value: v,
272
325
  })),
326
+ ...removals,
273
327
  ...(rebind ? [rebind] : []),
274
328
  ];
275
329
  }
@@ -280,6 +334,7 @@ export function setKeys(doc, id, keys, opts) {
280
334
  path: ['nodes', id, 'responsive', bp, namespace, k],
281
335
  value: v,
282
336
  })),
337
+ ...removals,
283
338
  ...(rebind ? [rebind] : []),
284
339
  ];
285
340
  }
@@ -492,7 +547,7 @@ export function setMany(doc, edits) {
492
547
  const patches = [];
493
548
  const touched = [];
494
549
  for (const e of edits) {
495
- patches.push(...setKeys(doc, e.id, e.keys, { namespace: e.namespace, breakpoint: e.breakpoint, base: e.base, state: e.state }));
550
+ patches.push(...setKeys(doc, e.id, e.keys, { namespace: e.namespace, breakpoint: e.breakpoint, base: e.base, state: e.state, unset: e.unset }));
496
551
  touched.push({ id: e.id, keys: Object.keys(e.keys) });
497
552
  }
498
553
  return { patches, touched };
@@ -0,0 +1,194 @@
1
+ import { HOVER_HOMES } from '../../catalog/elements.generated.js';
2
+ /**
3
+ * HOVER, AND THE FACT THAT IT HAS THREE HOMES.
4
+ *
5
+ * The platform grew a UNIVERSAL hover state in September 2026
6
+ * (`schema/src/hoverState.ts`, Go mirror `render/style/hover.go`) alongside the
7
+ * pinned one, and the obvious reading — "hover is `states.hover` now" — is
8
+ * wrong for twelve element types, silently.
9
+ *
10
+ * states.hover → <scope>:hover{…} the pointer is on THIS
11
+ * states.parentHover → <host>:hover <scope>{…} …on the box around this
12
+ *
13
+ * But the universal compiler STANDS ASIDE for every element whose meta declares
14
+ * a Hover variant of its own (`generated.HoverStateOwners`), because those have
15
+ * their own storage and their own compiler and a second generic rule would fight
16
+ * the narrower one. For a BUTTON that storage is `config.stateHover` — a flat,
17
+ * base-only style map — and the editor routes a hover edit there deliberately:
18
+ * "a hover edit on a button must go on being the button's :hover rule"
19
+ * (editor/src/trait/values.ts). Nothing reads `states.hover` on a button.
20
+ *
21
+ * MEASURED, on one publish of one page: `state:"hover"` on a product card's
22
+ * dataset-block emitted `@media (hover:hover){#card:hover{…}}`; the identical
23
+ * write on the BUTTON inside it emitted nothing at all. Both reported success.
24
+ * Every hover this server had ever written onto a button was dead.
25
+ *
26
+ * So the home is a per-type question, and `HOVER_HOMES` is generated from the
27
+ * metas plus the Go renderers that actually read the legacy map — never
28
+ * hand-kept, because the platform is still moving here.
29
+ */
30
+ /** The node's own pointer state. */
31
+ export const HOVER_STATE = 'hover';
32
+ /** "While the box around me is hovered." Universal on every element. */
33
+ export const PARENT_HOVER_STATE = 'parentHover';
34
+ /** The flat, base-only style map a Hover-variant element's own renderer reads. */
35
+ export const LEGACY_HOVER_KEY = 'stateHover';
36
+ /**
37
+ * The config key behind "only show while the card is hovered". Base-level,
38
+ * because a state can only override what the base already paints — and, like
39
+ * the parent state, it needs a host to hover.
40
+ */
41
+ export const REVEAL_ON_HOVER = 'revealOnHover';
42
+ /**
43
+ * Where this element type's `hover` state has to be written.
44
+ *
45
+ * 'state' is the default and the majority: the universal compiler serves it.
46
+ */
47
+ /**
48
+ * Where this element type's `hover` state has to be written.
49
+ *
50
+ * 'state' is the default and the overwhelming majority — `node.states.hover`,
51
+ * compiled either by the universal state or, for the twelve types that declare a
52
+ * Hover variant, by the element's own CSS. 'legacy' is the flat, base-only
53
+ * `config.stateHover` map, which is what the element's meta means when it
54
+ * declares a Hover variant with no `storage: 'node'`.
55
+ */
56
+ export function hoverHome(type) {
57
+ return HOVER_HOMES[type]?.home ?? 'state';
58
+ }
59
+ /**
60
+ * Element types that PROMISE `states.hover` and whose hover nothing compiles.
61
+ *
62
+ * Measured 2026-09-09 by rendering one node per Hover-variant type with a
63
+ * `states.hover` override and looking for the value in `BundleCSS`: every other
64
+ * type painted, `product-image-list` did not. Its meta declares
65
+ * `storage: 'node'`, so the value goes where the meta says — and the universal
66
+ * compiler stands aside for it, while nothing element-specific picks it up.
67
+ *
68
+ * A LIST, not a derivation, because there is nothing in the metas to derive it
69
+ * from: the fact lives in which Go renderer happens to read the slot. It is
70
+ * therefore a measurement with a date on it, and the note says so rather than
71
+ * pretending the platform still behaves this way.
72
+ */
73
+ const HOVER_UNCOMPILED = new Set(['product-image-list']);
74
+ /**
75
+ * The box whose `:hover` a parent-hover rule keys off, or null.
76
+ *
77
+ * Mirrors `HoverHostOf`: the node's PARENT, and only when the node is a real
78
+ * child of it. Three cases answer null, and each is a rule that would compile to
79
+ * nothing:
80
+ *
81
+ * - a SATELLITE, which hangs off `config[key]` rather than `data.nodes` and
82
+ * renders no element the selector could name;
83
+ * - a node whose parent is ROOT, because the pointer is inside the page
84
+ * whenever it is inside the window;
85
+ * - an orphan whose parent id points at nothing.
86
+ */
87
+ export function hoverHostOf(doc, id) {
88
+ const node = doc.nodes[id];
89
+ const parentId = node?.data.parent;
90
+ if (!node || !parentId)
91
+ return null;
92
+ const parent = doc.nodes[parentId];
93
+ if (!parent || !(parent.data.nodes ?? []).includes(id))
94
+ return null;
95
+ if (parent.data.type === 'root' || parentId === doc.root_node_id)
96
+ return null;
97
+ return parentId;
98
+ }
99
+ /** Is this node a satellite — referenced from its parent's config, not its children? */
100
+ export function isSatelliteNode(doc, id) {
101
+ const parentId = doc.nodes[id]?.data.parent;
102
+ if (!parentId)
103
+ return false;
104
+ const parent = doc.nodes[parentId];
105
+ return !!parent && !(parent.data.nodes ?? []).includes(id);
106
+ }
107
+ /**
108
+ * Refuse a parent-hover override that no selector would ever match, naming which
109
+ * of the three reasons applies — the caller is one structural fact away from the
110
+ * design they wanted, and "it does nothing" would not tell them which.
111
+ */
112
+ export function requireHoverHost(doc, id) {
113
+ if (hoverHostOf(doc, id))
114
+ return;
115
+ if (isSatelliteNode(doc, id)) {
116
+ throw new Error(`sbuilder: "${PARENT_HOVER_STATE}" needs a box to hover, and ${id} is a SATELLITE — it ` +
117
+ 'hangs off its owner\'s config rather than its children and renders no element of its ' +
118
+ 'own, so there is nothing for the rule to name. Style its own hover instead ' +
119
+ `(state: "${HOVER_STATE}"), which its owner compiles.`);
120
+ }
121
+ const parentId = doc.nodes[id]?.data.parent;
122
+ if (!parentId) {
123
+ throw new Error(`sbuilder: ${id} has no parent, so "${PARENT_HOVER_STATE}" has nothing to key off.`);
124
+ }
125
+ throw new Error(`sbuilder: "${PARENT_HOVER_STATE}" keys off the node's PARENT, and ${id}'s parent is ROOT — ` +
126
+ 'the pointer is inside the page whenever it is inside the window, so the platform emits ' +
127
+ 'no rule. Put this node inside a real box (a section\'s inner block, a card) and set it ' +
128
+ `there, or use state: "${HOVER_STATE}" for the node's own pointer state.`);
129
+ }
130
+ /**
131
+ * The one config key a hover state translates into a declaration, and only
132
+ * `true` — the same contract `stuckDecls` has, for the same reason: `false`
133
+ * would need `display: revert`, which rolls past the element's own static CSS to
134
+ * the UA default.
135
+ */
136
+ export function refuseHoverConfig(keys, state) {
137
+ const stray = Object.keys(keys).filter((k) => k !== 'hidden');
138
+ if (stray.length) {
139
+ throw new Error(`sbuilder: the "${state}" state translates exactly one config key — "hidden" — into a ` +
140
+ `declaration (display:none). ${stray.map((k) => `"${k}"`).join(', ')} would be stored ` +
141
+ 'and read by no compiler. Style is what a state paints.');
142
+ }
143
+ if ('hidden' in keys && keys.hidden !== true) {
144
+ throw new Error(`sbuilder: "${state}" config.hidden takes only true. false would have to mean "show it ` +
145
+ 'again while hovered", which needs display:revert — wrong here, because revert rolls ' +
146
+ "past the element's own static CSS to the UA default. To stop hiding it, remove the " +
147
+ 'override.');
148
+ }
149
+ }
150
+ /**
151
+ * Refuse a reveal that compiles to nothing.
152
+ *
153
+ * `compileHoverCss` emits the reveal pair only `if (hostScope && …)`, so the
154
+ * switch on a satellite or a top-level section is stored and the element simply
155
+ * stays visible — the caller asked for a quick-add button that appears on hover
156
+ * and got one that is always there, with nothing to say so.
157
+ */
158
+ export function refuseReveal(doc, id, keys) {
159
+ if (keys[REVEAL_ON_HOVER] !== true)
160
+ return;
161
+ if (hoverHostOf(doc, id))
162
+ return;
163
+ throw new Error(`sbuilder: config.${REVEAL_ON_HOVER} hides this until the box AROUND it is hovered, and ` +
164
+ `${id} has no such box (a satellite, or a direct child of ROOT). The platform emits ` +
165
+ 'neither half of the reveal, so the element would simply stay visible. Put it inside a ' +
166
+ 'card or a block and set it there.');
167
+ }
168
+ /**
169
+ * The note a caller gets when a hover write is routed somewhere other than
170
+ * `states.hover`, and the warning when nothing will read it either way.
171
+ *
172
+ * Said rather than silently done: the routing is right, but a caller who later
173
+ * reads the node back would otherwise find their keys somewhere they did not put
174
+ * them.
175
+ */
176
+ export function hoverRoutingNote(type) {
177
+ if (HOVER_UNCOMPILED.has(type)) {
178
+ return (`"${type}" declares a Hover variant, so the platform's universal hover compiler stands ` +
179
+ 'aside for it — and measured on 2026-09-09, no element-specific compiler picks the slot ' +
180
+ 'up either, so this override paints nothing. Written where its meta says it belongs ' +
181
+ '(states.hover), which is where it will start painting when the platform closes the gap. ' +
182
+ 'Style a wrapper around it if the hover has to be visible now.');
183
+ }
184
+ if (hoverHome(type) !== 'legacy')
185
+ return null;
186
+ return (`"${type}" stores its hover in config.${LEGACY_HOVER_KEY} — a flat map its own renderer ` +
187
+ 'compiles into the node\'s :hover rule — and the universal hover state stands aside for it, ' +
188
+ 'so states.hover here would be read by nobody. Written to that map instead. It is BASE-ONLY, ' +
189
+ 'so this value applies at every width.');
190
+ }
191
+ /** The path a legacy hover write lands on — flat, base-only, one key per patch. */
192
+ export function legacyHoverPath(id, key) {
193
+ return ['nodes', id, 'config', LEGACY_HOVER_KEY, key];
194
+ }
@@ -214,8 +214,20 @@ export function reviewDesign(doc) {
214
214
  });
215
215
  }
216
216
  }
217
- const hasStuck = styled.states?.[STUCK_STATE] !== undefined ||
218
- Object.values(styled.responsive ?? {}).some((r) => r?.states?.[STUCK_STATE] !== undefined);
217
+ // COUNT THE KEYS, don't ask whether the slot exists — the platform's own
218
+ // `HasStuckOverrides` tests `len(slot.Style) > 0`, and an EMPTY slot paints
219
+ // nothing whether or not there is a host. Asking the weaker question made
220
+ // this a false positive on the one document the fix produces: unsetting the
221
+ // last key leaves `{}` behind, so the repair the finding names left the
222
+ // finding standing.
223
+ const stuckSlots = [
224
+ styled.states?.[STUCK_STATE],
225
+ ...Object.values(styled.responsive ?? {}).map((r) => r?.states?.[STUCK_STATE]),
226
+ ];
227
+ const hasStuck = stuckSlots.some((slot) => {
228
+ const s = slot;
229
+ return !!s && (Object.keys(s.style ?? {}).length > 0 || Object.keys(s.config ?? {}).length > 0);
230
+ });
219
231
  if (hasStuck && !stuckHostOf(d, id)) {
220
232
  out.push({
221
233
  code: 'stuck_no_host',
@@ -15,6 +15,7 @@ import { globalWarning, restampPatches, RESPONSIVE_NOTICE } from '../domains/sit
15
15
  import { catalogMatches, traitsFor } from '../catalog/element-search.js';
16
16
  import { applyPatches } from '../core/patch.js';
17
17
  import { stickyWarning } from '../domains/site/sticky.js';
18
+ import { HOVER_STATE, hoverRoutingNote } from '../domains/site/hover.js';
18
19
  import { siteFor } from './context.js';
19
20
  import { projectList, PAGE_FIELDS, TEMPLATE_FIELDS } from './project.js';
20
21
  /**
@@ -262,6 +263,11 @@ export function registerPageTools(server, ctx) {
262
263
  id: z.string().optional(),
263
264
  namespace: z.enum(['style', 'config', 'specials']).optional(),
264
265
  keys: z.record(z.unknown()).optional(),
266
+ unset: z
267
+ .array(z.string())
268
+ .optional()
269
+ .describe('Keys to REMOVE from the same slot — the only way to undo a write. Setting null ' +
270
+ 'is not the same: null is a stored value, so the override still counts as present.'),
265
271
  breakpoint: z.enum(['desktop', 'laptop', 'tablet', 'mobile']).optional(),
266
272
  base: z.boolean().optional(),
267
273
  state: z
@@ -278,22 +284,38 @@ export function registerPageTools(server, ctx) {
278
284
  breakpoint: z.enum(['desktop', 'laptop', 'tablet', 'mobile']).optional(),
279
285
  base: z.boolean().optional(),
280
286
  state: z.string().optional(),
287
+ unset: z.array(z.string()).optional(),
281
288
  }))
282
289
  .optional(),
283
290
  dry_run: z.boolean().optional(),
284
291
  },
285
292
  annotations: { readOnlyHint: false, destructiveHint: false },
286
- }, async ({ id, namespace, keys, breakpoint, base, state, edits, dry_run }) => {
293
+ }, async ({ id, namespace, keys, breakpoint, base, state, unset, edits, dry_run }) => {
287
294
  const d = session.current();
288
295
  // One shape inside: a single edit is a batch of one.
289
296
  const batch = edits ?? [];
290
297
  if (!edits) {
291
- if (!id || !namespace || !keys) {
292
- throw new Error('sbuilder: sb_set needs id + namespace + keys, or edits[]');
298
+ // `keys` is optional when `unset` carries the work: a pure removal is a
299
+ // legitimate edit, and demanding an empty object alongside it would make
300
+ // the repair `sb_review` names read like a workaround.
301
+ if (!id || !namespace || (!keys && !unset?.length)) {
302
+ throw new Error('sbuilder: sb_set needs id + namespace + keys (or unset), or edits[]');
293
303
  }
294
- batch.push({ id, namespace, keys, breakpoint: breakpoint, base, state });
304
+ batch.push({ id, namespace, keys: keys ?? {}, breakpoint: breakpoint, base, state, unset });
295
305
  }
296
306
  const { patches, touched } = setMany(d, batch);
307
+ // WHERE A HOVER ACTUALLY WENT. Routing it silently would leave a caller
308
+ // who reads the node back looking for keys in a slot they never wrote to —
309
+ // and, for the elements with no compiler on either home, believing they
310
+ // had styled something.
311
+ const hoverNotes = {};
312
+ for (const e of batch) {
313
+ if (e.state !== HOVER_STATE || e.namespace !== 'style')
314
+ continue;
315
+ const note = hoverRoutingNote(d.doc.nodes[e.id]?.data.type ?? '');
316
+ if (note)
317
+ hoverNotes[e.id] = note;
318
+ }
297
319
  // THE STICKY WARNING IS COMPUTED AGAINST THE DOCUMENT AS IT WILL BE, so
298
320
  // the dry run and the real run say the same thing. A caller who is told
299
321
  // only after committing has already shipped a header that does not move.
@@ -324,6 +346,7 @@ export function registerPageTools(server, ctx) {
324
346
  dry_run: true,
325
347
  patches,
326
348
  ...(Object.keys(sw).length ? { warnings: sw } : {}),
349
+ ...(Object.keys(hoverNotes).length ? { hover: hoverNotes } : {}),
327
350
  ...(note ? { note } : {}),
328
351
  });
329
352
  }
@@ -337,9 +360,20 @@ export function registerPageTools(server, ctx) {
337
360
  }
338
361
  if (!edits) {
339
362
  const warn = warnings[batch[0].id];
340
- return text({ set: touched[0].keys, rev: d.rev, ...(warn ? { warning: warn } : {}) });
363
+ const hv = hoverNotes[batch[0].id];
364
+ return text({
365
+ set: touched[0].keys,
366
+ rev: d.rev,
367
+ ...(warn ? { warning: warn } : {}),
368
+ ...(hv ? { hover: hv } : {}),
369
+ });
341
370
  }
342
- return text({ set: touched, rev: d.rev, ...(Object.keys(warnings).length ? { warnings } : {}) });
371
+ return text({
372
+ set: touched,
373
+ rev: d.rev,
374
+ ...(Object.keys(warnings).length ? { warnings } : {}),
375
+ ...(Object.keys(hoverNotes).length ? { hover: hoverNotes } : {}),
376
+ });
343
377
  });
344
378
  server.registerTool('sb_move', {
345
379
  description: 'Move a node to another parent at an index.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.9.2",
3
+ "version": "0.11.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",