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 +16 -0
- package/CHANGELOG.vi.md +16 -0
- package/dist/catalog/elements.generated.js +4 -1
- package/dist/domains/site/builder.js +20 -0
- package/dist/domains/site/findings.js +10 -0
- package/dist/domains/site/importmap.js +16 -0
- package/dist/domains/site/review.js +39 -0
- package/dist/domains/site/sticky.js +208 -0
- package/dist/tools/importpage.js +10 -5
- package/dist/tools/page.js +40 -4
- package/dist/transport/media.js +47 -1
- package/dist/vision/capture.js +34 -6
- package/package.json +1 -1
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
|
+
}
|
package/dist/tools/importpage.js
CHANGED
|
@@ -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.
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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) {
|
package/dist/tools/page.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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];
|
package/dist/transport/media.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/vision/capture.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|