sbuilder-mcp 0.8.0 → 0.9.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,22 @@ 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.9.0] - 2026-09-08
10
+
11
+ ### Added
12
+ - sb_set's `state` argument now accepts `"stuck"`, the look a pinned (`position: sticky` or `fixed`) element wears once the platform's runtime island marks it stuck, refusing the write when neither the node nor any ancestor can pin since the renderer would compile no rule for it and the override would be stored, published and never painted.
13
+ - sb_set now seeds `top`/`zIndex` alongside a `position: sticky` write, matching what the platform's own editor seeds, since a pinned header with no z-index is painted over by later content the moment it scrolls past.
14
+ - sb_set warns (in both dry_run and the real write) when a sticky node sits inside an ancestor whose overflow clips it, since sticky resolves against the nearest scrolling ancestor and a clipping one silently defeats the pin.
15
+ - sb_review reports `stuck_no_host` for a `stuck` state override with no pinned self-or-ancestor, and `sticky_blocked` for a sticky node whose ancestor's overflow defeats it, so a document that reached either state through an import, a template, or a later edit is caught even when sb_set's own write-time checks were bypassed.
16
+ - sb_import now carries a source section's `sticky`/`fixed` positioning onto the imported node, including the same offset and layer-order seeds sb_set writes, since a section pinned to stay in view is a layout decision distinct from one that scrolls away.
17
+
18
+ ### Fixed
19
+ - sb_media_upload from a `url` no longer fails for every image and video type; the upload was sent with no declared content type, which the platform treats as neither an image nor a video, so it refused a plain PNG with a message that named the wrong cause. The source's own Content-Type header is used when it identifies the file, and the file extension otherwise.
20
+ - sb_import no longer drops a link's text when the link wraps markup (such as `<a><span>Docs</span></a>`) whose contents produce nothing importable; it now keeps the link's own words instead of importing nothing for it.
21
+
22
+ ### Internal
23
+ - The generated element catalog is refreshed against a current platform checkout: `dataset-block` and `list-dataset` now carry a content tip warning that a dataset node inside a repeater must bind to its own `config.datasetSource`, not the element's default.
24
+
9
25
  ## [0.8.0] - 2026-09-08
10
26
 
11
27
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,22 @@ 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.9.0] - 2026-09-08
10
+
11
+ ### Added
12
+ - Tham số `state` của sb_set giờ nhận `"stuck"`, trạng thái mà một phần tử được ghim (`position: sticky` hoặc `fixed`) mang khi runtime island của nền tảng đánh dấu nó là stuck; sb_set sẽ từ chối ghi khi không có node nào — bản thân node hay bất kỳ tổ tiên nào — có thể ghim được, vì khi đó renderer sẽ không biên dịch rule nào cả và override sẽ bị lưu, publish rồi không bao giờ được vẽ ra.
13
+ - sb_set giờ tự gieo `top`/`zIndex` khi ghi `position: sticky`, khớp với những gì editor của chính nền tảng gieo sẵn, vì một header được ghim mà không có z-index sẽ bị nội dung phía sau vẽ đè lên ngay khi cuộn qua.
14
+ - sb_set giờ cảnh báo (cả ở dry_run lẫn lần ghi thật) khi một node sticky nằm trong một tổ tiên có overflow cắt nó, vì sticky phân giải theo tổ tiên có thể cuộn gần nhất, và một tổ tiên cắt nội dung sẽ âm thầm vô hiệu hóa việc ghim.
15
+ - sb_review giờ báo cáo `stuck_no_host` cho một override trạng thái `stuck` mà không có node ghim nào ở chính nó hay tổ tiên, và `sticky_blocked` cho một node sticky bị tổ tiên có overflow vô hiệu hóa, để một document đi đến một trong hai trạng thái này qua import, template, hoặc một chỉnh sửa sau đó vẫn bị phát hiện dù đã bỏ qua được các kiểm tra lúc ghi của sb_set.
16
+ - sb_import giờ mang theo thuộc tính ghim `sticky`/`fixed` của một section nguồn sang node được import, kèm theo đúng các giá trị gieo về offset và thứ tự layer mà sb_set ghi, vì một section được ghim để luôn hiển thị là một quyết định bố cục khác với một section cuộn trôi đi.
17
+
18
+ ### Fixed
19
+ - sb_media_upload từ `url` không còn thất bại với mọi loại ảnh và video; trước đây lần upload được gửi đi mà không khai báo content type, khiến nền tảng coi đó không phải ảnh cũng không phải video, nên nó từ chối cả một file PNG bình thường với thông báo nêu sai nguyên nhân. Giờ đây content-type khai báo của nguồn được dùng khi nó xác định đúng loại file, và phần mở rộng của tên file được dùng khi không xác định được.
20
+ - sb_import không còn làm mất chữ của một liên kết khi liên kết đó bọc markup (như `<a><span>Docs</span></a>`) mà nội dung bên trong không import được gì; giờ nó giữ lại chính chữ của liên kết thay vì import ra không có gì cho nó.
21
+
22
+ ### Internal
23
+ - Catalog element được tạo lại dựa trên một bản checkout hiện tại của nền tảng: `dataset-block` và `list-dataset` giờ mang một content tip cảnh báo rằng một node dataset bên trong repeater phải bind theo đúng `config.datasetSource` của chính nó, chứ không phải theo mặc định của element.
24
+
9
25
  ## [0.8.0] - 2026-09-08
10
26
 
11
27
  ### Added
@@ -30090,7 +30090,9 @@ export const ELEMENTS = {
30090
30090
  "Place it on a product or collection page so its children inherit that page binding automatically; on any OTHER page, pin a standalone product via the Product trait group (and optionally a default variant) so the children preview that product",
30091
30091
  "Fill it with dataset children (media-dataset, text-dataset, pricing-dataset, product-variants, quantity, buttons); style each field on its own node",
30092
30092
  "Use direction + gap for the internal flex layout and content-width to cap and center the card",
30093
- "Prefer a horizontal split (media beside a content column) for buy boxes and a vertical stack for compact cards"
30093
+ "Prefer a horizontal split (media beside a content column) for buy boxes and a vertical stack for compact cards",
30094
+ "config.datasetSource names the ENTITY this card holds — \"product\" (the default when the key is absent), \"category\", \"article\", \"blogCategory\", \"course\" or \"review\". Inside a list-dataset it MUST equal the repeater's own datasetSource: it is what makes the card bind that entity's fields AND what gives the card its link, so a collection card left on the default binds product.url, resolves to nothing in a collection row, and publishes with no link on it at all",
30095
+ "Set the same datasetSource on every dataset child of the card (text-dataset, collection-media) — each child reads its own key, not the card's"
30094
30096
  ],
30095
30097
  "semantics": [
30096
30098
  "dataset",
@@ -31465,6 +31467,7 @@ export const ELEMENTS = {
31465
31467
  ],
31466
31468
  "contentTips": [
31467
31469
  "Always add exactly one dataset-block child as the repeated item template; every row clones that exact structure and styling",
31470
+ "The dataset-block template — and every dataset node inside it — must carry the SAME config.datasetSource as this repeater. Each node reads its OWN key, so a collection list whose card omits it leaves that card on the product default: it binds product.url, no product context reaches a collection row, and the card publishes with no link, so clicking it does nothing",
31468
31471
  "config.datasetSource picks the ENTITY: \"product\" for a product list, \"category\" for a collection list. It is what derives the node's binding target (product::product_list / category::collection_list)",
31469
31472
  "Product lists: config.collectionType \"all_products\" renders the whole catalog, \"collection\" renders the one named by config.collectionId, \"related\" renders the PAGE PRODUCT's neighbours — the products sharing one of its collections, minus itself",
31470
31473
  "Use \"related\" for a cross-sell shelf on a product page (\"Sản phẩm liên quan\" / \"You may also like\"). It reads no collectionId — its axis is whatever collections the page product is filed under — and it renders nothing on a page that names no product, so pair it with an emptyStateId if the section must never be blank",
@@ -2,6 +2,7 @@ import { isOverlay, subtreeIds, ancestors, appBlockRoot, SPEC_GLOBAL_ID, SPEC_GL
2
2
  import { ELEMENTS, ELEMENT_SEEDS, SATELLITE_RULES } from '../../catalog/elements.generated.js';
3
3
  import { bindingsForConfig, createNode, mintSatellites } from './node.js';
4
4
  import { refuseSecondTemplate } from './traps.js';
5
+ import { STUCK_STATE, refuseStuckConfig, requireStuckHost, stickySeeds } from './sticky.js';
5
6
  import { genId } from './ids.js';
6
7
  /** Append sentinel: splice clamps a too-large index, and `isSyncablePatch`
7
8
  * deliberately allows one — an append is a legitimate thing to describe. */
@@ -204,6 +205,16 @@ export function setKeys(doc, id, keys, opts) {
204
205
  // documents that exact call — `sb_set pr_option style base:true state:"active"`
205
206
  // — as the way to style a selected option.
206
207
  if (opts.state) {
208
+ // THE STUCK STATE IS THE ONE STATE WITH A PRECONDITION. Every other state
209
+ // is a pseudo-class the browser resolves on the node itself; `stuck` is a
210
+ // class a runtime island toggles on the PINNED element, and the renderer
211
+ // emits no rule at all when there is nothing pinned to hang it off. See
212
+ // sticky.ts — this is the whole reason that module exists.
213
+ if (opts.state === STUCK_STATE) {
214
+ requireStuckHost(doc.doc, id);
215
+ if (namespace === 'config')
216
+ refuseStuckConfig(keys);
217
+ }
207
218
  // Base state and per-breakpoint state are DIFFERENT PLACES in the document,
208
219
  // and the old path (`states[state][bp][ns]`) was neither of them: it buried
209
220
  // a breakpoint inside the base-state cluster, where nothing reads it.
@@ -219,6 +230,15 @@ export function setKeys(doc, id, keys, opts) {
219
230
  ...(rebind ? [rebind] : []),
220
231
  ];
221
232
  }
233
+ // WRITING `position: sticky` IS THREE WRITES, and the editor makes all three.
234
+ // An agent that made only the first shipped a header the page paints over —
235
+ // measured in Chromium by the platform, not inferred. Seeded only where the
236
+ // caller and the node are both silent, so an explicit answer always wins.
237
+ if (namespace === 'style') {
238
+ const seeds = stickySeeds(doc.node(id), keys, opts.base ? undefined : (opts.breakpoint ?? 'desktop'));
239
+ if (Object.keys(seeds).length)
240
+ keys = { ...seeds, ...keys };
241
+ }
222
242
  if (opts.base) {
223
243
  // Base IS legitimate, and this used to throw for anything that was not an
224
244
  // identity key — a refusal built on a misread of the platform's responsive
@@ -54,6 +54,16 @@ export const FIX = {
54
54
  off_canvas: 'Give it a width that can shrink — sb_set id "<id>", namespace style, keys { "maxWidth": "100%" } at this breakpoint.',
55
55
  text_too_small: 'Raise it for this breakpoint: sb_set id "<id>", namespace style, keys { "fontSize": "16px" }.',
56
56
  overlap: 'Check the two for a fixed height or a negative margin at this breakpoint; sb_look with node_id on each shows which one is out of place.',
57
+ // Both sticky findings name a node OTHER than the one at fault, because in
58
+ // both cases the node carrying the defect is not the node to edit.
59
+ sticky_blocked: 'Clear the clip on the ancestor: sb_set id "<key>", namespace style, keys ' +
60
+ '{ "overflowX": "visible", "overflowY": "visible" } — or move the pinned node outside it. ' +
61
+ 'Sticky resolves against its nearest SCROLLING ancestor, so a clipping one becomes that ' +
62
+ 'ancestor and the node pins inside a box that never scrolls.',
63
+ stuck_no_host: 'Pin something: sb_set id "<id>" (or the section it lives in), namespace style, keys ' +
64
+ '{ "position": "sticky" } — sb_set seeds the offset and the layer order with it. Until ' +
65
+ 'then the platform compiles no rule for this state at all, so the override is stored, ' +
66
+ 'published and never painted. To drop it instead, remove the stuck slot.',
57
67
  };
58
68
  /** The template for `code`, with every `<name>` in `vars` substituted. */
59
69
  export function fill(code, vars) {
@@ -1,4 +1,5 @@
1
1
  import { walk } from '../../core/tree.js';
2
+ import { stickySeeds } from './sticky.js';
2
3
  /** Style keys read off a node, ignoring anything unset. */
3
4
  function styleOf(n) {
4
5
  return (n?.style ?? {});
@@ -219,6 +220,20 @@ function one(c, t) {
219
220
  // inner block is what carries the page's own measure — an imported band
220
221
  // that runs the full window width on a page whose sections are 1200 reads
221
222
  // as a different site even when every colour matches.
223
+ // A PINNED SECTION ARRIVES WITH ALL THREE KEYS, never just `position`.
224
+ // The offset and the layer order are not decoration: measured in Chromium
225
+ // by the platform, a pinned section with no z-index is painted OVER by any
226
+ // `position: relative` element in a later section the moment it scrolls
227
+ // past, which reads as the import having produced a broken band rather
228
+ // than as a stacking question nobody answered. `stickySeeds` is the one
229
+ // place that says what they are, so this cannot drift from `sb_set`.
230
+ //
231
+ // `fixed` is carried as the source declared it, seeds and all — a fixed
232
+ // bar with no offset would sit wherever the flow left it, which on an
233
+ // imported page is not where the source had it.
234
+ const pin = c.pinned
235
+ ? { position: c.pinned, ...stickySeeds(undefined, { position: 'sticky' }) }
236
+ : {};
222
237
  return {
223
238
  type: 'flex-section',
224
239
  style: {
@@ -227,6 +242,7 @@ function one(c, t) {
227
242
  flexDirection: 'column',
228
243
  alignItems: 'center',
229
244
  ...(t.sectionPadding ? { padding: t.sectionPadding } : { padding: '64px 24px' }),
245
+ ...pin,
230
246
  },
231
247
  children: [
232
248
  {
@@ -1,4 +1,5 @@
1
1
  import { childrenOf, childrenWithSatellites, isOverlay, pageChildren, appBlockRoot, SPEC_GLOBAL_REF, SPEC_APP_BLOCK_REF } from '../../core/tree.js';
2
+ import { STUCK_STATE, stickyBlockedBy, stuckHostOf, isPinnedNode } from './sticky.js';
2
3
  import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
3
4
  import { fill } from './findings.js';
4
5
  /**
@@ -188,6 +189,44 @@ export function reviewDesign(doc) {
188
189
  });
189
190
  continue;
190
191
  }
192
+ // PINNING FAILS WITH NOTHING ON SCREEN AND NOTHING IN THE LOG — both halves
193
+ // of it — which is exactly what a review is for. `sb_set` refuses the second
194
+ // case at write time, but a document reaches here by other roads: an import,
195
+ // a template, or a later edit that un-pinned the host and left the overrides
196
+ // behind.
197
+ const styled = n;
198
+ if (isPinnedNode(n)) {
199
+ const blocker = stickyBlockedBy(d, id);
200
+ // Sticky only: a `fixed` element is positioned against the viewport and an
201
+ // overflow ancestor cannot take that away from it.
202
+ const sticky = String(styled.style?.position) === 'sticky' ||
203
+ Object.values(styled.responsive ?? {}).some((r) => String(r?.style?.position) === 'sticky');
204
+ if (blocker && sticky) {
205
+ out.push({
206
+ code: 'sticky_blocked',
207
+ nodeId: id,
208
+ type,
209
+ problem: `Pinned, but ancestor "${blocker}" clips its overflow — so that ancestor becomes ` +
210
+ 'the scroll container this node pins inside, and it never scrolls. The node does ' +
211
+ 'not move, and nothing reports it.',
212
+ key: blocker,
213
+ fix: fill('sticky_blocked', { id, key: blocker }),
214
+ });
215
+ }
216
+ }
217
+ const hasStuck = styled.states?.[STUCK_STATE] !== undefined ||
218
+ Object.values(styled.responsive ?? {}).some((r) => r?.states?.[STUCK_STATE] !== undefined);
219
+ if (hasStuck && !stuckHostOf(d, id)) {
220
+ out.push({
221
+ code: 'stuck_no_host',
222
+ nodeId: id,
223
+ type,
224
+ problem: 'Carries a "stuck" override, but neither it nor any ancestor can pin — so the ' +
225
+ 'renderer emits no rule for it. The styling is stored, saved and published, and ' +
226
+ 'never painted.',
227
+ fix: fill('stuck_no_host', { id }),
228
+ });
229
+ }
191
230
  const bindings = n.bindings ?? [];
192
231
  // A container with nothing in it is a band of empty space. The commonest way
193
232
  // to ship one is to add the section and then get distracted.
@@ -0,0 +1,208 @@
1
+ import { ancestors } from '../../core/tree.js';
2
+ /**
3
+ * PINNING, AND THE STATE A PINNED ELEMENT WEARS ONCE IT IS STUCK.
4
+ *
5
+ * The platform gained this in September 2026 (`schema/src/stickyState.ts`, Go
6
+ * mirror `server/render/style/sticky.go`), and every part of it fails SILENTLY
7
+ * when authored wrong — which is why it needs a module here rather than a note.
8
+ *
9
+ * `position: sticky` changes nothing observable when it engages: CSS has no
10
+ * `:stuck` pseudo-class. So the platform toggles ONE class (`wb-stuck`) on the
11
+ * pinned element from a runtime island, and compiles every rule an author wrote
12
+ * for the pinned look against it — the node itself as `#self.wb-stuck`, a
13
+ * DESCENDANT as `#host.wb-stuck #self`, which is what lets a pinned header
14
+ * shrink its logo without the logo knowing what pinned it.
15
+ *
16
+ * Three things about it are load-bearing here, and each is a write that
17
+ * disappears without one:
18
+ *
19
+ * 1. THE STATE NEEDS A HOST. `render/css.go` emits the stuck rules inside
20
+ * `if stuckHost := stuckHostFor(...); stuckHost != ""`. A `stuck` override
21
+ * on a node with no pinned self-or-ancestor compiles to NOTHING — stored,
22
+ * saved, published and ignored forever, the same shape as a binding in a
23
+ * namespace `applyBindings` does not honour.
24
+ * 2. THE SEEDS ARE NOT COSMETIC. The editor writes `top: 0px` and
25
+ * `zIndex: 10` the moment an author picks "Stick on scroll", and the
26
+ * platform's own commit records why the second one exists: measured in
27
+ * Chromium, a pinned header with no z-index is painted OVER by any
28
+ * `position: relative` element in a later section the moment it scrolls
29
+ * past. An agent writing `position: sticky` through `sb_set` reaches none
30
+ * of that, so its first sticky header half-works in the way that reads as
31
+ * the feature being broken.
32
+ * 3. A CLIPPING ANCESTOR DEFEATS IT ENTIRELY. Sticky resolves against its
33
+ * nearest SCROLLING ancestor, so an ancestor with `overflow: hidden|auto|
34
+ * scroll|clip|overlay` becomes that ancestor and the node pins inside a box
35
+ * that never scrolls. Nothing on screen and nothing in the log explains it.
36
+ */
37
+ /** The state key, in `node.states` and `responsive[bp].states`. */
38
+ export const STUCK_STATE = 'stuck';
39
+ /**
40
+ * The positions that HAVE a pinned moment.
41
+ *
42
+ * `fixed` earns its place for a different reason than `sticky`: it is pinned
43
+ * from the first frame, so "the moment it pins" never happens to it — but "the
44
+ * moment the page has scrolled past where it would have been" does, and that is
45
+ * the same design an author reaches for (the fixed header that goes opaque once
46
+ * the hero is behind it). `absolute` is deliberately absent: it scrolls away
47
+ * with the page, so it has no relationship to the scroll position at all.
48
+ */
49
+ const PINNED_POSITIONS = new Set(['sticky', 'fixed']);
50
+ /**
51
+ * Overflow values that make an ancestor a scroll container — an ALLOWLIST of
52
+ * the clipping ones, not `!== 'visible'`, so an unknown or misspelled value
53
+ * never produces a warning nobody can act on. Mirrors the editor's
54
+ * `dnd/stickyBlockers.ts`.
55
+ */
56
+ const CLIPPING = new Set(['hidden', 'auto', 'scroll', 'clip', 'overlay']);
57
+ const BREAKPOINTS = ['desktop', 'laptop', 'tablet', 'mobile'];
58
+ function styleAt(node, bp) {
59
+ if (!node)
60
+ return {};
61
+ return (bp ? node.responsive?.[bp]?.style : node.style) ?? {};
62
+ }
63
+ /**
64
+ * Does any slot on this node ask for a position that can pin?
65
+ *
66
+ * Breakpoint-agnostic on purpose, exactly as the platform's `isPinnedNode` is: a
67
+ * header pinned only on desktop still compiles its stuck rules at every width —
68
+ * they simply never match, because the class only appears while the element is
69
+ * genuinely stuck, which the island re-decides per viewport.
70
+ */
71
+ export function isPinnedNode(node) {
72
+ if (!node)
73
+ return false;
74
+ if (PINNED_POSITIONS.has(String(styleAt(node).position)))
75
+ return true;
76
+ return BREAKPOINTS.some((bp) => PINNED_POSITIONS.has(String(styleAt(node, bp).position)));
77
+ }
78
+ /**
79
+ * The pinned self-or-ancestor whose `wb-stuck` class this node's stuck rules
80
+ * would key off, or null. Nearest wins; a pinned node answers with itself.
81
+ */
82
+ export function stuckHostOf(doc, id) {
83
+ if (isPinnedNode(doc.nodes[id]))
84
+ return id;
85
+ for (const up of ancestors(doc, id)) {
86
+ if (isPinnedNode(doc.nodes[up]))
87
+ return up;
88
+ }
89
+ return null;
90
+ }
91
+ /**
92
+ * The nearest ancestor whose overflow stops a sticky node from working, or null.
93
+ *
94
+ * Starts at the PARENT: a sticky element's own overflow clips its children, not
95
+ * itself, and blaming it would send the caller to fix a property that is not the
96
+ * problem. Reads base overlaid with `bp` — the cascade's own order for a single
97
+ * key — so a clip declared only at mobile is named only when writing mobile.
98
+ */
99
+ export function stickyBlockedBy(doc, id, bp) {
100
+ for (const up of ancestors(doc, id)) {
101
+ const node = doc.nodes[up];
102
+ if (!node)
103
+ return null;
104
+ const s = { ...styleAt(node), ...(bp ? styleAt(node, bp) : {}) };
105
+ if (CLIPPING.has(String(s.overflowX)) || CLIPPING.has(String(s.overflowY)))
106
+ return up;
107
+ }
108
+ return null;
109
+ }
110
+ /**
111
+ * The keys the editor writes alongside `position: sticky`, for the same reasons
112
+ * — offered here only when the caller has not answered them and the node does
113
+ * not already, so an explicit choice is never overwritten.
114
+ *
115
+ * `top` is skipped when the same call pins to another edge: the inspector writes
116
+ * one key at a time and cannot have that case, an agent writing
117
+ * `{ position: 'sticky', bottom: '0px' }` in one object plainly can, and seeding
118
+ * `top: 0` over it would pin a bottom bar to the ceiling.
119
+ *
120
+ * `10` for the layer order is a specific number, not a large one: it sits above
121
+ * ordinary page content and BELOW the overlay ladder the static CSS owns (cart
122
+ * scrim 40, drawer 41, pop-up scrim 50, pop-up 51). A header that outranked
123
+ * those would cover the drawer it opens.
124
+ */
125
+ export function stickySeeds(node, keys, bp) {
126
+ if (String(keys.position) !== 'sticky')
127
+ return {};
128
+ const merged = { ...styleAt(node), ...(bp ? styleAt(node, bp) : {}), ...keys };
129
+ const seeds = {};
130
+ if (merged.top === undefined && merged.bottom === undefined)
131
+ seeds.top = '0px';
132
+ if (merged.zIndex === undefined)
133
+ seeds.zIndex = '10';
134
+ return seeds;
135
+ }
136
+ /**
137
+ * What a stuck state may carry in `config`.
138
+ *
139
+ * `stuckDecls` reads the style slot whole and translates exactly ONE config key
140
+ * — `hidden` — into `display: none`, because a state has no bands and no cascade
141
+ * of its own, so a declaration is the only shape available. Every other config
142
+ * key in a stuck slot is stored by the document and read by no compiler.
143
+ *
144
+ * And only `true` is honoured: `false` would have to mean "show it again while
145
+ * pinned", which needs `display: revert`, and the platform's `render/css.go`
146
+ * documents at length why revert is wrong here — it rolls the property back past
147
+ * the element's own static CSS and lands on the UA default. So OFF means "no
148
+ * override", i.e. remove the key.
149
+ */
150
+ export function refuseStuckConfig(keys) {
151
+ const stray = Object.keys(keys).filter((k) => k !== 'hidden');
152
+ if (stray.length) {
153
+ throw new Error(`sbuilder: the stuck state translates exactly one config key — "hidden" — into a ` +
154
+ `declaration (display:none). ${stray.map((k) => `"${k}"`).join(', ')} would be stored ` +
155
+ 'and read by no compiler. A state has no bands and no cascade, so style is what it ' +
156
+ 'paints: write these in the style namespace, or at base if they are not stuck-specific.');
157
+ }
158
+ if ('hidden' in keys && keys.hidden !== true) {
159
+ throw new Error('sbuilder: stuck config.hidden takes only true. false would have to mean "show it again ' +
160
+ 'while pinned", which needs display:revert — wrong here, because revert rolls past the ' +
161
+ "element's own static CSS to the UA default. To stop hiding it, remove the override.");
162
+ }
163
+ }
164
+ /**
165
+ * Refuse a stuck override that no selector would ever match.
166
+ *
167
+ * The message names the fix rather than the rule, because the caller who lands
168
+ * here is one step from the design they wanted: pin this node, or pin the
169
+ * section it lives in, and the same override starts painting.
170
+ */
171
+ export function requireStuckHost(doc, id) {
172
+ if (stuckHostOf(doc, id))
173
+ return;
174
+ const up = ancestors(doc, id)[0];
175
+ throw new Error(`sbuilder: "${STUCK_STATE}" is the state a PINNED element wears once it is stuck, and ` +
176
+ `neither ${id} nor any ancestor can pin — so the platform compiles no rule for it ` +
177
+ '(render/css.go emits stuck CSS only under a stuck host). The override would be stored, ' +
178
+ 'saved, published and never painted. Pin the element that scrolls first: ' +
179
+ `sb_set ${up ?? id} style { position: "sticky" } — a descendant then styles itself ` +
180
+ 'through the host, so this node needs no position of its own.');
181
+ }
182
+ /**
183
+ * The warning a sticky node earns when an ancestor's overflow will defeat it.
184
+ *
185
+ * Only for `sticky`. A `fixed` element is positioned against the viewport and
186
+ * an overflow ancestor cannot take that away from it — the ancestors that CAN
187
+ * (a `transform`, a `filter`, a `contain`) are a different question this does
188
+ * not pretend to answer.
189
+ *
190
+ * A warning rather than a refusal, because the clip may be the deliberate half:
191
+ * the author may be about to remove it, or may mean the node to pin inside a
192
+ * scrolling panel. Silence is the only wrong answer — the failure shows up as
193
+ * nothing on screen and nothing in the log.
194
+ */
195
+ export function stickyWarning(doc, id, bp) {
196
+ const node = doc.nodes[id];
197
+ const sticky = String(styleAt(node).position) === 'sticky' ||
198
+ BREAKPOINTS.some((b) => String(styleAt(node, b).position) === 'sticky');
199
+ if (!sticky)
200
+ return null;
201
+ const blocker = stickyBlockedBy(doc, id, bp);
202
+ if (!blocker)
203
+ return null;
204
+ return (`${id} is sticky, but ancestor ${blocker} clips its overflow (hidden/auto/scroll/clip/` +
205
+ 'overlay), so it becomes the scroll container this node pins inside — a box that never ' +
206
+ `scrolls. The node will not move. Clear overflowX/overflowY on ${blocker}, or pin a node ` +
207
+ 'outside it.');
208
+ }
@@ -89,11 +89,16 @@ export function registerImportTools(server, ctx, session) {
89
89
  const rehosted = new Map();
90
90
  // WHY it failed, not just how many. Four images refused for the same
91
91
  // reason is ONE thing to fix, and a bare count is the shape that sends a
92
- // caller to re-run the import hoping for a different answer. Measured: a
93
- // real import lost all four images to `only image, video, or font
94
- // (woff2/woff/ttf/otf) uploads are supported` the platform refusing SVG,
95
- // whose own sentinel exists precisely so a caller can be told "convert it
96
- // first". The count alone said none of that.
92
+ // caller to re-run the import hoping for a different answer.
93
+ //
94
+ // The measurement that proved it worth having also proved a CONCLUSION
95
+ // WRONG. A real import lost all four images to `only image, video, or font
96
+ // (woff2/woff/ttf/otf) uploads are supported`, and that was written up here
97
+ // as the platform refusing SVG. It does not refuse SVG: it accepts any
98
+ // declared `image/*`, and `image/svg+xml` is one. `uploadMedia` was sending
99
+ // a typeless Blob, so EVERY url upload arrived as `application/octet-stream`
100
+ // — a PNG was refused by the same message. A reason carried verbatim is
101
+ // what makes a wrong reading of it findable.
97
102
  const failures = new Map();
98
103
  if (upload_images !== false) {
99
104
  for (const src of images) {
@@ -13,6 +13,8 @@ import { readinessGaps, READINESS_NOTICE } from '../domains/site/readiness.js';
13
13
  import { gatherReadiness } from '../domains/site/readiness-fetch.js';
14
14
  import { globalWarning, restampPatches, RESPONSIVE_NOTICE } from '../domains/site/traps.js';
15
15
  import { catalogMatches, traitsFor } from '../catalog/element-search.js';
16
+ import { applyPatches } from '../core/patch.js';
17
+ import { stickyWarning } from '../domains/site/sticky.js';
16
18
  import { siteFor } from './context.js';
17
19
  import { projectList, PAGE_FIELDS, TEMPLATE_FIELDS } from './project.js';
18
20
  /**
@@ -262,7 +264,12 @@ export function registerPageTools(server, ctx) {
262
264
  keys: z.record(z.unknown()).optional(),
263
265
  breakpoint: z.enum(['desktop', 'laptop', 'tablet', 'mobile']).optional(),
264
266
  base: z.boolean().optional(),
265
- state: z.string().optional().describe('An interaction state, e.g. "hover"'),
267
+ state: z
268
+ .string()
269
+ .optional()
270
+ .describe('An interaction state — "hover", or "stuck" for how a pinned element looks once ' +
271
+ 'it is stuck (needs a sticky/fixed self-or-ancestor; a descendant styles itself ' +
272
+ 'through the host).'),
266
273
  edits: z
267
274
  .array(z.object({
268
275
  id: z.string(),
@@ -287,17 +294,46 @@ export function registerPageTools(server, ctx) {
287
294
  batch.push({ id, namespace, keys, breakpoint: breakpoint, base, state });
288
295
  }
289
296
  const { patches, touched } = setMany(d, batch);
297
+ // THE STICKY WARNING IS COMPUTED AGAINST THE DOCUMENT AS IT WILL BE, so
298
+ // the dry run and the real run say the same thing. A caller who is told
299
+ // only after committing has already shipped a header that does not move.
300
+ const stuck = () => {
301
+ // Gated on the batch actually being able to change the answer, because
302
+ // a page document is hundreds of KB and sb_set is the hottest write
303
+ // there is: a clone on every call would tax every edit for a warning
304
+ // that fires on almost none. `position` and `overflow*` are the only
305
+ // two keys in the question.
306
+ const relevant = batch.some((e) => e.namespace === 'style' &&
307
+ ('position' in e.keys || 'overflowX' in e.keys || 'overflowY' in e.keys));
308
+ const out = {};
309
+ if (!relevant)
310
+ return out;
311
+ const probe = JSON.parse(JSON.stringify(d.doc));
312
+ applyPatches(probe, patches);
313
+ for (const e of batch) {
314
+ const w = stickyWarning(probe, e.id, e.base ? undefined : (e.breakpoint ?? 'desktop'));
315
+ if (w)
316
+ out[e.id] = w;
317
+ }
318
+ return out;
319
+ };
290
320
  if (dry_run !== false) {
291
321
  const note = ctx.notices.once('responsive', RESPONSIVE_NOTICE);
292
- return text({ dry_run: true, patches, ...(note ? { note } : {}) });
322
+ const sw = stuck();
323
+ return text({
324
+ dry_run: true,
325
+ patches,
326
+ ...(Object.keys(sw).length ? { warnings: sw } : {}),
327
+ ...(note ? { note } : {}),
328
+ });
293
329
  }
330
+ const warnings = stuck();
294
331
  session.applyAndPublish(patches);
295
332
  await session.save();
296
- const warnings = {};
297
333
  for (const t of touched) {
298
334
  const w = globalWarning(d.doc, t.id);
299
335
  if (w)
300
- warnings[t.id] = w;
336
+ warnings[t.id] = warnings[t.id] ? `${warnings[t.id]} ${w}` : w;
301
337
  }
302
338
  if (!edits) {
303
339
  const warn = warnings[batch[0].id];
@@ -15,10 +15,37 @@ import { siteToken } from '../tools/credentialpick.js';
15
15
  *
16
16
  * Node ≥22 has FormData, Blob and fetch as globals, so this needs no dependency.
17
17
  */
18
+ /**
19
+ * A content type from the file NAME, for the local-path case and as the fallback
20
+ * when a server answers with nothing useful.
21
+ *
22
+ * Deliberately small: the platform decides what it accepts, and duplicating its
23
+ * whole table here would be a second place for that policy to drift. These are
24
+ * the types a page actually carries.
25
+ */
26
+ const TYPE_BY_EXT = {
27
+ '.png': 'image/png',
28
+ '.jpg': 'image/jpeg',
29
+ '.jpeg': 'image/jpeg',
30
+ '.gif': 'image/gif',
31
+ '.webp': 'image/webp',
32
+ '.avif': 'image/avif',
33
+ '.svg': 'image/svg+xml',
34
+ '.ico': 'image/x-icon',
35
+ '.bmp': 'image/bmp',
36
+ '.mp4': 'video/mp4',
37
+ '.webm': 'video/webm',
38
+ '.mov': 'video/quicktime',
39
+ };
40
+ function typeForName(name) {
41
+ const dot = name.lastIndexOf('.');
42
+ return dot < 0 ? '' : (TYPE_BY_EXT[name.slice(dot).toLowerCase()] ?? '');
43
+ }
18
44
  export async function uploadMedia(ctx, siteId, source) {
19
45
  const doFetch = ctx.fetchImpl ?? fetch;
20
46
  let bytes;
21
47
  let filename;
48
+ let declared = '';
22
49
  if (source.path) {
23
50
  bytes = await readFile(source.path);
24
51
  filename = source.name ?? basename(source.path);
@@ -29,6 +56,8 @@ export async function uploadMedia(ctx, siteId, source) {
29
56
  throw new ApiError(res.status, 'source_unreachable', `could not fetch ${source.url}`);
30
57
  }
31
58
  bytes = new Uint8Array(await res.arrayBuffer());
59
+ // The SOURCE'S OWN answer first — it is the only party that actually knows.
60
+ declared = (res.headers.get('content-type') ?? '').split(';')[0].trim();
32
61
  // A URL's last segment is usually the filename; when it is not (a query-only
33
62
  // CDN link), name it rather than uploading something called "".
34
63
  filename = source.name ?? (new URL(source.url).pathname.split('/').pop() || 'image');
@@ -36,8 +65,25 @@ export async function uploadMedia(ctx, siteId, source) {
36
65
  else {
37
66
  throw new Error('sbuilder: give sb_media_upload either a local path or a url');
38
67
  }
68
+ // THE HEADER ONLY WINS WHEN IT SAYS SOMETHING. A CDN answering
69
+ // `application/octet-stream` for a PNG is ordinary, and it is exactly the
70
+ // value the platform refuses — so "the server declared a type" is not the
71
+ // question; "the server declared a type that identifies the file" is.
72
+ const usable = declared.startsWith('image/') || declared.startsWith('video/');
73
+ const type = (usable ? declared : '') || typeForName(filename) || declared;
39
74
  const form = new FormData();
40
- form.set('file', new Blob([bytes]), filename);
75
+ // THE BLOB'S TYPE IS THE UPLOAD'S CONTENT TYPE, and omitting it broke every
76
+ // upload this function ever made from a URL. A typeless Blob is sent as
77
+ // `application/octet-stream`, the platform accepts a file whose declared type
78
+ // starts with `image/` or `video/` (or whose EXTENSION is a known font or
79
+ // document), and octet-stream is none of those — so a PNG fetched from a URL
80
+ // was refused with "only image, video, or font uploads are supported", which
81
+ // reads as a policy about the FILE and is really a bug in this line.
82
+ //
83
+ // It cost a wrong conclusion too: the same refusal on an SVG was written up
84
+ // here as "the platform deliberately refuses SVG". It does not — it accepts
85
+ // any `image/*`, and `image/svg+xml` is one.
86
+ form.set('file', new Blob([bytes], type ? { type } : undefined), filename);
41
87
  if (source.name)
42
88
  form.set('name', source.name);
43
89
  if (source.folderId)
@@ -84,6 +84,14 @@ function capturePage(limits) {
84
84
  * result ten deep for nothing.
85
85
  */
86
86
  const leaves = (root) => {
87
+ const walkChildren = (el) => {
88
+ const kids = [];
89
+ for (const child of Array.from(el.children)) {
90
+ for (const c of walk(child))
91
+ kids.push(c);
92
+ }
93
+ return kids;
94
+ };
87
95
  const walk = (el) => {
88
96
  // ONE BOUND, on the whole import. There used to be a second, per section,
89
97
  // and it kept doing the same wrong job under a new number: a page whose
@@ -150,6 +158,21 @@ function capturePage(limits) {
150
158
  taken.nodes++;
151
159
  return [{ kind: 'button', variant: 'cta', text, ...(href ? { href: abs(href) } : {}) }];
152
160
  }
161
+ // A LINK WITH MARKUP INSIDE IT IS STILL A LINK. `<a><span>Docs</span></a>`
162
+ // has children, so it fell through to the child walk — and when those
163
+ // children produced nothing renderable (an icon font's ligature, a
164
+ // decorative span) the link's words went with them. Same shape as the
165
+ // text fallback below, and for the same reason: offer your own content
166
+ // only when nothing inside offered any.
167
+ const inner = walkChildren(el);
168
+ if (inner.length > 0)
169
+ return inner;
170
+ if (text) {
171
+ const href = el.getAttribute('href');
172
+ taken.nodes++;
173
+ return [{ kind: 'button', variant: 'link', text, ...(href ? { href: abs(href) } : {}) }];
174
+ }
175
+ return [];
153
176
  }
154
177
  if (tag === 'UL' || tag === 'OL') {
155
178
  const items = Array.from(el.querySelectorAll('li'))
@@ -167,11 +190,7 @@ function capturePage(limits) {
167
190
  taken.nodes++;
168
191
  return [{ kind: 'text', text }];
169
192
  }
170
- const kids = [];
171
- for (const child of Array.from(el.children)) {
172
- for (const c of walk(child))
173
- kids.push(c);
174
- }
193
+ const kids = walkChildren(el);
175
194
  if (kids.length > 0) {
176
195
  const cs = getComputedStyle(el);
177
196
  const lays = cs.display === 'flex' || cs.display === 'grid' ||
@@ -256,7 +275,16 @@ function capturePage(limits) {
256
275
  skip('empty-section');
257
276
  continue;
258
277
  }
259
- acc.push({ kind: 'section', children });
278
+ // PINNING IS THE ONE BEHAVIOUR WORTH CARRYING OVER, and the only one this
279
+ // importer reads off computed style rather than off the tree. A sticky
280
+ // category bar or a fixed buy bar is a layout DECISION — the section is
281
+ // there to stay in view — and a copy that scrolls away is not the same
282
+ // section. Sticky and fixed only: `absolute` and `relative` describe where
283
+ // a box sits inside a layout this import is not copying, so carrying them
284
+ // would place a section against coordinates that no longer exist.
285
+ const pos = getComputedStyle(el).position;
286
+ const pinned = pos === 'sticky' || pos === 'fixed' ? pos : undefined;
287
+ acc.push({ kind: 'section', children, ...(pinned ? { pinned } : {}) });
260
288
  }
261
289
  return acc;
262
290
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",