@gemboss/gem-framework 0.18.15 → 0.19.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.
Files changed (54) hide show
  1. package/README.md +106 -1
  2. package/dist/SOURCE.json +3 -3
  3. package/dist/gem-framework.css +23 -1
  4. package/dist/gem-runtime.js +473 -55
  5. package/dist/index.js +2062 -44
  6. package/dist/motion/core.d.ts +208 -0
  7. package/dist/motion/core.js +1 -0
  8. package/dist/motion/orbit.d.ts +4 -0
  9. package/dist/motion/orbit.js +1 -0
  10. package/dist/motion/quick-view.d.ts +4 -0
  11. package/dist/motion/quick-view.js +1 -0
  12. package/dist/motion/rail.d.ts +5 -0
  13. package/dist/motion/rail.js +1 -0
  14. package/dist/orbit-base.css +39 -0
  15. package/dist/plugins/before_after.js +1 -1
  16. package/dist/plugins/bundle.d.ts +9 -0
  17. package/dist/plugins/bundle.js +394 -0
  18. package/dist/plugins/carousel.js +1 -1
  19. package/dist/plugins/cart-drawer.js +1 -1
  20. package/dist/plugins/context-adapter.js +1 -1
  21. package/dist/plugins/countdown.js +1 -1
  22. package/dist/plugins/disclosure.js +1 -1
  23. package/dist/plugins/hero-slideshow.js +1 -1
  24. package/dist/plugins/img-in.js +1 -1
  25. package/dist/plugins/localization.js +1 -1
  26. package/dist/plugins/media-gallery.js +1 -1
  27. package/dist/plugins/mobile-menu.js +1 -1
  28. package/dist/plugins/offer-engine.js +1 -1
  29. package/dist/plugins/orbit.d.ts +9 -0
  30. package/dist/plugins/orbit.js +1 -0
  31. package/dist/plugins/predictive-search.js +1 -1
  32. package/dist/plugins/promo-popup.js +1 -1
  33. package/dist/plugins/quantity-input.js +1 -1
  34. package/dist/plugins/quick-view.js +1 -69
  35. package/dist/plugins/rail.d.ts +9 -0
  36. package/dist/plugins/rail.js +1 -0
  37. package/dist/plugins/recently-viewed.js +1 -1
  38. package/dist/plugins/reveal-on-scroll.js +1 -1
  39. package/dist/plugins/reviews.js +2 -2
  40. package/dist/plugins/runtime-slot-loader.js +1 -1
  41. package/dist/plugins/seasonal-effects.js +1 -1
  42. package/dist/plugins/size-guide.d.ts +9 -0
  43. package/dist/plugins/size-guide.js +38 -0
  44. package/dist/plugins/sticky-atc.js +1 -1
  45. package/dist/plugins/tabs.js +1 -1
  46. package/dist/plugins/variant-selects.js +1 -1
  47. package/dist/popup-shell.css +15 -0
  48. package/dist/primitives.css +4 -1
  49. package/dist/rail-base.css +48 -0
  50. package/dist/runtime-core.d.ts +2 -0
  51. package/dist/runtime-core.js +26 -15
  52. package/dist/section-gate.js +2754 -37
  53. package/dist/tokens.css +4 -0
  54. package/package.json +11 -1
@@ -186,6 +186,11 @@ function gemButtonGradientEndCss(accent = "var(--gem-color-accent)", accentText
186
186
  var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
187
187
  var GEM_TAP_MIN_CSS_VAR = "--gem-tap-min: 44px;";
188
188
  var GEM_FOCUS_OFFSET_CSS_VAR = "--gem-focus-offset: 3px;";
189
+ var GEM_STROKE_CSS_VARS = [
190
+ "--gem-stroke: calc(1px * var(--gem-style-border, 1));",
191
+ "--gem-stroke-thick: calc(2px * var(--gem-style-border, 1));"
192
+ ];
193
+ var GEM_UNDERLINE_THICKNESS_CSS_VAR = "--gem-underline-thickness: 2px;";
189
194
  var GEM_DERIVED_SPACE_STEPS = { "2xs": 0.125, ms: 0.75, "4xl": 7 };
190
195
  var GEM_DERIVED_SPACE_CSS_VARIABLES = Object.keys(GEM_DERIVED_SPACE_STEPS).map((key) => `--gem-space-${slugify(key)}`);
191
196
  function gemDerivedSpaceCssVars(spacing) {
@@ -330,15 +335,15 @@ function gemBandScheme(c, band) {
330
335
  const away = mixHex(g, hexRelativeLuminance(i) > hexRelativeLuminance(g) ? "#000000" : "#ffffff", 0.08);
331
336
  const surface = hexContrastRatio(i, toward) >= MIN_TEXT_CONTRAST ? toward : away;
332
337
  const grounds = [g, surface];
333
- const text = aaTextColor(i, grounds);
338
+ const text2 = aaTextColor(i, grounds);
334
339
  const accent = aaTextColor(c.accent, grounds);
335
340
  return {
336
341
  background: g,
337
342
  surface,
338
- text,
343
+ text: text2,
339
344
  muted: aaTextColor(mixHex(i, g, 0.3), grounds),
340
345
  accent,
341
- accentText: aaLabelColor(c.accentText, accent, [g, text]),
346
+ accentText: aaLabelColor(c.accentText, accent, [g, text2]),
342
347
  border: mixHex(g, i, 0.22),
343
348
  success: aaTextColor(c.success, grounds),
344
349
  warning: aaTextColor(c.warning, grounds),
@@ -563,7 +568,7 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
563
568
  ...phone.layout.section ? [`--gem-layout-section-mobile: ${phone.layout.section};`] : [],
564
569
  ...toCssVars("component", tokens.component),
565
570
  ...toCssVars("morphology", tokens.morphology),
566
- // THE GOVERNED TYPE RAMP — --gem-h1..h6 / --gem-body / --gem-text-sm.
571
+ // THE GOVERNED TYPE RAMP — --gem-h1..h6 / --gem-body / --gem-text-sm / --gem-text-xs (26/10/2026).
567
572
  // gemcapture maps every measured font-size onto these names via `font_token`
568
573
  // (clone_kit._type_token), and the gem-framework contract documents them, but nothing in the
569
574
  // live framework defined them: a rebuild that followed the contract styled its headings with
@@ -578,7 +583,9 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
578
583
  // type ramp and registered in validation.ts. Follows the accent, so presets retint it for free.
579
584
  GEM_FOCUS_RING_CSS_VAR,
580
585
  GEM_TAP_MIN_CSS_VAR,
581
- GEM_FOCUS_OFFSET_CSS_VAR
586
+ GEM_FOCUS_OFFSET_CSS_VAR,
587
+ ...GEM_STROKE_CSS_VARS,
588
+ GEM_UNDERLINE_THICKNESS_CSS_VAR
582
589
  ];
583
590
  const formBlock = gemFormCssBlock(tokens.form);
584
591
  const buttonCase = gemButtonCaseCss(tokens, selector);
@@ -626,6 +633,13 @@ function gemTypeRampCssVars(typography) {
626
633
  return [
627
634
  `--gem-type-base: ${typeRampValue(typography, "base") ?? "1rem"};`,
628
635
  `--gem-type-scale: ${typeRampValue(typography, "scale") ?? "1.22"};`,
636
+ // Chris 26/10/2026: P3 of the GemPages v7 scale (H1–H6, P1–P3 + Custom), one step under text-sm. P1 is body, P2 is
637
+ // text-sm; P3 had no name, so a caption or a fine-print line could only be a px count that no style pack retunes.
638
+ // Same pattern as text-sm, off the base rather than off text-sm: base ÷ scale², 10.75px at 16px and 1.22. Declared
639
+ // FIRST because this list is the ramp's order, small to large, and the seam's `steps` must equal it in order
640
+ // (check-token-steps-match-framework.ts). At the defaults it sits above gem_font_size's 10px floor; that floor bounds
641
+ // the px number only, so a plan with a steeper ratio (1.333: 9px) can take this step lower, as it can text-sm.
642
+ "--gem-text-xs: calc(var(--gem-type-base) / (var(--gem-type-scale) * var(--gem-type-scale)));",
629
643
  "--gem-text-sm: calc(var(--gem-type-base) / var(--gem-type-scale));",
630
644
  "--gem-body: var(--gem-type-base);",
631
645
  "--gem-h6: calc(var(--gem-type-base) * var(--gem-type-scale));",
@@ -735,6 +749,1330 @@ function gemFrameworkEditionCss() {
735
749
  return GEM_FRAMEWORK_EDITIONS.flatMap((e) => e.css.map((rule) => rule.replaceAll("&", gemFrameworkEditionScope(e.edition)))).join("\n");
736
750
  }
737
751
 
752
+ // ../../packages/gem-framework/src/motion/core.js
753
+ var clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
754
+ var lerp = (a, b, t) => a + (b - a) * t;
755
+ var mod = (v, n) => (v % n + n) % n;
756
+ function smoothstep(edge0, edge1, v) {
757
+ const t = clamp((v - edge0) / (edge1 - edge0), 0, 1);
758
+ return t * t * (3 - 2 * t);
759
+ }
760
+ var follow = (dt, tau) => tau <= 0 ? 1 : 1 - Math.exp(-dt / tau);
761
+ function spring(s, target, dt, stiffness, damping) {
762
+ const f = dt / 16.67;
763
+ s.v += (-(s.x - target) * stiffness - s.v * damping) * f;
764
+ s.x += s.v * f;
765
+ return s;
766
+ }
767
+ function ringDelta(a, b, n) {
768
+ let d = mod(b - a, n);
769
+ if (d > n / 2) d -= n;
770
+ return d;
771
+ }
772
+ function ringPose(i, n, pos, o) {
773
+ const theta = ringDelta(pos, i, n) * o.spread;
774
+ const hidden = Math.abs(theta) > Math.PI;
775
+ const depth = (1 - Math.cos(theta)) / 2;
776
+ return {
777
+ x: o.rx * Math.sin(theta),
778
+ y: -o.ry * depth,
779
+ depth,
780
+ scale: lerp(1, o.back, depth),
781
+ opacity: hidden ? 0 : lerp(1, o.fade, depth),
782
+ blur: o.blur * depth,
783
+ z: Math.round((1 - depth) * 100),
784
+ lean: Math.sin(theta) * o.lean,
785
+ hidden
786
+ };
787
+ }
788
+ var throwTarget = (pos, velocity, carry, maxSlots) => Math.round(pos + clamp(velocity * carry, -maxSlots, maxSlots));
789
+ var written = /* @__PURE__ */ new WeakMap();
790
+ function setStyle(el, prop, value) {
791
+ let rec = written.get(el);
792
+ if (!rec) written.set(el, rec = {});
793
+ if (rec[prop] === value) return;
794
+ rec[prop] = value;
795
+ if (value === "") el.style.removeProperty(prop);
796
+ else el.style.setProperty(prop, value);
797
+ }
798
+ var effects = /* @__PURE__ */ new Map();
799
+ function defineEffect(spec) {
800
+ const effect = { hook: `[data-gem-${spec.key}]`, load: "viewport", renderer: "dom", tier: "static", fallback: "markup", settings: {}, ...spec, declared: spec };
801
+ effects.set(effect.key, effect);
802
+ return effect;
803
+ }
804
+ function frameTask(ctx, el, step, options) {
805
+ const task = ctx.scheduler.add(step, options);
806
+ ctx.cleanup(() => task.remove());
807
+ observeVisibility(ctx, el, (shown) => task.visible(shown));
808
+ return task;
809
+ }
810
+ function observeVisibility(ctx, el, cb, margin) {
811
+ const IO = ctx.window.IntersectionObserver;
812
+ if (typeof IO !== "function") return;
813
+ new IO((entries) => cb(entries.some((e) => e.isIntersecting)), { rootMargin: margin || "120px" }).observe(el);
814
+ }
815
+ function observeSize(ctx, el, cb) {
816
+ const RO = ctx.window.ResizeObserver;
817
+ if (typeof RO === "function") new RO(() => cb()).observe(el);
818
+ else ctx.on(ctx.window, "resize", () => cb());
819
+ }
820
+ function horizontalDrag(ctx, el, h) {
821
+ let id = null;
822
+ let x0 = 0;
823
+ let y0 = 0;
824
+ let decided = false;
825
+ let horizontal = false;
826
+ let moved = 0;
827
+ let samples = [];
828
+ const release = (e, fire) => {
829
+ if (e.pointerId !== id) return;
830
+ id = null;
831
+ const first = samples[0];
832
+ const last = samples[samples.length - 1];
833
+ const span = last.t - first.t;
834
+ const vx = span > 0 && e.timeStamp - last.t < 80 ? (last.x - first.x) / span : 0;
835
+ if (fire && horizontal) h.end(vx, moved, e);
836
+ else h.end(0, 0, e);
837
+ };
838
+ ctx.on(el, "pointerdown", (e) => {
839
+ if (e.button !== 0 || id !== null) return;
840
+ id = e.pointerId;
841
+ x0 = e.clientX;
842
+ y0 = e.clientY;
843
+ decided = horizontal = false;
844
+ moved = 0;
845
+ samples = [{ x: e.clientX, t: e.timeStamp }];
846
+ h.start(e);
847
+ });
848
+ ctx.on(el, "pointermove", (e) => {
849
+ if (e.pointerId !== id) return;
850
+ const dx = e.clientX - x0;
851
+ if (!decided && Math.hypot(dx, e.clientY - y0) > 6) {
852
+ decided = true;
853
+ horizontal = Math.abs(dx) > Math.abs(e.clientY - y0);
854
+ if (!horizontal) return release(e, false);
855
+ try {
856
+ el.setPointerCapture(e.pointerId);
857
+ } catch {
858
+ }
859
+ }
860
+ if (!horizontal) return;
861
+ moved = Math.max(moved, Math.abs(dx));
862
+ samples.push({ x: e.clientX, t: e.timeStamp });
863
+ while (samples.length > 2 && e.timeStamp - samples[0].t > 90) samples.shift();
864
+ h.move(dx, e);
865
+ });
866
+ ctx.on(el, "pointerup", (e) => release(e, true));
867
+ ctx.on(el, "pointercancel", (e) => release(e, true));
868
+ }
869
+ function wheelSteps(ctx, el, onStep) {
870
+ let acc = 0;
871
+ let lock = 0;
872
+ ctx.on(
873
+ el,
874
+ "wheel",
875
+ (e) => {
876
+ if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) || e.target.closest && e.target.closest("dialog")) return;
877
+ e.preventDefault();
878
+ acc += e.deltaX;
879
+ if (e.timeStamp < lock || Math.abs(acc) < 24) return;
880
+ onStep(Math.sign(acc));
881
+ acc = 0;
882
+ lock = e.timeStamp + 280;
883
+ },
884
+ { passive: false }
885
+ );
886
+ }
887
+ function arrowKeys(ctx, el, { step, activate, accept }) {
888
+ ctx.on(el, "keydown", (e) => {
889
+ if (accept && !accept(e)) return;
890
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") step(e.key === "ArrowRight" ? 1 : -1);
891
+ else if (!activate || e.key !== "Enter" && e.key !== " " || activate(e) === false) return;
892
+ e.preventDefault();
893
+ });
894
+ }
895
+ function flip(el, from, options) {
896
+ if (!from || !from.width || typeof el.animate !== "function") return null;
897
+ const box = el.getBoundingClientRect();
898
+ if (!box.height) return null;
899
+ const away = { transform: `translate(${from.left + from.width / 2 - box.left - box.width / 2}px, ${from.top + from.height / 2 - box.top - box.height / 2}px) scale(${from.height / box.height})` };
900
+ const home = { transform: "none" };
901
+ return el.animate(options.back ? [home, away] : [away, home], { duration: options.duration, easing: options.easing, fill: "both" });
902
+ }
903
+ function loadImage(doc, image) {
904
+ const img = doc.createElement("img");
905
+ if (image.srcset) img.srcset = image.srcset;
906
+ img.src = image.src;
907
+ return typeof img.decode === "function" ? img.decode().then(() => img, () => img) : Promise.resolve(img);
908
+ }
909
+ function putImage(img, image) {
910
+ if (!img || !image || !image.src || img.getAttribute("src") === image.src) return;
911
+ if (image.srcset) img.setAttribute("srcset", image.srcset);
912
+ else img.removeAttribute("srcset");
913
+ img.setAttribute("src", image.src);
914
+ }
915
+ function swapText(el, text2, still) {
916
+ if (!el || el.textContent === text2) return;
917
+ if (still || typeof el.animate !== "function") {
918
+ el.textContent = text2;
919
+ return;
920
+ }
921
+ const out = el.animate([{ opacity: 1 }, { opacity: 0, transform: "translateY(-6px)" }], { duration: 140, fill: "forwards" });
922
+ out.onfinish = () => {
923
+ el.textContent = text2;
924
+ out.cancel();
925
+ el.animate([{ opacity: 0, transform: "translateY(8px)" }, { opacity: 1, transform: "none" }], { duration: 220, easing: "cubic-bezier(.2,.7,.2,1)" });
926
+ };
927
+ }
928
+ function readProduct(el) {
929
+ try {
930
+ const p = JSON.parse(el.getAttribute("data-gem-product") || "null");
931
+ return p && p.image && p.image.src ? p : null;
932
+ } catch {
933
+ return null;
934
+ }
935
+ }
936
+ function formatMoney(cents, format) {
937
+ const value = (Number(cents) || 0) / 100;
938
+ const group = (str, sep) => str.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
939
+ const [int, dec] = value.toFixed(2).split(".");
940
+ return (format || "${{amount}}").replace(/\{\{\s*(\w+)\s*\}\}/, (_, key) => {
941
+ if (key === "amount_no_decimals") return group(String(Math.round(value)), ",");
942
+ if (key === "amount_with_comma_separator") return `${group(int, ".")},${dec}`;
943
+ if (key === "amount_no_decimals_with_comma_separator") return group(String(Math.round(value)), ".");
944
+ return `${group(int, ",")}.${dec}`;
945
+ });
946
+ }
947
+ function priceText(p, el, which) {
948
+ const compare = which === "compare";
949
+ const words = compare ? p.compare_at_text : p.price_text;
950
+ if (words != null) return String(words);
951
+ const cents = compare ? p.compare_at : p.price;
952
+ if (cents == null || cents === "") return "";
953
+ if (typeof cents !== "number") return String(cents);
954
+ const holder = el && el.closest ? el.closest("[data-gem-money-format]") : null;
955
+ return formatMoney(cents, holder ? holder.getAttribute("data-gem-money-format") : void 0);
956
+ }
957
+
958
+ // ../../packages/gem-framework/src/motion/orbit.js
959
+ var pad = (v) => String(v).padStart(2, "0");
960
+ function mountOrbit(root, ctx) {
961
+ const items = [];
962
+ const products = [];
963
+ const hits = [];
964
+ const imgs = [];
965
+ root.querySelectorAll("[data-gem-orbit-item]").forEach((item) => {
966
+ const product = readProduct(item);
967
+ if (!product) return;
968
+ items.push(item);
969
+ products.push(product);
970
+ hits.push(item.querySelector(".gem-orbit__hit") || item);
971
+ imgs.push(item.querySelector("img"));
972
+ });
973
+ const n = items.length;
974
+ if (!n) return false;
975
+ const part = (name) => root.querySelector(".gem-orbit__" + name);
976
+ const stage = part("stage") || root;
977
+ const track = part("track");
978
+ const style = ctx.renderer.style;
979
+ let s = ctx.settings;
980
+ const start = Math.min(n - 1, s.start);
981
+ let pos = start;
982
+ let target = start;
983
+ let dragFrom = 0;
984
+ let dragging = false;
985
+ let rendered = false;
986
+ let front = -1;
987
+ let ring = null;
988
+ let slotPx = 60;
989
+ const full = () => ctx.tier() === "full";
990
+ const layout = () => {
991
+ s = ctx.reread();
992
+ const w = stage.clientWidth;
993
+ ring = {
994
+ rx: w * s.rx,
995
+ ry: stage.clientHeight * s.tilt,
996
+ back: s.back,
997
+ fade: s.fade,
998
+ blur: full() ? s.blur : 0,
999
+ lean: s.lean,
1000
+ spread: (s.spread || 360 / n) * Math.PI / 180
1001
+ };
1002
+ slotPx = Math.max(60, ring.rx * ring.spread / 1.2);
1003
+ };
1004
+ const setTint = (tint) => {
1005
+ if (!s.tint) return;
1006
+ if (tint) root.style.setProperty("--gem-motion-tint", tint);
1007
+ else root.style.removeProperty("--gem-motion-tint");
1008
+ };
1009
+ const frontIndex = () => mod(Math.round(pos), n);
1010
+ const indexOf = (el) => items.findIndex((item) => item === el || item.contains(el));
1011
+ const updateFront = (initial) => {
1012
+ const i = frontIndex();
1013
+ if (i === front) return;
1014
+ front = i;
1015
+ const p = products[i];
1016
+ items.forEach((el, k) => {
1017
+ if (k === i) el.setAttribute("aria-current", "true");
1018
+ else el.removeAttribute("aria-current");
1019
+ hits[k].tabIndex = k === i ? 0 : -1;
1020
+ });
1021
+ if (!initial) {
1022
+ const still = ctx.tier() === "static";
1023
+ const price = priceText(p, root);
1024
+ swapText(part("name"), p.title || "", still);
1025
+ swapText(part("price"), price, still);
1026
+ const count = part("count");
1027
+ if (count) count.textContent = pad(i + 1) + " / " + pad(n);
1028
+ const bar = part("bar");
1029
+ if (bar) bar.style.transform = "scaleX(" + (i + 1) / n + ")";
1030
+ const live = part("live");
1031
+ const words = { index: i + 1, count: n, title: p.title || "", price };
1032
+ if (live) live.textContent = s.live.replace(/\{(\w+)\}/g, (m, k) => Object.hasOwn(words, k) ? String(words[k]) : m);
1033
+ setTint(p.tint);
1034
+ }
1035
+ ctx.emit("gem:motion:front", { index: i, product: p });
1036
+ };
1037
+ const frame = (dt, time) => {
1038
+ let moving = dragging;
1039
+ if (!dragging) {
1040
+ const d = target - pos;
1041
+ if (ctx.tier() === "static" || Math.abs(d) < 8e-4) pos = target;
1042
+ else {
1043
+ pos += d * follow(dt, s.ease);
1044
+ moving = true;
1045
+ }
1046
+ }
1047
+ const drift = s.drift && full();
1048
+ for (let i = 0; i < n; i++) {
1049
+ const p = ringPose(i, n, pos, ring);
1050
+ const bob = drift ? Math.sin(time / 1100 + i * 1.9) * 5 * (1 - p.depth) : 0;
1051
+ const el = items[i];
1052
+ style(el, "transform", `translate3d(${p.x.toFixed(1)}px, ${(p.y + bob).toFixed(1)}px, 0) scale(${p.scale.toFixed(3)}) rotate(${p.lean.toFixed(2)}deg)`);
1053
+ style(el, "opacity", p.opacity.toFixed(3));
1054
+ style(el, "z-index", String(p.z));
1055
+ const blur = Math.round(p.blur * 2) / 2;
1056
+ style(el, "filter", blur > 0 ? `blur(${blur}px)` : "none");
1057
+ style(el, "visibility", p.hidden ? "hidden" : "visible");
1058
+ }
1059
+ rendered = true;
1060
+ updateFront(false);
1061
+ return moving || drift;
1062
+ };
1063
+ const refresh = () => {
1064
+ layout();
1065
+ if (rendered) task.kick();
1066
+ };
1067
+ const task = frameTask(ctx, root, frame, { onDegrade: refresh });
1068
+ const goTo = (index) => {
1069
+ const from = Math.round(target);
1070
+ target = from + ringDelta(from, mod(index, n), n);
1071
+ task.kick();
1072
+ };
1073
+ hits.forEach((hit, i) => {
1074
+ ctx.on(hit, "click", (event) => {
1075
+ if (i === frontIndex()) return;
1076
+ event.preventDefault();
1077
+ goTo(i);
1078
+ });
1079
+ });
1080
+ root.querySelectorAll("[data-gem-orbit-step]").forEach((button) => {
1081
+ ctx.on(button, "click", () => goTo(target + (Number(button.getAttribute("data-gem-orbit-step")) || 0)));
1082
+ });
1083
+ if (track) {
1084
+ ctx.on(track, "click", (event) => {
1085
+ const r = track.getBoundingClientRect();
1086
+ if (r.width) goTo(Math.round((event.clientX - r.left) / r.width * (n - 1)));
1087
+ });
1088
+ }
1089
+ horizontalDrag(ctx, stage, {
1090
+ start: () => {
1091
+ dragFrom = pos;
1092
+ },
1093
+ move: (dx) => {
1094
+ dragging = true;
1095
+ root.setAttribute("data-gem-state", "dragging");
1096
+ pos = dragFrom - dx / slotPx;
1097
+ task.kick();
1098
+ },
1099
+ end: (vx, moved) => {
1100
+ if (!dragging) return;
1101
+ dragging = false;
1102
+ root.setAttribute("data-gem-state", "ready");
1103
+ target = moved < 4 ? Math.round(pos) : throwTarget(pos, -vx / slotPx, s.carry, Math.min(3, Math.ceil(n / 2)));
1104
+ task.kick();
1105
+ }
1106
+ });
1107
+ wheelSteps(ctx, root, (d) => goTo(target + d));
1108
+ arrowKeys(ctx, root, {
1109
+ accept: (e) => e.target === root || hits.includes(e.target),
1110
+ step: (d) => goTo(target + d),
1111
+ activate: (e) => e.target === root ? hits[frontIndex()].click() : false
1112
+ });
1113
+ layout();
1114
+ observeSize(ctx, root, refresh);
1115
+ root.gemQuickViewHost = {
1116
+ key: "orbit",
1117
+ products,
1118
+ indexOf,
1119
+ opens: (el) => indexOf(el) === frontIndex(),
1120
+ imageFor: (i) => imgs[i] || null,
1121
+ triggerFor: (i) => hits[i] || null,
1122
+ goTo,
1123
+ setOpen: (open) => root.setAttribute("data-gem-state", open ? "open" : "ready"),
1124
+ setImage: (i, image) => putImage(imgs[i], image),
1125
+ setTint
1126
+ };
1127
+ root.setAttribute("data-gem-quick-view-host", "orbit");
1128
+ root.setAttribute("data-gem-state", "ready");
1129
+ updateFront(true);
1130
+ if (s.drift && full()) task.kick();
1131
+ return () => {
1132
+ delete root.gemQuickViewHost;
1133
+ root.removeAttribute("data-gem-quick-view-host");
1134
+ root.removeAttribute("data-gem-state");
1135
+ };
1136
+ }
1137
+ var orbitEffect = defineEffect({
1138
+ key: "orbit",
1139
+ hook: "[data-gem-orbit]",
1140
+ load: "viewport",
1141
+ renderer: "dom",
1142
+ tier: "static",
1143
+ fallback: "markup",
1144
+ budget: { gzip: 6144 },
1145
+ settings: {
1146
+ rx: { type: "number", min: 0.1, max: 0.5, step: 0.01, default: 0.36, css: true, label: "Ring width (share of the stage width)" },
1147
+ tilt: { type: "number", min: 0, max: 0.5, step: 0.01, default: 0.3, css: true, label: "Tilt (rise of the back, share of the stage height)" },
1148
+ size: { type: "number", min: 0.2, max: 0.9, step: 0.01, default: 0.62, css: true, label: "Piece size (share of the stage height)" },
1149
+ back: { type: "number", min: 0.1, max: 1, step: 0.01, default: 0.42, css: true, label: "Scale at the back" },
1150
+ fade: { type: "number", min: 0, max: 1, step: 0.01, default: 0.35, css: true, label: "Opacity at the back" },
1151
+ blur: { type: "number", min: 0, max: 8, step: 0.5, default: 2.5, css: true, label: "Blur at the back (px)" },
1152
+ lean: { type: "number", min: 0, max: 20, step: 1, default: 5, css: true, label: "Lean at the sides (degrees)" },
1153
+ spread: { type: "number", min: 0, max: 180, step: 1, default: 0, css: true, label: "Degrees between pieces (0: round the full ring)" },
1154
+ start: { type: "integer", min: 0, default: 0, css: true, label: "Piece in front at first" },
1155
+ ease: { type: "number", min: 40, max: 600, step: 10, default: 140, label: "Settle time (ms)" },
1156
+ carry: { type: "number", min: 0, max: 800, step: 10, default: 240, label: "How far a throw carries (ms)" },
1157
+ tint: { type: "boolean", default: true, label: "Tint the band with the front piece's colour" },
1158
+ drift: { type: "boolean", default: false, label: "Idle float (full tier only)" },
1159
+ live: { type: "text", default: "{index} / {count}: {title}, {price}", label: "What a screen reader hears when the front piece changes" }
1160
+ },
1161
+ mount: mountOrbit
1162
+ });
1163
+
1164
+ // ../../packages/gem-framework/src/motion/rail.js
1165
+ var COMPACT_BELOW = 700;
1166
+ var ROD_TOP = 14;
1167
+ function createTurn(el) {
1168
+ if (!el) return null;
1169
+ const side = el.querySelector(".gem-turn__side");
1170
+ const front = el.querySelector(".gem-turn__front");
1171
+ const sheets = el.querySelectorAll(".gem-turn__sheet");
1172
+ const frames = Math.floor(Number(el.getAttribute("data-gem-turn-frames"))) || 0;
1173
+ const cols = Math.floor(Number(el.getAttribute("data-gem-turn-cols"))) || 0;
1174
+ const mode = sheets.length > 1 && frames > 1 && cols > 0 ? "sprite" : side && front ? "pair" : front ? "flip" : null;
1175
+ if (!mode) return null;
1176
+ const rows = Math.ceil(frames / Math.max(1, cols)) || 1;
1177
+ const place = (sheet, f) => setStyle(sheet, "transform", `translate(${(-(f % cols) * 100 / cols).toFixed(3)}%, ${(-Math.floor(f / cols) * 100 / rows).toFixed(3)}%)`);
1178
+ let last = -1;
1179
+ return {
1180
+ mode,
1181
+ /** what the quick view flies out of */
1182
+ image: mode === "sprite" ? el : front,
1183
+ set(t) {
1184
+ const q = Math.round(t * 1e3) / 1e3;
1185
+ if (q === last) return;
1186
+ last = q;
1187
+ if (mode === "pair") {
1188
+ setStyle(side, "transform", `perspective(900px) rotateY(${(-82 * q).toFixed(1)}deg)`);
1189
+ setStyle(side, "opacity", (1 - smoothstep(0.42, 0.78, q)).toFixed(3));
1190
+ setStyle(front, "opacity", smoothstep(0.45, 0.82, q).toFixed(3));
1191
+ setStyle(front, "transform", `scale(${lerp(0.9, 1, q).toFixed(3)})`);
1192
+ } else if (mode === "sprite") {
1193
+ const f = q * (frames - 1);
1194
+ const a = Math.floor(f);
1195
+ place(sheets[0], a);
1196
+ place(sheets[1], Math.min(frames - 1, a + 1));
1197
+ setStyle(sheets[1], "opacity", (f - a).toFixed(3));
1198
+ } else {
1199
+ setStyle(front, "transform", `perspective(900px) rotateY(${lerp(-74, 0, q).toFixed(1)}deg) scale(${lerp(0.86, 1, q).toFixed(3)})`);
1200
+ }
1201
+ },
1202
+ /** Another colour of the same piece (picked in the quick view). A sprite keeps its sheet. */
1203
+ setImages(image, sideImage) {
1204
+ if (mode === "sprite") return;
1205
+ putImage(front, image);
1206
+ putImage(side, sideImage);
1207
+ }
1208
+ };
1209
+ }
1210
+ function mountRail(root, ctx) {
1211
+ const items = [];
1212
+ const products = [];
1213
+ const hits = [];
1214
+ const bodies = [];
1215
+ const turns = [];
1216
+ root.querySelectorAll("[data-gem-rail-item]").forEach((item) => {
1217
+ const product = readProduct(item);
1218
+ if (!product) return;
1219
+ const hit = item.querySelector(".gem-rail__hit") || item;
1220
+ items.push(item);
1221
+ products.push(product);
1222
+ hits.push(hit);
1223
+ bodies.push(item.querySelector(".gem-rail__body") || hit);
1224
+ turns.push(createTurn(item.querySelector(".gem-turn")));
1225
+ });
1226
+ const n = items.length;
1227
+ if (!n) return false;
1228
+ const stage = root.querySelector(".gem-rail__stage") || root;
1229
+ const track = root.querySelector(".gem-rail__track") || stage;
1230
+ const caption = root.querySelector(".gem-rail__caption");
1231
+ const rod = root.querySelector(".gem-rail__rod path");
1232
+ const style = ctx.renderer.style;
1233
+ const turn = items.map(() => 0);
1234
+ const hangers = items.map(() => ({ x: 0, v: 0 }));
1235
+ let s = ctx.settings;
1236
+ let goal = items.map(() => 0);
1237
+ let focused = -1;
1238
+ let compact = false;
1239
+ let scrolled = false;
1240
+ let rendered = false;
1241
+ let w = 0;
1242
+ let h = 0;
1243
+ let lastX = 0;
1244
+ let lastT = 0;
1245
+ let dpx = 0;
1246
+ const still = () => ctx.tier() === "static";
1247
+ const rodPath = () => `M0 ${ROD_TOP} Q50 ${Math.round(ROD_TOP * 10 + s.sag * 2e3) / 10} 100 ${ROD_TOP}`;
1248
+ if (rod && rod.getAttribute("d") !== rodPath()) rod.setAttribute("d", rodPath());
1249
+ const layout = () => {
1250
+ s = ctx.reread();
1251
+ const was = compact;
1252
+ compact = root.clientWidth < COMPACT_BELOW;
1253
+ w = stage.clientWidth;
1254
+ h = stage.clientHeight;
1255
+ if (compact && !was) items.forEach((el) => style(el, "transform", ""));
1256
+ };
1257
+ const rodY = (x) => {
1258
+ const t = clamp(x / Math.max(1, w), 0, 1);
1259
+ return h * ROD_TOP / 100 + 4 * s.sag * h * t * (1 - t);
1260
+ };
1261
+ const slots = () => {
1262
+ const weights = turn.map((t) => 1 + s.grow * t * t);
1263
+ const unit = w * 0.92 / weights.reduce((a, b) => a + b, 0);
1264
+ let acc = w * 0.04;
1265
+ return weights.map((x) => {
1266
+ const c = acc + x * unit / 2;
1267
+ acc += x * unit;
1268
+ return { c, width: x * unit };
1269
+ });
1270
+ };
1271
+ const nearest = (x) => {
1272
+ let best = -1;
1273
+ let bestD = Infinity;
1274
+ slots().forEach(({ c, width }, i) => {
1275
+ const d = Math.abs(c - x);
1276
+ if (d < bestD && d < width * 1.2) {
1277
+ best = i;
1278
+ bestD = d;
1279
+ }
1280
+ });
1281
+ return best;
1282
+ };
1283
+ const setTint = (tint) => {
1284
+ if (!s.tint) return;
1285
+ if (tint) root.style.setProperty("--gem-motion-tint", tint);
1286
+ else root.style.removeProperty("--gem-motion-tint");
1287
+ };
1288
+ const setFocus = (i, initial) => {
1289
+ if (i === focused) return;
1290
+ focused = i;
1291
+ goal = goal.map((_, k) => k === i ? 1 : 0);
1292
+ const p = products[i];
1293
+ if (initial) {
1294
+ turn[i] = 1;
1295
+ if (turns[i]) turns[i].set(1);
1296
+ return;
1297
+ }
1298
+ if (caption) caption.textContent = p ? [p.title, priceText(p, root)].filter(Boolean).join(" \xB7 ") : "";
1299
+ if (p) {
1300
+ setTint(p.tint);
1301
+ ctx.emit("gem:motion:front", { index: i, product: p });
1302
+ }
1303
+ task.kick();
1304
+ };
1305
+ const frame = (dt) => {
1306
+ const k = follow(dt, s.ease);
1307
+ let moving = false;
1308
+ if (compact && scrolled) {
1309
+ const mid = track.scrollLeft + track.clientWidth / 2;
1310
+ let best = 0;
1311
+ items.forEach((el, i) => {
1312
+ if (Math.abs(el.offsetLeft + el.offsetWidth / 2 - mid) < Math.abs(items[best].offsetLeft + items[best].offsetWidth / 2 - mid)) best = i;
1313
+ });
1314
+ if (best !== focused) setFocus(best);
1315
+ }
1316
+ for (let i = 0; i < n; i++) {
1317
+ const d = goal[i] - turn[i];
1318
+ turn[i] = still() || Math.abs(d) < 2e-3 ? goal[i] : turn[i] + d * k;
1319
+ if (turn[i] !== goal[i]) moving = true;
1320
+ const hanger = hangers[i];
1321
+ if (still()) hanger.x = hanger.v = 0;
1322
+ else {
1323
+ spring(hanger, 0, dt, 0.02, 0.09);
1324
+ hanger.x = clamp(hanger.x, -9, 9);
1325
+ if (Math.abs(hanger.x) > 0.02 || Math.abs(hanger.v) > 0.02) moving = true;
1326
+ else hanger.x = hanger.v = 0;
1327
+ }
1328
+ if (turns[i]) turns[i].set(turn[i]);
1329
+ style(bodies[i], "transform", `rotate(${hanger.x.toFixed(2)}deg)`);
1330
+ }
1331
+ if (!compact) {
1332
+ slots().forEach(({ c }, i) => {
1333
+ style(items[i], "transform", `translate3d(${c.toFixed(1)}px, ${rodY(c).toFixed(1)}px, 0)`);
1334
+ style(items[i], "z-index", String(Math.round(10 + turn[i] * 10)));
1335
+ });
1336
+ }
1337
+ rendered = true;
1338
+ return moving;
1339
+ };
1340
+ const refresh = () => {
1341
+ layout();
1342
+ if (rendered) task.kick();
1343
+ };
1344
+ const task = frameTask(ctx, root, frame, { onDegrade: refresh });
1345
+ hits.forEach((hit, i) => ctx.on(hit, "focus", () => setFocus(i)));
1346
+ ctx.on(stage, "click", (event) => {
1347
+ if (compact || hits.some((hit) => hit.contains(event.target))) return;
1348
+ const i = nearest(event.clientX - stage.getBoundingClientRect().left);
1349
+ if (i >= 0) hits[i].click();
1350
+ });
1351
+ ctx.on(stage, "pointermove", (event) => {
1352
+ if (compact || event.pointerType !== "mouse") return;
1353
+ const x = event.clientX - stage.getBoundingClientRect().left;
1354
+ dpx = clamp((x - lastX) / Math.max(1, event.timeStamp - lastT), -3, 3);
1355
+ lastX = x;
1356
+ lastT = event.timeStamp;
1357
+ if (!still() && s.swing) {
1358
+ slots().forEach(({ c, width }, i) => {
1359
+ hangers[i].v += dpx * 0.35 * s.swing * clamp(1 - Math.abs(c - x) / (width * 2.5), 0, 1);
1360
+ });
1361
+ }
1362
+ setFocus(nearest(x));
1363
+ task.kick();
1364
+ });
1365
+ ctx.on(stage, "pointerleave", () => {
1366
+ if (!compact) setFocus(-1);
1367
+ });
1368
+ ctx.on(track, "keydown", (event) => {
1369
+ if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
1370
+ event.preventDefault();
1371
+ hits[clamp(Math.max(0, focused) + (event.key === "ArrowRight" ? 1 : -1), 0, n - 1)].focus();
1372
+ });
1373
+ ctx.on(
1374
+ track,
1375
+ "scroll",
1376
+ () => {
1377
+ if (!compact) return;
1378
+ scrolled = true;
1379
+ task.kick();
1380
+ },
1381
+ { passive: true }
1382
+ );
1383
+ layout();
1384
+ observeSize(ctx, root, refresh);
1385
+ if (compact) setFocus(0, true);
1386
+ const goTo = (index) => {
1387
+ const i = (index % n + n) % n;
1388
+ if (compact && typeof track.scrollTo === "function") {
1389
+ track.scrollTo({ left: items[i].offsetLeft - (track.clientWidth - items[i].offsetWidth) / 2, behavior: still() ? "auto" : "smooth" });
1390
+ }
1391
+ setFocus(i);
1392
+ };
1393
+ root.gemQuickViewHost = {
1394
+ key: "rail",
1395
+ products,
1396
+ indexOf: (el) => items.findIndex((item) => item === el || item.contains(el)),
1397
+ opens: () => true,
1398
+ imageFor: (i) => (turns[i] ? turns[i].image : items[i] && items[i].querySelector("img")) || null,
1399
+ triggerFor: (i) => hits[i] || null,
1400
+ goTo,
1401
+ setOpen: (open) => root.setAttribute("data-gem-state", open ? "open" : "ready"),
1402
+ setImage: (i, image, side) => turns[i] && turns[i].setImages(image, side),
1403
+ setTint
1404
+ };
1405
+ root.setAttribute("data-gem-quick-view-host", "rail");
1406
+ root.setAttribute("data-gem-state", "ready");
1407
+ return () => {
1408
+ delete root.gemQuickViewHost;
1409
+ root.removeAttribute("data-gem-quick-view-host");
1410
+ root.removeAttribute("data-gem-state");
1411
+ };
1412
+ }
1413
+ var railEffect = defineEffect({
1414
+ key: "rail",
1415
+ hook: "[data-gem-rail]",
1416
+ load: "viewport",
1417
+ renderer: "dom",
1418
+ tier: "static",
1419
+ fallback: "markup",
1420
+ budget: { gzip: 6144 },
1421
+ settings: {
1422
+ sag: { type: "number", min: 0, max: 0.2, step: 0.01, default: 0.07, css: true, label: "How far the rod dips (share of the stage height)" },
1423
+ height: { type: "number", min: 0.3, max: 0.85, step: 0.01, default: 0.7, css: true, label: "Garment height (share of the stage height)" },
1424
+ grow: { type: "number", min: 0, max: 3, step: 0.1, default: 1.5, label: "How much wider a turned piece gets" },
1425
+ swing: { type: "number", min: 0, max: 2, step: 0.1, default: 1, label: "Hanger swing (0: off)" },
1426
+ ease: { type: "number", min: 40, max: 600, step: 10, default: 120, label: "Turn time (ms)" },
1427
+ tint: { type: "boolean", default: true, label: "Tint the band with the turned piece's colour" }
1428
+ },
1429
+ mount: mountRail
1430
+ });
1431
+
1432
+ // ../../packages/gem-framework/src/motion/quick-view.js
1433
+ var EASE_OUT = "cubic-bezier(.2,.75,.15,1)";
1434
+ var LABELS = {
1435
+ add: "Add to bag",
1436
+ adding: "Adding\u2026",
1437
+ added: "Added",
1438
+ added_status: "added to bag",
1439
+ failed: "Could not add to bag",
1440
+ choose: "Choose an option",
1441
+ sold_out: "Sold out",
1442
+ details: "Full details",
1443
+ previous: "Previous product",
1444
+ next: "Next product",
1445
+ close: "Close",
1446
+ colour: "Colour",
1447
+ size: "Size"
1448
+ };
1449
+ var panelCount = 0;
1450
+ var pad2 = (v) => String(v).padStart(2, "0");
1451
+ var text = (v) => v == null ? "" : String(v);
1452
+ function cardHost(trigger) {
1453
+ const own = trigger.closest("[data-gem-product]");
1454
+ const group = trigger.closest("[data-gem-quick-view-group]");
1455
+ const products = [];
1456
+ const sources = [];
1457
+ let index = -1;
1458
+ for (const el of group ? group.querySelectorAll("[data-gem-product]") : own ? [own] : []) {
1459
+ const product = readProduct(el);
1460
+ if (!product) continue;
1461
+ if (el === own) index = products.length;
1462
+ products.push(product);
1463
+ sources.push(el);
1464
+ }
1465
+ return {
1466
+ index,
1467
+ host: {
1468
+ key: "card",
1469
+ products,
1470
+ imageFor: (i) => sources[i] ? sources[i].querySelector("img") : null,
1471
+ triggerFor: (i) => sources[i] ? sources[i].matches("[data-gem-quick-view-trigger]") ? sources[i] : sources[i].querySelector("[data-gem-quick-view-trigger]") : null,
1472
+ goTo: () => {
1473
+ },
1474
+ setOpen: () => {
1475
+ }
1476
+ }
1477
+ };
1478
+ }
1479
+ function sessionOf(ctx) {
1480
+ if (ctx.session.quickView) return ctx.session.quickView;
1481
+ const shared = { panels: /* @__PURE__ */ new Map(), pending: null };
1482
+ ctx.session.quickView = shared;
1483
+ ctx.on(ctx.window, "gem:cart:added", () => shared.pending && shared.pending(true));
1484
+ ctx.on(ctx.window, "gem:cart:add-failed", () => shared.pending && shared.pending(false));
1485
+ ctx.cleanup(() => {
1486
+ shared.panels.forEach((panel) => panel.destroy());
1487
+ shared.panels.clear();
1488
+ });
1489
+ return shared;
1490
+ }
1491
+ function createPanel(parent, ctx, shared) {
1492
+ const doc = parent.ownerDocument;
1493
+ const win = ctx.window;
1494
+ const still = () => ctx.tier() === "static";
1495
+ const make = (tag, className, attrs) => {
1496
+ const node = doc.createElement(tag);
1497
+ node.className = className;
1498
+ for (const [k, v] of Object.entries(attrs || {})) node.setAttribute(k, v);
1499
+ return node;
1500
+ };
1501
+ panelCount += 1;
1502
+ const titleId = "gem-quick-view-title-" + panelCount;
1503
+ const shell = make("div", "gem-popup-shell gem-quick-view__shell", {
1504
+ "data-gem-quick-view-panel": "",
1505
+ "data-gem-popup-desktop": "split-left",
1506
+ "data-gem-popup-mobile": "sheet",
1507
+ "data-gem-popup-media": "top",
1508
+ "data-gem-popup-align": "start"
1509
+ });
1510
+ const dialog = make("dialog", "gem-popup-shell__dialog gem-quick-view__dialog", { "aria-labelledby": titleId });
1511
+ const frame = make("div", "gem-popup-shell__frame gem-quick-view__frame");
1512
+ const surface = make("div", "gem-quick-view__surface", { "aria-hidden": "true" });
1513
+ const close = make("button", "gem-popup-shell__close gem-quick-view__close", { type: "button" });
1514
+ const media = make("div", "gem-popup-shell__media gem-quick-view__media");
1515
+ const img = make("img", "gem-quick-view__image", { alt: "" });
1516
+ const imgNext = make("img", "gem-quick-view__image gem-quick-view__image--next", { alt: "", "aria-hidden": "true" });
1517
+ const body = make("div", "gem-popup-shell__panel gem-quick-view__panel");
1518
+ const bar = make("div", "gem-quick-view__bar");
1519
+ const prev = make("button", "gem-quick-view__icon", { type: "button" });
1520
+ const next = make("button", "gem-quick-view__icon", { type: "button" });
1521
+ const counter = make("span", "gem-quick-view__count");
1522
+ const eyebrow = make("p", "gem-quick-view__eyebrow");
1523
+ const heading = make("h2", "gem-quick-view__title", { id: titleId });
1524
+ const price = make("p", "gem-quick-view__price");
1525
+ const compare = make("s", "gem-quick-view__compare");
1526
+ const swatches = make("div", "gem-quick-view__swatches", { role: "radiogroup" });
1527
+ const sizes = make("div", "gem-quick-view__sizes", { role: "radiogroup" });
1528
+ const add = make("button", "gem-button gem-button--primary gem-quick-view__add", { type: "button" });
1529
+ const status = make("p", "gem-quick-view__status", { role: "status" });
1530
+ const desc = make("p", "gem-quick-view__description");
1531
+ const details = make("a", "gem-quick-view__details");
1532
+ close.textContent = "\xD7";
1533
+ prev.textContent = "\u2039";
1534
+ next.textContent = "\u203A";
1535
+ media.append(img, imgNext);
1536
+ bar.append(prev, counter, next);
1537
+ body.append(bar, eyebrow, heading, price, compare, swatches, sizes, add, status, desc, details);
1538
+ frame.append(close, media, body);
1539
+ dialog.append(surface, frame);
1540
+ shell.append(dialog);
1541
+ parent.append(shell);
1542
+ const chosen = /* @__PURE__ */ new Map();
1543
+ let host = null;
1544
+ let list = [];
1545
+ let index = 0;
1546
+ let color = 0;
1547
+ let size = -1;
1548
+ let source = null;
1549
+ let returnTo = null;
1550
+ let priceAt = null;
1551
+ let labels = LABELS;
1552
+ let closing = false;
1553
+ let resetTimer = 0;
1554
+ const product = () => list[index];
1555
+ const animate = (el, frames, options) => typeof el.animate === "function" ? el.animate(frames, options) : null;
1556
+ const cancelAll = (el) => {
1557
+ if (el.getAnimations) el.getAnimations().forEach((a) => a.cancel());
1558
+ };
1559
+ const hideSource = (hide) => {
1560
+ if (!source) return;
1561
+ if (hide) source.setAttribute("data-gem-quick-view-source", "");
1562
+ else source.removeAttribute("data-gem-quick-view-source");
1563
+ };
1564
+ const moveSource = () => {
1565
+ const target = host.imageFor(index);
1566
+ if (target === source) return;
1567
+ hideSource(false);
1568
+ source = target;
1569
+ if (dialog.open) hideSource(true);
1570
+ };
1571
+ const setTint = (tint) => {
1572
+ if (host.setTint) host.setTint(tint);
1573
+ else if (tint) shell.style.setProperty("--gem-motion-tint", tint);
1574
+ else shell.style.removeProperty("--gem-motion-tint");
1575
+ };
1576
+ const showImage = (image, fade) => {
1577
+ img.alt = image.alt || text(product().title);
1578
+ if (!fade || still() || typeof imgNext.animate !== "function") return putImage(img, image);
1579
+ putImage(imgNext, image);
1580
+ const swap = () => {
1581
+ const settle = () => {
1582
+ putImage(img, image);
1583
+ imgNext.removeAttribute("src");
1584
+ imgNext.removeAttribute("srcset");
1585
+ };
1586
+ const run = imgNext.animate([{ opacity: 0, transform: "scale(.97)" }, { opacity: 1, transform: "none" }], { duration: 280, easing: EASE_OUT });
1587
+ if (run && run.finished) run.finished.then(settle, settle);
1588
+ else settle();
1589
+ };
1590
+ if (typeof imgNext.decode === "function") imgNext.decode().then(swap, swap);
1591
+ else swap();
1592
+ };
1593
+ const variant = () => {
1594
+ const p = product();
1595
+ const c = p.colors && p.colors[color];
1596
+ const z = p.sizes && p.sizes[size];
1597
+ if (!Array.isArray(p.variants)) return null;
1598
+ return p.variants.find((v) => (!c || (v.options || []).includes(c.name)) && (!z || (v.options || []).includes(z.name))) || null;
1599
+ };
1600
+ const variantId = () => {
1601
+ const p = product();
1602
+ const match = variant();
1603
+ if (match) return match.available === false ? null : match.id;
1604
+ const c = p.colors && p.colors[color];
1605
+ const z = p.sizes && p.sizes[size];
1606
+ if (z && z.variant_id != null) return z.variant_id;
1607
+ if (c && c.variant_ids && c.variant_ids.length) return c.variant_ids[0];
1608
+ return p.variant_id != null ? p.variant_id : null;
1609
+ };
1610
+ const priced = () => {
1611
+ const v = variant();
1612
+ return v && (v.price_text != null || v.price != null) ? v : product();
1613
+ };
1614
+ const showPrice = (fade) => {
1615
+ const words = priceText(priced(), priceAt);
1616
+ if (fade) swapText(price, words, still());
1617
+ else price.textContent = words;
1618
+ compare.textContent = priceText(priced(), priceAt, "compare");
1619
+ compare.hidden = !compare.textContent;
1620
+ };
1621
+ const updateAdd = () => {
1622
+ const p = product();
1623
+ const z = p.sizes && p.sizes[size];
1624
+ const soldOut = p.sold_out === true || Boolean(p.sizes && p.sizes.length) && size < 0;
1625
+ add.disabled = soldOut;
1626
+ add.textContent = soldOut ? labels.sold_out : [labels.add, z ? z.name : "", priceText(priced(), priceAt)].filter(Boolean).join(" \xB7 ");
1627
+ };
1628
+ const render = (fade) => {
1629
+ const p = product();
1630
+ const colors = Array.isArray(p.colors) ? p.colors : [];
1631
+ color = Math.min(chosen.get(p.id) || 0, Math.max(0, colors.length - 1));
1632
+ size = (p.sizes || []).findIndex((z) => z.available !== false);
1633
+ const c = colors[color];
1634
+ counter.textContent = pad2(index + 1) + " / " + pad2(list.length);
1635
+ bar.hidden = list.length < 2;
1636
+ const put = (el, value) => {
1637
+ if (fade) swapText(el, value, still());
1638
+ else el.textContent = value;
1639
+ };
1640
+ put(eyebrow, text(p.eyebrow));
1641
+ put(heading, text(p.title));
1642
+ showPrice(fade);
1643
+ eyebrow.hidden = !p.eyebrow;
1644
+ desc.textContent = text(p.description);
1645
+ desc.hidden = !p.description;
1646
+ details.textContent = labels.details;
1647
+ if (p.url) details.setAttribute("href", p.url);
1648
+ details.hidden = !p.url;
1649
+ showImage(c && c.image || p.image, fade);
1650
+ setTint(c && c.tint || p.tint);
1651
+ swatches.replaceChildren(
1652
+ ...colors.map((col, i) => {
1653
+ const b = make("button", "gem-quick-view__swatch", { type: "button", role: "radio", "aria-checked": String(i === color), "aria-label": col.name, title: col.name, "data-gem-quick-view-color": String(i) });
1654
+ if (col.hex) b.style.setProperty("--gem-quick-view-swatch", col.hex);
1655
+ return b;
1656
+ })
1657
+ );
1658
+ swatches.hidden = colors.length < 2;
1659
+ sizes.replaceChildren(
1660
+ ...(p.sizes || []).map((z, i) => {
1661
+ const b = make("button", "gem-quick-view__size", { type: "button", role: "radio", "aria-checked": String(i === size), "data-gem-quick-view-size": String(i) });
1662
+ b.textContent = z.name;
1663
+ if (z.available === false) {
1664
+ b.disabled = true;
1665
+ b.setAttribute("aria-label", z.name + ", " + labels.sold_out);
1666
+ }
1667
+ return b;
1668
+ })
1669
+ );
1670
+ sizes.hidden = !(p.sizes && p.sizes.length);
1671
+ status.textContent = "";
1672
+ updateAdd();
1673
+ moveSource();
1674
+ for (const k of [index - 1, index + 1]) if (list.length > 1) loadImage(doc, list[(k + list.length) % list.length].image);
1675
+ };
1676
+ const step = (delta) => {
1677
+ if (list.length < 2) return;
1678
+ index = (index + delta + list.length) % list.length;
1679
+ host.goTo(index);
1680
+ render(true);
1681
+ };
1682
+ const finish = (ok, before) => {
1683
+ shared.pending = null;
1684
+ add.disabled = false;
1685
+ add.textContent = ok ? labels.added : before;
1686
+ status.textContent = ok ? text(product().title) + ": " + labels.added_status : labels.failed;
1687
+ if (resetTimer) win.clearTimeout(resetTimer);
1688
+ resetTimer = ok ? win.setTimeout(updateAdd, 1600) : 0;
1689
+ };
1690
+ const addToCart = () => {
1691
+ const id = variantId();
1692
+ if (id == null || id === "") {
1693
+ status.textContent = labels.choose;
1694
+ return;
1695
+ }
1696
+ const before = add.textContent;
1697
+ add.disabled = true;
1698
+ add.textContent = labels.adding;
1699
+ add.dispatchEvent(new win.CustomEvent("gem:cart:add", { bubbles: true, composed: true, detail: { variantId: String(id), quantity: 1 } }));
1700
+ if (win.__gemCartAddBridge) shared.pending = (ok) => finish(ok, before);
1701
+ else finish(true, before);
1702
+ };
1703
+ const pick = (group, i) => {
1704
+ Array.from(group.children).forEach((el, k) => el.setAttribute("aria-checked", String(k === i)));
1705
+ if (group === sizes) size = i;
1706
+ else {
1707
+ const p = product();
1708
+ const c = p.colors && p.colors[i];
1709
+ if (!c) return;
1710
+ color = i;
1711
+ chosen.set(p.id, i);
1712
+ if (c.image) {
1713
+ showImage(c.image, true);
1714
+ if (host.setImage) host.setImage(index, c.image, c.side);
1715
+ }
1716
+ setTint(c.tint || p.tint);
1717
+ }
1718
+ showPrice(false);
1719
+ updateAdd();
1720
+ };
1721
+ const done = (reason) => {
1722
+ dialog.removeAttribute("data-gem-state");
1723
+ cancelAll(img);
1724
+ if (dialog.open) dialog.close();
1725
+ hideSource(false);
1726
+ host.setOpen(false);
1727
+ closing = false;
1728
+ const back = host.triggerFor(index) || returnTo;
1729
+ if (back && typeof back.focus === "function") back.focus();
1730
+ ctx.api.emit("gem:quick-view:close", { source: "gem-runtime", reason, productId: product().id });
1731
+ };
1732
+ const shut = (reason) => {
1733
+ if (!dialog.open || closing) return;
1734
+ closing = true;
1735
+ moveSource();
1736
+ const to = source && source.getBoundingClientRect ? source.getBoundingClientRect() : null;
1737
+ const flight = still() || !to ? null : flip(img, to, { back: true, duration: 440, easing: "cubic-bezier(.4,0,.2,1)" });
1738
+ if (!flight || !flight.finished) return done(reason);
1739
+ dialog.setAttribute("data-gem-state", "closing");
1740
+ for (const el of [body, surface, close]) animate(el, [{ opacity: 1 }, { opacity: 0 }], { duration: 220, fill: "forwards" });
1741
+ host.setOpen(false);
1742
+ const end = () => {
1743
+ [body, surface, close].forEach(cancelAll);
1744
+ done(reason);
1745
+ };
1746
+ flight.finished.then(end, end);
1747
+ };
1748
+ ctx.on(close, "click", () => shut("close-control"));
1749
+ ctx.on(prev, "click", () => step(-1));
1750
+ ctx.on(next, "click", () => step(1));
1751
+ ctx.on(add, "click", addToCart);
1752
+ for (const [group, attr] of [[swatches, "data-gem-quick-view-color"], [sizes, "data-gem-quick-view-size"]]) {
1753
+ ctx.on(group, "click", (event) => {
1754
+ const b = event.target.closest && event.target.closest(`[${attr}]`);
1755
+ if (b && !b.disabled) pick(group, Number(b.getAttribute(attr)));
1756
+ });
1757
+ }
1758
+ ctx.on(dialog, "cancel", (event) => {
1759
+ event.preventDefault();
1760
+ shut("escape");
1761
+ });
1762
+ ctx.on(dialog, "click", (event) => {
1763
+ if (event.target === dialog) shut("backdrop");
1764
+ });
1765
+ ctx.on(dialog, "keydown", (event) => {
1766
+ if (event.target.closest && event.target.closest(".gem-quick-view__swatches, .gem-quick-view__sizes")) return;
1767
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") step(event.key === "ArrowRight" ? 1 : -1);
1768
+ });
1769
+ return {
1770
+ shell,
1771
+ open(nextHost, at, trigger, nextLabels) {
1772
+ if (dialog.open) return;
1773
+ cancelAll(img);
1774
+ host = nextHost;
1775
+ list = host.products;
1776
+ index = at;
1777
+ labels = nextLabels;
1778
+ returnTo = trigger;
1779
+ priceAt = trigger;
1780
+ source = null;
1781
+ close.setAttribute("aria-label", labels.close);
1782
+ prev.setAttribute("aria-label", labels.previous);
1783
+ next.setAttribute("aria-label", labels.next);
1784
+ swatches.setAttribute("aria-label", labels.colour);
1785
+ sizes.setAttribute("aria-label", labels.size);
1786
+ render(false);
1787
+ source = host.imageFor(index);
1788
+ const from = source && source.getBoundingClientRect ? source.getBoundingClientRect() : null;
1789
+ dialog.showModal();
1790
+ dialog.setAttribute("data-gem-state", "open");
1791
+ host.setOpen(true);
1792
+ hideSource(true);
1793
+ ctx.api.emit("gem:quick-view:open", { source: "gem-runtime", productId: product().id, productUrl: product().url || null, index, count: list.length, host: host.key });
1794
+ if (still()) return;
1795
+ flip(img, from, { duration: 560, easing: EASE_OUT });
1796
+ animate(surface, [{ opacity: 0, transform: "scale(.97)" }, { opacity: 1, transform: "none" }], { duration: 360, easing: EASE_OUT });
1797
+ Array.from(body.children).forEach(
1798
+ (el, i) => animate(el, [{ opacity: 0, transform: "translateY(12px)" }, { opacity: 1, transform: "none" }], { duration: 380, delay: 120 + i * 40, easing: EASE_OUT, fill: "backwards" })
1799
+ );
1800
+ },
1801
+ /** Unmount: close at once (no flight) and leave the root as the server drew it. */
1802
+ destroy() {
1803
+ if (dialog.open) done("unmount");
1804
+ shell.remove();
1805
+ }
1806
+ };
1807
+ }
1808
+ function mountQuickView(trigger, ctx) {
1809
+ const shared = sessionOf(ctx);
1810
+ ctx.on(trigger, "click", (event) => {
1811
+ if (event.defaultPrevented) return;
1812
+ const hostEl = trigger.closest("[data-gem-quick-view-host]");
1813
+ const host = hostEl && hostEl.gemQuickViewHost;
1814
+ if (host && !host.opens(trigger)) return;
1815
+ const card = host ? null : cardHost(trigger);
1816
+ const index = host ? host.indexOf(trigger) : card.index;
1817
+ if (index < 0 || !(host || card.host).products[index]) return;
1818
+ event.preventDefault();
1819
+ const parent = host && hostEl || trigger.closest("[data-gem-quick-view-group]") || trigger.closest(".gem-section, [data-gem-section-id]") || trigger.parentElement;
1820
+ let panel = shared.panels.get(parent);
1821
+ if (!panel || !panel.shell.isConnected) shared.panels.set(parent, panel = createPanel(parent, ctx, shared));
1822
+ const own = ctx.reread().labels;
1823
+ panel.open(host || card.host, index, trigger, { ...LABELS, ...own && typeof own === "object" ? own : {} });
1824
+ });
1825
+ }
1826
+ var quickViewEffect = defineEffect({
1827
+ key: "quick-view",
1828
+ hook: "[data-gem-quick-view-trigger]",
1829
+ load: "interaction",
1830
+ renderer: "dom",
1831
+ tier: "static",
1832
+ fallback: "markup",
1833
+ budget: { gzip: 6144 },
1834
+ settings: {
1835
+ labels: {
1836
+ type: "json",
1837
+ default: {},
1838
+ inherit: true,
1839
+ label: "The panel's words, JSON on the trigger or an ancestor: add, adding, added, added_status, failed, choose, sold_out, details, previous, next, close, colour, size"
1840
+ }
1841
+ },
1842
+ mount: mountQuickView
1843
+ });
1844
+
1845
+ // ../../packages/gem-framework/src/motion-engine.ts
1846
+ var GEM_ORBIT_BASE_CSS_LINES = [
1847
+ ".gem-orbit { display: block; position: relative; background-color: var(--gem-motion-tint, transparent); }",
1848
+ ".gem-orbit:focus-visible { outline: var(--gem-focus-ring); outline-offset: calc(-1 * var(--gem-focus-offset)); }",
1849
+ ".gem-orbit__stage { position: relative; height: var(--gem-orbit-stage-height, clamp(340px, 58vh, 620px)); overflow: hidden; container-type: size; }",
1850
+ ".gem-orbit__ring { list-style: none; margin: 0; padding: 0; position: absolute; left: 50%; top: 66%; width: 0; height: 0; }",
1851
+ ".gem-orbit__item {",
1852
+ " --gem-orbit-k: calc(mod(var(--gem-orbit-i, 0) - var(--gem-orbit-start, 0) + var(--gem-orbit-n, 1) / 2, var(--gem-orbit-n, 1)) - var(--gem-orbit-n, 1) / 2);",
1853
+ " --gem-orbit-a: calc(var(--gem-orbit-k) * var(--gem-orbit-spread, calc(360 / var(--gem-orbit-n, 1))) * 1deg);",
1854
+ " --gem-orbit-d: calc((1 - cos(var(--gem-orbit-a))) / 2);",
1855
+ " position: absolute; left: calc(var(--gem-orbit-size, 0.62) * -50cqh); top: calc(var(--gem-orbit-size, 0.62) * -50cqh);",
1856
+ " width: calc(var(--gem-orbit-size, 0.62) * 100cqh); height: calc(var(--gem-orbit-size, 0.62) * 100cqh);",
1857
+ " transform: translate3d(calc(var(--gem-orbit-rx, 0.36) * 100cqw * sin(var(--gem-orbit-a))), calc(var(--gem-orbit-tilt, 0.3) * -100cqh * var(--gem-orbit-d)), 0)",
1858
+ " scale(calc(1 + (var(--gem-orbit-back, 0.42) - 1) * var(--gem-orbit-d))) rotate(calc(sin(var(--gem-orbit-a)) * var(--gem-orbit-lean, 5) * 1deg));",
1859
+ " opacity: calc(1 + (var(--gem-orbit-fade, 0.35) - 1) * var(--gem-orbit-d));",
1860
+ " z-index: calc((1 - var(--gem-orbit-d)) * 100);",
1861
+ " filter: blur(calc(var(--gem-orbit-blur, 2.5) * var(--gem-orbit-d) * 1px));",
1862
+ "}",
1863
+ ".gem-orbit__item::after {",
1864
+ " content: ''; position: absolute; left: 18%; right: 18%; bottom: -2%; height: 7%; z-index: -1; border-radius: var(--gem-radius-pill);",
1865
+ " background: radial-gradient(closest-side, color-mix(in srgb, var(--gem-color-scrim, #000) 18%, transparent), transparent);",
1866
+ "}",
1867
+ // A hit is a <button> or an <a href> to the product (still a link on a page without the engine).
1868
+ ".gem-orbit__hit { display: block; width: 100%; height: 100%; padding: 0; border: 0; color: inherit; background: transparent; text-decoration: none; cursor: pointer; }",
1869
+ ".gem-orbit__hit:focus-visible { outline: var(--gem-focus-ring); outline-offset: var(--gem-focus-offset); border-radius: var(--gem-radius-md); }",
1870
+ ".gem-orbit__img { display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; }",
1871
+ ".gem-orbit__caption { display: grid; justify-items: center; gap: var(--gem-space-2xs); margin: calc(-1 * var(--gem-space-xs)) 0 0; text-align: center; }",
1872
+ ".gem-orbit__name { margin: 0; font-family: var(--gem-font-heading); font-size: var(--gem-h4); font-weight: 600; line-height: 1.2; }",
1873
+ ".gem-orbit__price { margin: 0; color: var(--gem-color-muted); font-variant-numeric: tabular-nums; }",
1874
+ ".gem-orbit__dock { display: flex; align-items: center; justify-content: center; gap: var(--gem-space-sm); padding: var(--gem-space-md) 0 var(--gem-space-xs); font-size: var(--gem-text-sm); font-variant-numeric: tabular-nums; }",
1875
+ ".gem-orbit__arrow {",
1876
+ " display: inline-grid; place-items: center; width: var(--gem-tap-min); height: var(--gem-tap-min); padding: 0; cursor: pointer;",
1877
+ " border: var(--gem-stroke, 1px) solid currentColor; border-radius: var(--gem-radius-pill); color: inherit; background: transparent; font: inherit; line-height: 1;",
1878
+ "}",
1879
+ ".gem-orbit__arrow:hover { color: var(--gem-color-background); background: var(--gem-color-text); border-color: var(--gem-color-text); }",
1880
+ ".gem-orbit__arrow:focus-visible { outline: var(--gem-focus-ring); outline-offset: var(--gem-focus-offset); }",
1881
+ ".gem-orbit__count { min-width: 7ch; text-align: center; font-variant-numeric: tabular-nums; }",
1882
+ ".gem-orbit__live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }",
1883
+ ".gem-orbit__track { position: relative; width: min(180px, 30vw); height: var(--gem-stroke-thick, 2px); background: color-mix(in srgb, currentColor 18%, transparent); }",
1884
+ ".gem-orbit__track::before { content: ''; position: absolute; inset: calc(-1 * var(--gem-space-sm)) 0; }",
1885
+ ".gem-orbit__bar { position: absolute; inset: 0; background: currentColor; transform-origin: left; transform: scaleX(calc((var(--gem-orbit-start, 0) + 1) / var(--gem-orbit-n, 1))); }",
1886
+ // The light and static tiers draw no blur (a phone, reduced motion): the first paint already matches what they draw.
1887
+ "@media (pointer: coarse), (prefers-reduced-motion: reduce) { .gem-orbit__item { filter: none; } }"
1888
+ ];
1889
+ var GEM_RAIL_BASE_CSS_LINES = [
1890
+ ".gem-rail { display: block; position: relative; container-type: inline-size; background-color: var(--gem-motion-tint, transparent); }",
1891
+ ".gem-rail__stage { position: relative; height: var(--gem-rail-stage-height, clamp(380px, 62vh, 640px)); overflow: hidden; container-type: size; }",
1892
+ // The rod is drawn in a 0..100 viewBox with preserveAspectRatio="none": it fits any stage without being redrawn.
1893
+ ".gem-rail__rod { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }",
1894
+ ".gem-rail__rod path { fill: none; stroke: var(--gem-rail-rod, currentColor); stroke-width: 5; stroke-linecap: round; vector-effect: non-scaling-stroke; opacity: 0.85; }",
1895
+ ".gem-rail__track { list-style: none; margin: 0; padding: 0; position: absolute; inset: 0; }",
1896
+ // At rest slot i of n is centred at 4% + 92% · (i + ½) / n of the width and hangs from the rod's curve there.
1897
+ ".gem-rail__item {",
1898
+ " --gem-rail-t: calc(0.04 + 0.92 * (var(--gem-rail-i, 0) + 0.5) / var(--gem-rail-n, 1));",
1899
+ " position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 10;",
1900
+ " transform: translate3d(calc(var(--gem-rail-t) * 100cqw), calc((0.14 + 4 * var(--gem-rail-sag, 0.07) * var(--gem-rail-t) * (1 - var(--gem-rail-t))) * 100cqh), 0);",
1901
+ "}",
1902
+ ".gem-rail__hit {",
1903
+ " position: absolute; left: 0; top: -6px; width: calc(var(--gem-rail-height, 0.7) * 78cqh); height: calc(var(--gem-rail-height, 0.7) * 100cqh + 34px);",
1904
+ " padding: 0; border: 0; color: inherit; background: transparent; text-decoration: none; cursor: pointer; transform: translateX(-50%);",
1905
+ "}",
1906
+ ".gem-rail__hit:focus-visible { outline: var(--gem-focus-ring); outline-offset: var(--gem-focus-offset); border-radius: var(--gem-radius-md); }",
1907
+ ".gem-rail__body { position: absolute; inset: 0; display: block; transform-origin: 50% 0; }",
1908
+ ".gem-rail__hook {",
1909
+ " position: absolute; left: calc(50% - 8px); top: 0; width: 16px; height: 22px; opacity: 0.8;",
1910
+ " border: var(--gem-stroke-thick, 2px) solid var(--gem-rail-rod, currentColor); border-bottom: 0; border-radius: var(--gem-radius-md) var(--gem-radius-md) 0 0;",
1911
+ "}",
1912
+ ".gem-rail__hook::after { content: ''; position: absolute; left: calc(50% - 23px); top: 18px; width: 46px; height: var(--gem-stroke-thick, 2px); background: var(--gem-rail-rod, currentColor); border-radius: var(--gem-radius-sm); }",
1913
+ ".gem-rail__tag {",
1914
+ " position: absolute; left: calc(50% + 14px); top: 18px; z-index: 2; padding: var(--gem-space-2xs) var(--gem-space-xs); transform: rotate(4deg);",
1915
+ " font-family: var(--gem-font-mono); font-size: var(--gem-text-xs); font-weight: 600; line-height: 1; font-variant-numeric: tabular-nums;",
1916
+ " color: var(--gem-color-text); background: var(--gem-color-background); border-radius: var(--gem-radius-sm); box-shadow: var(--gem-shadow-sm);",
1917
+ "}",
1918
+ ".gem-rail .gem-turn { position: absolute; inset: 26px 0 0; width: auto; height: auto; }",
1919
+ ".gem-rail__caption { margin: var(--gem-space-xs) 0 0; min-height: 1.5em; text-align: center; font-variant-numeric: tabular-nums; }",
1920
+ // Under 700 px (the effect's breakpoint, on the same width): a swipe row with snap points; the centred piece turns.
1921
+ "@container (max-width: 699px) {",
1922
+ " .gem-rail__stage { height: auto; overflow: visible; container-type: normal; }",
1923
+ " .gem-rail__rod { display: none; }",
1924
+ " .gem-rail__track { position: static; display: flex; gap: var(--gem-space-2xs); overflow-x: auto; scroll-snap-type: x mandatory; padding: 0 20cqw var(--gem-space-xs); scrollbar-width: none; }",
1925
+ " .gem-rail__track::-webkit-scrollbar { display: none; }",
1926
+ " .gem-rail__item { position: relative; flex: 0 0 60cqw; width: auto; height: calc(72cqw + 34px); transform: none; scroll-snap-align: center; border-top: var(--gem-stroke-thick, 4px) solid var(--gem-rail-rod, currentColor); }",
1927
+ " .gem-rail__hit { left: 50%; width: 100%; height: 100%; }",
1928
+ // The row rests with its first piece centred (the track's padding) and facing the shopper; the plugin settles there.
1929
+ " .gem-rail__item:first-child .gem-turn__side { opacity: 0; transform: perspective(900px) rotateY(-82deg); }",
1930
+ " .gem-rail__item:first-child .gem-turn:has(> .gem-turn__side) > .gem-turn__front { opacity: 1; transform: scale(1); }",
1931
+ " .gem-rail__item:first-child .gem-turn:not(:has(> .gem-turn__side)) > .gem-turn__front { transform: perspective(900px) rotateY(0deg) scale(1); }",
1932
+ " .gem-rail__item:first-child .gem-turn__sheet { transform: translate(calc(-100% / var(--gem-turn-cols, 1) * mod(var(--gem-turn-frames, 1) - 1, var(--gem-turn-cols, 1))), calc(-100% / var(--gem-turn-rows, 1) * round(down, (var(--gem-turn-frames, 1) - 1) / var(--gem-turn-cols, 1)))); }",
1933
+ "}",
1934
+ // The turn rests on its side view: pair (side + front photo), flip (front only), sprite (two copies of a frame sheet).
1935
+ ".gem-turn { position: relative; display: block; width: 100%; height: 100%; }",
1936
+ ".gem-turn[data-gem-turn-frames] { overflow: hidden; }",
1937
+ ".gem-turn__side, .gem-turn__front { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: contain; object-position: 50% 0; pointer-events: none; }",
1938
+ ".gem-turn__side { transform: perspective(900px) rotateY(0deg); backface-visibility: hidden; }",
1939
+ ".gem-turn:has(> .gem-turn__side) > .gem-turn__front { opacity: 0; transform: scale(0.9); }",
1940
+ ".gem-turn:not(:has(> .gem-turn__side)) > .gem-turn__front { transform: perspective(900px) rotateY(-74deg) scale(0.86); }",
1941
+ ".gem-turn__sheet { position: absolute; left: 0; top: 0; display: block; max-width: none; pointer-events: none; width: calc(var(--gem-turn-cols, 1) * 100%); height: calc(var(--gem-turn-rows, 1) * 100%); }",
1942
+ ".gem-turn__sheet + .gem-turn__sheet { opacity: 0; }"
1943
+ ];
1944
+ var GEM_ORBIT_BASE_CSS = GEM_ORBIT_BASE_CSS_LINES.join("\n") + "\n";
1945
+ var GEM_RAIL_BASE_CSS = GEM_RAIL_BASE_CSS_LINES.join("\n") + "\n";
1946
+ var GEM_ORBIT_RUNTIME_CSS = [
1947
+ ".gem-orbit { --gem-orbit-css: 1; outline: none; user-select: none; -webkit-user-select: none; transition: background-color var(--gem-motion-slow, 0.6s) var(--gem-ease-soft, ease); }",
1948
+ ".gem-orbit[data-gem-state] .gem-orbit__stage { touch-action: pan-y; cursor: grab; }",
1949
+ ".gem-orbit[data-gem-state='dragging'] .gem-orbit__stage { cursor: grabbing; }",
1950
+ ".gem-orbit[data-gem-state] .gem-orbit__item { will-change: transform; }",
1951
+ ".gem-orbit[data-gem-state] .gem-orbit__hit { transition: opacity var(--gem-motion-base, 0.35s) var(--gem-ease-soft, ease), transform var(--gem-motion-base, 0.35s) var(--gem-ease-soft, ease); }",
1952
+ ".gem-orbit[data-gem-state] .gem-orbit__bar { transition: transform var(--gem-motion-slow, 0.4s) var(--gem-ease-soft, ease); }",
1953
+ ".gem-orbit[data-gem-state] .gem-orbit__arrow { transition: color var(--gem-motion-fast, 0.2s) ease, background-color var(--gem-motion-fast, 0.2s) ease; }",
1954
+ ".gem-orbit[data-gem-state='open'] .gem-orbit__item:not([aria-current='true']) .gem-orbit__hit { opacity: 0; transform: scale(0.92); }",
1955
+ "@media (prefers-reduced-motion: reduce) { .gem-orbit, .gem-orbit[data-gem-state] .gem-orbit__hit, .gem-orbit[data-gem-state] .gem-orbit__bar, .gem-orbit[data-gem-state] .gem-orbit__arrow { transition: none; } }"
1956
+ ].join("\n") + "\n";
1957
+ var GEM_RAIL_RUNTIME_CSS = [
1958
+ ".gem-rail { --gem-rail-css: 1; user-select: none; -webkit-user-select: none; transition: background-color var(--gem-motion-slow, 0.6s) var(--gem-ease-soft, ease); }",
1959
+ ".gem-rail[data-gem-state] .gem-rail__item { will-change: transform; }",
1960
+ // Unturned neighbours' hit boxes overlap on the rail: once bound, a click on the stage goes to the piece whose slot it is in
1961
+ // (motion/rail.js), so the hits take no pointer events; keyboard focus and Enter stay on them. The phone row keeps its own.
1962
+ ".gem-rail[data-gem-state] .gem-rail__stage { cursor: pointer; }",
1963
+ ".gem-rail[data-gem-state] .gem-rail__hit { pointer-events: none; transition: opacity var(--gem-motion-base, 0.35s) var(--gem-ease-soft, ease); }",
1964
+ "@container (max-width: 699px) { .gem-rail[data-gem-state] .gem-rail__hit { pointer-events: auto; } }",
1965
+ ".gem-rail[data-gem-state='open'] .gem-rail__hit { opacity: 0.15; }",
1966
+ "@media (prefers-reduced-motion: reduce) { .gem-rail, .gem-rail[data-gem-state] .gem-rail__hit { transition: none; } }"
1967
+ ].join("\n") + "\n";
1968
+ var GEM_QUICK_VIEW_CSS = [
1969
+ "[data-gem-quick-view-trigger] { --gem-quick-view-css: 1; }",
1970
+ ".gem-quick-view__shell { display: contents; }",
1971
+ ".gem-quick-view__shell .gem-quick-view__dialog { overflow: visible; isolation: isolate; color: var(--gem-color-text); background: transparent; box-shadow: none; user-select: text; -webkit-user-select: text; }",
1972
+ ".gem-quick-view__surface {",
1973
+ " position: absolute; inset: 0; z-index: -1; border-radius: inherit; box-shadow: var(--gem-shadow-md);",
1974
+ " background: color-mix(in srgb, var(--gem-motion-tint, var(--gem-color-background)) 45%, var(--gem-color-background));",
1975
+ " transition: background-color var(--gem-motion-slow, 0.5s) var(--gem-ease-soft, ease);",
1976
+ "}",
1977
+ ".gem-quick-view__shell .gem-quick-view__media { overflow: visible; background: transparent; min-height: 0; }",
1978
+ ".gem-quick-view__image { position: absolute; inset: 6% 4%; width: 92%; height: 88%; object-fit: contain; transform-origin: 50% 50%; will-change: transform; }",
1979
+ ".gem-quick-view__image--next { opacity: 0; pointer-events: none; }",
1980
+ ".gem-quick-view__image--next:not([src]) { display: none; }",
1981
+ ".gem-quick-view__shell .gem-quick-view__panel { justify-content: flex-start; gap: var(--gem-space-sm); }",
1982
+ ".gem-quick-view__shell [hidden] { display: none; }",
1983
+ ".gem-quick-view__bar { display: flex; align-items: center; gap: var(--gem-space-xs); font-family: var(--gem-font-mono); font-size: var(--gem-text-xs); font-variant-numeric: tabular-nums; }",
1984
+ ".gem-quick-view__icon {",
1985
+ " display: inline-grid; place-items: center; width: var(--gem-tap-min); height: var(--gem-tap-min); padding: 0; cursor: pointer;",
1986
+ " border: var(--gem-stroke, 1px) solid color-mix(in srgb, currentColor 25%, transparent); border-radius: var(--gem-radius-pill);",
1987
+ " color: inherit; background: transparent; font: inherit; font-size: var(--gem-h5); line-height: 1;",
1988
+ "}",
1989
+ ".gem-quick-view__icon:hover { border-color: currentColor; }",
1990
+ ".gem-quick-view__icon:focus-visible, .gem-quick-view__swatch:focus-visible, .gem-quick-view__size:focus-visible, .gem-quick-view__details:focus-visible { outline: var(--gem-focus-ring); outline-offset: var(--gem-focus-offset); }",
1991
+ ".gem-quick-view__eyebrow { margin: var(--gem-space-md) 0 0; color: var(--gem-color-muted); font-family: var(--gem-font-mono); font-size: var(--gem-text-xs); letter-spacing: 0.12em; line-height: 1.3; text-transform: uppercase; }",
1992
+ ".gem-quick-view__title { margin: 0; font-family: var(--gem-font-heading); font-size: var(--gem-h2); line-height: 1.05; }",
1993
+ ".gem-quick-view__price { margin: 0; font-size: var(--gem-h5); font-variant-numeric: tabular-nums; }",
1994
+ ".gem-quick-view__compare { margin: calc(-1 * var(--gem-space-xs)) 0 0; color: var(--gem-color-muted); font-size: var(--gem-text-sm); }",
1995
+ ".gem-quick-view__swatches, .gem-quick-view__sizes { display: flex; flex-wrap: wrap; gap: var(--gem-space-xs); }",
1996
+ ".gem-quick-view__swatch { position: relative; width: var(--gem-tap-min); height: var(--gem-tap-min); padding: 0; border: 0; color: inherit; background: transparent; cursor: pointer; }",
1997
+ ".gem-quick-view__swatch::before {",
1998
+ " content: ''; position: absolute; inset: 8px; border-radius: var(--gem-radius-pill); background: var(--gem-quick-view-swatch, var(--gem-color-surface));",
1999
+ " box-shadow: inset 0 0 0 var(--gem-stroke, 1px) color-mix(in srgb, var(--gem-color-text) 15%, transparent);",
2000
+ "}",
2001
+ ".gem-quick-view__swatch[aria-checked='true']::after { content: ''; position: absolute; inset: 3px; border: var(--gem-stroke-thick, 2px) solid currentColor; border-radius: var(--gem-radius-pill); }",
2002
+ ".gem-quick-view__size {",
2003
+ " display: inline-grid; place-items: center; min-width: var(--gem-tap-min); height: var(--gem-tap-min); padding: 0 var(--gem-space-sm); cursor: pointer;",
2004
+ " border: var(--gem-stroke, 1px) solid color-mix(in srgb, currentColor 22%, transparent); border-radius: var(--gem-radius-pill);",
2005
+ " color: inherit; background: transparent; font: inherit; font-size: var(--gem-text-sm);",
2006
+ "}",
2007
+ ".gem-quick-view__size[aria-checked='true'] { color: var(--gem-color-background); background: var(--gem-color-text); border-color: var(--gem-color-text); }",
2008
+ ".gem-quick-view__size:disabled { opacity: 0.4; text-decoration: line-through; cursor: not-allowed; }",
2009
+ ".gem-quick-view__add { width: 100%; margin: var(--gem-space-xs) 0 0; font-variant-numeric: tabular-nums; }",
2010
+ ".gem-quick-view__status { margin: 0; min-height: 1.2em; color: var(--gem-color-muted); font-size: var(--gem-text-sm); }",
2011
+ ".gem-quick-view__description { margin: 0; max-width: 46ch; font-size: var(--gem-text-sm); line-height: 1.55; }",
2012
+ ".gem-quick-view__details { color: inherit; font-size: var(--gem-text-sm); text-underline-offset: 3px; }",
2013
+ // The photo the panel flew out of stays hidden in the page while the panel shows it.
2014
+ "[data-gem-quick-view-source] { visibility: hidden; }",
2015
+ "@keyframes gem-quick-view-fade { from { opacity: 0; } to { opacity: 1; } }",
2016
+ ".gem-quick-view__dialog[data-gem-state='open']::backdrop { animation: gem-quick-view-fade var(--gem-motion-base, 0.35s) ease both; }",
2017
+ ".gem-quick-view__dialog[data-gem-state='closing']::backdrop { animation: gem-quick-view-fade var(--gem-motion-base, 0.35s) ease reverse both; }",
2018
+ "@media (max-width: 749px) { .gem-quick-view__shell .gem-quick-view__media { height: 34dvh; } }",
2019
+ "@media (prefers-reduced-motion: reduce) { .gem-quick-view__dialog::backdrop { animation: none; } .gem-quick-view__surface { transition: none; } }"
2020
+ ].join("\n") + "\n";
2021
+ var GEM_MOTION_CLASSES = [
2022
+ ...["", "__stage", "__ring", "__item", "__hit", "__img", "__caption", "__name", "__price", "__dock", "__arrow", "__count", "__track", "__bar", "__live"].map((part) => `gem-orbit${part}`),
2023
+ ...["", "__stage", "__rod", "__track", "__item", "__hit", "__body", "__hook", "__tag", "__caption"].map((part) => `gem-rail${part}`),
2024
+ ...["", "__side", "__front", "__sheet"].map((part) => `gem-turn${part}`),
2025
+ ...[
2026
+ "shell",
2027
+ "dialog",
2028
+ "frame",
2029
+ "surface",
2030
+ "close",
2031
+ "media",
2032
+ "image",
2033
+ "image--next",
2034
+ "panel",
2035
+ "bar",
2036
+ "icon",
2037
+ "count",
2038
+ "eyebrow",
2039
+ "title",
2040
+ "price",
2041
+ "compare",
2042
+ "swatches",
2043
+ "swatch",
2044
+ "sizes",
2045
+ "size",
2046
+ "add",
2047
+ "status",
2048
+ "description",
2049
+ "details"
2050
+ ].map((part) => `gem-quick-view__${part}`)
2051
+ ];
2052
+ var gemMotionEffectList = [orbitEffect, railEffect, quickViewEffect];
2053
+ var compactCss = (css) => css.replace(/\s+/g, " ").replace(/\s*([{};])\s*/g, "$1").replace(/:\s+/g, ":").replace(/,\s+/g, ",").replace(/;}/g, "}").trim();
2054
+ var effectPluginLines = (effect, name, css, before = [], detail = "count") => [
2055
+ ` registerPlugin('${effect.key}', ({ api }) => {`,
2056
+ ...before,
2057
+ " const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };",
2058
+ ` const count = mountEffects(${name}, { window, document, on, mark, api, css: ${JSON.stringify(compactCss(css))} }, onUnmount);`,
2059
+ ` api.emit('gem:${effect.key}:ready', { source: 'gem-runtime', ${detail} });`,
2060
+ " });"
2061
+ ];
2062
+ var effectImports = (file, name) => ['import { mountEffects } from "../motion/core.js";', `import { ${name} } from "../motion/${file}.js";`];
2063
+ function gemMotionChunks(legacyQuickView) {
2064
+ return {
2065
+ orbit: { hook: orbitEffect.hook, when: orbitEffect.load, imports: effectImports("orbit", "orbitEffect"), body: effectPluginLines(orbitEffect, "orbitEffect", GEM_ORBIT_RUNTIME_CSS) },
2066
+ rail: { hook: railEffect.hook, when: railEffect.load, imports: effectImports("rail", "railEffect"), body: effectPluginLines(railEffect, "railEffect", GEM_RAIL_RUNTIME_CSS) },
2067
+ "quick-view": {
2068
+ hook: `${quickViewEffect.hook}, ${legacyQuickView.hook}`,
2069
+ when: quickViewEffect.load,
2070
+ imports: effectImports("quick-view", "quickViewEffect"),
2071
+ body: effectPluginLines(quickViewEffect, "quickViewEffect", GEM_QUICK_VIEW_CSS, legacyQuickView.lines, legacyQuickView.readyDetail)
2072
+ }
2073
+ };
2074
+ }
2075
+
738
2076
  // ../../packages/gem-framework/src/manifest.ts
739
2077
  var gemFrameworkPrimitiveGroups = [
740
2078
  {
@@ -930,6 +2268,12 @@ var gemFrameworkPrimitiveGroups = [
930
2268
  "gem-popup-shell__teaser"
931
2269
  ]
932
2270
  },
2271
+ {
2272
+ // The motion engine (motion-engine.ts, 0.19.0): the orbit, rail and turn markup a section draws, and the quick view panel
2273
+ // the quick-view plugin builds. Styled by their own base / runtime stylesheets, never by gem-framework.css.
2274
+ group: "motion",
2275
+ classes: [...GEM_MOTION_CLASSES]
2276
+ },
933
2277
  {
934
2278
  group: "sections",
935
2279
  classes: [
@@ -1103,6 +2447,24 @@ var GEM_POPUP_SHELL_CSS_LINES = [
1103
2447
  " .gem-popup-shell[data-gem-popup-media='background']:is([data-gem-popup-desktop='split-left'], [data-gem-popup-desktop='split-right'], [data-gem-popup-desktop='card']) .gem-popup-shell__close { color: var(--gem-color-text); }",
1104
2448
  " .gem-popup-shell[data-gem-popup-media='background']:is([data-gem-popup-desktop='split-left'], [data-gem-popup-desktop='split-right'], [data-gem-popup-desktop='card']) .gem-popup-shell__media::after { content: none; }",
1105
2449
  "}",
2450
+ // Ngăn bám mép (drawer-end, GemFactory gap #49, 21/10/2026): bảng size mở từ nút cạnh ô chọn size là ngăn cao hết màn ở mép
2451
+ // phải trên desktop (American Tall 640px, Alo 745px), không phải hộp giữa màn. Rộng theo --gem-popup-drawer-width (mặc định
2452
+ // 640px, không quá màn), bám mép CUỐI dòng (margin-inline: auto 0, nên dưới RTL là mép trái) và trượt vào từ đó
2453
+ // (--gem-motion-base / --gem-ease-enter, như ngăn giỏ của framework). Chỉ là bố cục desktop: mobile vẫn takeover / sheet.
2454
+ "@keyframes gem-popup-drawer-in { from { translate: calc(100% * var(--gem-popup-drawer-dir, 1)) 0; } }",
2455
+ ".gem-popup-shell__dialog:dir(rtl) { --gem-popup-drawer-dir: -1; }",
2456
+ "@media (min-width: 750px) {",
2457
+ " .gem-popup-shell[data-gem-popup-desktop='drawer-end'] .gem-popup-shell__dialog {",
2458
+ " width: min(var(--gem-popup-drawer-width, 640px), 100vw); max-width: 100vw; height: 100dvh; max-height: 100dvh;",
2459
+ " margin-block: 0; margin-inline: auto 0; border-radius: 0;",
2460
+ " animation: gem-popup-drawer-in var(--gem-motion-base, 180ms) var(--gem-ease-enter, ease-out);",
2461
+ " }",
2462
+ " .gem-popup-shell[data-gem-popup-desktop='drawer-end'] .gem-popup-shell__frame { grid-template-columns: 1fr; align-content: start; min-height: 100%; }",
2463
+ " .gem-popup-shell[data-gem-popup-desktop='drawer-end'] .gem-popup-shell__media { max-height: 240px; }",
2464
+ " .gem-popup-shell[data-gem-popup-desktop='drawer-end'] .gem-popup-shell__panel { justify-content: flex-start; align-items: stretch; text-align: start; }",
2465
+ "}",
2466
+ // SAU rule animation và cùng độ ưu tiên (bài học ngăn giỏ 02/10/2026: đặt trước hay yếu hơn thì ngăn vẫn trượt).
2467
+ "@media (prefers-reduced-motion: reduce) { .gem-popup-shell[data-gem-popup-desktop='drawer-end'] .gem-popup-shell__dialog { animation: none; } }",
1106
2468
  "@media (max-width: 749px) {",
1107
2469
  // Mobile: takeover is the whole screen; a sheet rises from the bottom to at most 88% of it. The image
1108
2470
  // sits under the text (top when media=top), capped at a third of the screen; with no image or the image
@@ -1138,6 +2500,9 @@ var GEM_POPUP_SHELL_CSS_LINES = [
1138
2500
  "@media (min-width: 750px) {",
1139
2501
  " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] { padding: 0; }",
1140
2502
  " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] .gem-popup-shell__dialog { height: 720px; max-height: none; }",
2503
+ // Ngăn trong canvas: cao như khung xem trước của fullscreen, đứng yên (không trượt mỗi lần canvas vẽ lại).
2504
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='drawer-end'] { padding-inline: 0; }",
2505
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='drawer-end'] .gem-popup-shell__dialog { height: 720px; max-height: none; animation: none; }",
1141
2506
  "}",
1142
2507
  "@media (max-width: 749px) {",
1143
2508
  " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] { padding: 0; }",
@@ -1333,7 +2698,7 @@ var GEM_INLINE_MARK_CSS_LINES = [
1333
2698
  ":where(.gem-mark--alert) { color: var(--gem-color-danger, #b42318); }",
1334
2699
  ":where(.gem-mark--highlight.gem-mark--accent), :where(.gem-mark--highlight.gem-mark--alert) { color: var(--gem-mark-highlight-ink, #1f1d18); }",
1335
2700
  ".gem-mark-link { color: var(--gem-color-accent); text-decoration-line: underline; text-decoration-thickness: from-font; text-underline-offset: 0.18em; }",
1336
- ".gem-mark-link:hover { text-decoration-thickness: 2px; }",
2701
+ ".gem-mark-link:hover { text-decoration-thickness: var(--gem-underline-thickness, 2px); }",
1337
2702
  // Bút dạ thắng màu link: chữ trên nền vàng giữ mực đậm, dù span nằm trong link hay link nằm trong span (review #740 đo màu nhấn
1338
2703
  // trên vàng 4,32:1 ở nền trắng, 2,47:1 trên băng tối, 1,05:1 trên băng màu nhấn).
1339
2704
  ".gem-mark-link.gem-mark--highlight, .gem-mark--highlight .gem-mark-link { color: var(--gem-mark-highlight-ink, #1f1d18); }"
@@ -1552,6 +2917,14 @@ function createPrimitiveCss() {
1552
2917
  " cursor: not-allowed;",
1553
2918
  " opacity: 0.55;",
1554
2919
  "}",
2920
+ // Button shape of ONE section (GemFactory gap #69, 22/10/2026): matching a source, a section needs square, softly rounded
2921
+ // or pill buttons whatever the preset's button radius is, and writing border-radius on .gem-button takes the button
2922
+ // away from the preset for good (GemFactory's button_token_shadowed). `data-gem-button-shape` on the section root (or on
2923
+ // one button) sets the radius the button already reads; with no attribute the preset decides, as before. Square and
2924
+ // pill are shapes, so they do not scale; soft is a size, so it follows the preset's radius scale (--gem-style-radius).
2925
+ "[data-gem-button-shape='square'] { --gem-radius-button: 0px; }",
2926
+ "[data-gem-button-shape='soft'] { --gem-radius-button: var(--gem-radius-sm); }",
2927
+ "[data-gem-button-shape='pill'] { --gem-radius-button: 999px; }",
1555
2928
  ".gem-input:disabled, .gem-select:disabled, .gem-textarea:disabled, .gem-quantity-input__button:disabled {",
1556
2929
  " cursor: not-allowed;",
1557
2930
  " opacity: 0.6;",
@@ -2929,8 +4302,8 @@ function createPrimitiveCss() {
2929
4302
  function createGemSectionRuntimeContractCss(selector = "[data-gem-section-library-id]", options = {}) {
2930
4303
  const background = options.backgroundVar ?? "var(--gem-color-background)";
2931
4304
  const surface = options.surfaceVar ?? "var(--gem-color-surface)";
2932
- const text = options.textVar ?? "var(--gem-color-text)";
2933
- const mutedText = options.mutedTextVar ?? `color-mix(in srgb, ${text} 72%, transparent)`;
4305
+ const text2 = options.textVar ?? "var(--gem-color-text)";
4306
+ const mutedText = options.mutedTextVar ?? `color-mix(in srgb, ${text2} 72%, transparent)`;
2934
4307
  const accent = options.accentVar ?? "var(--gem-color-accent)";
2935
4308
  const accentText = options.accentTextVar ?? "var(--gem-color-accent-text, #ffffff)";
2936
4309
  const border = options.borderVar ?? "var(--gem-color-border)";
@@ -2940,7 +4313,7 @@ function createGemSectionRuntimeContractCss(selector = "[data-gem-section-librar
2940
4313
  `${selector} {`,
2941
4314
  " box-sizing: border-box;",
2942
4315
  ` background: ${background};`,
2943
- ` color: ${text};`,
4316
+ ` color: ${text2};`,
2944
4317
  "}",
2945
4318
  `${selector} *,`,
2946
4319
  `${selector} *::before,`,
@@ -2999,7 +4372,7 @@ function createGemSectionRuntimeContractCss(selector = "[data-gem-section-librar
2999
4372
  " border-radius: var(--gem-radius-pill, 999px);",
3000
4373
  " padding: 6px 10px;",
3001
4374
  ` background: ${surface};`,
3002
- ` color: ${text};`,
4375
+ ` color: ${text2};`,
3003
4376
  " font-size: 0.8125rem;",
3004
4377
  " font-weight: 700;",
3005
4378
  "}",
@@ -4921,7 +6294,7 @@ function reviewsEngineRuntimeScript() {
4921
6294
  const b = document.createElement('button');
4922
6295
  b.type = 'button'; b.className = strip.getAttribute('data-rv-thumb-class') || ''; b.setAttribute('data-rv-lb-thumb', String(k));
4923
6296
  b.setAttribute('aria-label', (strip.getAttribute('data-rv-thumb-label') || 'Media {n} of {total}').replace('{n}', String(k + 1)).replace('{total}', String(lbSet.length)));
4924
- const img = document.createElement('img'); img.src = x.thumb; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
6297
+ const img = document.createElement('img'); img.loading = 'lazy'; img.decoding = 'async'; img.alt = ''; img.src = x.thumb;
4925
6298
  b.appendChild(img); strip.appendChild(b);
4926
6299
  });
4927
6300
  strip.hidden = lbSet.length <= 1;
@@ -5046,7 +6419,1141 @@ function reviewsEngineRuntimeScript() {
5046
6419
  api.emit('gem:reviews:ready', { source: 'gem-runtime', count: boundReviews });`;
5047
6420
  }
5048
6421
 
6422
+ // ../../packages/gem-framework/src/bundle-engine.ts
6423
+ function bundleEngineRuntimeScript() {
6424
+ return `let boundBundles = 0;
6425
+ const BB_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
6426
+ document.querySelectorAll('[data-bb-root]').forEach((root) => {
6427
+ if (!mark(root, 'bundle')) return;
6428
+ boundBundles += 1;
6429
+ const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };
6430
+ const isEl = (t) => t instanceof window.Element;
6431
+ const parse = (attr, fallback) => { try { const v = JSON.parse(root.getAttribute(attr) || 'null'); return v == null ? fallback : v; } catch (e) { return fallback; } };
6432
+ let rules = parse('data-bb-rules', {});
6433
+ const text = root.hasAttribute('data-bb-messages') ? parse('data-bb-messages', {}) : parse('data-bb-labels', {});
6434
+ const viMoney = root.getAttribute('data-bb-money') === 'vi';
6435
+ const currency = root.getAttribute('data-bb-currency') || (viMoney ? 'VND' : 'USD');
6436
+ let nf = null;
6437
+ if (!viMoney) { try { nf = new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: currency === 'VND' ? 0 : 2 }); } catch (e) { nf = null; } }
6438
+ const money = (n) => (viMoney ? String(Math.round(n)).replace(/\\B(?=(\\d{3})+(?!\\d))/g, '.') + '\u0111' : nf ? nf.format(Math.round(n * 100) / 100) : (Math.round(n * 100) / 100).toFixed(2));
6439
+ // A strike price as stores show it: a whole number drops its cents ("$42", not "$42.00").
6440
+ const moneyShort = (n) => { const s = money(n); return Math.abs(n - Math.round(n)) < 0.005 ? s.replace(/[.,]00(?=\\D*$)/, '') : s; };
6441
+ const fill = (tpl, vars) => String(tpl || '').replace(/\\{(\\w+)\\}/g, (m, k) => (Object.hasOwn(vars, k) ? String(vars[k]) : m));
6442
+ // A sentence with {reward}: the reward is a <strong>, the rest plain text. Never innerHTML.
6443
+ const say = (el, tpl, vars) => {
6444
+ if (!el) return;
6445
+ const parts = String(tpl || '').split('{reward}');
6446
+ el.replaceChildren();
6447
+ parts.forEach((part, i) => {
6448
+ if (part) el.append(document.createTextNode(fill(part, vars)));
6449
+ if (i < parts.length - 1 && vars.reward) { const s = document.createElement('strong'); s.textContent = vars.reward; el.append(s); }
6450
+ });
6451
+ };
6452
+ let min = Math.max(1, Number(rules.min) || 1);
6453
+ let cap = Number(rules.max) > 0 ? Number(rules.max) : Infinity;
6454
+ const per = Number(rules.per) > 0 ? Number(rules.per) : Infinity;
6455
+ let tiers = Array.isArray(rules.tiers) ? rules.tiers : [];
6456
+ let goal = tiers.length ? tiers[tiers.length - 1] : null;
6457
+ // The catalog: one entry per item, plus one per extra variant of a card (<key>~<index>).
6458
+ const catalog = new Map();
6459
+ const cards = new Map();
6460
+ root.querySelectorAll('[data-bb-item]').forEach((el) => {
6461
+ const key = el.getAttribute('data-bb-item');
6462
+ const base = {
6463
+ el,
6464
+ variantId: el.getAttribute('data-bb-variant') || '',
6465
+ price: Number(el.getAttribute('data-bb-price')) || 0,
6466
+ compare: Number(el.getAttribute('data-bb-compare')) || 0,
6467
+ title: el.getAttribute('data-bb-title') || '',
6468
+ subtitle: el.getAttribute('data-bb-subtitle') || '',
6469
+ image: el.getAttribute('data-bb-image') || '',
6470
+ group: el.getAttribute('data-bb-group') || '',
6471
+ step: el.getAttribute('data-bb-step') || '',
6472
+ short: el.getAttribute('data-bb-short') || '',
6473
+ available: el.getAttribute('data-available') !== 'false',
6474
+ };
6475
+ catalog.set(key, base);
6476
+ let vs = [];
6477
+ try { vs = JSON.parse(el.getAttribute('data-bb-variants') || '[]'); } catch (e) { vs = []; }
6478
+ if (!Array.isArray(vs)) vs = [];
6479
+ vs.forEach((v, i) => {
6480
+ if (!i) return;
6481
+ catalog.set(key + '~' + i, { ...base, variantId: String(v.id || ''), price: Number(v.price) || 0, compare: Number(v.compare) || 0, image: v.image || base.image, variantTitle: v.title || '' });
6482
+ });
6483
+ if (vs[0]) base.variantTitle = vs[0].title || '';
6484
+ cards.set(key, { el, variants: vs, gift: el.hasAttribute('data-bb-gift') });
6485
+ });
6486
+ const gifts = Array.from(cards.entries()).filter(([, c]) => c.gift).map(([k]) => k);
6487
+ // The variant a card shows now (data-bb-current), as the key an action picks.
6488
+ const currentKey = (key) => { const c = cards.get(key); const i = c ? Number(c.el.getAttribute('data-bb-current')) || 0 : 0; return i ? key + '~' + i : key; };
6489
+ let picks = [];
6490
+ let plan = 'one_time';
6491
+ const planOf = (el) => (el.type === 'checkbox' ? (el.checked ? 'subscribe' : 'one_time') : el.getAttribute('data-bb-plan') || 'one_time');
6492
+ const planInputs = Array.from(root.querySelectorAll('input[data-bb-plan]'));
6493
+ const checkedPlan = planInputs.find((el) => el.checked);
6494
+ if (checkedPlan) plan = planOf(checkedPlan);
6495
+ const tierFor = (n) => tiers.filter((t) => n >= t.min).pop() || null;
6496
+ const pctOf = (t, p) => (!t ? 0 : p === 'subscribe' ? Number(t.sub) || Number(t.pct) || 0 : Number(t.pct) || 0);
6497
+ // The reward in a progress sentence is always "N% off" for the plan in force; a tier's label is the text on its milestone.
6498
+ const rewardOf = (t, p) => (!t ? '' : pctOf(t, p) + '% off');
6499
+ const qtyOf = (key) => picks.filter((p) => p === key).length;
6500
+ const canAdd = (key) => picks.length < cap && (rules.repeat !== false || !picks.includes(key)) && qtyOf(key) < per;
6501
+
6502
+ // Box sizes, when the root declares them (data-bb-sizes): choosing a size sets floor = ceiling = its item count. "trim":
6503
+ // a smaller box drops the LAST picks; "queue": they wait, in order, and come back when there is room again.
6504
+ const sizeMode = root.getAttribute('data-bb-sizes');
6505
+ const sizes = Array.isArray(rules.sizes) ? rules.sizes : [];
6506
+ let size = null;
6507
+ const waiting = [];
6508
+ const refill = () => { while (sizeMode === 'queue' && picks.length < cap && waiting.length) picks.push(waiting.shift()); };
6509
+ const applySize = (n) => {
6510
+ const s = sizes.find((x) => Number(x.items) === n);
6511
+ if (!s) return false;
6512
+ size = s; min = Number(s.items); cap = Number(s.items);
6513
+ goal = tierFor(min) || (tiers.length ? tiers[tiers.length - 1] : null);
6514
+ if (picks.length > cap) { const out = picks.splice(cap); if (sizeMode === 'queue') waiting.unshift(...out); }
6515
+ refill();
6516
+ return true;
6517
+ };
6518
+ if (sizeMode) { const s0 = sizes.find((s) => Number(s.items) === Number(rules.size)) || sizes[0] || null; if (s0) applySize(Number(s0.items)); }
6519
+
6520
+ // Kits (rules.kits): each keeps its own picks and steps; a kit's own rules replace the bundle's while it is chosen.
6521
+ // A kit with steps fills each step's slots in order and moves on by itself; optional steps open after the required ones.
6522
+ const kits = Array.isArray(rules.kits) && rules.kits.length ? rules.kits : null;
6523
+ const stateOf = new Map();
6524
+ (kits || []).forEach((k) => {
6525
+ const steps = Array.isArray(k.steps) ? k.steps : [];
6526
+ const slots = {};
6527
+ steps.forEach((s) => { slots[s.id] = Array.from({ length: Math.max(1, Number(s.pick) || 1) }, () => null); });
6528
+ stateOf.set(k.id, { kit: k, steps, fill: slots, picks: [], current: steps.length ? steps[0].id : null });
6529
+ });
6530
+ let st = kits ? stateOf.get(kits[0].id) : { kit: null, steps: [], fill: {}, picks, current: null };
6531
+ const useKit = (id) => {
6532
+ if (!kits) return;
6533
+ st = stateOf.get(id) || stateOf.get(kits[0].id);
6534
+ const k = st.kit;
6535
+ rules = { ...rules, min: k.min, max: k.max, tiers: k.tiers, repeat: k.repeat, price_mode: k.price_mode, fixed_price: k.fixed_price };
6536
+ min = Math.max(1, Number(k.min) || 1);
6537
+ cap = Number(k.max) > 0 ? Number(k.max) : Infinity;
6538
+ tiers = Array.isArray(k.tiers) ? k.tiers : [];
6539
+ goal = tiers.length ? tiers[tiers.length - 1] : null;
6540
+ picks = st.picks;
6541
+ };
6542
+ if (kits) useKit(kits[0].id);
6543
+ const stepsMode = () => st.steps.length > 0;
6544
+ const required = () => st.steps.filter((s) => !s.optional);
6545
+ const isFull = (s) => st.fill[s.id].every(Boolean);
6546
+ // picks (in step order) are rebuilt from the slots after every change: price and cart read picks.
6547
+ const syncPicks = () => { picks.length = 0; st.steps.forEach((s) => st.fill[s.id].forEach((k) => { if (k) picks.push(k); })); };
6548
+ const openRequired = () => required().find((s) => !isFull(s)) || null;
6549
+ const nextOpen = () => openRequired() || st.steps.find((s) => s.optional && !isFull(s)) || null;
6550
+ const doneCount = () => required().filter(isFull).length;
6551
+ const isComplete = () => (stepsMode() ? doneCount() === required().length : picks.length >= min);
6552
+ const reachable = (s) => { const open = openRequired(); return !open || st.steps.indexOf(s) <= st.steps.indexOf(open); };
6553
+ const slotRef = (idx) => {
6554
+ let n = 0;
6555
+ for (const s of st.steps) for (let j = 0; j < st.fill[s.id].length; j++) { if (n === idx) return [s, j]; n++; }
6556
+ return null;
6557
+ };
6558
+ // Into the card's step: its first empty slot; a full step swaps its last pick (a full optional step refuses).
6559
+ const addToStep = (key) => {
6560
+ const it = catalog.get(key);
6561
+ const step = (it && st.steps.find((s) => s.id === it.step)) || st.steps.find((s) => s.id === st.current);
6562
+ if (!step || (rules.repeat === false && picks.includes(key))) return false;
6563
+ const row = st.fill[step.id];
6564
+ let i = row.indexOf(null);
6565
+ if (i < 0) { if (step.optional) return false; i = row.length - 1; }
6566
+ row[i] = key;
6567
+ syncPicks();
6568
+ if (isFull(step)) { const n = nextOpen(); if (n) st.current = n.id; }
6569
+ return true;
6570
+ };
6571
+ // "Edit" on a required step empties the slot, closes the gap and goes back to that step; on an optional step it only removes.
6572
+ const editSlot = (idx) => {
6573
+ const ref = slotRef(idx);
6574
+ if (!ref) return;
6575
+ const [s, j] = ref;
6576
+ const row = st.fill[s.id];
6577
+ row.splice(j, 1); row.push(null);
6578
+ syncPicks();
6579
+ if (!s.optional) st.current = s.id;
6580
+ };
6581
+ const clearAll = () => {
6582
+ st.steps.forEach((s) => { st.fill[s.id] = st.fill[s.id].map(() => null); });
6583
+ picks.length = 0; waiting.length = 0;
6584
+ st.current = st.steps.length ? st.steps[0].id : null;
6585
+ };
6586
+
6587
+ // Prices. totals(): the bundle price at the tier the bundle WILL reach once it has its minimum (stores show the bundle
6588
+ // price from the first pick), plus a container's base price. totalsNow(): at the tier reached now, rounded to the cent
6589
+ // first so total + saved add up.
6590
+ let basePrice = 0;
6591
+ let cartHead = null;
6592
+ const sumOf = () => {
6593
+ let sum = 0; let compare = 0;
6594
+ picks.forEach((k) => { const it = catalog.get(k); if (!it) return; sum += it.price; compare += Math.max(it.compare, it.price); });
6595
+ return { sum, compare };
6596
+ };
6597
+ const totals = (p) => {
6598
+ const { sum, compare } = sumOf();
6599
+ if (rules.price_mode === 'fixed' && !basePrice) {
6600
+ // A box size with a fixed one-time price; or a kit with one price whatever is picked (no strike price then).
6601
+ if (size && Number(size.fixed) > 0) { if (p !== 'subscribe') return { sum, compare, total: Number(size.fixed) }; }
6602
+ else if (Number(rules.fixed_price) > 0) return { sum, compare: 0, total: Number(rules.fixed_price) };
6603
+ }
6604
+ const t = tierFor(Math.max(picks.length, min)) || goal;
6605
+ return { sum, compare, total: basePrice + sum * (1 - pctOf(t, p) / 100) };
6606
+ };
6607
+ const totalsNow = (p) => {
6608
+ const { sum, compare } = sumOf();
6609
+ return { sum, compare, total: Math.round(sum * (1 - pctOf(tierFor(picks.length), p) / 100) * 100 + 1e-6) / 100 };
6610
+ };
6611
+ // One item's price on its card at the bundle's current tier, counting at least one item.
6612
+ const unitNow = (it, p) => it.price * (1 - pctOf(tierFor(Math.max(picks.length, 1)), p) / 100);
6613
+
6614
+ const frequency = () => root.querySelector('[data-bb-frequency]');
6615
+ const planId = () => {
6616
+ if (plan !== 'subscribe') return '';
6617
+ const f = frequency();
6618
+ if (!f) return '';
6619
+ if (f.matches('select')) return f.value;
6620
+ const c = f.querySelector('input:checked');
6621
+ return c ? c.value : '';
6622
+ };
6623
+ const bundleName = () => {
6624
+ const n = (st.kit && st.kit.label) || (root.querySelector('[data-bb-name]') || {}).textContent || 'Bundle';
6625
+ return String(n).trim();
6626
+ };
6627
+ const toCart = () => {
6628
+ const counts = new Map();
6629
+ picks.concat(gifts).forEach((k) => { const it = catalog.get(k); if (it && it.variantId) counts.set(it.variantId, (counts.get(it.variantId) || 0) + 1); });
6630
+ const ruleKey = root.getAttribute('data-bb-discount-key') || '';
6631
+ const id = (root.getAttribute('data-gem-section-id') || 'bundle') + '-' + Date.now().toString(36);
6632
+ const props = [{ key: '_bundle_id', value: id }, { key: '_bundle', value: bundleName() }].concat(ruleKey ? [{ key: '_bundle_rule', value: ruleKey }] : []);
6633
+ const pid = planId();
6634
+ const lines = Array.from(counts.entries()).map(([variantId, quantity]) => ({ variantId, quantity, properties: props.slice(), ...(pid ? { sellingPlanId: pid } : {}) }));
6635
+ // A container line (a gift box) goes first and carries the section's own properties (lid, gift message).
6636
+ const head = cartHead ? cartHead() : null;
6637
+ if (head) {
6638
+ const hp = props.concat(Array.isArray(head.properties) ? head.properties : []);
6639
+ if (head.variantId) lines.unshift({ variantId: head.variantId, quantity: 1, properties: hp, ...(pid ? { sellingPlanId: pid } : {}) });
6640
+ else if (lines[0]) lines[0].properties = hp;
6641
+ }
6642
+ if (lines.length && typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:cart:add-many', { detail: { lines, after: 'drawer' } }));
6643
+ return lines.length;
6644
+ };
6645
+
6646
+ let busy = false;
6647
+ let wasComplete = false;
6648
+ const state = () => {
6649
+ const n = picks.length;
6650
+ const counts = {};
6651
+ picks.forEach((k) => { counts[k] = (counts[k] || 0) + 1; });
6652
+ return {
6653
+ picks, count: n, counts, min, cap, max: cap === Infinity ? null : cap, per, rules, text, tiers, goal, plan, size, sizes, waiting,
6654
+ catalog, cards, gifts, reached: tierFor(n), next: tiers.find((t) => n < t.min) || null, left: Math.max(0, min - n),
6655
+ kit: st.kit, kits, steps: st.steps, fill: st.fill, current: st.current, stepsMode: stepsMode(), done: doneCount(),
6656
+ required: required(), complete: isComplete(), busy, base: basePrice,
6657
+ };
6658
+ };
6659
+ const outs = () => root.querySelectorAll('[data-bb-out]');
6660
+ const emit = (action, extra) => {
6661
+ const n = picks.length;
6662
+ const complete = isComplete();
6663
+ root.setAttribute('data-bb-count', String(n));
6664
+ root.setAttribute('data-bb-state', n === 0 ? 'empty' : n >= cap ? 'full' : complete ? 'complete' : 'partial');
6665
+ if (outs().length) {
6666
+ const t = totals(plan); const reached = tierFor(n);
6667
+ const v = { count: String(n), left: String(Math.max(0, min - n)), total: money(t.total), compare: money(t.compare), saved: money(Math.max(0, t.compare - t.total)), reward: reached ? rewardOf(reached, plan) : '' };
6668
+ outs().forEach((el) => { const k = el.getAttribute('data-bb-out'); if (Object.hasOwn(v, k)) el.textContent = v[k]; });
6669
+ }
6670
+ const detail = { source: 'gem-runtime', action, ...(extra || {}), count: n, min, max: cap === Infinity ? null : cap, plan, size: size ? Number(size.items) : null, kit: st.kit ? st.kit.id : null, step: st.current, complete, reward: tierFor(n) ? rewardOf(tierFor(n), plan) : '', total: totals(plan).total };
6671
+ if (typeof window.CustomEvent === 'function') {
6672
+ root.dispatchEvent(new window.CustomEvent('gem:bundle:change', { bubbles: true, detail }));
6673
+ if (complete && !wasComplete) root.dispatchEvent(new window.CustomEvent('gem:bundle:complete', { bubbles: true, detail }));
6674
+ }
6675
+ wasComplete = complete;
6676
+ };
6677
+
6678
+ // Actions: the handlers below call these, and so may a section's own controls (root.gemBundle.add(key)\u2026).
6679
+ const add = (key) => {
6680
+ const before = st.current;
6681
+ if (root.hasAttribute('data-bb-add-toggle') && picks.includes(key)) { picks.splice(picks.indexOf(key), 1); emit('remove', { key }); return true; }
6682
+ if (stepsMode()) { if (!addToStep(key)) return false; }
6683
+ else { if (!canAdd(key)) return false; picks.push(key); }
6684
+ emit('add', { key, from: before });
6685
+ return true;
6686
+ };
6687
+ const removeAt = (idx) => { if (stepsMode()) editSlot(idx); else { picks.splice(idx, 1); refill(); } emit('remove', { index: idx }); };
6688
+ const dec = (key) => { const at = picks.lastIndexOf(key); if (at < 0) return false; picks.splice(at, 1); refill(); emit('dec', { key }); return true; };
6689
+ const uncheck = (key) => { for (let i = picks.length - 1; i >= 0; i -= 1) if (picks[i] === key) picks.splice(i, 1); refill(); emit('uncheck', { key }); };
6690
+ const clear = () => { clearAll(); emit('clear'); };
6691
+ // A typed quantity: exactly k picks of that item, within the bundle's ceiling, the item's own and no-repeat.
6692
+ const setQty = (key, want) => {
6693
+ if (!catalog.has(key)) return;
6694
+ const has = qtyOf(key);
6695
+ const room = cap === Infinity ? Infinity : cap - (picks.length - has);
6696
+ const k = Math.max(0, Math.min(Math.floor(Number(want)) || 0, room, per, rules.repeat === false ? 1 : Infinity));
6697
+ for (let i = picks.length - 1; i >= 0; i--) if (picks[i] === key) picks.splice(i, 1);
6698
+ for (let i = 0; i < k; i++) picks.push(key);
6699
+ emit('qty', { key });
6700
+ };
6701
+ const setPlan = (p) => { plan = p === 'subscribe' ? 'subscribe' : 'one_time'; emit('plan'); };
6702
+ const setSize = (n) => {
6703
+ if (!sizeMode || !applySize(Number(n))) return false;
6704
+ // A box size may name its default selling plan.
6705
+ const f = frequency();
6706
+ if (f && f.matches('select') && size && size.plan && Array.from(f.options).some((o) => o.value === size.plan)) f.value = size.plan;
6707
+ emit('size');
6708
+ return true;
6709
+ };
6710
+ // An item's variant radio (a pack size): the whole row changes variant and price, its count stays.
6711
+ const applyVariant = (radio) => {
6712
+ const it = catalog.get(radio.getAttribute('data-bb-size'));
6713
+ if (!it) return false;
6714
+ it.variantId = radio.getAttribute('data-bb-variant') || '';
6715
+ it.price = Number(radio.getAttribute('data-bb-price')) || 0;
6716
+ it.compare = Number(radio.getAttribute('data-bb-compare')) || 0;
6717
+ it.el.setAttribute('data-bb-variant', it.variantId);
6718
+ it.el.setAttribute('data-bb-price', String(it.price));
6719
+ it.el.setAttribute('data-bb-compare', it.compare ? String(it.compare) : '');
6720
+ return true;
6721
+ };
6722
+ const selectKit = (id) => { if (!kits) return; useKit(id); emit('kit'); };
6723
+ const goStep = (id) => { const s = st.steps.find((x) => x.id === id); if (!s || !reachable(s)) return false; st.current = id; emit('step'); return true; };
6724
+ const finish = (ok) => {
6725
+ busy = false;
6726
+ root.querySelectorAll('[data-bb-cta]').forEach((c) => { c.removeAttribute('data-state'); c.removeAttribute('aria-busy'); });
6727
+ if (ok) clearAll();
6728
+ emit(ok ? 'added' : 'add-failed');
6729
+ };
6730
+ const checkout = () => {
6731
+ if (!isComplete() || busy || !picks.length) return false;
6732
+ if (!toCart()) return false;
6733
+ // With gemsite's cart bridge: wait for its answer before clearing (a failure keeps the bundle to try again).
6734
+ if (window.__gemCartAddBridge) {
6735
+ busy = true;
6736
+ const box = st.kit ? root.querySelector('[data-bb-kit-box="' + st.kit.id + '"]') : null;
6737
+ (box || root).querySelectorAll('[data-bb-cta]').forEach((c) => { c.setAttribute('data-state', 'loading'); c.setAttribute('aria-busy', 'true'); });
6738
+ emit('checkout');
6739
+ } else finish(true);
6740
+ return true;
6741
+ };
6742
+
6743
+ on(root, 'click', (event) => {
6744
+ const t = isEl(event.target) ? event.target : null;
6745
+ if (!t) return;
6746
+ const addEl = t.closest('[data-bb-add], [data-bb-inc]');
6747
+ if (addEl) { add(currentKey(addEl.getAttribute('data-bb-add') || addEl.getAttribute('data-bb-inc'))); return; }
6748
+ const decEl = t.closest('[data-bb-dec], [data-bb-minus]');
6749
+ if (decEl) { dec(currentKey(decEl.getAttribute('data-bb-dec') || decEl.getAttribute('data-bb-minus'))); return; }
6750
+ const un = t.closest('[data-bb-uncheck]');
6751
+ if (un) { uncheck(currentKey(un.getAttribute('data-bb-uncheck'))); return; }
6752
+ const rm = t.closest('[data-bb-remove]');
6753
+ if (rm) { removeAt(Number(rm.getAttribute('data-bb-remove')) || 0); return; }
6754
+ if (t.closest('[data-bb-clear]')) { clear(); return; }
6755
+ const nav = t.closest('[data-bb-step-nav]');
6756
+ if (nav) { if (!nav.disabled) goStep(nav.getAttribute('data-bb-step-nav')); return; }
6757
+ const tab = t.closest('[data-bb-kit-tab]');
6758
+ if (tab) { selectKit(tab.getAttribute('data-bb-kit-tab')); return; }
6759
+ if (t.closest('[data-bb-skip]')) { checkout(); return; }
6760
+ const cta = t.closest('[data-bb-cta]');
6761
+ if (cta) { event.preventDefault(); if (cta.getAttribute('aria-disabled') !== 'true') checkout(); }
6762
+ });
6763
+ on(root, 'change', (event) => {
6764
+ const t = isEl(event.target) ? event.target : null;
6765
+ if (!t) return;
6766
+ const p = t.closest('input[data-bb-plan]');
6767
+ if (p) { if (p.type === 'checkbox' || p.checked) setPlan(planOf(p)); return; }
6768
+ const s = t.closest('input[data-bb-size]');
6769
+ if (s && s.checked) { if (s.hasAttribute('data-bb-variant')) { if (applyVariant(s)) emit('variant', { key: s.getAttribute('data-bb-size') }); } else setSize(Number(s.value)); return; }
6770
+ const q = t.closest('input[data-bb-count]');
6771
+ if (q) setQty(q.getAttribute('data-bb-count'), q.value);
6772
+ });
6773
+ // A typed quantity applies as soon as it is a number (an emptied box waits for "change" to go back to 0).
6774
+ on(root, 'input', (event) => {
6775
+ const q = isEl(event.target) ? event.target.closest('input[data-bb-count]') : null;
6776
+ if (q && q.value !== '' && Number.isFinite(Number(q.value))) setQty(q.getAttribute('data-bb-count'), q.value);
6777
+ });
6778
+ on(window, 'gem:cart:added', () => { if (busy) finish(true); });
6779
+ on(window, 'gem:cart:add-failed', () => { if (busy) finish(false); });
6780
+
6781
+ // A page brought back from history restores its form: read back the variant radios and typed quantities in view.
6782
+ root.querySelectorAll('input[data-bb-size][data-bb-variant]').forEach((r) => { if (r.checked) applyVariant(r); });
6783
+ root.querySelectorAll('input[data-bb-count]').forEach((inp) => {
6784
+ const v = Math.floor(Number(inp.value)) || 0;
6785
+ const key = inp.getAttribute('data-bb-count');
6786
+ if (v > 0 && catalog.has(key)) { const k = Math.min(v, per, rules.repeat === false ? 1 : Infinity, cap === Infinity ? Infinity : cap - picks.length); for (let i = 0; i < k; i++) picks.push(key); }
6787
+ });
6788
+
6789
+ root.gemBundle = {
6790
+ state, money, moneyShort, fill, say, words: BB_WORDS, tierFor, pctOf, rewardOf, canAdd, qtyOf, currentKey, totals, totalsNow, unitNow,
6791
+ reachable, isFull, slotRef, required, add, removeAt, dec, uncheck, clear, setQty, setPlan, setSize, selectKit, goStep, checkout,
6792
+ setBase: (n) => { basePrice = Number(n) || 0; emit('base'); },
6793
+ setCartHead: (fn) => { cartHead = typeof fn === 'function' ? fn : null; },
6794
+ refresh: () => emit('refresh'),
6795
+ };
6796
+ root.setAttribute('data-bb-ready', '');
6797
+ emit('init');
6798
+ });
6799
+ api.emit('gem:bundle:ready', { source: 'gem-runtime', count: boundBundles });`;
6800
+ }
6801
+
5049
6802
  // ../../packages/gem-framework/src/runtime.ts
6803
+ var cartDrawerPluginRegistrationLines = [
6804
+ " registerPlugin('cart-drawer', ({ api }) => {",
6805
+ " const drawers = document.querySelectorAll('[data-gem-cart-drawer], [data-gem-cart-canvas], .gem-cart-drawer');",
6806
+ ` const cartLinks = document.querySelectorAll('[data-gem-cart-drawer-trigger], [data-gem-cart-toggle], a[href*="/cart"]');`,
6807
+ ` const cartForms = document.querySelectorAll('form[action*="/cart/add"], form[action*="/cart"]');`,
6808
+ " const drawer = drawers[0];",
6809
+ " const setDrawerA11y = (isOpen) => {",
6810
+ " if (!drawer) return;",
6811
+ " drawer.setAttribute && drawer.setAttribute('aria-expanded', isOpen ? 'true' : 'false');",
6812
+ " const panel = drawer.querySelector && drawer.querySelector('[data-gem-cart-canvas-panel], .gem-cart-drawer__panel');",
6813
+ " if (panel && panel.setAttribute) panel.setAttribute('aria-hidden', isOpen ? 'false' : 'true');",
6814
+ " };",
6815
+ " const openDrawer = (reason, href) => {",
6816
+ " if (drawer && drawer.tagName === 'DETAILS') drawer.open = true;",
6817
+ " if (drawer && drawer.dataset) drawer.dataset.gemCartDrawerState = 'open';",
6818
+ " setDrawerA11y(true);",
6819
+ " api.emit('gem:cart:open', { source: 'gem-runtime', reason, href });",
6820
+ " };",
6821
+ " const closeDrawer = (reason) => {",
6822
+ " if (drawer && drawer.tagName === 'DETAILS') drawer.open = false;",
6823
+ " if (drawer && drawer.dataset) drawer.dataset.gemCartDrawerState = 'closed';",
6824
+ " setDrawerA11y(false);",
6825
+ " api.emit('gem:cart:close', { source: 'gem-runtime', reason });",
6826
+ " };",
6827
+ " let bound = 0;",
6828
+ " drawers.forEach((drawer) => {",
6829
+ " if (!mark(drawer, 'cart-drawer')) return;",
6830
+ " drawer.dataset.gemCartDrawerState = drawer.hasAttribute('open') ? 'open' : 'ready';",
6831
+ " setDrawerA11y(drawer.hasAttribute('open'));",
6832
+ " drawer.querySelectorAll('[data-gem-cart-drawer-close], [data-gem-cart-canvas-close]').forEach((control) => { if (!canListen(control)) return; control.addEventListener('click', (event) => {",
6833
+ " event.preventDefault && event.preventDefault();",
6834
+ " closeDrawer('close-control');",
6835
+ " }); });",
6836
+ " if (canListen(drawer)) drawer.addEventListener('toggle', () => { drawer.dataset.gemCartDrawerState = drawer.hasAttribute('open') ? 'open' : 'closed'; });",
6837
+ " bound += 1;",
6838
+ " });",
6839
+ " cartLinks.forEach((link) => {",
6840
+ " if (!mark(link, 'cart-link')) return;",
6841
+ " if (!canListen(link)) return;",
6842
+ " link.addEventListener('click', (event) => {",
6843
+ " if (drawer && (link.hasAttribute('data-gem-cart-drawer-trigger') || link.hasAttribute('data-gem-cart-toggle'))) event.preventDefault && event.preventDefault();",
6844
+ " openDrawer('cart-link', link.href || undefined);",
6845
+ " });",
6846
+ " bound += 1;",
6847
+ " });",
6848
+ " if (canListen(document)) document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && drawer && drawer.hasAttribute('open')) closeDrawer('escape'); });",
6849
+ " cartForms.forEach((form) => {",
6850
+ " if (!mark(form, 'cart-form')) return;",
6851
+ " if (!canListen(form)) return;",
6852
+ " form.addEventListener('submit', () => api.emit('gem:cart:submit', { source: 'gem-runtime', action: form.getAttribute('action') || undefined }));",
6853
+ " bound += 1;",
6854
+ " });",
6855
+ " api.emit('gem:cart:ready', { source: 'gem-runtime', count: bound });",
6856
+ " });"
6857
+ ];
6858
+ var mobileMenuPluginRegistrationLines = [
6859
+ " registerPlugin('mobile-menu', ({ api }) => {",
6860
+ " const menus = document.querySelectorAll('[data-gem-mobile-menu], details.gem-shell-header__drawer');",
6861
+ " let bound = 0;",
6862
+ " menus.forEach((menu) => {",
6863
+ " if (!mark(menu, 'mobile-menu')) return;",
6864
+ " const setMenuA11y = () => {",
6865
+ " menu.setAttribute && menu.setAttribute('aria-expanded', menu.open ? 'true' : 'false');",
6866
+ " const panel = menu.querySelector && menu.querySelector('[data-gem-mobile-menu-panel], .gem-shell-header__drawer-panel');",
6867
+ " if (panel && panel.setAttribute) panel.setAttribute('aria-hidden', menu.open ? 'false' : 'true');",
6868
+ " };",
6869
+ " menu.dataset.gemMenuState = menu.open ? 'open' : 'closed';",
6870
+ " setMenuA11y();",
6871
+ " if (!canListen(menu)) return;",
6872
+ " menu.addEventListener('toggle', () => {",
6873
+ " menu.dataset.gemMenuState = menu.open ? 'open' : 'closed';",
6874
+ " setMenuA11y();",
6875
+ " if (menu.open) {",
6876
+ " const panel = menu.querySelector && menu.querySelector('[data-gem-mobile-menu-panel], .gem-shell-header__drawer-panel');",
6877
+ ` const firstFocusable = panel && panel.querySelector && panel.querySelector('a[href], button, select, input, textarea, summary, [tabindex]:not([tabindex="-1"])');`,
6878
+ " if (firstFocusable && firstFocusable.focus) firstFocusable.focus();",
6879
+ " }",
6880
+ " api.emit(menu.open ? 'gem:menu:open' : 'gem:menu:close', { source: 'gem-runtime', id: menu.id || undefined });",
6881
+ " });",
6882
+ " menu.addEventListener('keydown', (event) => {",
6883
+ " if (event.key !== 'Escape' || !menu.open) return;",
6884
+ " menu.open = false;",
6885
+ " menu.dataset.gemMenuState = 'closed';",
6886
+ " setMenuA11y();",
6887
+ " const summary = menu.querySelector && menu.querySelector('summary');",
6888
+ " if (summary && summary.focus) summary.focus();",
6889
+ " });",
6890
+ " bound += 1;",
6891
+ " });",
6892
+ " api.emit('gem:menu:ready', { source: 'gem-runtime', count: bound });",
6893
+ " });"
6894
+ ];
6895
+ var quantityInputPluginRegistrationLines = [
6896
+ " registerPlugin('quantity-input', ({ api }) => {",
6897
+ " const controls = document.querySelectorAll('[data-gem-quantity-input], .gem-quantity-input');",
6898
+ " let bound = 0;",
6899
+ " const clamp = (value, min, max) => Math.max(min, Math.min(max, value));",
6900
+ " controls.forEach((control) => {",
6901
+ " if (!mark(control, 'quantity-input')) return;",
6902
+ ` const input = control.querySelector && control.querySelector('input[type="number"]');`,
6903
+ " if (!input) return;",
6904
+ " const setValue = (nextValue, reason) => {",
6905
+ " const min = Number.isFinite(Number(input.min)) ? Number(input.min) : 0;",
6906
+ " const max = Number.isFinite(Number(input.max)) && input.max !== '' ? Number(input.max) : Number.MAX_SAFE_INTEGER;",
6907
+ " const value = clamp(Number(nextValue) || min, min, max);",
6908
+ " input.value = String(value);",
6909
+ " api.emit('gem:quantity:change', { source: 'gem-runtime', value, reason, name: input.name || undefined });",
6910
+ " };",
6911
+ " if (!canListen(control) || !canListen(input)) return;",
6912
+ " control.addEventListener('click', (event) => {",
6913
+ " const button = event.target && event.target.closest ? event.target.closest('[data-gem-quantity-change]') : null;",
6914
+ " if (!button) return;",
6915
+ " event.preventDefault && event.preventDefault();",
6916
+ " const delta = Number(button.getAttribute('data-gem-quantity-change') || '0');",
6917
+ " setValue(Number(input.value || 0) + delta, delta > 0 ? 'increase' : 'decrease');",
6918
+ " });",
6919
+ " input.addEventListener('change', () => setValue(input.value, 'input'));",
6920
+ " bound += 1;",
6921
+ " });",
6922
+ " api.emit('gem:quantity:ready', { source: 'gem-runtime', count: bound });",
6923
+ " });"
6924
+ ];
6925
+ var variantSelectsPluginRegistrationLines = [
6926
+ " registerPlugin('variant-selects', ({ api }) => {",
6927
+ ` const selects = document.querySelectorAll('[data-gem-variant-select], select[name="id"]');`,
6928
+ " let bound = 0;",
6929
+ " selects.forEach((select) => {",
6930
+ " if (!mark(select, 'variant-selects')) return;",
6931
+ " let hasInitialized = false;",
6932
+ " const update = () => {",
6933
+ " const option = select.options && select.options[select.selectedIndex];",
6934
+ " const available = !option || option.getAttribute('data-gem-variant-available') !== 'false';",
6935
+ " const price = option && option.getAttribute('data-gem-variant-price');",
6936
+ " const mediaId = option && option.getAttribute('data-gem-variant-media-id');",
6937
+ " const form = select.closest && select.closest('form');",
6938
+ " const scope = (form && form.closest && form.closest('[data-gem-section-id], .gem-section, [data-qa-surface]')) || (select.closest && select.closest('[data-gem-section-id], .gem-section, [data-qa-surface]')) || document;",
6939
+ ` const submit = form && form.querySelector && form.querySelector('[type="submit"]');`,
6940
+ " const status = form && form.querySelector && form.querySelector('[data-gem-variant-status]');",
6941
+ " const priceOutput = scope && scope.querySelector && scope.querySelector('[data-gem-variant-price-output]');",
6942
+ " const availabilityOutput = scope && scope.querySelector && scope.querySelector('[data-gem-variant-availability]');",
6943
+ " if (form && form.dataset) form.dataset.gemSelectedVariantId = String(select.value || '');",
6944
+ " if (form && form.dataset && price) form.dataset.gemSelectedVariantPrice = price;",
6945
+ " if (form && form.dataset && mediaId) form.dataset.gemSelectedMediaId = mediaId;",
6946
+ " if (submit) submit.disabled = !available;",
6947
+ " if (submit) submit.setAttribute('aria-disabled', available ? 'false' : 'true');",
6948
+ " if (status) status.textContent = available ? 'Selected variant available' : 'Selected variant unavailable';",
6949
+ " if (hasInitialized && price && priceOutput) priceOutput.textContent = price;",
6950
+ " if (availabilityOutput) {",
6951
+ " availabilityOutput.textContent = available ? 'In stock' : 'Sold out';",
6952
+ " availabilityOutput.classList && availabilityOutput.classList.toggle('gem-product-main__availability--in-stock', available);",
6953
+ " availabilityOutput.classList && availabilityOutput.classList.toggle('gem-product-main__availability--sold-out', !available);",
6954
+ " }",
6955
+ " if (mediaId) {",
6956
+ ` const escapedMediaId = String(mediaId).replace(/["\\\\]/g, '\\\\$&');`,
6957
+ " const gallery = (scope && scope.querySelector && scope.querySelector('[data-gem-media-gallery], .gem-product-media-gallery')) || document.querySelector('[data-gem-media-gallery], .gem-product-media-gallery');",
6958
+ ` const thumb = gallery && gallery.querySelector && gallery.querySelector('[data-gem-media-id="' + escapedMediaId + '"]');`,
6959
+ " const mediaIndex = thumb && thumb.getAttribute && thumb.getAttribute('data-gem-media-index');",
6960
+ " if (thumb && typeof thumb.click === 'function') thumb.click();",
6961
+ " if (gallery && mediaIndex !== null && mediaIndex !== undefined) {",
6962
+ " gallery.dataset && (gallery.dataset.gemMediaActiveIndex = String(mediaIndex));",
6963
+ " gallery.querySelectorAll && gallery.querySelectorAll('[data-gem-media-thumb]').forEach((control) => {",
6964
+ " const isActive = control.getAttribute && control.getAttribute('data-gem-media-index') === String(mediaIndex);",
6965
+ " if (isActive) control.setAttribute('aria-current', 'true'); else control.removeAttribute('aria-current');",
6966
+ " if (control.dataset) control.dataset.gemMediaActive = isActive ? 'true' : 'false';",
6967
+ " });",
6968
+ " }",
6969
+ " }",
6970
+ " api.emit('gem:variant:change', { source: 'gem-runtime', variantId: select.value || undefined, available, price, mediaId });",
6971
+ " hasInitialized = true;",
6972
+ " };",
6973
+ " if (!canListen(select)) return;",
6974
+ " select.addEventListener('change', update);",
6975
+ " update();",
6976
+ " bound += 1;",
6977
+ " });",
6978
+ " api.emit('gem:variant:ready', { source: 'gem-runtime', count: bound });",
6979
+ " });"
6980
+ ];
6981
+ var predictiveSearchPluginRegistrationLines = [
6982
+ " registerPlugin('predictive-search', ({ api }) => {",
6983
+ ` const forms = document.querySelectorAll('[data-gem-predictive-search], [data-gem-search-canvas], form[action*="/search"], form[role="search"]');`,
6984
+ " const toggles = document.querySelectorAll('[data-gem-search-toggle]');",
6985
+ " const canvas = document.querySelector('[data-gem-search-canvas]');",
6986
+ " let bound = 0;",
6987
+ " const setCanvasState = (state, reason = 'api') => {",
6988
+ " if (!canvas || !canvas.dataset) return false;",
6989
+ " canvas.dataset.gemSearchCanvasState = state;",
6990
+ " canvas.setAttribute && canvas.setAttribute('aria-hidden', state === 'open' ? 'false' : 'true');",
6991
+ " if (state === 'open') {",
6992
+ ` const input = canvas.querySelector && canvas.querySelector('input[type="search"], input[name="q"]');`,
6993
+ " if (input && input.focus) input.focus();",
6994
+ " }",
6995
+ " api.emit(state === 'open' ? 'gem:search:open' : 'gem:search:close', { source: 'gem-runtime', reason });",
6996
+ " return true;",
6997
+ " };",
6998
+ " forms.forEach((form) => {",
6999
+ " if (!mark(form, 'predictive-search')) return;",
7000
+ ` const input = form.querySelector && form.querySelector('input[type="search"], input[name="q"]');`,
7001
+ " const results = form.querySelector && form.querySelector('[data-gem-predictive-search-results]');",
7002
+ " const setState = (state, value = '') => {",
7003
+ " if (form.dataset) form.dataset.gemPredictiveSearchState = state;",
7004
+ " if (results) {",
7005
+ " results.hidden = state === 'closed';",
7006
+ " if (results.dataset) results.dataset.gemPredictiveSearchState = state;",
7007
+ " results.setAttribute && results.setAttribute('aria-hidden', state === 'closed' ? 'true' : 'false');",
7008
+ " if (state === 'typing') results.textContent = value ? 'Search suggestions for ' + value : 'Start typing to search';",
7009
+ " if (state === 'empty') results.textContent = 'Start typing to search';",
7010
+ " }",
7011
+ " };",
7012
+ " if (results) {",
7013
+ " results.hidden = true;",
7014
+ " results.setAttribute && results.setAttribute('role', 'status');",
7015
+ " results.setAttribute && results.setAttribute('aria-live', 'polite');",
7016
+ " }",
7017
+ " setState('closed');",
7018
+ " if (canListen(input)) input.addEventListener('focus', () => { setState(input.value ? 'typing' : 'empty', input.value || ''); api.emit('gem:search:open', { source: 'gem-runtime', value: input.value || '' }); });",
7019
+ " const closeOnEscape = (event) => { if (event.key === 'Escape') { event.preventDefault && event.preventDefault(); event.stopPropagation && event.stopPropagation(); setState('closed'); api.emit('gem:search:close', { source: 'gem-runtime', reason: 'escape' }); } };",
7020
+ " if (canListen(input)) input.addEventListener('input', () => { setState(input.value ? 'typing' : 'empty', input.value || ''); api.emit('gem:search:input', { source: 'gem-runtime', value: input.value || '' }); });",
7021
+ " if (canListen(input)) input.addEventListener('keydown', closeOnEscape);",
7022
+ " if (canListen(form)) form.addEventListener('keydown', closeOnEscape);",
7023
+ " if (canListen(form)) form.addEventListener('submit', () => { setState('submit', input && input.value || ''); api.emit('gem:search:submit', { source: 'gem-runtime', value: input && input.value || '' }); });",
7024
+ " bound += 1;",
7025
+ " });",
7026
+ " if (canvas) {",
7027
+ " canvas.dataset && (canvas.dataset.gemSearchCanvasState = 'closed');",
7028
+ " canvas.setAttribute && canvas.setAttribute('aria-hidden', 'true');",
7029
+ " if (mark(canvas, 'search-canvas')) bound += 1;",
7030
+ " if (canListen(canvas)) canvas.addEventListener('keydown', (event) => { if (event.key === 'Escape') setCanvasState('closed', 'escape'); });",
7031
+ " }",
7032
+ " toggles.forEach((toggle) => {",
7033
+ " if (!mark(toggle, 'search-toggle')) return;",
7034
+ " if (!canListen(toggle)) return;",
7035
+ " toggle.addEventListener('click', (event) => {",
7036
+ " if (canvas) event.preventDefault && event.preventDefault();",
7037
+ " setCanvasState('open', 'toggle');",
7038
+ " });",
7039
+ " bound += 1;",
7040
+ " });",
7041
+ " api.emit('gem:search:ready', { source: 'gem-runtime', count: bound });",
7042
+ " });"
7043
+ ];
7044
+ var localizationPluginRegistrationLines = [
7045
+ " registerPlugin('localization', ({ api }) => {",
7046
+ ` const forms = document.querySelectorAll('form[action*="/localization"], form[action*="localization"], [data-gem-footer-localization], [data-gem-header-localization], .gem-navigation-shell__localization, .gem-shell-footer__localization, .gem-shell-header__localization-form');`,
7047
+ " let bound = 0;",
7048
+ " forms.forEach((form) => {",
7049
+ " if (!mark(form, 'localization')) return;",
7050
+ " form.dataset && (form.dataset.gemLocalizationState = 'ready');",
7051
+ ` form.querySelectorAll && form.querySelectorAll('select[name="country_code"], select[name="locale_code"]').forEach((select) => {`,
7052
+ " if (!canListen(select)) return;",
7053
+ " select.addEventListener('change', () => {",
7054
+ " form.dataset && (form.dataset.gemLocalizationState = 'changed');",
7055
+ " api.emit('gem:localization:change', { source: 'gem-runtime', name: select.name || undefined, value: select.value || undefined });",
7056
+ " });",
7057
+ " });",
7058
+ " canListen(form) && form.addEventListener('submit', () => {",
7059
+ " form.dataset && (form.dataset.gemLocalizationState = 'submit');",
7060
+ " api.emit('gem:localization:submit', { source: 'gem-runtime' });",
7061
+ " });",
7062
+ " bound += 1;",
7063
+ " });",
7064
+ " api.emit('gem:localization:ready', { source: 'gem-runtime', count: bound });",
7065
+ " });"
7066
+ ];
7067
+ var disclosurePluginRegistrationLines = [
7068
+ " registerPlugin('disclosure', ({ api }) => {",
7069
+ " const items = document.querySelectorAll('details[data-gem-disclosure], .gem-accordion details, .gem-tabs details, .gem-footer-shell details, [data-gem-footer-shell] details');",
7070
+ " let bound = 0;",
7071
+ " items.forEach((item) => {",
7072
+ " if (!mark(item, 'disclosure')) return;",
7073
+ " item.dataset.gemDisclosureState = item.open ? 'open' : 'closed';",
7074
+ " if (!canListen(item)) return;",
7075
+ " item.addEventListener('toggle', () => {",
7076
+ " item.dataset.gemDisclosureState = item.open ? 'open' : 'closed';",
7077
+ " api.emit(item.open ? 'gem:disclosure:open' : 'gem:disclosure:close', { source: 'gem-runtime', id: item.id || undefined });",
7078
+ " });",
7079
+ " item.addEventListener('keydown', (event) => {",
7080
+ " if (event.key !== 'Escape' || !item.open) return;",
7081
+ " event.preventDefault && event.preventDefault();",
7082
+ " item.open = false;",
7083
+ " item.dataset.gemDisclosureState = 'closed';",
7084
+ " const summary = item.querySelector && item.querySelector('summary');",
7085
+ " if (summary && summary.focus) summary.focus();",
7086
+ " api.emit('gem:disclosure:close', { source: 'gem-runtime', id: item.id || undefined, reason: 'escape' });",
7087
+ " });",
7088
+ " bound += 1;",
7089
+ " });",
7090
+ " api.emit('gem:disclosure:ready', { source: 'gem-runtime', count: bound });",
7091
+ " });"
7092
+ ];
7093
+ var tabsPluginRegistrationLines = [
7094
+ " registerPlugin('tabs', ({ api }) => {",
7095
+ // Ported from the Liquid theme's initTabs (gem-framework.js) so BOTH render paths honour the same
7096
+ // contract: [data-gem-tabs] root, [data-gem-tab=key] controls, [data-gem-panel=key] panels. The
7097
+ // headless runtime had no tabs plugin at all, while gemcapture's guidance told rebuilds to fake
7098
+ // tabs with <details data-gem-disclosure> — which is an accordion, not tabs, and binds nothing on
7099
+ // the Liquid side. Now the guidance names one contract and both runtimes implement it.
7100
+ " const roots = document.querySelectorAll('[data-gem-tabs]');",
7101
+ " let bound = 0;",
7102
+ " roots.forEach((root) => {",
7103
+ " if (!mark(root, 'tabs')) return;",
7104
+ " const tabs = Array.prototype.slice.call(root.querySelectorAll('[data-gem-tab]'));",
7105
+ " const panels = Array.prototype.slice.call(root.querySelectorAll('[data-gem-panel]'));",
7106
+ " if (!tabs.length || !panels.length) return;",
7107
+ " const select = (key, userInitiated) => {",
7108
+ " tabs.forEach((t) => t.setAttribute('aria-selected', String(t.getAttribute('data-gem-tab') === key)));",
7109
+ " let active = null;",
7110
+ " panels.forEach((p) => {",
7111
+ " if (p.getAttribute('data-gem-panel') === key) { p.removeAttribute('hidden'); active = p; }",
7112
+ " else p.setAttribute('hidden', '');",
7113
+ " });",
7114
+ // Same smart-scroll guard as the Liquid implementation: switching a tab must never jump the page,
7115
+ // and block:'nearest' is a no-op when the panel is already on screen.
7116
+ " if (userInitiated && active && active.getBoundingClientRect) {",
7117
+ " const r = active.getBoundingClientRect();",
7118
+ " const vh = window.innerHeight || document.documentElement.clientHeight;",
7119
+ " if (!(r.top < vh * 0.85 && r.bottom > vh * 0.15) && active.scrollIntoView) {",
7120
+ " active.scrollIntoView({ behavior: 'smooth', block: 'nearest' });",
7121
+ " }",
7122
+ " }",
7123
+ " api.emit('gem:tabs:change', { source: 'gem-runtime', key: key });",
7124
+ " };",
7125
+ " tabs.forEach((t) => {",
7126
+ " if (!canListen(t)) return;",
7127
+ " t.addEventListener('click', () => select(t.getAttribute('data-gem-tab'), true));",
7128
+ " });",
7129
+ // Seed from aria-selected so a captured `state: \"active\"` tab opens on the correct panel.
7130
+ " const initial = tabs.filter((t) => t.getAttribute('aria-selected') === 'true')[0] || tabs[0];",
7131
+ " select(initial.getAttribute('data-gem-tab'), false);",
7132
+ " bound += 1;",
7133
+ " });",
7134
+ " api.emit('gem:tabs:ready', { source: 'gem-runtime', count: bound });",
7135
+ " });"
7136
+ ];
7137
+ var mediaGalleryPluginRegistrationLines = [
7138
+ " registerPlugin('media-gallery', ({ api }) => {",
7139
+ " const galleries = document.querySelectorAll('[data-gem-media-gallery], .gem-media-gallery, .gem-product-media-gallery');",
7140
+ " let bound = 0;",
7141
+ " galleries.forEach((gallery) => {",
7142
+ // A wrapper matched only by its class (the thumbnail-stack <section class="gem-product-media-gallery">) around a
7143
+ // [data-gem-media-gallery] hook is not a second gallery: binding both ran every click twice and the outer one
7144
+ // renumbered the thumbnails' data-gem-media-index by counting every <img>, the main image included.
7145
+ " const hooked = !!(gallery.hasAttribute && gallery.hasAttribute('data-gem-media-gallery'));",
7146
+ " if (!hooked && gallery.querySelector && gallery.querySelector('[data-gem-media-gallery]')) return;",
7147
+ " if (!mark(gallery, 'media-gallery')) return;",
7148
+ // Main image + thumbnails (product-media-gallery.thumbnail-stack.v1): a thumbnail click puts its
7149
+ // data-gem-media-src / -alt on [data-gem-media-main], as the section's behavior.js did. Without this the plugin
7150
+ // only moved aria-current and the big image never changed. The markup's data-gem-media-index is kept.
7151
+ " const main = gallery.querySelector && gallery.querySelector('[data-gem-media-main]');",
7152
+ " const thumbs = main && gallery.querySelectorAll ? Array.prototype.slice.call(gallery.querySelectorAll('[data-gem-media-item]')) : [];",
7153
+ " if (main && thumbs.length) {",
7154
+ " gallery.dataset.gemMediaState = 'ready';",
7155
+ " const choose = (item, reason) => {",
7156
+ " const src = item.getAttribute('data-gem-media-src');",
7157
+ " const alt = item.getAttribute('data-gem-media-alt');",
7158
+ " if (src) { main.setAttribute('src', src); main.removeAttribute('srcset'); if (alt != null) main.setAttribute('alt', alt); }",
7159
+ " const index = Number(item.getAttribute('data-gem-media-index') || thumbs.indexOf(item));",
7160
+ " thumbs.forEach((thumb) => { const on = thumb === item; thumb.setAttribute('aria-current', String(on)); if (thumb.dataset) thumb.dataset.gemMediaActive = on ? 'true' : 'false'; });",
7161
+ " gallery.dataset.gemMediaActiveIndex = String(index);",
7162
+ " if (gallery.dispatchEvent && typeof window.CustomEvent === 'function') gallery.dispatchEvent(new window.CustomEvent('gem:media:select', { bubbles: true, detail: { index } }));",
7163
+ " api.emit('gem:media:change', { source: 'gem-runtime', index, galleryId: gallery.id || undefined, reason });",
7164
+ " };",
7165
+ " if (canListen(gallery)) gallery.addEventListener('click', (event) => {",
7166
+ " const item = event.target && event.target.closest ? event.target.closest('[data-gem-media-item]') : null;",
7167
+ " if (!item || !gallery.contains(item)) return;",
7168
+ " if (event.preventDefault) event.preventDefault();",
7169
+ " choose(item, 'click');",
7170
+ " });",
7171
+ " bound += 1;",
7172
+ " return;",
7173
+ " }",
7174
+ " const items = gallery.querySelectorAll('[data-gem-media-item], img, video, model-viewer');",
7175
+ " const thumbControls = gallery.querySelectorAll('[data-gem-media-thumb]');",
7176
+ " const controls = thumbControls.length ? thumbControls : items;",
7177
+ " gallery.dataset.gemMediaState = 'ready';",
7178
+ " items.forEach((item, index) => {",
7179
+ " if (!item.dataset) return;",
7180
+ " item.dataset.gemMediaIndex = String(index);",
7181
+ " });",
7182
+ " const activate = (target, reason = 'click') => {",
7183
+ " if (!target) return;",
7184
+ " const index = target.dataset && target.dataset.gemMediaIndex ? Number(target.dataset.gemMediaIndex) : undefined;",
7185
+ " if (!Number.isFinite(index)) return;",
7186
+ " controls.forEach((control) => {",
7187
+ " const isActive = control.dataset && Number(control.dataset.gemMediaIndex) === index;",
7188
+ " if (isActive) control.setAttribute('aria-current', 'true'); else control.removeAttribute('aria-current');",
7189
+ " if (control.dataset) control.dataset.gemMediaActive = isActive ? 'true' : 'false';",
7190
+ " });",
7191
+ " gallery.dataset.gemMediaActiveIndex = String(index);",
7192
+ " api.emit('gem:media:change', { source: 'gem-runtime', index, galleryId: gallery.id || undefined, reason });",
7193
+ " };",
7194
+ ` const firstActive = gallery.querySelector('[aria-current="true"], [data-gem-media-thumb]');`,
7195
+ " if (firstActive) activate(firstActive, 'init');",
7196
+ " if (canListen(gallery)) gallery.addEventListener('click', (event) => {",
7197
+ " const target = event.target && event.target.closest ? event.target.closest('[data-gem-media-item], img, video, model-viewer') : null;",
7198
+ " if (!target) return;",
7199
+ " activate(target, 'click');",
7200
+ " });",
7201
+ " controls.forEach((control) => { if (!canListen(control)) return; control.addEventListener('keydown', (event) => {",
7202
+ " if (!['Enter', ' '].includes(event.key)) return;",
7203
+ " event.preventDefault && event.preventDefault();",
7204
+ " activate(control, 'keyboard');",
7205
+ " }); });",
7206
+ " bound += 1;",
7207
+ " });",
7208
+ " api.emit('gem:media:ready', { source: 'gem-runtime', count: bound });",
7209
+ " });"
7210
+ ];
7211
+ var carouselPluginRegistrationLines = [
7212
+ " registerPlugin('carousel', ({ api }) => {",
7213
+ " const carousels = document.querySelectorAll('[data-gem-carousel], .gem-carousel');",
7214
+ " let bound = 0;",
7215
+ " carousels.forEach((carousel) => {",
7216
+ " if (!mark(carousel, 'carousel')) return;",
7217
+ " const viewport = carousel.querySelector && (carousel.querySelector('[data-gem-carousel-viewport]') || carousel.querySelector('.gem-carousel__viewport'));",
7218
+ " const slides = carousel.querySelectorAll('[data-gem-carousel-slide], .gem-carousel__slide');",
7219
+ " const previous = carousel.querySelector && carousel.querySelector('[data-gem-carousel-prev]');",
7220
+ " const next = carousel.querySelector && carousel.querySelector('[data-gem-carousel-next]');",
7221
+ " const slideCount = slides.length || 0;",
7222
+ " const currentIndex = () => {",
7223
+ " if (!viewport || !slideCount) return 0;",
7224
+ " const width = Number(viewport.clientWidth || viewport.offsetWidth || 1);",
7225
+ " return Math.max(0, Math.min(slideCount - 1, Math.round(Number(viewport.scrollLeft || 0) / width)));",
7226
+ " };",
7227
+ " const setIndex = (index, reason = 'api') => {",
7228
+ " const nextIndex = Math.max(0, Math.min(slideCount - 1, Number.isFinite(index) ? index : 0));",
7229
+ " carousel.dataset.gemCarouselIndex = String(nextIndex);",
7230
+ " carousel.dataset.gemCarouselCount = String(slideCount);",
7231
+ " if (viewport && viewport.scrollTo) viewport.scrollTo({ left: Number(viewport.clientWidth || viewport.offsetWidth || 0) * nextIndex, behavior: 'smooth' });",
7232
+ " api.emit('gem:carousel:change', { source: 'gem-runtime', id: carousel.id || undefined, index: nextIndex, count: slideCount, reason });",
7233
+ " };",
7234
+ " slides.forEach((slide, index) => { if (slide.dataset) slide.dataset.gemCarouselIndex = String(index); });",
7235
+ " carousel.dataset.gemCarouselState = slideCount > 1 ? 'ready' : 'single';",
7236
+ " carousel.dataset.gemCarouselCount = String(slideCount);",
7237
+ " carousel.dataset.gemCarouselIndex = String(currentIndex());",
7238
+ " if (canListen(previous)) previous.addEventListener('click', (event) => { event.preventDefault && event.preventDefault(); setIndex(currentIndex() - 1, 'previous'); });",
7239
+ " if (canListen(next)) next.addEventListener('click', (event) => { event.preventDefault && event.preventDefault(); setIndex(currentIndex() + 1, 'next'); });",
7240
+ " if (canListen(viewport)) viewport.addEventListener('scroll', () => { carousel.dataset.gemCarouselIndex = String(currentIndex()); }, { passive: true });",
7241
+ " bound += 1;",
7242
+ " });",
7243
+ " api.emit('gem:carousel:ready', { source: 'gem-runtime', count: bound });",
7244
+ " });"
7245
+ ];
7246
+ var heroSlideshowPluginRegistrationLines = [
7247
+ " registerPlugin('hero-slideshow', ({ api }) => {",
7248
+ " const roots = document.querySelectorAll('[data-gem-hero-slideshow]');",
7249
+ " let bound = 0;",
7250
+ " roots.forEach((root) => {",
7251
+ " if (!mark(root, 'hero-slideshow')) return;",
7252
+ " const slides = Array.prototype.slice.call(root.querySelectorAll('[data-gem-hero-slide]'));",
7253
+ " if (slides.length < 2) return;",
7254
+ " const dots = Array.prototype.slice.call(root.querySelectorAll('[data-gem-hero-dot]'));",
7255
+ " const previous = root.querySelector && root.querySelector('[data-gem-hero-prev]');",
7256
+ " const next = root.querySelector && root.querySelector('[data-gem-hero-next]');",
7257
+ " const pause = root.querySelector && root.querySelector('[data-gem-hero-pause]');",
7258
+ " const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;",
7259
+ " const autoplay = !!(root.dataset && root.dataset.autoplay === 'true') && !reduce;",
7260
+ " const interval = Math.max(Number((root.dataset && root.dataset.autoplayInterval) || 6000), 4000);",
7261
+ " let active = 0;",
7262
+ " for (let i = 0; i < slides.length; i += 1) { if ((slides[i].className || '').indexOf('gem-hero-slideshow__slide--active') > -1) { active = i; break; } }",
7263
+ " let timer = null; let paused = false;",
7264
+ " const show = (index) => {",
7265
+ " active = (index + slides.length) % slides.length;",
7266
+ " slides.forEach((s, i) => { const on = i === active; if (s.classList) s.classList.toggle('gem-hero-slideshow__slide--active', on); s.hidden = !on; });",
7267
+ // `tabindex` đi cùng `aria-selected` vì các chấm này khai `role=\"tab\"`: một tablist chỉ được
7268
+ // chiếm MỘT điểm dừng Tab, và mũi tên mới là thứ chạy giữa các tab (xử lý ngay dưới).
7269
+ " dots.forEach((d, i) => { const on = i === active; if (d.classList) d.classList.toggle('gem-hero-slideshow__dot--active', on); if (d.setAttribute) { d.setAttribute('aria-selected', String(on)); d.setAttribute('tabindex', on ? '0' : '-1'); } });",
7270
+ " api.emit('gem:hero-slideshow:change', { source: 'gem-runtime', id: root.id || undefined, index: active, count: slides.length });",
7271
+ " };",
7272
+ " const stop = () => { if (timer) { window.clearInterval(timer); timer = null; } };",
7273
+ " const start = () => { stop(); if (autoplay && !paused) timer = window.setInterval(() => { show(active + 1); }, interval); };",
7274
+ " if (canListen(previous)) previous.addEventListener('click', (e) => { if (e.preventDefault) e.preventDefault(); show(active - 1); start(); });",
7275
+ " if (canListen(next)) next.addEventListener('click', (e) => { if (e.preventDefault) e.preventDefault(); show(active + 1); start(); });",
7276
+ " dots.forEach((d, i) => { if (canListen(d)) d.addEventListener('click', () => { show(Number((d.dataset && d.dataset.slideIndex) || i)); start(); }); });",
7277
+ " const focusDot = (i) => { const n = dots.length; if (!n) return; const k = ((i % n) + n) % n; show(k); start(); if (dots[k].focus) dots[k].focus(); };",
7278
+ " dots.forEach((d, i) => { if (canListen(d)) d.addEventListener('keydown', (e) => {",
7279
+ " const k = e.key;",
7280
+ " if (k === 'ArrowRight' || k === 'ArrowDown') { if (e.preventDefault) e.preventDefault(); focusDot(i + 1); }",
7281
+ " else if (k === 'ArrowLeft' || k === 'ArrowUp') { if (e.preventDefault) e.preventDefault(); focusDot(i - 1); }",
7282
+ " else if (k === 'Home') { if (e.preventDefault) e.preventDefault(); focusDot(0); }",
7283
+ " else if (k === 'End') { if (e.preventDefault) e.preventDefault(); focusDot(dots.length - 1); }",
7284
+ " }); });",
7285
+ " // The visible label follows the state like the section's own behavior did: the button's own text while playing",
7286
+ " // (kept, so a translated label survives), data-gem-hero-play-label (or 'Play') while paused. Only a text-only button.",
7287
+ " const pauseText = pause && pause.childElementCount === 0 ? pause.textContent : null;",
7288
+ " const playText = (pause && pause.getAttribute && pause.getAttribute('data-gem-hero-play-label')) || 'Play';",
7289
+ " if (canListen(pause)) pause.addEventListener('click', () => { paused = !paused; if (pause.setAttribute) pause.setAttribute('aria-pressed', String(paused)); if (pauseText !== null) pause.textContent = paused ? playText : pauseText; start(); });",
7290
+ " show(active); start(); bound += 1;",
7291
+ " });",
7292
+ " api.emit('gem:hero-slideshow:ready', { source: 'gem-runtime', count: bound });",
7293
+ " });"
7294
+ ];
7295
+ var legacyQuickViewLines = [
7296
+ ` const triggers = document.querySelectorAll('[data-gem-quick-view], [data-gem-action="quick-view"]');`,
7297
+ ` const surface = document.querySelector('[data-gem-quick-view-surface], .gem-quick-view, .gem-modal[data-gem-component="quick-view"]');`,
7298
+ " let bound = 0;",
7299
+ " const close = (reason = 'close-control') => {",
7300
+ " if (surface) {",
7301
+ " surface.dataset.gemQuickViewState = 'closed';",
7302
+ " surface.removeAttribute && surface.removeAttribute('open');",
7303
+ " surface.setAttribute && surface.setAttribute('aria-hidden', 'true');",
7304
+ " }",
7305
+ " api.emit('gem:quick-view:close', { source: 'gem-runtime', reason });",
7306
+ " };",
7307
+ " const openSurface = (detail) => {",
7308
+ " if (surface) {",
7309
+ " surface.dataset.gemQuickViewState = 'open';",
7310
+ " surface.dataset.gemProductHandle = detail.productHandle || '';",
7311
+ " surface.dataset.gemProductUrl = detail.productUrl || '';",
7312
+ " surface.setAttribute && surface.setAttribute('open', '');",
7313
+ " surface.setAttribute && surface.setAttribute('aria-hidden', 'false');",
7314
+ " surface.setAttribute && surface.setAttribute('role', 'dialog');",
7315
+ " surface.setAttribute && surface.setAttribute('aria-modal', 'true');",
7316
+ " const content = surface.querySelector && surface.querySelector('[data-gem-quick-view-content]');",
7317
+ " if (content && content.textContent !== undefined) content.textContent = 'Quick view for ' + (detail.productHandle || 'product');",
7318
+ " else if (surface.textContent !== undefined) surface.textContent = 'Quick view for ' + (detail.productHandle || 'product');",
7319
+ " const closeButton = surface.querySelector && surface.querySelector('[data-gem-quick-view-close]');",
7320
+ " if (closeButton && closeButton.focus) closeButton.focus();",
7321
+ " }",
7322
+ " api.emit('gem:quick-view:open', { source: 'gem-runtime', ...detail, hasSurface: Boolean(surface) });",
7323
+ " };",
7324
+ " triggers.forEach((trigger) => {",
7325
+ " if (!mark(trigger, 'quick-view')) return;",
7326
+ " if (!canListen(trigger)) return;",
7327
+ " trigger.addEventListener('click', (event) => {",
7328
+ " const productUrl = trigger.getAttribute && (trigger.getAttribute('data-gem-product-url') || trigger.getAttribute('href'));",
7329
+ " const productHandle = trigger.getAttribute && trigger.getAttribute('data-gem-product-handle');",
7330
+ " if (!productUrl && !productHandle) return;",
7331
+ " event.preventDefault && event.preventDefault();",
7332
+ " openSurface({ productUrl, productHandle });",
7333
+ " });",
7334
+ " bound += 1;",
7335
+ " });",
7336
+ " if (surface && mark(surface, 'quick-view-surface')) {",
7337
+ " surface.dataset.gemQuickViewState = 'closed';",
7338
+ " surface.setAttribute && surface.setAttribute('aria-hidden', 'true');",
7339
+ " if (canListen(surface)) surface.addEventListener('click', (event) => {",
7340
+ " const target = event.target;",
7341
+ " if (target && target.closest && target.closest('[data-gem-quick-view-close]')) close('close-control');",
7342
+ " });",
7343
+ " if (canListen(surface)) surface.addEventListener('keydown', (event) => { if (event.key === 'Escape') close('escape'); });",
7344
+ " }",
7345
+ // A mount on one late trigger (gem-runtime.js binds a section added after boot on its own) finds no surface: it keeps the
7346
+ // page's api.quickView.
7347
+ " if (surface || !api.quickView) api.quickView = { open: (detail = {}) => openSurface({ ...detail }), close };"
7348
+ ];
7349
+ var runtimeChunks = gemMotionChunks({
7350
+ hook: '[data-gem-quick-view], [data-gem-action="quick-view"]',
7351
+ lines: legacyQuickViewLines,
7352
+ readyDetail: "count: bound + count, hasSurface: Boolean(surface)"
7353
+ });
7354
+ var promoPopupPluginRegistrationLines = [
7355
+ " registerPlugin('promo-popup', ({ api }) => {",
7356
+ // Only the explicit hook opts in. Every promo-popup section carries the archetype class .gem-promo-popup, and
7357
+ // binding by class took over variants that run their own behavior.js (GemFactory gap #16, 03/10/2026).
7358
+ // A section that has the hook AND its own behavior.js (promo-popup.email-capture.v1) renders the plugin's bound
7359
+ // mark, data-gem-bound-promo-popup, so mark() below skips it (gem-framework 0.8.3).
7360
+ " const popups = document.querySelectorAll('[data-gem-promo-popup]');",
7361
+ " let bound = 0;",
7362
+ " const shown = [];",
7363
+ " const storagePrefix = 'gem:promo-popup-dismissed:';",
7364
+ " const now = () => Date.now ? Date.now() : new Date().getTime();",
7365
+ // ONE storage key, two meanings in the wild (gem-framework 0.8.0). gemsite's port stores WHEN the shopper
7366
+ // dismissed; runtimes up to 0.7 stored when the dismissal ENDS. Read both: a value in the future can only be an
7367
+ // end, so it holds until then (and is rewritten once as a dismissal time); a value in the past is a dismissal time and
7368
+ // the popup's own window applies. Written from now on: the dismissal time (gemsite's meaning), which also lets a
7369
+ // seller who shortens the window shorten it for past dismissals. An end written by 0.7 that has already passed
7370
+ // reads as a dismissal time: that popup stays away one more window at most, never less than the shopper asked.
7371
+ " const readDismissal = (key) => {",
7372
+ " try { const value = Number(target.localStorage && target.localStorage.getItem(storagePrefix + key) || '0'); return Number.isFinite(value) && value > 0 ? value : 0; }",
7373
+ " catch { return 0; }",
7374
+ " };",
7375
+ " const writeDismissedAt = (key, timestamp) => {",
7376
+ " try { if (target.localStorage) target.localStorage.setItem(storagePrefix + key, String(timestamp)); }",
7377
+ " catch { /* storage can be unavailable in private contexts */ }",
7378
+ " };",
7379
+ " const isDismissed = (key, windowMs) => {",
7380
+ " const stored = readDismissal(key);",
7381
+ " if (!stored) return false;",
7382
+ " const at = now();",
7383
+ // Converted ONCE: only when the end minus this window is already past (it is then the dismissal time). An end
7384
+ // further out than one window (the 0.7 window was longer) is left as it is and holds until it passes.
7385
+ " if (stored > at) { if (stored - windowMs <= at) writeDismissedAt(key, stored - windowMs); return true; }",
7386
+ " return at - stored < windowMs;",
7387
+ " };",
7388
+ " popups.forEach((popup, index) => {",
7389
+ " if (!mark(popup, 'promo-popup')) return;",
7390
+ " const key = popup.id || popup.getAttribute && popup.getAttribute('data-gem-section-library-id') || String(index);",
7391
+ " const delaySeconds = Number(popup.getAttribute && (popup.getAttribute('data-gem-promo-delay') || '0'));",
7392
+ " const frequencyDays = Number(popup.getAttribute && (popup.getAttribute('data-gem-promo-frequency-days') || '7'));",
7393
+ " const windowMs = (Number.isFinite(frequencyDays) ? Math.max(1, frequencyDays) : 7) * 86400000;",
7394
+ " const dialog = popup.querySelector && popup.querySelector('[data-gem-promo-popup-dialog]');",
7395
+ " const overlay = popup.querySelector && popup.querySelector('[data-gem-promo-popup-overlay]');",
7396
+ " const show = (reason) => {",
7397
+ " popup.hidden = false;",
7398
+ " if (dialog) dialog.hidden = false;",
7399
+ " if (overlay) overlay.hidden = false;",
7400
+ " popup.dataset.gemPromoPopupState = 'open';",
7401
+ " api.emit('gem:promo-popup:open', { source: 'gem-runtime', id: popup.id || undefined, reason });",
7402
+ " };",
7403
+ " const open = (reason = 'timer') => {",
7404
+ " if (isDismissed(key, windowMs)) { popup.dataset.gemPromoPopupState = 'dismissed'; return false; }",
7405
+ " show(reason);",
7406
+ " return true;",
7407
+ " };",
7408
+ " const close = (reason = 'dismiss') => {",
7409
+ " writeDismissedAt(key, now());",
7410
+ " if (dialog) dialog.hidden = true;",
7411
+ " if (overlay) overlay.hidden = true;",
7412
+ " popup.dataset.gemPromoPopupState = 'closed';",
7413
+ " api.emit('gem:promo-popup:close', { source: 'gem-runtime', id: popup.id || undefined, reason });",
7414
+ " };",
7415
+ " popup.querySelectorAll && popup.querySelectorAll('[data-gem-promo-popup-dismiss]').forEach((button) => {",
7416
+ " if (!canListen(button)) return;",
7417
+ " button.addEventListener('click', (event) => { event.preventDefault && event.preventDefault(); close('dismiss'); });",
7418
+ " });",
7419
+ " if (canListen(overlay)) overlay.addEventListener('click', () => close('overlay'));",
7420
+ " popup.dataset.gemPromoPopupState = 'ready';",
7421
+ " const delay = Math.max(0, Number.isFinite(delaySeconds) ? delaySeconds : 0) * 1000;",
7422
+ // WHEN it opens, gemsite's contract (components/behaviors/promoPopup.js): data-gem-promo-trigger = "delay"
7423
+ // (default, and any unknown value) | "exit_intent" (the pointer leaves through the top 10px of the window; a
7424
+ // touch screen has no pointer to leave with, so it falls back to the delay) | "scroll" (the shopper has scrolled
7425
+ // data-gem-promo-scroll-percent of the page, 10 to 90, default 50; a page that still cannot scroll at the delay
7426
+ // opens then). It opens once per page view, whichever fires first. data-gem-promo-armed says which one is live.
7427
+ // The delay keeps the runtime's default of 0 seconds (gemsite's port defaults to 6).
7428
+ " const trigger = String(popup.getAttribute && popup.getAttribute('data-gem-promo-trigger') || 'delay').trim();",
7429
+ " const rawPercent = String(popup.getAttribute && popup.getAttribute('data-gem-promo-scroll-percent') || '').trim();",
7430
+ " const parsedPercent = rawPercent ? Number(rawPercent) : NaN;",
7431
+ " const scrollPercent = Number.isFinite(parsedPercent) ? Math.min(90, Math.max(10, parsedPercent)) : 50;",
7432
+ " const canHover = () => { try { return Boolean(target.matchMedia && target.matchMedia('(hover: hover) and (pointer: fine)').matches); } catch { return false; } };",
7433
+ " const armed = trigger === 'exit_intent' && canHover() ? 'exit_intent' : trigger === 'scroll' ? 'scroll' : 'delay';",
7434
+ " if (popup.setAttribute) popup.setAttribute('data-gem-promo-armed', armed);",
7435
+ " let opened = false;",
7436
+ " const disarm = [];",
7437
+ " const openOnce = (reason) => { if (opened) return; opened = true; disarm.splice(0).forEach((off) => off()); open(reason); };",
7438
+ // An explicit open (api.promoPopup.open) counts as THE open of this page view: it disarms the pending trigger, so the
7439
+ // delay or exit intent does not show the popup a second time (code review of #675), and it does nothing on a popup
7440
+ // that is already open.
7441
+ " shown.push((reason) => { opened = true; disarm.splice(0).forEach((off) => off()); if (popup.dataset.gemPromoPopupState !== 'open') show(reason); });",
7442
+ " if (armed === 'exit_intent') {",
7443
+ " const onMouseOut = (event) => { if (event.relatedTarget || event.clientY >= 10) return; openOnce('exit_intent'); };",
7444
+ " if (canListen(document)) {",
7445
+ " document.addEventListener('mouseout', onMouseOut);",
7446
+ " disarm.push(() => document.removeEventListener && document.removeEventListener('mouseout', onMouseOut));",
7447
+ " }",
7448
+ " } else if (armed === 'scroll') {",
7449
+ " const room = () => Number(document.documentElement && document.documentElement.scrollHeight || 0) - Number(target.innerHeight || 0);",
7450
+ " const check = () => { const r = room(); if (r > 0 && (Number(target.scrollY || target.pageYOffset || 0) / r) * 100 >= scrollPercent) openOnce('scroll'); };",
7451
+ " let ticking = false;",
7452
+ " const onScroll = () => {",
7453
+ " if (ticking) return;",
7454
+ " ticking = true;",
7455
+ " const frame = target.requestAnimationFrame ? (fn) => target.requestAnimationFrame(fn) : (fn) => target.setTimeout && target.setTimeout(fn, 16);",
7456
+ " frame(() => { ticking = false; check(); });",
7457
+ " };",
7458
+ " if (canListen(target)) {",
7459
+ " target.addEventListener('scroll', onScroll, { passive: true });",
7460
+ " target.addEventListener('resize', check);",
7461
+ " target.addEventListener('load', check);",
7462
+ " disarm.push(() => { if (!target.removeEventListener) return; target.removeEventListener('scroll', onScroll); target.removeEventListener('resize', check); target.removeEventListener('load', check); });",
7463
+ " }",
7464
+ " if (target.setTimeout) {",
7465
+ " const fallback = target.setTimeout(() => { if (room() <= 0) openOnce('timer'); }, delay);",
7466
+ " disarm.push(() => target.clearTimeout && target.clearTimeout(fallback));",
7467
+ " }",
7468
+ " check();",
7469
+ " } else {",
7470
+ " target.setTimeout ? target.setTimeout(() => openOnce('timer'), delay) : openOnce('timer');",
7471
+ " }",
7472
+ " bound += 1;",
7473
+ " });",
7474
+ // The popups THIS plugin bound, never every [data-gem-promo-popup]: one a section's own behavior.js drives carries the
7475
+ // bound mark already (email-capture renders data-gem-bound-promo-popup="section") and is not the plugin's to open. An
7476
+ // explicit open (a teaser, a test) shows the dialog itself, not only the root, and ignores the dismissal window,
7477
+ // which is about opening on its own. Before 0.8.3 it unhid the root only, so the dialog stayed hidden.
7478
+ " api.promoPopup = { open: () => shown.forEach((show) => show('api')) };",
7479
+ " api.emit('gem:promo-popup:ready', { source: 'gem-runtime', count: bound });",
7480
+ " });"
7481
+ ];
7482
+ var stickyAtcPluginRegistrationLines = [
7483
+ " registerPlugin('sticky-atc', ({ api }) => {",
7484
+ " const bars = document.querySelectorAll('[data-gem-sticky-atc], .gem-sticky-atc');",
7485
+ " let bound = 0;",
7486
+ " const setState = (bar, state) => {",
7487
+ " if (!bar || !bar.dataset) return;",
7488
+ " if (bar.dataset.gemStickyAtcState === state) return;",
7489
+ " bar.dataset.gemStickyAtcState = state;",
7490
+ " api.emit(state === 'visible' ? 'gem:sticky-atc:show' : 'gem:sticky-atc:hide', { source: 'gem-runtime', id: bar.id || undefined });",
7491
+ " };",
7492
+ " bars.forEach((bar) => {",
7493
+ " if (!mark(bar, 'sticky-atc')) return;",
7494
+ // Two attribute names (gem-framework 0.8.0), each with its own default. data-gem-sticky-threshold is the
7495
+ // runtime's (default 240). data-gem-sticky-atc-threshold is gemsite's (StickyAtcCompactBar.tsx): when the bar
7496
+ // carries it, gemsite's rule decides: that number, else the --gem-sticky-atc-threshold CSS variable, else 360.
7497
+ // A bar carrying both follows gemsite's attribute.
7498
+ " const cssThreshold = () => { try { return target.getComputedStyle ? Number(target.getComputedStyle(bar).getPropertyValue('--gem-sticky-atc-threshold')) : 0; } catch { return 0; } };",
7499
+ " const threshold = bar.hasAttribute && bar.hasAttribute('data-gem-sticky-atc-threshold')",
7500
+ " ? (Number(bar.getAttribute('data-gem-sticky-atc-threshold')) || cssThreshold() || 360)",
7501
+ " : Number(bar.getAttribute && (bar.getAttribute('data-gem-sticky-threshold') || '240'));",
7502
+ " const update = () => {",
7503
+ " const scrollY = Number(target.scrollY || target.pageYOffset || 0);",
7504
+ " setState(bar, scrollY >= threshold ? 'visible' : 'hidden');",
7505
+ " };",
7506
+ " update();",
7507
+ " if (canListen(target)) target.addEventListener('scroll', update, { passive: true });",
7508
+ // The price follows the variant <select> (each <option> carries its own data-gem-variant-price), as the section's
7509
+ // behavior.js did: otherwise the bar shows the first variant's price whatever the shopper picked.
7510
+ " const priceEl = bar.querySelector && bar.querySelector('[data-gem-sticky-atc-price]');",
7511
+ " const select = bar.querySelector && bar.querySelector('select');",
7512
+ " const syncPrice = () => { if (!priceEl || !select) return; const opt = select.options && select.selectedIndex > -1 ? select.options[select.selectedIndex] : null; const price = opt && opt.getAttribute && opt.getAttribute('data-gem-variant-price'); if (price) priceEl.textContent = price; };",
7513
+ " if (canListen(select)) select.addEventListener('change', syncPrice);",
7514
+ " syncPrice();",
7515
+ // A form WITHOUT an action (the headless bar) has nowhere to post: the plugin adds to cart by dispatching
7516
+ // gem:cart:add with the SELECTED variant (the button's data-gem-variant-id is only the first one), which the
7517
+ // storefront's cart bridge consumes. A form WITH an action (a Liquid /cart/add form) is left to the browser.
7518
+ " const form = bar.querySelector && bar.querySelector('form');",
7519
+ " if (canListen(form)) form.addEventListener('submit', (event) => {",
7520
+ " if (!(form.hasAttribute && form.hasAttribute('action'))) {",
7521
+ " if (event && event.preventDefault) event.preventDefault();",
7522
+ " const choice = form.querySelector && form.querySelector('select');",
7523
+ " const trigger = bar.querySelector && bar.querySelector('[data-gem-variant-id]');",
7524
+ " const variantId = (choice && choice.value) || (trigger && trigger.getAttribute('data-gem-variant-id')) || undefined;",
7525
+ " if (bar.dispatchEvent && typeof window.CustomEvent === 'function') bar.dispatchEvent(new window.CustomEvent('gem:cart:add', { bubbles: true, detail: { variantId, quantity: 1 } }));",
7526
+ " }",
7527
+ " api.emit('gem:sticky-atc:submit', { source: 'gem-runtime', id: bar.id || undefined });",
7528
+ " });",
7529
+ " bound += 1;",
7530
+ " });",
7531
+ " api.emit('gem:sticky-atc:ready', { source: 'gem-runtime', count: bound });",
7532
+ " });"
7533
+ ];
7534
+ var beforeAfterPluginRegistrationLines = [
7535
+ " registerPlugin('before_after', ({ api }) => {",
7536
+ " const frames = document.querySelectorAll('[data-gem-before-after-frame], .gem-before-after__frame');",
7537
+ " let bound = 0;",
7538
+ " const clamp = (value) => Math.max(0, Math.min(100, Number.isFinite(value) ? value : 50));",
7539
+ " frames.forEach((frame) => {",
7540
+ " if (!mark(frame, 'before_after')) return;",
7541
+ ` const range = frame.querySelector && frame.querySelector('[data-gem-before-after-range], input[type="range"]');`,
7542
+ " const setPosition = (value, reason = 'init') => {",
7543
+ " const position = clamp(Number(value));",
7544
+ " if (frame.style && frame.style.setProperty) frame.style.setProperty('--gem-before-after-position', position + '%');",
7545
+ " frame.dataset.gemBeforeAfterPosition = String(position);",
7546
+ " if (range && range.value !== undefined) range.value = String(position);",
7547
+ " api.emit('gem:before-after:change', { source: 'gem-runtime', position, reason, id: frame.id || undefined });",
7548
+ " };",
7549
+ " const initial = range && range.value !== undefined ? range.value : frame.getAttribute && frame.getAttribute('data-gem-before-after-position');",
7550
+ " setPosition(initial || 50);",
7551
+ " if (canListen(range)) range.addEventListener('input', () => setPosition(range.value, 'input'));",
7552
+ " bound += 1;",
7553
+ " });",
7554
+ " api.emit('gem:before-after:ready', { source: 'gem-runtime', count: bound });",
7555
+ " });"
7556
+ ];
5050
7557
  var COUNTDOWN_END_RUNTIME_LINES = [
5051
7558
  " const COUNTDOWN_ISO = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$/i;",
5052
7559
  " const countdownEnd = (value) => {",
@@ -5232,6 +7739,141 @@ var countdownPluginRegistrationLines = [
5232
7739
  " api.emit('gem:countdown:ready', { source: 'gem-runtime', count: bound });",
5233
7740
  " });"
5234
7741
  ];
7742
+ var revealOnScrollPluginRegistrationLines = [
7743
+ " registerPlugin('reveal-on-scroll', ({ api }) => {",
7744
+ " const nodes = document.querySelectorAll('[data-gem-reveal], .gem-reveal');",
7745
+ " let bound = 0;",
7746
+ " const reveal = (node) => { node.dataset.gemRevealState = 'in'; };",
7747
+ " // Reduced motion and no IntersectionObserver land on the same answer: show everything now (from gemsite revealOnScroll.js).",
7748
+ " const reduce = typeof target.matchMedia === 'function' && target.matchMedia('(prefers-reduced-motion: reduce)').matches;",
7749
+ " if (reduce || !target.IntersectionObserver) { nodes.forEach(reveal); api.emit('gem:reveal:ready', { source: 'gem-runtime', count: 0 }); return; }",
7750
+ " // Above the fold the browser has already painted the section: writing 'ready' would blink it out and back in.",
7751
+ ' // `none` and `default` are the knob\'s "apply nothing" values; the attribute is still written, so skip them here.',
7752
+ " const off = (node) => { const v = node.getAttribute ? node.getAttribute('data-gem-reveal') : null; return v === 'none' || v === 'default'; };",
7753
+ " const inViewport = (node) => { if (!node.getBoundingClientRect) return true; const r = node.getBoundingClientRect(); const h = target.innerHeight || (document.documentElement && document.documentElement.clientHeight) || 0; return r.top < h && r.bottom > 0; };",
7754
+ " const observer = new target.IntersectionObserver((entries, obs) => {",
7755
+ " entries.forEach((entry) => {",
7756
+ " if (!entry.isIntersecting) return;",
7757
+ " reveal(entry.target);",
7758
+ " obs.unobserve(entry.target);",
7759
+ " api.emit('gem:reveal:in', { source: 'gem-runtime', id: entry.target.id || undefined });",
7760
+ " });",
7761
+ " }, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' });",
7762
+ " nodes.forEach((node) => {",
7763
+ " if (!mark(node, 'reveal-on-scroll')) return;",
7764
+ " if (off(node) || inViewport(node)) { reveal(node); bound += 1; return; }",
7765
+ " if (!node.dataset.gemRevealState) node.dataset.gemRevealState = 'ready';",
7766
+ " observer.observe(node);",
7767
+ " bound += 1;",
7768
+ " });",
7769
+ " api.emit('gem:reveal:ready', { source: 'gem-runtime', count: bound });",
7770
+ " });"
7771
+ ];
7772
+ var imgInPluginRegistrationLines = [
7773
+ " registerPlugin('img-in', ({ api }) => {",
7774
+ " const STATE = 'data-gem-img-in-state';",
7775
+ " const BOUND = 'data-gem-bound-img-in';",
7776
+ " const reduce = typeof target.matchMedia === 'function' && target.matchMedia('(prefers-reduced-motion: reduce)').matches;",
7777
+ " const style = (img) => (typeof target.getComputedStyle === 'function' ? target.getComputedStyle(img) : null);",
7778
+ " const owned = (s) => Boolean(s && ((s.opacity && s.opacity !== '1') || (s.filter && s.filter !== 'none') || (s.animationName && s.animationName !== 'none')));",
7779
+ " const overridden = (s) => Boolean(s && ((s.opacity && s.opacity !== '0.6') || (s.filter && s.filter.indexOf('blur') === -1)));",
7780
+ " const settle = (event) => {",
7781
+ " const img = event.target;",
7782
+ " if (!img || img.tagName !== 'IMG' || !img.hasAttribute(BOUND)) return;",
7783
+ " const state = img.getAttribute(STATE);",
7784
+ " if (event.type === 'load' || event.type === 'error') {",
7785
+ " if (state !== 'loading') return;",
7786
+ " if (event.type === 'load' && !reduce && !overridden(style(img))) img.setAttribute(STATE, 'in');",
7787
+ " else img.removeAttribute(STATE);",
7788
+ " return;",
7789
+ " }",
7790
+ " if (state === 'in' && event.animationName === 'gem-img-in') img.removeAttribute(STATE);",
7791
+ " };",
7792
+ " ['load', 'error', 'animationend', 'animationcancel'].forEach((type) => document.addEventListener(type, settle, true));",
7793
+ " const candidates = [];",
7794
+ " document.querySelectorAll('img').forEach((img) => {",
7795
+ " if (!img.closest('[data-gem-img-in]')) return;",
7796
+ " if (img.complete) { if (!img.hasAttribute(BOUND)) img.removeAttribute(STATE); return; }",
7797
+ " if (mark(img, 'img-in')) candidates.push(img);",
7798
+ " });",
7799
+ " const own = candidates.map((img) => owned(style(img)));",
7800
+ " let bound = 0;",
7801
+ " candidates.forEach((img, i) => { if (own[i]) return; img.setAttribute(STATE, 'loading'); bound += 1; });",
7802
+ " api.emit('gem:img-in:ready', { source: 'gem-runtime', count: bound });",
7803
+ " });"
7804
+ ];
7805
+ var recentlyViewedPluginRegistrationLines = [
7806
+ " registerPlugin('recently-viewed', ({ api }) => {",
7807
+ " const storageKey = 'gem:recently-viewed-products';",
7808
+ " const readItems = () => {",
7809
+ " try { return JSON.parse(target.localStorage && target.localStorage.getItem(storageKey) || '[]'); }",
7810
+ " catch { return []; }",
7811
+ " };",
7812
+ " const writeItems = (items) => {",
7813
+ " try { if (target.localStorage) target.localStorage.setItem(storageKey, JSON.stringify(items.slice(0, 12))); }",
7814
+ " catch { /* storage can be unavailable in private contexts */ }",
7815
+ " };",
7816
+ ` const escapeHtml = (value) => String(value || '').replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char]));`,
7817
+ " const renderItem = (item) => {",
7818
+ " const url = item && item.url ? String(item.url) : '#';",
7819
+ " const label = item && (item.title || item.handle || item.url) ? String(item.title || item.handle || item.url) : 'Recently viewed product';",
7820
+ ` return '<a class="gem-card gem-recently-viewed__item" href="' + escapeHtml(url) + '"><strong>' + escapeHtml(label) + '</strong></a>';`,
7821
+ " };",
7822
+ " const current = document.querySelector('[data-gem-current-product], [data-gem-product-handle]');",
7823
+ " if (current) {",
7824
+ " const handle = current.getAttribute && current.getAttribute('data-gem-product-handle');",
7825
+ " const url = current.getAttribute && (current.getAttribute('data-gem-product-url') || current.getAttribute('href'));",
7826
+ " const title = current.getAttribute && current.getAttribute('data-gem-product-title');",
7827
+ " if (handle || url) {",
7828
+ " const nextItem = { handle, url, title, recordedAt: new Date().toISOString() };",
7829
+ " const items = [nextItem, ...readItems().filter((item) => item && item.handle !== handle && item.url !== url)];",
7830
+ " writeItems(items);",
7831
+ " api.emit('gem:recently-viewed:record', { source: 'gem-runtime', handle, url });",
7832
+ " }",
7833
+ " }",
7834
+ " const rails = document.querySelectorAll('[data-gem-recently-viewed], .gem-recently-viewed');",
7835
+ " let bound = 0;",
7836
+ " rails.forEach((rail) => {",
7837
+ " if (!mark(rail, 'recently-viewed')) return;",
7838
+ " const limit = Number(rail.getAttribute && (rail.getAttribute('data-gem-recently-viewed-limit') || '4'));",
7839
+ " const items = readItems().slice(0, Number.isFinite(limit) ? limit : 4);",
7840
+ " const list = rail.querySelector && (rail.querySelector('[data-gem-recently-viewed-list]') || rail);",
7841
+ " const empty = rail.querySelector && rail.querySelector('[data-gem-recently-viewed-empty]');",
7842
+ " rail.dataset.gemRecentlyViewedCount = String(items.length);",
7843
+ " if (items.length > 0) {",
7844
+ " rail.dataset.gemRecentlyViewedState = 'ready';",
7845
+ " if (list) {",
7846
+ " list.hidden = false;",
7847
+ " if (list.dataset) list.dataset.gemRecentlyViewedCount = String(items.length);",
7848
+ " if ('innerHTML' in list) list.innerHTML = items.map(renderItem).join('');",
7849
+ " else if ('textContent' in list) list.textContent = items.map((item) => item && (item.title || item.handle || item.url) || 'Recently viewed product').join(', ');",
7850
+ " }",
7851
+ " if (empty) empty.hidden = true;",
7852
+ " } else {",
7853
+ " rail.dataset.gemRecentlyViewedState = 'empty';",
7854
+ " if (empty) empty.hidden = false;",
7855
+ " }",
7856
+ " bound += 1;",
7857
+ " });",
7858
+ " api.recentlyViewed = { list: readItems };",
7859
+ " api.emit('gem:recently-viewed:ready', { source: 'gem-runtime', count: bound });",
7860
+ " });"
7861
+ ];
7862
+ var runtimeSlotLoaderPluginRegistrationLines = [
7863
+ " registerPlugin('runtime-slot-loader', ({ api }) => {",
7864
+ " const slots = document.querySelectorAll('[data-gem-runtime-slot], [data-gem-footer-block-builder], .gem-navigation-shell__layout');",
7865
+ " let bound = 0;",
7866
+ " slots.forEach((slot, index) => {",
7867
+ " if (!mark(slot, 'runtime-slot-loader')) return;",
7868
+ " if (slot.dataset) {",
7869
+ " slot.dataset.gemRuntimeSlotState = 'ready';",
7870
+ " if (!slot.dataset.gemRuntimeSlotIndex) slot.dataset.gemRuntimeSlotIndex = String(index);",
7871
+ " }",
7872
+ " bound += 1;",
7873
+ " });",
7874
+ " api.emit('gem:slot:ready', { source: 'gem-runtime', count: bound });",
7875
+ " });"
7876
+ ];
5235
7877
  var offerEnginePluginRegistrationLines = [
5236
7878
  " registerPlugin('offer-engine', ({ api }) => {",
5237
7879
  " " + offerEngineRuntimeScript(),
@@ -5243,6 +7885,63 @@ var reviewsPluginRegistrationLines = [
5243
7885
  " " + reviewsEngineRuntimeScript(),
5244
7886
  " });"
5245
7887
  ];
7888
+ var bundlePluginRegistrationLines = [
7889
+ " registerPlugin('bundle', ({ api }) => {",
7890
+ " " + bundleEngineRuntimeScript(),
7891
+ " });"
7892
+ ];
7893
+ var sizeGuidePluginRegistrationLines = [
7894
+ " registerPlugin('size-guide', ({ api }) => {",
7895
+ " const page = document.documentElement && typeof document.documentElement.querySelectorAll === 'function' ? document.documentElement : document;",
7896
+ " const guides = Array.prototype.slice.call(page.querySelectorAll('[data-gem-size-guide]'));",
7897
+ " const opens = (target) => guides.some((guide) => !target || guide.getAttribute('data-gem-size-guide') === target || guide.id === target);",
7898
+ " let bound = 0;",
7899
+ " document.querySelectorAll('[data-gem-size-guide-trigger]').forEach((trigger) => {",
7900
+ " const target = trigger.getAttribute('data-gem-size-guide-trigger') || '';",
7901
+ " if (!opens(target)) { trigger.hidden = true; return; }",
7902
+ " if (!mark(trigger, 'size-guide')) return;",
7903
+ " trigger.hidden = false;",
7904
+ " if (!canListen(trigger)) return;",
7905
+ " trigger.addEventListener('click', (event) => {",
7906
+ " event.preventDefault && event.preventDefault();",
7907
+ " const holder = trigger.closest ? trigger.closest('[data-product-id]') : null;",
7908
+ " const detail = { source: 'gem-runtime', sectionId: target || null, productId: holder ? holder.getAttribute('data-product-id') : null };",
7909
+ " if (typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:size-guide:open', { detail }));",
7910
+ " api.emit('gem:size-guide:open', detail);",
7911
+ " });",
7912
+ " bound += 1;",
7913
+ " });",
7914
+ " api.emit('gem:size-guide:ready', { source: 'gem-runtime', count: bound, guides: guides.length });",
7915
+ " });"
7916
+ ];
7917
+ var runtimePluginSourceLines = [
7918
+ ["cart-drawer", cartDrawerPluginRegistrationLines],
7919
+ ["mobile-menu", mobileMenuPluginRegistrationLines],
7920
+ ["quantity-input", quantityInputPluginRegistrationLines],
7921
+ ["variant-selects", variantSelectsPluginRegistrationLines],
7922
+ ["predictive-search", predictiveSearchPluginRegistrationLines],
7923
+ ["localization", localizationPluginRegistrationLines],
7924
+ ["disclosure", disclosurePluginRegistrationLines],
7925
+ ["tabs", tabsPluginRegistrationLines],
7926
+ ["media-gallery", mediaGalleryPluginRegistrationLines],
7927
+ ["carousel", carouselPluginRegistrationLines],
7928
+ ["hero-slideshow", heroSlideshowPluginRegistrationLines],
7929
+ ["quick-view", runtimeChunks["quick-view"].body],
7930
+ ["promo-popup", promoPopupPluginRegistrationLines],
7931
+ ["sticky-atc", stickyAtcPluginRegistrationLines],
7932
+ ["before_after", beforeAfterPluginRegistrationLines],
7933
+ ["countdown", countdownPluginRegistrationLines],
7934
+ ["reveal-on-scroll", revealOnScrollPluginRegistrationLines],
7935
+ ["img-in", imgInPluginRegistrationLines],
7936
+ ["recently-viewed", recentlyViewedPluginRegistrationLines],
7937
+ ["runtime-slot-loader", runtimeSlotLoaderPluginRegistrationLines],
7938
+ ["offer-engine", offerEnginePluginRegistrationLines],
7939
+ ["reviews", reviewsPluginRegistrationLines],
7940
+ ["bundle", bundlePluginRegistrationLines],
7941
+ ["size-guide", sizeGuidePluginRegistrationLines],
7942
+ ["orbit", runtimeChunks.orbit.body],
7943
+ ["rail", runtimeChunks.rail.body]
7944
+ ];
5246
7945
 
5247
7946
  // ../../packages/gem-framework/src/validation.ts
5248
7947
  var validationRulesByCode = new Map(gemFrameworkValidationRules.map((rule) => [rule.code, rule]));
@@ -5254,7 +7953,7 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
5254
7953
  const namespace = group === "typography" ? "font" : group === "spacing" ? "space" : group;
5255
7954
  return keys.map((key) => `--gem-${namespace}-${slugify(key)}`);
5256
7955
  }),
5257
- // The governed type ramp (--gem-h1…h6, --gem-body, --gem-text-sm, --gem-type-base/-scale). The
7956
+ // The governed type ramp (--gem-h1…h6, --gem-body, --gem-text-sm, --gem-text-xs, --gem-type-base/-scale). The
5258
7957
  // framework stylesheet declares it on every page, but it is not a token GROUP, so it was missing here
5259
7958
  // and a section sizing its heading with `var(--gem-h2)` got `unknown_gem_css_variable`. Read from the
5260
7959
  // function that emits it, so the ramp and this list cannot drift.
@@ -5273,7 +7972,12 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
5273
7972
  // The row primitive's variables (primitives.ts GEM_ROW_CSS). Sections SET these on their grid.
5274
7973
  ...GEM_ROW_CSS_VARIABLES,
5275
7974
  // The form set's variables (form-set.ts): set from a plan's `tokens.form`, read by fields under [data-gem-form-set].
5276
- ...GEM_FORM_CSS_VARIABLES
7975
+ ...GEM_FORM_CSS_VARIABLES,
7976
+ // The motion engine's (motion-engine.ts, 0.19.0), all of them: a section copies the base CSS VERBATIM into its own CSS,
7977
+ // internals (--gem-orbit-a, --gem-rail-t) included, and sets --gem-orbit-i / -n, the settings and --gem-motion-tint.
7978
+ ...new Set([GEM_ORBIT_BASE_CSS, GEM_RAIL_BASE_CSS, GEM_ORBIT_RUNTIME_CSS, GEM_RAIL_RUNTIME_CSS, GEM_QUICK_VIEW_CSS].join("\n").match(/--gem-[a-z0-9-]+/g) ?? []),
7979
+ // …and every setting an effect reads from a CSS variable (`css: true`): a section sets it in its stylesheet, per breakpoint.
7980
+ ...gemMotionEffectList.flatMap((effect) => Object.entries(effect.settings).filter(([, setting]) => setting.css).map(([name]) => `--gem-${effect.key}-${name}`))
5277
7981
  ]);
5278
7982
  var TOKENIZED_PROPERTIES = [
5279
7983
  "background",
@@ -5381,14 +8085,22 @@ function validateGemFrameworkMarkup(markup, artifactPath, options = {}) {
5381
8085
  }
5382
8086
  return result(issues);
5383
8087
  }
8088
+ var NAMESPACE_URL = String.raw`https?:\/\/www\.w3\.org\/[\w.\/#:~%+-]*`;
8089
+ var SHARE_NETWORK = "facebook|twitter|x|pinterest|whatsapp|threads";
8090
+ var AS_A_LINK = String.raw`(?<=(?:<a\s[^<>]{0,400}?(?<![\w-])href=\{?|[{,]\s{0,40}["']?(?:${SHARE_NETWORK})["']?\s{0,8}:\s{0,8})["'\x60])`;
8091
+ var SHARE_INTENT = String.raw`https?:\/\/(?:(?:www\.)?facebook\.com\/sharer\/sharer\.php|(?:twitter|x)\.com\/intent\/(?:tweet|post)|(?:www\.)?pinterest\.com\/pin\/create\/button\/|wa\.me\/\d*|(?:www\.)?threads\.(?:net|com)\/intent\/post)\?`;
8092
+ var NEVER_FETCHED_URL = new RegExp(`${NAMESPACE_URL}|${AS_A_LINK}${SHARE_INTENT}`, "gi");
8093
+ var JSON_LD = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>[\s\S]*?<\/script>/gi;
8094
+ function namesExternalDependency(withoutJsonLd) {
8095
+ return /https?:\/\/|\/\/cdn\./i.test(withoutJsonLd.replace(NEVER_FETCHED_URL, ""));
8096
+ }
5384
8097
  function validateSharedSafety(content, artifactPath) {
5385
8098
  const issues = [];
5386
- const contentWithoutJsonLd = content.replace(/<script\b[^>]*type=["']application\/ld\+json["'][^>]*>[\s\S]*?<\/script>/gi, "");
5387
- const contentWithoutNamespaces = contentWithoutJsonLd.replace(/https?:\/\/www\.w3\.org\/[^\s"'<>]*/gi, "");
5388
- if (/<script\b/i.test(contentWithoutJsonLd)) {
8099
+ const withoutJsonLd = content.replace(JSON_LD, "");
8100
+ if (/<script\b/i.test(withoutJsonLd)) {
5389
8101
  issues.push(ruleIssue("inline_script", "Gem Framework artifacts must not include inline script tags.", artifactPath));
5390
8102
  }
5391
- if (/https?:\/\/|\/\/cdn\./i.test(contentWithoutNamespaces)) {
8103
+ if (namesExternalDependency(withoutJsonLd)) {
5392
8104
  issues.push(ruleIssue("external_dependency", "Gem Framework artifacts must not include external runtime or CDN dependencies.", artifactPath));
5393
8105
  }
5394
8106
  return issues;
@@ -5417,6 +8129,9 @@ function gemFrameworkPublicCssVariables() {
5417
8129
  String(createSectionCss()),
5418
8130
  String(createShellCss()),
5419
8131
  String(createGemSectionRuntimeContractCss()),
8132
+ // popup-shell.css (its own file since 0.8.3, so not in createPrimitiveCss): a popup section sets
8133
+ // --gem-popup-drawer-width (0.18.17, drawer-end) and must not be told the framework's own variable is unknown.
8134
+ GEM_POPUP_SHELL_CSS,
5420
8135
  // The button case rule only exists when a token sets it; a section may still read its variables (a CTA rule
5421
8136
  // beats the zero-specificity rule, so opting in means `text-transform: var(--gem-font-button-transform, none)`).
5422
8137
  gemButtonCaseCss({ typography: { buttonTransform: "none", buttonLetterSpacing: "normal" } })
@@ -5427,11 +8142,13 @@ function gemFrameworkPublicCssVariables() {
5427
8142
  }
5428
8143
  var ROOT_SELECTOR = /^\s*(?::root|html|:host)(?:\[[^\]]*\]|:[a-z-]+(?:\([^()]*\))?)*\s*$/;
5429
8144
  var EDITION_ROOT_SELECTOR = /^\s*:where\(\s*\[data-gem-edition=(["'])\d+\1\](?:\s*,\s*\[data-gem-edition=(["'])\d+\2\])*\s*\)\s*$/;
8145
+ var KNOB_SCOPE_SELECTOR = /^\s*\[data-gem-[a-z0-9-]+(?:=(["'])[^"']*\1)?\]\s*$/;
5430
8146
  function gemPublicCssVariablesFrom(stylesheet) {
5431
8147
  const css = stylesheet.replace(/\/\*[\s\S]*?\*\//g, " ");
5432
8148
  const declaredOnRoot = /* @__PURE__ */ new Set();
5433
8149
  const declaredElsewhere = /* @__PURE__ */ new Set();
5434
8150
  for (const [, selector, body] of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
8151
+ if (selector.split(",").every((part) => KNOB_SCOPE_SELECTOR.test(part))) continue;
5435
8152
  const onRoot = EDITION_ROOT_SELECTOR.test(selector) || selector.split(",").every((part) => ROOT_SELECTOR.test(part));
5436
8153
  for (const [, name] of body.matchAll(/(--gem-[a-z0-9-]+)\s*:/g)) (onRoot ? declaredOnRoot : declaredElsewhere).add(name);
5437
8154
  }
@@ -5752,9 +8469,9 @@ var LABEL_LEAD = /^(?:(?:grand\s+|sub\s*)?total|subtotal|price|unless\s+noted|t
5752
8469
  var QUESTION_END = /[??❓❔][\s\p{Extended_Pictographic}\uFE0F]*$/u;
5753
8470
  var MENTIONS_SHIPPING = /ship|deliver|vận chuyển|giao hàng/iu;
5754
8471
  var SHIPPING_ZERO_ENTRIES = Object.entries(SHIPPING_ZERO_SENTENCES);
5755
- function sentencesOf(text) {
8472
+ function sentencesOf(text2) {
5756
8473
  const out = [];
5757
- for (const line of text.split(/\n+|\s[|•·]\s|\s(?=[✓✔☑]\s)/u)) {
8474
+ for (const line of text2.split(/\n+|\s[|•·]\s|\s(?=[✓✔☑]\s)/u)) {
5758
8475
  let cur = "";
5759
8476
  for (const piece of line.split(/(?<=[.!?;])\s+/u)) {
5760
8477
  cur = cur ? `${cur} ${piece}` : piece;
@@ -5769,8 +8486,8 @@ function sentencesOf(text) {
5769
8486
  }
5770
8487
  function shippingZeroPromiseIn(raw) {
5771
8488
  if (!MENTIONS_SHIPPING.test(raw)) return null;
5772
- const text = raw.normalize("NFC").replace(/\\n/g, "\n").replace(/\\(['"’])/g, "$1");
5773
- for (const sentence of sentencesOf(text)) {
8489
+ const text2 = raw.normalize("NFC").replace(/\\n/g, "\n").replace(/\\(['"’])/g, "$1");
8490
+ for (const sentence of sentencesOf(text2)) {
5774
8491
  if (QUESTION_END.test(sentence)) continue;
5775
8492
  const whole = sentence.replace(DECOR_START, "").replace(DECOR_END, "").trim();
5776
8493
  const clauses = LABEL_LEAD.test(whole) ? [whole] : [whole, ...whole.split(/,\s+|\s+[—–]\s+/u)].map((c) => c.replace(DECOR_START, "").replace(DECOR_END, "").trim());
@@ -5824,26 +8541,26 @@ function demoValues(src) {
5824
8541
  return out;
5825
8542
  }
5826
8543
  var PRICE_ANY = new RegExp(`[$\xA3\u20AC]\\s?\\d|\\b\\d[\\d.,]*\\s?(?:USD|EUR|GBP)\\b|${PROMISE_VI_PARTS.price}`, "iu");
5827
- function isQuestion(text) {
5828
- const t = text.trim();
8544
+ function isQuestion(text2) {
8545
+ const t = text2.trim();
5829
8546
  if (!/\?$/.test(t) || /[.!?]\s+\S/.test(t)) return false;
5830
8547
  return /^(?:can|do|does|did|how|what|when|where|why|who|which|is|are|will|would|should|could|may)\b/i.test(t) || /(?:không|chưa|nào|sao|gì|đâu|bao lâu|bao nhiêu|mấy|được không|à|hả|nhỉ)\s*\?$/iu.test(t) || /^(?:làm sao|tại sao|vì sao|khi nào|bao lâu|có)(?![\p{L}])/iu.test(t);
5831
8548
  }
5832
8549
  function claimIn(raw) {
5833
- const text = raw.normalize("NFC");
5834
- if (CLAIM_TEXT.test(text) || CLAIM_TEXT_VI.test(text)) return "hardcoded claim";
5835
- if (INVENTED_PERSON.test(`"${text}"`) || INVENTED_PERSON_VI.test(text)) return "invented person";
5836
- if (isQuestion(text)) return PRICE_ANY.test(text) || PRICE_K_VI.test(text) ? "promise the merchant never made" : null;
5837
- if (PRICE_K_VI.test(text) && /^\s*\d[\d.,]*\s?k\s*\+?\s*$/.test(text)) return "hardcoded figure";
5838
- if (PROMISE.test(text) || PROMISE_VI.test(text) || shippingZeroPromiseIn(text) || PRICE_K_VI.test(text)) return "promise the merchant never made";
8550
+ const text2 = raw.normalize("NFC");
8551
+ if (CLAIM_TEXT.test(text2) || CLAIM_TEXT_VI.test(text2)) return "hardcoded claim";
8552
+ if (INVENTED_PERSON.test(`"${text2}"`) || INVENTED_PERSON_VI.test(text2)) return "invented person";
8553
+ if (isQuestion(text2)) return PRICE_ANY.test(text2) || PRICE_K_VI.test(text2) ? "promise the merchant never made" : null;
8554
+ if (PRICE_K_VI.test(text2) && /^\s*\d[\d.,]*\s?k\s*\+?\s*$/.test(text2)) return "hardcoded figure";
8555
+ if (PROMISE.test(text2) || PROMISE_VI.test(text2) || shippingZeroPromiseIn(text2) || PRICE_K_VI.test(text2)) return "promise the merchant never made";
5839
8556
  return null;
5840
8557
  }
5841
8558
  function demoInvents(value) {
5842
8559
  const v = value.trim();
5843
8560
  if (/^-?\d/.test(v)) return "number";
5844
8561
  if (/^[{[]/.test(v) && /(?:^|[:[,])\s*-?\d/.test(v.slice(1))) return "number";
5845
- for (const text of stringLiteralsIn(v)) {
5846
- const kind = claimIn(text);
8562
+ for (const text2 of stringLiteralsIn(v)) {
8563
+ const kind = claimIn(text2);
5847
8564
  if (kind) return kind;
5848
8565
  }
5849
8566
  return null;
@@ -5896,12 +8613,12 @@ function fabricatedProofInSource(raw) {
5896
8613
  caught.add(`${at}\0key\0${key}`);
5897
8614
  out.push({ line: at, why: `plan literal ${key}: ${num}`, text: line.trim() });
5898
8615
  }
5899
- for (const text of stringLiteralsIn(line)) {
5900
- if (text.includes("${")) continue;
5901
- const kind = claimIn(text);
8616
+ for (const text2 of stringLiteralsIn(line)) {
8617
+ if (text2.includes("${")) continue;
8618
+ const kind = claimIn(text2);
5902
8619
  if (!kind) continue;
5903
- caught.add(`${at}\0str\0${text}`);
5904
- out.push({ line: at, why: `${kind} ${JSON.stringify(text)}`, text: line.trim() });
8620
+ caught.add(`${at}\0str\0${text2}`);
8621
+ out.push({ line: at, why: `${kind} ${JSON.stringify(text2)}`, text: line.trim() });
5905
8622
  }
5906
8623
  });
5907
8624
  const code = src.replace(/(["'])((?:\\.|(?!\1)[^\\\n])*)\1/g, (_m, q, body) => q + body.replace(/[^\n]/g, " ") + q);
@@ -5910,7 +8627,7 @@ function fabricatedProofInSource(raw) {
5910
8627
  out.push({ line: at, why: `verified by default (${m[0].replace(/\s+/g, " ").trim()}); badge only on verified === true`, text: (lines[at - 1] ?? "").trim() });
5911
8628
  }
5912
8629
  for (const r of demoReads) {
5913
- const onDemoLine = caught.has(`${r.entry.line}\0key\0${r.key}`) || stringLiteralsIn(r.entry.value).some((text) => caught.has(`${r.entry.line}\0str\0${text}`));
8630
+ const onDemoLine = caught.has(`${r.entry.line}\0key\0${r.key}`) || stringLiteralsIn(r.entry.value).some((text2) => caught.has(`${r.entry.line}\0str\0${text2}`));
5914
8631
  if (!onDemoLine) out.push({ line: r.at, why: r.why, text: r.text });
5915
8632
  }
5916
8633
  return out.sort((a, b) => a.line - b.line);