sbuilder-mcp 0.37.0 → 0.38.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,23 @@ All notable changes to this project are documented in this file.
6
6
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.38.1] - 2026-09-11
10
+
11
+ ### Fixed
12
+ - sb_look no longer photographs a reveal-on-scroll band (config.animation trigger:"view") as blank; every animation is now stopped before the shutter opens, since the lazy-image walk returns to the top of the page and would otherwise catch the band back at its opacity:0 starting keyframe.
13
+ - sb_import and sb_import_site no longer drop a `<video>` element that carries a poster image and no explicit width/height, which previously measured as a zero-size box and was skipped like a genuinely hidden element.
14
+
15
+ ## [0.38.0] - 2026-09-11
16
+
17
+ ### Added
18
+ - The catalog gained a 112th element, spline-scene: an interactive 3D scene embedded from a Spline viewer link, seeded by default with a real scene from Spline's own demo project, with a warning at creation time telling the caller to replace it with the merchant's own export before publishing.
19
+ - config.animation grew from five keys to ten (intensity, trigger, range, repeat, and alternate join active, type, easing, delay, and duration), including a new `trigger: "view"` for reveal-on-scroll animation, compiled as a pure-CSS animation-timeline override with no added script.
20
+ - sb_traits_for now returns an animation_values field listing every legal value for each config.animation key (all 46 types, 7 easings, every intensity and trigger) for any element that carries an animation control.
21
+ - sb_set now warns when config.animation combines alternate:true with a finite repeat count, since that combination publishes a node that finishes on its opacity:0 keyframe and is invisible to every visitor, and when intensity or trigger is set to a value the platform does not recognize.
22
+
23
+ ### Changed
24
+ - config.animation is no longer base-only: sb_set now writes it per breakpoint like any other config key, instead of always routing it to base, so an animation can now be turned off on mobile.
25
+
9
26
  ## [0.37.0] - 2026-09-11
10
27
 
11
28
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,23 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
6
6
  Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.38.1] - 2026-09-11
10
+
11
+ ### Fixed
12
+ - sb_look không còn chụp một band hiệu ứng xuất hiện khi cuộn (config.animation trigger:"view") thành ảnh trống nữa; mọi animation giờ được dừng lại trước khi chụp, vì bước quét ảnh lazy-load quay về đầu trang và trước đây sẽ bắt band đó đứng yên ở keyframe khởi đầu opacity:0.
13
+ - sb_import và sb_import_site không còn bỏ sót phần tử `<video>` có ảnh poster nhưng không khai báo width/height tường minh, trước đây phần tử này đo được kích thước bằng 0 nên bị bỏ qua như một phần tử thực sự ẩn.
14
+
15
+ ## [0.38.0] - 2026-09-11
16
+
17
+ ### Added
18
+ - Catalog có thêm element thứ 112, spline-scene: một cảnh 3D tương tác được nhúng từ link viewer của Spline, mặc định được gán sẵn một cảnh thật từ project demo của chính Spline, kèm cảnh báo ngay lúc tạo yêu cầu caller thay bằng file export của merchant trước khi publish.
19
+ - config.animation tăng từ năm key lên mười key (intensity, trigger, range, repeat và alternate được thêm bên cạnh active, type, easing, delay và duration), trong đó có `trigger: "view"` mới cho hiệu ứng xuất hiện khi cuộn trang, được biên dịch thành một override CSS thuần bằng animation-timeline, không cần thêm script.
20
+ - sb_traits_for giờ trả về thêm trường animation_values liệt kê mọi giá trị hợp lệ cho từng key của config.animation (đủ 46 type, 7 easing, mọi intensity và trigger) với bất kỳ element nào có control animation.
21
+ - sb_set giờ cảnh báo khi config.animation kết hợp alternate:true với một số lần repeat hữu hạn, vì tổ hợp này publish một node kết thúc ở keyframe opacity:0 và không visitor nào nhìn thấy được, cũng như khi intensity hoặc trigger được đặt một giá trị mà nền tảng không nhận ra.
22
+
23
+ ### Changed
24
+ - config.animation không còn base-only nữa: sb_set giờ ghi giá trị này theo từng breakpoint như mọi config key khác, thay vì luôn dồn về base, nên giờ có thể tắt animation riêng trên mobile.
25
+
9
26
  ## [0.37.0] - 2026-09-11
10
27
 
11
28
  ### Added
@@ -1,5 +1,5 @@
1
1
  import { ELEMENTS, TRAIT_WRITES } from './elements.generated.js';
2
- import { animationVocabulary, vocabulariesForWrites } from '../domains/site/vocabulary.js';
2
+ import { animationValues, animationVocabulary, vocabulariesForWrites } from '../domains/site/vocabulary.js';
3
3
  import { neverTranslatedOn, translatableSpecials } from '../domains/site/translate.js';
4
4
  /** Eight to choose from; the hints for the chosen one come with sb_traits_for. */
5
5
  export const DEFAULT_CATALOG_LIMIT = 8;
@@ -130,7 +130,9 @@ export function traitsFor(type, control) {
130
130
  // with a required gate, and the three ways to get it wrong all render
131
131
  // NOTHING rather than something else. An element that does not offer the
132
132
  // control says nothing, so this is silent on the other 38.
133
- ...(el.controls.includes('animation') ? { animation: animationVocabulary() } : {}),
133
+ ...(el.controls.includes('animation')
134
+ ? { animation: animationVocabulary(), animation_values: animationValues() }
135
+ : {}),
134
136
  isContainer: el.isContainer,
135
137
  isRootOnly: el.isRootOnly,
136
138
  childAllows: el.childAllows,
@@ -1,5 +1,5 @@
1
1
  export const ELEMENT_SOURCE = {
2
- "count": 111,
2
+ "count": 112,
3
3
  "docSchemaVersion": 2
4
4
  };
5
5
  export const ELEMENTS = {
@@ -3686,6 +3686,146 @@ export const ELEMENTS = {
3686
3686
  "player"
3687
3687
  ]
3688
3688
  },
3689
+ "spline-scene": {
3690
+ "type": "spline-scene",
3691
+ "label": "Spline scene",
3692
+ "category": "media",
3693
+ "isContainer": false,
3694
+ "isRootOnly": false,
3695
+ "locked": false,
3696
+ "hideInLayer": false,
3697
+ "childAllows": [],
3698
+ "defaults": {
3699
+ "specials": {
3700
+ "sceneUrl": "https://prod.spline.design/HqdfCmOueigtautT/scene.splinecode",
3701
+ "posterUrl": "",
3702
+ "sceneControls": []
3703
+ },
3704
+ "style": {
3705
+ "width": "100%",
3706
+ "height": "480px"
3707
+ },
3708
+ "config": {
3709
+ "background": "transparent",
3710
+ "eventsTarget": "local",
3711
+ "mobileMode": "scene"
3712
+ }
3713
+ },
3714
+ "inspector": [
3715
+ {
3716
+ "tab": "general",
3717
+ "groups": [
3718
+ {
3719
+ "key": "size",
3720
+ "label": "Size",
3721
+ "controls": [
3722
+ "width_select",
3723
+ "size_bounds"
3724
+ ]
3725
+ },
3726
+ {
3727
+ "key": "spline",
3728
+ "label": "Spline",
3729
+ "controls": [
3730
+ "spline_source"
3731
+ ]
3732
+ },
3733
+ {
3734
+ "key": "scene_display",
3735
+ "label": "Display",
3736
+ "controls": [
3737
+ "scene_display"
3738
+ ]
3739
+ },
3740
+ {
3741
+ "key": "scene_controls",
3742
+ "label": "Scene controls",
3743
+ "controls": [
3744
+ "scene_controls"
3745
+ ]
3746
+ },
3747
+ {
3748
+ "key": "shape",
3749
+ "label": "Shape",
3750
+ "controls": [
3751
+ "border",
3752
+ "corner",
3753
+ "shadow"
3754
+ ]
3755
+ }
3756
+ ]
3757
+ },
3758
+ {
3759
+ "tab": "advanced",
3760
+ "groups": [
3761
+ {
3762
+ "key": "spacing",
3763
+ "label": "Spacing",
3764
+ "controls": [
3765
+ "padding_margin"
3766
+ ]
3767
+ },
3768
+ {
3769
+ "key": "display",
3770
+ "label": "Display",
3771
+ "controls": [
3772
+ "display"
3773
+ ]
3774
+ },
3775
+ {
3776
+ "key": "animation",
3777
+ "label": "Animation",
3778
+ "controls": [
3779
+ "animation"
3780
+ ]
3781
+ },
3782
+ {
3783
+ "key": "class_css",
3784
+ "label": "Class",
3785
+ "controls": [
3786
+ "class_css"
3787
+ ]
3788
+ }
3789
+ ]
3790
+ }
3791
+ ],
3792
+ "controls": [
3793
+ "width_select",
3794
+ "size_bounds",
3795
+ "spline_source",
3796
+ "scene_display",
3797
+ "scene_controls",
3798
+ "border",
3799
+ "corner",
3800
+ "shadow",
3801
+ "padding_margin",
3802
+ "display",
3803
+ "animation",
3804
+ "class_css"
3805
+ ],
3806
+ "description": "An interactive 3D scene made in Spline, embedded from its viewer URL.",
3807
+ "useWhen": [
3808
+ "For a hero or product moment that should move in 3D as the visitor scrolls or hovers",
3809
+ "When the merchant already designs in Spline and has a .splinecode link"
3810
+ ],
3811
+ "avoidWhen": [
3812
+ "For a plain product photo — use image",
3813
+ "For a video — use video / youtube / vimeo",
3814
+ "More than three 3D scenes on one page (each one costs ~600 KB of JS once it scrolls into view)"
3815
+ ],
3816
+ "contentTips": [
3817
+ "Paste the link from Spline: Export → Viewer → copy link (…/scene.splinecode)",
3818
+ "Add a poster image so the box is not blank while the scene loads"
3819
+ ],
3820
+ "semantics": [
3821
+ "3d",
3822
+ "spline",
3823
+ "scene",
3824
+ "webgl",
3825
+ "animation",
3826
+ "interactive"
3827
+ ]
3828
+ },
3689
3829
  "vimeo": {
3690
3830
  "type": "vimeo",
3691
3831
  "label": "Vimeo",
@@ -36195,7 +36335,6 @@ export const BASE_ONLY_CONFIG = [
36195
36335
  "collectionId",
36196
36336
  "collectionType",
36197
36337
  "quantity",
36198
- "animation",
36199
36338
  "rowLimit"
36200
36339
  ];
36201
36340
  /**
@@ -36207,20 +36346,38 @@ export const BASE_ONLY_EXCEPTIONS = [
36207
36346
  "quantity-button:iconSize"
36208
36347
  ];
36209
36348
  /**
36210
- * The ENTRANCE ANIMATION's vocabulary — config.animation, offered by 73 of the
36211
- * 111 element types and describable by nothing until now.
36349
+ * The ENTRANCE ANIMATION's vocabulary — config.animation, offered by most of
36350
+ * the element library and describable by nothing until this table existed.
36212
36351
  *
36213
36352
  * Three ways to miss, all silent (AnimationTypeOf answers "" and no keyframes,
36214
36353
  * no rule and no error are emitted, through save, publish and render):
36215
- * - it is an OBJECT, not a string: {active, type, easing, delay, duration}
36354
+ * - it is an OBJECT, not a string
36216
36355
  * - active:true is REQUIRED; a stored type is deliberately NOT consent,
36217
36356
  * because the panel keeps the type when the switch goes off
36218
36357
  * - type is a keyframe key spelled with UNDERSCORES: fade_in, never fade-in
36219
36358
  *
36220
36359
  * easing is the mild one: an unrecognised value falls back to "ease".
36221
36360
  *
36222
- * It is also BASE-ONLY (see BASE_ONLY_CONFIG) — render/css.go emits it into the
36223
- * base lane because the config object is read with no responsive merge.
36361
+ * TWO THINGS THIS TABLE USED TO SAY THAT ARE NO LONGER TRUE, kept as a
36362
+ * correction because both were recorded here as settled facts:
36363
+ *
36364
+ * - IT IS NOT BASE-ONLY ANY MORE. The compiler reads config through
36365
+ * MergeNamespace and emits per lane, so the key left the platform's
36366
+ * base-only ledger — and the side effect is the thing merchants ask for
36367
+ * most, an animation that is off on mobile. A caller still writing it to
36368
+ * base gets the cascade's fallback layer, which is correct but cannot vary.
36369
+ * - REVEAL-ON-SCROLL HAS AN ANSWER. trigger:"view" compiles to
36370
+ * animation-timeline: view() inside an @supports override, so it costs no
36371
+ * JavaScript and the engines without it keep animating at first paint.
36372
+ *
36373
+ * The object now carries ten keys. intensity travels as CSS variables (a
36374
+ * distance is a quantity, so it reaches the page per breakpoint) and ABSENCE IS
36375
+ * NOT medium — a document with no intensity keeps the old 0.5s fallback.
36376
+ *
36377
+ * alternateNeedsInfinite is the guard worth reading before using repeat:
36378
+ * alternate with an EVEN finite count finishes on the from keyframe, and every
36379
+ * entrance keyframe starts at opacity:0 — so the node publishes INVISIBLE. The
36380
+ * compiler honours alternate only alongside an infinite repeat.
36224
36381
  */
36225
36382
  /**
36226
36383
  * The eight node-style keys a THEME TEXT STYLE controls, and the var prop each
@@ -36237,18 +36394,79 @@ export const BASE_ONLY_EXCEPTIONS = [
36237
36394
  export const TEXT_STYLE_KEYS = [["fontFamily", "family"], ["fontSize", "size"], ["fontWeight", "weight"], ["fontStyle", "style"], ["lineHeight", "line-height"], ["letterSpacing", "letter-spacing"], ["textTransform", "transform"], ["color", "color"]];
36238
36395
  export const ANIMATION = {
36239
36396
  "types": [
36397
+ "blur_in",
36398
+ "bounce",
36399
+ "bounce_in",
36400
+ "elastic_in",
36240
36401
  "fade_in",
36402
+ "fade_in_bottom_left",
36403
+ "fade_in_bottom_right",
36404
+ "fade_in_down",
36405
+ "fade_in_left",
36406
+ "fade_in_right",
36407
+ "fade_in_top_left",
36408
+ "fade_in_top_right",
36409
+ "fade_in_up",
36410
+ "flash",
36411
+ "flip",
36412
+ "flip_in_x",
36413
+ "flip_in_y",
36414
+ "head_shake",
36415
+ "heart_beat",
36416
+ "jack_in_the_box",
36417
+ "jello",
36418
+ "light_speed_in_left",
36419
+ "light_speed_in_right",
36420
+ "pulse",
36421
+ "roll_in",
36422
+ "rotate_in",
36423
+ "rotate_in_down_left",
36424
+ "rotate_in_down_right",
36425
+ "rotate_in_up_left",
36426
+ "rotate_in_up_right",
36427
+ "rubber_band",
36428
+ "shake_x",
36429
+ "shake_y",
36241
36430
  "slide_down",
36431
+ "slide_in_down",
36432
+ "slide_in_left",
36433
+ "slide_in_right",
36434
+ "slide_in_up",
36435
+ "slide_left",
36436
+ "slide_right",
36242
36437
  "slide_up",
36243
- "zoom_in"
36438
+ "swing",
36439
+ "tada",
36440
+ "wobble",
36441
+ "zoom_in",
36442
+ "zoom_out"
36244
36443
  ],
36245
36444
  "easings": [
36246
36445
  "ease",
36247
36446
  "ease-in",
36248
36447
  "ease-out",
36249
- "linear"
36448
+ "ease-in-out",
36449
+ "linear",
36450
+ "spring",
36451
+ "bounce"
36250
36452
  ],
36251
36453
  "easingFallback": "ease",
36252
36454
  "durationDefault": 0.5,
36455
+ "intensities": [
36456
+ "soft",
36457
+ "medium",
36458
+ "strong"
36459
+ ],
36460
+ "intensityDurations": {
36461
+ "soft": 0.4,
36462
+ "medium": 0.6,
36463
+ "strong": 0.9
36464
+ },
36465
+ "triggers": [
36466
+ "view"
36467
+ ],
36468
+ "rangeDefault": 60,
36469
+ "repeatMax": 100,
36470
+ "alternateNeedsInfinite": true,
36253
36471
  "readBy": "AnimationTypeOf + CompileEntranceAnimationCSS"
36254
36472
  };
@@ -3,7 +3,7 @@
3
3
  export const TRANSLATION_SOURCE = {
4
4
  "elements": 58,
5
5
  "pairs": 141,
6
- "neverKeys": 156,
6
+ "neverKeys": 159,
7
7
  "entityTypes": 12
8
8
  };
9
9
  /** Every entity type a translation record can name, including "node". */
@@ -402,6 +402,7 @@ export const NEVER_TRANSLATED = [
402
402
  "payload",
403
403
  "picker",
404
404
  "poster",
405
+ "posterUrl",
405
406
  "prefillValue",
406
407
  "presetCode",
407
408
  "provinceField",
@@ -409,6 +410,8 @@ export const NEVER_TRANSLATED = [
409
410
  "required",
410
411
  "reviewForm",
411
412
  "sameDefault",
413
+ "sceneControls",
414
+ "sceneUrl",
412
415
  "searchBehavior",
413
416
  "searchClearSwap",
414
417
  "searchDebounceMs",
@@ -26,6 +26,19 @@ export const INERT_ON_ADD = {
26
26
  'store that is not in English, set it (e.g. "Trang chủ") or the trail reads half-translated ' +
27
27
  'on every product page.',
28
28
  },
29
+ // THE SHARPEST OF THE THREE, because the placeholder is not a placeholder: it
30
+ // is a REAL scene on Spline's own servers, and it loads, renders and responds
31
+ // to the mouse. The other two entries here describe an element that looks
32
+ // finished; this one looks finished AND is somebody else's work, published on
33
+ // the merchant's domain. `sb_review` reads a correct tree, `sb_look`
34
+ // photographs a convincing 3D hero, and nothing anywhere says whose it is.
35
+ 'spline-scene': {
36
+ note: "A spline-scene is born pointing at SPLINE'S OWN DEMO SCENE (specials.sceneUrl defaults to " +
37
+ 'a real prod.spline.design link), so an unset one publishes a 3D hero that loads, moves, ' +
38
+ "and belongs to somebody else — it looks finished, so no check can flag it. Set sceneUrl " +
39
+ 'to the merchant\'s own export (Spline: Export → Viewer → the …/scene.splinecode link), and ' +
40
+ 'set specials.posterUrl too or the box is blank until the engine chunk arrives.',
41
+ },
29
42
  };
30
43
  /** The hint for a type, or null. */
31
44
  export function inertHint(type) {
@@ -79,9 +79,31 @@ export function unknownValueNote(key, value) {
79
79
  * falls back to `ease`, so the animation still runs — it is a wrong answer, not
80
80
  * a missing one.
81
81
  *
82
- * And it is BASE-ONLY, which is a fourth way to lose it — but that one is
83
- * ROUTED rather than warned about, by `baseonly.ts`, because the platform's
84
- * ledger names the key and `sb_set` can simply write it to the right layer.
82
+ * IT WAS BASE-ONLY AND IS NOT ANY MORE, and the correction is kept in that
83
+ * shape because this file stated it as a settled fact. The compiler used to
84
+ * index `node.Config["animation"]` with no responsive merge, so a per-breakpoint
85
+ * write landed where nothing looked and `baseonly.ts` ROUTED it. Adding an
86
+ * intensity ended that: a distance is a QUANTITY, and this repo's own mandate is
87
+ * that a quantity reaches the page per breakpoint. `CompileEntranceAnimationCSS`
88
+ * now takes a `bp` and emits per lane, the key left the platform's ledger, and
89
+ * the routing stopped on its own — the table is generated, which is exactly why
90
+ * it could. What the caller gains is the thing merchants ask for most: an
91
+ * animation that is off on mobile.
92
+ *
93
+ * THE OBJECT ALSO GREW TO TEN KEYS, and two of them change what is possible
94
+ * rather than merely how it looks:
95
+ *
96
+ * - `trigger: "view"` IS REVEAL-ON-SCROLL, which this repo recorded as having
97
+ * no answer at all ("the platform has nowhere to put it"). It does now:
98
+ * `animation-timeline: view()` inside an `@supports` override, no island and
99
+ * no JavaScript, with the engines that lack it still animating at first
100
+ * paint. A note that says "you cannot" outlives the thing that made it true,
101
+ * and this is the third time that has cost something here.
102
+ * - `repeat` + `alternate` is the one combination that publishes an INVISIBLE
103
+ * node. `alternate` with an EVEN finite count finishes on the `from`
104
+ * keyframe and every entrance keyframe starts at `opacity: 0`, so the author
105
+ * sees it on the canvas and a visitor never sees it at all. The compiler
106
+ * honours `alternate` only alongside `"infinite"`.
85
107
  */
86
108
  export const ANIMATION_VOCAB = ANIMATION;
87
109
  /**
@@ -96,10 +118,68 @@ export const ANIMATION_VOCAB = ANIMATION;
96
118
  * mistake is actually made.
97
119
  */
98
120
  export function animationVocabulary() {
99
- return (`config.animation is an OBJECT: { active: true, type: ${ANIMATION.types.join('|')}, ` +
100
- `easing?: ${ANIMATION.easings.join('|')}, delay?, duration? }. active:true is REQUIRED ` +
101
- '(a type alone is not consent), the type is UNDERSCORED, and it is base-only. Anything ' +
102
- 'else renders no animation at all, with no error.');
121
+ return ('config.animation is an OBJECT, never a string: { active: true, type, easing?, intensity?, ' +
122
+ 'trigger?, delay?, duration?, repeat?, alternate?, range? }. active:true is REQUIRED (a type ' +
123
+ 'alone is not consent) and the type is UNDERSCORED (fade_in, never fade-in); either miss ' +
124
+ 'renders no animation at all, with no error. Written per breakpoint like any config, so it ' +
125
+ 'can be off on mobile. See animation_values below for what each key accepts.');
126
+ }
127
+ /**
128
+ * THE SAME FACTS AS DATA, because forty-six type names are not a sentence.
129
+ *
130
+ * The line above used to enumerate every type, which worked while there were
131
+ * four and became 848 bytes of prose the moment the platform shipped 46 — and
132
+ * the budget test caught it, correctly, for the second time on this same field.
133
+ * The first catch (a six-field object, 12,396 bytes) moved the long form OUT of
134
+ * the trait sheet; this one moves the ENUMERATION out of the prose. What is left
135
+ * in the sentence is what decides whether a write does anything at all.
136
+ *
137
+ * It rides on `sb_traits_for`, which answers for ONE element per call, and not
138
+ * on `sb_catalog_search`, which answers for many — so the cost is paid once by
139
+ * the caller who has already chosen the element it is about to animate.
140
+ *
141
+ * Every value here is GENERATED from the Go that renders, so a platform that
142
+ * adds a trigger or an intensity brings it to this sheet on the next codegen,
143
+ * and one that drops a field stops claiming it.
144
+ */
145
+ export function animationValues() {
146
+ const a = ANIMATION;
147
+ const mid = a.intensities[Math.floor(a.intensities.length / 2)];
148
+ return {
149
+ type: a.types,
150
+ easing: { values: a.easings, unknown_falls_back_to: a.easingFallback },
151
+ // `absence is not the middle setting` is the platform's own point and the
152
+ // one reading a caller gets wrong without being told; the rest of the
153
+ // intensity story is a duration table, which speaks for itself.
154
+ ...(a.intensities.length
155
+ ? {
156
+ intensity: {
157
+ values: a.intensities,
158
+ implies_duration_s: a.intensityDurations,
159
+ note: `omitting it is NOT "${mid}" — it keeps the ${a.durationDefault}s default instead`,
160
+ },
161
+ }
162
+ : {}),
163
+ // THE ONE ENTRY THAT MUST BE PROACTIVE. Every other trap here is caught by
164
+ // animationNote at the moment of the mistake — but there is no mistake to
165
+ // catch in never knowing a capability exists, so reveal-on-scroll has to be
166
+ // discoverable from the sheet itself.
167
+ ...(a.triggers.length
168
+ ? {
169
+ trigger: {
170
+ values: a.triggers,
171
+ default: 'omitted = runs at first paint',
172
+ view: `reveal-on-scroll (animation-timeline), range = % of entry, default ${a.rangeDefault}`,
173
+ },
174
+ }
175
+ : {}),
176
+ ...(a.repeatMax ? { repeat: `1-${a.repeatMax} or "infinite"; above that is clamped` } : {}),
177
+ ...(a.alternateNeedsInfinite
178
+ ? { alternate: 'requires repeat:"infinite" — DROPPED on a finite count, which would end invisible' }
179
+ : {}),
180
+ duration_default_s: a.durationDefault,
181
+ read_by: a.readBy,
182
+ };
103
183
  }
104
184
  /**
105
185
  * The warning for a `config.animation` write that will not animate.
@@ -138,6 +218,33 @@ export function animationNote(value) {
138
218
  notes.push(`easing ${JSON.stringify(e)} is outside ${ANIMATION.easings.join(', ')}, so the renderer ` +
139
219
  `uses "${ANIMATION.easingFallback}" — the animation runs, with a curve you did not choose`);
140
220
  }
221
+ const i = value.intensity;
222
+ if (i !== undefined && (typeof i !== 'string' || !ANIMATION.intensities.includes(i))) {
223
+ // Same family as easing — it runs — but the miss is quieter still: no
224
+ // variables are emitted and the keyframes fall back to their own literals,
225
+ // so the node animates at a distance nobody chose and nothing looks broken.
226
+ notes.push(`intensity ${JSON.stringify(i)} is outside ${ANIMATION.intensities.join(', ')}, so no ` +
227
+ 'intensity variables are emitted and the keyframes use their built-in distances');
228
+ }
229
+ const tr = value.trigger;
230
+ if (tr !== undefined && (typeof tr !== 'string' || !ANIMATION.triggers.includes(tr))) {
231
+ notes.push(`trigger ${JSON.stringify(tr)} is not ${ANIMATION.triggers.map((v) => JSON.stringify(v)).join(' or ')}, ` +
232
+ 'so the animation runs at first paint rather than on scroll — stored, published, and ignored');
233
+ }
234
+ // THE ONE THAT PUBLISHES AN INVISIBLE NODE. `alternate` is honoured only
235
+ // alongside an infinite repeat: with an even finite count the animation
236
+ // finishes on the `from` keyframe, and every entrance keyframe starts at
237
+ // opacity:0. The author sees it play on the canvas and the visitor sees
238
+ // nothing at all, with no error anywhere to explain it.
239
+ if (ANIMATION.alternateNeedsInfinite && value.alternate === true && value.repeat !== 'infinite') {
240
+ notes.push('alternate:true is DROPPED unless repeat is "infinite" — with an even finite count the ' +
241
+ 'animation would finish on the from keyframe, which is opacity:0, and publish a node no ' +
242
+ 'visitor can see; the renderer refuses that rather than shipping it');
243
+ }
244
+ if (typeof value.repeat === 'number' && ANIMATION.repeatMax && value.repeat > ANIMATION.repeatMax) {
245
+ notes.push(`repeat ${value.repeat} is clamped to ${ANIMATION.repeatMax} — the count reaches CSS as a ` +
246
+ 'number, and a document is not a trusted source');
247
+ }
141
248
  if (!notes.length)
142
249
  return null;
143
250
  return `config.animation will not do what this says. ${notes.join('. ')}.`;
@@ -22,7 +22,24 @@ function capturePage(limits) {
22
22
  if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0')
23
23
  return false;
24
24
  const r = el.getBoundingClientRect();
25
- return r.width > 0 && r.height > 0;
25
+ if (r.width > 0 && r.height > 0)
26
+ return true;
27
+ // A ZERO BOX MEANS "HIDDEN" ONLY FOR AN ELEMENT THAT SIZES ITSELF FROM ITS
28
+ // CONTENT. A `<video>` sizes itself from its MEDIA, and a `poster` is what
29
+ // it measures before any media loads — so a video whose poster has not
30
+ // resolved measures 0×0 while being perfectly present, and Chrome only
31
+ // falls back to 300×150 once that load has actually failed.
32
+ //
33
+ // MEASURED: `<video src poster>` with no explicit width/height was dropped
34
+ // with nothing but a `hidden` count, while the identical element carrying
35
+ // `width`/`height` — or carrying NO poster — came through. So the one video
36
+ // most worth importing, the one with a still frame on it, was the one that
37
+ // disappeared, and only on the pages slow enough for the race to be lost.
38
+ //
39
+ // Narrow on purpose: a `<video>` alone, and only when it names something to
40
+ // play or to show. An empty `<video></video>` is a genuinely empty box and
41
+ // goes on being skipped.
42
+ return el.tagName === 'VIDEO' && !!(el.getAttribute('src') || el.getAttribute('poster'));
26
43
  };
27
44
  const clean = (s) => (s ?? '').replace(/\s+/g, ' ').trim();
28
45
  // ABSOLUTE where it can be, RAW where it cannot. `new URL(rel, base)` throws
@@ -250,6 +250,42 @@ async function settleLazyImages(page) {
250
250
  .waitForFunction(() => [...document.querySelectorAll('img')].every((i) => i.complete === true), undefined, { timeout: 3_000 })
251
251
  .catch(() => { });
252
252
  }
253
+ /**
254
+ * SETTLE EVERY ENTRANCE ANIMATION, or photograph a band that is not there.
255
+ *
256
+ * A still picture wants the page a visitor ends up looking at, and the settled
257
+ * state of every entrance animation is "visible". Two ways a correct page
258
+ * photographs BLANK without this, both of them new since the platform shipped
259
+ * 46 effects and a scroll trigger, and both silent:
260
+ *
261
+ * - `trigger: "view"` compiles to `animation-timeline: view()`, whose progress
262
+ * is a function of where the element sits in the scrollport. The walk above
263
+ * scrolls and then RETURNS TO THE TOP, so every revealed section is back at
264
+ * its `from` keyframe — `opacity: 0` — at the moment the shutter opens.
265
+ * MEASURED: a four-band page photographed with the third band entirely
266
+ * empty, the other three correct.
267
+ * - `fill: both` applies the `from` keyframe during a `delay`, so a section
268
+ * with a delay long enough to outlast the settle photographs blank too.
269
+ *
270
+ * The honest reading of either picture is "this band is broken", which sends
271
+ * the caller to fix a page that works — the same failure the lazy-image walk
272
+ * above exists to prevent, arriving by a different route that the walk cannot
273
+ * fix because the walk's own return to the top is what causes it.
274
+ *
275
+ * `animation: none` rather than forcing the timeline to `auto`: that one
276
+ * RESTARTS the animation against the document timeline and the shot catches it
277
+ * mid-flight (measured at opacity 0.317). Removing the animation drops the
278
+ * element to its own declared style, which for every entrance effect here is
279
+ * exactly the end state — measured back at opacity 1, with the band's text on
280
+ * screen.
281
+ *
282
+ * Best-effort: a page that refuses a stylesheet is still worth photographing.
283
+ */
284
+ async function settleAnimations(page) {
285
+ await page
286
+ .addStyleTag({ content: '*,*::before,*::after{animation:none !important}' })
287
+ .catch(() => { });
288
+ }
253
289
  async function shootOne(page, url, width, format, opts) {
254
290
  // LOAD, then a BOUNDED settle — never `networkidle` alone.
255
291
  //
@@ -287,6 +323,7 @@ async function shootOne(page, url, width, format, opts) {
287
323
  }
288
324
  await settleDom(page);
289
325
  await settleLazyImages(page);
326
+ await settleAnimations(page);
290
327
  // A RENDERED page carries its node ids as the HTML `id` attribute — not as
291
328
  // `data-node-id`, which is the editor CANVAS's hook and never reaches the
292
329
  // renderer. Selecting the canvas attribute here returned an empty box list
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.37.0",
3
+ "version": "0.38.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",