sbuilder-mcp 0.10.0 → 0.11.1

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,24 @@ 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.1] - 2026-09-09
10
+
11
+ ### Added
12
+ - sb_review now reports `hover_dead` for a node whose element keeps its hover state in a legacy home its own renderer reads instead of the universal `states.hover` slot — a `button`, for instance — naming the sb_set call that moves the values where they will actually paint.
13
+ - npm run codegen now refuses to run against a web_builder checkout with uncommitted changes in the directories it reads (schema/src, editor/src, server/render, server/docs), naming the dirty files instead of silently baking a concurrent session's half-finished work into the committed catalog; `--dirty` overrides it for generating against your own in-progress change.
14
+
15
+ ### Fixed
16
+ - The generated element catalog is regenerated from a committed ref, removing a `cart-count` element and its satellite owner that had leaked in from another session's uncommitted platform change; the catalog is back to 108 elements.
17
+
18
+ ## [0.11.0] - 2026-09-09
19
+
20
+ ### Added
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+ - 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.
25
+ - 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.
26
+
9
27
  ## [0.10.0] - 2026-09-09
10
28
 
11
29
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,24 @@ 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.1] - 2026-09-09
10
+
11
+ ### Added
12
+ - sb_review giờ báo cáo `hover_dead` cho một node mà element của nó lưu hover state ở nơi cũ mà chính renderer của nó đọc, thay vì slot `states.hover` phổ quát — ví dụ như `button` — và nêu rõ lệnh sb_set để chuyển giá trị tới đúng nơi sẽ thực sự hiển thị.
13
+ - npm run codegen giờ từ chối chạy trên một checkout web_builder có thay đổi chưa commit trong các thư mục mà nó đọc (schema/src, editor/src, server/render, server/docs), nêu tên các file bẩn thay vì âm thầm đưa công việc dở dang của một phiên làm việc đồng thời vào catalog đã commit; `--dirty` cho phép ghi đè khi bạn tự tạo ra thay đổi đang dở dang đó.
14
+
15
+ ### Fixed
16
+ - Catalog element được sinh lại từ một ref đã commit, loại bỏ một element `cart-count` và chủ sở hữu satellite của nó vốn đã lọt vào từ thay đổi nền tảng chưa commit của một phiên làm việc khác; catalog trở lại 108 element.
17
+
18
+ ## [0.11.0] - 2026-09-09
19
+
20
+ ### Added
21
+ - `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ờ.
22
+ - 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.
23
+ - 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ị.
24
+ - 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`.
25
+ - 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.
26
+
9
27
  ## [0.10.0] - 2026-09-09
10
28
 
11
29
  ### Added
@@ -33564,6 +33564,11 @@ export const TRAIT_WRITES = {
33564
33564
  "target": "config",
33565
33565
  "writeKey": "hidden",
33566
33566
  "type": "boolean"
33567
+ },
33568
+ {
33569
+ "target": "config",
33570
+ "writeKey": "revealOnHover",
33571
+ "type": "boolean"
33567
33572
  }
33568
33573
  ]
33569
33574
  },
@@ -34600,3 +34605,41 @@ export const ELEMENT_SEEDS = {
34600
34605
  export const FIRST_CHILD_ONLY = [
34601
34606
  "list-dataset"
34602
34607
  ];
34608
+ export const HOVER_HOMES = {
34609
+ "button": {
34610
+ "home": "legacy"
34611
+ },
34612
+ "filter-checkbox": {
34613
+ "home": "state"
34614
+ },
34615
+ "filter-color": {
34616
+ "home": "state"
34617
+ },
34618
+ "filter-radio": {
34619
+ "home": "state"
34620
+ },
34621
+ "filter-tag": {
34622
+ "home": "state"
34623
+ },
34624
+ "tab-item": {
34625
+ "home": "state"
34626
+ },
34627
+ "accordion-item": {
34628
+ "home": "state"
34629
+ },
34630
+ "menu-item": {
34631
+ "home": "state"
34632
+ },
34633
+ "product-image-list": {
34634
+ "home": "state"
34635
+ },
34636
+ "quantity-button": {
34637
+ "home": "state"
34638
+ },
34639
+ "product-variant-option": {
34640
+ "home": "state"
34641
+ },
34642
+ "text-dataset": {
34643
+ "home": "state"
34644
+ }
34645
+ };
@@ -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,12 @@ 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
+ }
191
196
  // The paths `keys` and `unset` share. Computed once so a removal can never
192
197
  // land somewhere a write would not have.
193
198
  const slot = () => {
@@ -242,6 +247,32 @@ export function setKeys(doc, id, keys, opts) {
242
247
  if (namespace === 'config')
243
248
  refuseStuckConfig(keys);
244
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
+ }
245
276
  // Base state and per-breakpoint state are DIFFERENT PLACES in the document,
246
277
  // and the old path (`states[state][bp][ns]`) was neither of them: it buried
247
278
  // a breakpoint inside the base-state cluster, where nothing reads it.
@@ -60,6 +60,10 @@ export const FIX = {
60
60
  '{ "overflowX": "visible", "overflowY": "visible" } — or move the pinned node outside it. ' +
61
61
  'Sticky resolves against its nearest SCROLLING ancestor, so a clipping one becomes that ' +
62
62
  'ancestor and the node pins inside a box that never scrolls.',
63
+ hover_dead: 'Rewrite it: sb_set id "<id>", namespace style, state "hover", keys { … } — sb_set now ' +
64
+ 'routes a hover to the home its element declares, and reports where it went. The values ' +
65
+ 'currently in states.hover are read by nobody; remove them with the same call\'s `unset` ' +
66
+ 'once the working copy is in place.',
63
67
  stuck_no_host: 'Pin something: sb_set id "<id>" (or the section it lives in), namespace style, keys ' +
64
68
  '{ "position": "sticky" } — sb_set seeds the offset and the layer order with it. Until ' +
65
69
  'then the platform compiles no rule for this state at all, so the override is stored, ' +
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { childrenOf, childrenWithSatellites, isOverlay, pageChildren, appBlockRoot, SPEC_GLOBAL_REF, SPEC_APP_BLOCK_REF } from '../../core/tree.js';
2
2
  import { STUCK_STATE, stickyBlockedBy, stuckHostOf, isPinnedNode } from './sticky.js';
3
+ import { HOVER_STATE, hoverHome } from './hover.js';
3
4
  import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
4
5
  import { fill } from './findings.js';
5
6
  /**
@@ -224,6 +225,32 @@ export function reviewDesign(doc) {
224
225
  styled.states?.[STUCK_STATE],
225
226
  ...Object.values(styled.responsive ?? {}).map((r) => r?.states?.[STUCK_STATE]),
226
227
  ];
228
+ // A HOVER STORED WHERE NOBODY READS IT. The universal hover state is not the
229
+ // only home: an element whose meta declares a Hover variant keeps it
230
+ // somewhere its own renderer looks, and for a `button` that is the flat
231
+ // `config.stateHover` map. Every hover this server wrote onto a button
232
+ // before it learned that is still sitting in `states.hover`, painting
233
+ // nothing — on a control that is the single most common thing anyone gives
234
+ // a hover to.
235
+ const hoverSlots = [
236
+ styled.states?.[HOVER_STATE],
237
+ ...Object.values(styled.responsive ?? {}).map((r) => r?.states?.[HOVER_STATE]),
238
+ ];
239
+ const hasHover = hoverSlots.some((slot) => {
240
+ const h = slot;
241
+ return !!h && (Object.keys(h.style ?? {}).length > 0 || Object.keys(h.config ?? {}).length > 0);
242
+ });
243
+ if (hasHover && hoverHome(type) === 'legacy') {
244
+ out.push({
245
+ code: 'hover_dead',
246
+ nodeId: id,
247
+ type,
248
+ problem: `"${type}" keeps its hover in config.stateHover — the flat map its own renderer ` +
249
+ 'compiles — and the platform\'s universal hover compiler stands aside for it. These ' +
250
+ 'states.hover values are stored, saved, published and painted by nobody.',
251
+ fix: fill('hover_dead', { id }),
252
+ });
253
+ }
227
254
  const hasStuck = stuckSlots.some((slot) => {
228
255
  const s = slot;
229
256
  return !!s && (Object.keys(s.style ?? {}).length > 0 || Object.keys(s.config ?? {}).length > 0);
@@ -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
  /**
@@ -303,6 +304,18 @@ export function registerPageTools(server, ctx) {
303
304
  batch.push({ id, namespace, keys: keys ?? {}, breakpoint: breakpoint, base, state, unset });
304
305
  }
305
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
+ }
306
319
  // THE STICKY WARNING IS COMPUTED AGAINST THE DOCUMENT AS IT WILL BE, so
307
320
  // the dry run and the real run say the same thing. A caller who is told
308
321
  // only after committing has already shipped a header that does not move.
@@ -333,6 +346,7 @@ export function registerPageTools(server, ctx) {
333
346
  dry_run: true,
334
347
  patches,
335
348
  ...(Object.keys(sw).length ? { warnings: sw } : {}),
349
+ ...(Object.keys(hoverNotes).length ? { hover: hoverNotes } : {}),
336
350
  ...(note ? { note } : {}),
337
351
  });
338
352
  }
@@ -346,9 +360,20 @@ export function registerPageTools(server, ctx) {
346
360
  }
347
361
  if (!edits) {
348
362
  const warn = warnings[batch[0].id];
349
- 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
+ });
350
370
  }
351
- 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
+ });
352
377
  });
353
378
  server.registerTool('sb_move', {
354
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.10.0",
3
+ "version": "0.11.1",
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",