@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
package/dist/index.js CHANGED
@@ -121,11 +121,11 @@ function color(value) {
121
121
  function gemSectionDividerStyle(props, bandBackground) {
122
122
  if (!gemSectionDividers(props).length) return {};
123
123
  const value = props?.divider_flip;
124
- const flip = value === true || value === "true" || value === 1 || value === "1";
124
+ const flip2 = value === true || value === "true" || value === 1 || value === "1";
125
125
  return {
126
126
  "--gem-element-divider-height": `${height(props?.divider_height) ?? 48}px`,
127
127
  "--gem-element-divider-color": color(props?.divider_color) ?? color(bandBackground) ?? "var(--gem-section-bg, var(--gem-color-background))",
128
- "--gem-element-divider-scale-x": flip ? "-1" : "1"
128
+ "--gem-element-divider-scale-x": flip2 ? "-1" : "1"
129
129
  };
130
130
  }
131
131
  function gemSectionDividerResponsiveStyle(props) {
@@ -435,10 +435,15 @@ function gemButtonGradientEndCss(accent = "var(--gem-color-accent)", accentText
435
435
  }
436
436
 
437
437
  // ../../packages/gem-framework/src/tokens.ts
438
- var gemFrameworkVersion = "0.18.15";
438
+ var gemFrameworkVersion = "0.19.0";
439
439
  var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
440
440
  var GEM_TAP_MIN_CSS_VAR = "--gem-tap-min: 44px;";
441
441
  var GEM_FOCUS_OFFSET_CSS_VAR = "--gem-focus-offset: 3px;";
442
+ var GEM_STROKE_CSS_VARS = [
443
+ "--gem-stroke: calc(1px * var(--gem-style-border, 1));",
444
+ "--gem-stroke-thick: calc(2px * var(--gem-style-border, 1));"
445
+ ];
446
+ var GEM_UNDERLINE_THICKNESS_CSS_VAR = "--gem-underline-thickness: 2px;";
442
447
  var GEM_DERIVED_SPACE_STEPS = { "2xs": 0.125, ms: 0.75, "4xl": 7 };
443
448
  var GEM_DERIVED_SPACE_CSS_VARIABLES = Object.keys(GEM_DERIVED_SPACE_STEPS).map((key) => `--gem-space-${slugify(key)}`);
444
449
  function gemDerivedSpaceCssVars(spacing) {
@@ -583,15 +588,15 @@ function gemBandScheme(c, band) {
583
588
  const away = mixHex(g, hexRelativeLuminance(i) > hexRelativeLuminance(g) ? "#000000" : "#ffffff", 0.08);
584
589
  const surface = hexContrastRatio(i, toward) >= MIN_TEXT_CONTRAST ? toward : away;
585
590
  const grounds = [g, surface];
586
- const text = aaTextColor(i, grounds);
591
+ const text2 = aaTextColor(i, grounds);
587
592
  const accent = aaTextColor(c.accent, grounds);
588
593
  return {
589
594
  background: g,
590
595
  surface,
591
- text,
596
+ text: text2,
592
597
  muted: aaTextColor(mixHex(i, g, 0.3), grounds),
593
598
  accent,
594
- accentText: aaLabelColor(c.accentText, accent, [g, text]),
599
+ accentText: aaLabelColor(c.accentText, accent, [g, text2]),
595
600
  border: mixHex(g, i, 0.22),
596
601
  success: aaTextColor(c.success, grounds),
597
602
  warning: aaTextColor(c.warning, grounds),
@@ -859,7 +864,7 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
859
864
  ...phone.layout.section ? [`--gem-layout-section-mobile: ${phone.layout.section};`] : [],
860
865
  ...toCssVars("component", tokens.component),
861
866
  ...toCssVars("morphology", tokens.morphology),
862
- // THE GOVERNED TYPE RAMP — --gem-h1..h6 / --gem-body / --gem-text-sm.
867
+ // THE GOVERNED TYPE RAMP — --gem-h1..h6 / --gem-body / --gem-text-sm / --gem-text-xs (26/10/2026).
863
868
  // gemcapture maps every measured font-size onto these names via `font_token`
864
869
  // (clone_kit._type_token), and the gem-framework contract documents them, but nothing in the
865
870
  // live framework defined them: a rebuild that followed the contract styled its headings with
@@ -874,7 +879,9 @@ function createGemTokenCss(tokens, selector = ":root", options = {}) {
874
879
  // type ramp and registered in validation.ts. Follows the accent, so presets retint it for free.
875
880
  GEM_FOCUS_RING_CSS_VAR,
876
881
  GEM_TAP_MIN_CSS_VAR,
877
- GEM_FOCUS_OFFSET_CSS_VAR
882
+ GEM_FOCUS_OFFSET_CSS_VAR,
883
+ ...GEM_STROKE_CSS_VARS,
884
+ GEM_UNDERLINE_THICKNESS_CSS_VAR
878
885
  ];
879
886
  const formBlock = gemFormCssBlock(tokens.form);
880
887
  const buttonCase = gemButtonCaseCss(tokens, selector);
@@ -922,6 +929,13 @@ function gemTypeRampCssVars(typography) {
922
929
  return [
923
930
  `--gem-type-base: ${typeRampValue(typography, "base") ?? "1rem"};`,
924
931
  `--gem-type-scale: ${typeRampValue(typography, "scale") ?? "1.22"};`,
932
+ // 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
933
+ // 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.
934
+ // Same pattern as text-sm, off the base rather than off text-sm: base ÷ scale², 10.75px at 16px and 1.22. Declared
935
+ // FIRST because this list is the ramp's order, small to large, and the seam's `steps` must equal it in order
936
+ // (check-token-steps-match-framework.ts). At the defaults it sits above gem_font_size's 10px floor; that floor bounds
937
+ // the px number only, so a plan with a steeper ratio (1.333: 9px) can take this step lower, as it can text-sm.
938
+ "--gem-text-xs: calc(var(--gem-type-base) / (var(--gem-type-scale) * var(--gem-type-scale)));",
925
939
  "--gem-text-sm: calc(var(--gem-type-base) / var(--gem-type-scale));",
926
940
  "--gem-body: var(--gem-type-base);",
927
941
  "--gem-h6: calc(var(--gem-type-base) * var(--gem-type-scale));",
@@ -1050,6 +1064,1398 @@ function gemFrameworkEditionCss() {
1050
1064
  return GEM_FRAMEWORK_EDITIONS.flatMap((e) => e.css.map((rule) => rule.replaceAll("&", gemFrameworkEditionScope(e.edition)))).join("\n");
1051
1065
  }
1052
1066
 
1067
+ // ../../packages/gem-framework/src/motion/core.js
1068
+ var clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
1069
+ var lerp = (a, b, t) => a + (b - a) * t;
1070
+ var mod = (v, n) => (v % n + n) % n;
1071
+ function smoothstep(edge0, edge1, v) {
1072
+ const t = clamp((v - edge0) / (edge1 - edge0), 0, 1);
1073
+ return t * t * (3 - 2 * t);
1074
+ }
1075
+ var follow = (dt, tau) => tau <= 0 ? 1 : 1 - Math.exp(-dt / tau);
1076
+ function spring(s, target2, dt, stiffness, damping) {
1077
+ const f = dt / 16.67;
1078
+ s.v += (-(s.x - target2) * stiffness - s.v * damping) * f;
1079
+ s.x += s.v * f;
1080
+ return s;
1081
+ }
1082
+ function ringDelta(a, b, n) {
1083
+ let d = mod(b - a, n);
1084
+ if (d > n / 2) d -= n;
1085
+ return d;
1086
+ }
1087
+ function ringPose(i, n, pos, o) {
1088
+ const theta = ringDelta(pos, i, n) * o.spread;
1089
+ const hidden = Math.abs(theta) > Math.PI;
1090
+ const depth = (1 - Math.cos(theta)) / 2;
1091
+ return {
1092
+ x: o.rx * Math.sin(theta),
1093
+ y: -o.ry * depth,
1094
+ depth,
1095
+ scale: lerp(1, o.back, depth),
1096
+ opacity: hidden ? 0 : lerp(1, o.fade, depth),
1097
+ blur: o.blur * depth,
1098
+ z: Math.round((1 - depth) * 100),
1099
+ lean: Math.sin(theta) * o.lean,
1100
+ hidden
1101
+ };
1102
+ }
1103
+ var throwTarget = (pos, velocity, carry, maxSlots) => Math.round(pos + clamp(velocity * carry, -maxSlots, maxSlots));
1104
+ var scrollProgress = (rect, height2) => clamp((height2 - rect.top) / (height2 + rect.height), 0, 1);
1105
+ var TIERS = ["static", "light", "full"];
1106
+ var query = (win, q) => Boolean(win && typeof win.matchMedia === "function" && win.matchMedia(q).matches);
1107
+ var reducedMotion = (win) => query(win, "(prefers-reduced-motion: reduce)");
1108
+ function capabilityTier(win) {
1109
+ if (reducedMotion(win)) return "static";
1110
+ const nav = win && win.navigator || {};
1111
+ const lean = nav.connection && nav.connection.saveData || nav.deviceMemory && nav.deviceMemory <= 2 || query(win, "(pointer: coarse)");
1112
+ return lean ? "light" : "full";
1113
+ }
1114
+ var written = /* @__PURE__ */ new WeakMap();
1115
+ function setStyle(el, prop, value) {
1116
+ let rec = written.get(el);
1117
+ if (!rec) written.set(el, rec = {});
1118
+ if (rec[prop] === value) return;
1119
+ rec[prop] = value;
1120
+ if (value === "") el.style.removeProperty(prop);
1121
+ else el.style.setProperty(prop, value);
1122
+ }
1123
+ var effects = /* @__PURE__ */ new Map();
1124
+ var SETTING_TYPES = ["number", "integer", "boolean", "enum", "text", "json"];
1125
+ function defineEffect(spec) {
1126
+ const effect = { hook: `[data-gem-${spec.key}]`, load: "viewport", renderer: "dom", tier: "static", fallback: "markup", settings: {}, ...spec, declared: spec };
1127
+ effects.set(effect.key, effect);
1128
+ return effect;
1129
+ }
1130
+ function checkEffect(effect) {
1131
+ const spec = effect.declared || effect;
1132
+ const key = String(spec.key);
1133
+ const problems = [];
1134
+ if (!/^[a-z][a-z0-9-]*$/.test(key)) problems.push(`key "${key}" is not kebab-case`);
1135
+ if (typeof spec.mount !== "function") problems.push(`${key} has no mount()`);
1136
+ if (spec.tier && !TIERS.includes(spec.tier)) problems.push(`${key}: tier must be one of ${TIERS.join(", ")}`);
1137
+ if ((spec.tier && spec.tier !== "static" || spec.renderer && spec.renderer !== "dom") && !["markup", "poster"].includes(spec.fallback)) {
1138
+ problems.push(`${key} is heavy and must declare a static fallback (markup or poster)`);
1139
+ }
1140
+ for (const [name, s] of Object.entries(spec.settings || {})) {
1141
+ if (!SETTING_TYPES.includes(s.type) || !("default" in s) || !s.label) problems.push(`${key}.${name} needs a type, a default and a label`);
1142
+ else if ((s.type === "number" || s.type === "integer") && (s.min != null && s.default < s.min || s.max != null && s.default > s.max)) problems.push(`${key}.${name}: default out of range`);
1143
+ else if (s.type === "enum" && !(s.options || []).includes(s.default)) problems.push(`${key}.${name}: default is not an option`);
1144
+ }
1145
+ return problems;
1146
+ }
1147
+ function frameTask(ctx, el, step, options) {
1148
+ const task = ctx.scheduler.add(step, options);
1149
+ ctx.cleanup(() => task.remove());
1150
+ observeVisibility(ctx, el, (shown) => task.visible(shown));
1151
+ return task;
1152
+ }
1153
+ function observeVisibility(ctx, el, cb, margin) {
1154
+ const IO = ctx.window.IntersectionObserver;
1155
+ if (typeof IO !== "function") return;
1156
+ new IO((entries) => cb(entries.some((e) => e.isIntersecting)), { rootMargin: margin || "120px" }).observe(el);
1157
+ }
1158
+ function observeSize(ctx, el, cb) {
1159
+ const RO = ctx.window.ResizeObserver;
1160
+ if (typeof RO === "function") new RO(() => cb()).observe(el);
1161
+ else ctx.on(ctx.window, "resize", () => cb());
1162
+ }
1163
+ function horizontalDrag(ctx, el, h) {
1164
+ let id = null;
1165
+ let x0 = 0;
1166
+ let y0 = 0;
1167
+ let decided = false;
1168
+ let horizontal = false;
1169
+ let moved = 0;
1170
+ let samples = [];
1171
+ const release = (e, fire) => {
1172
+ if (e.pointerId !== id) return;
1173
+ id = null;
1174
+ const first = samples[0];
1175
+ const last = samples[samples.length - 1];
1176
+ const span = last.t - first.t;
1177
+ const vx = span > 0 && e.timeStamp - last.t < 80 ? (last.x - first.x) / span : 0;
1178
+ if (fire && horizontal) h.end(vx, moved, e);
1179
+ else h.end(0, 0, e);
1180
+ };
1181
+ ctx.on(el, "pointerdown", (e) => {
1182
+ if (e.button !== 0 || id !== null) return;
1183
+ id = e.pointerId;
1184
+ x0 = e.clientX;
1185
+ y0 = e.clientY;
1186
+ decided = horizontal = false;
1187
+ moved = 0;
1188
+ samples = [{ x: e.clientX, t: e.timeStamp }];
1189
+ h.start(e);
1190
+ });
1191
+ ctx.on(el, "pointermove", (e) => {
1192
+ if (e.pointerId !== id) return;
1193
+ const dx = e.clientX - x0;
1194
+ if (!decided && Math.hypot(dx, e.clientY - y0) > 6) {
1195
+ decided = true;
1196
+ horizontal = Math.abs(dx) > Math.abs(e.clientY - y0);
1197
+ if (!horizontal) return release(e, false);
1198
+ try {
1199
+ el.setPointerCapture(e.pointerId);
1200
+ } catch {
1201
+ }
1202
+ }
1203
+ if (!horizontal) return;
1204
+ moved = Math.max(moved, Math.abs(dx));
1205
+ samples.push({ x: e.clientX, t: e.timeStamp });
1206
+ while (samples.length > 2 && e.timeStamp - samples[0].t > 90) samples.shift();
1207
+ h.move(dx, e);
1208
+ });
1209
+ ctx.on(el, "pointerup", (e) => release(e, true));
1210
+ ctx.on(el, "pointercancel", (e) => release(e, true));
1211
+ }
1212
+ function wheelSteps(ctx, el, onStep) {
1213
+ let acc = 0;
1214
+ let lock = 0;
1215
+ ctx.on(
1216
+ el,
1217
+ "wheel",
1218
+ (e) => {
1219
+ if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) || e.target.closest && e.target.closest("dialog")) return;
1220
+ e.preventDefault();
1221
+ acc += e.deltaX;
1222
+ if (e.timeStamp < lock || Math.abs(acc) < 24) return;
1223
+ onStep(Math.sign(acc));
1224
+ acc = 0;
1225
+ lock = e.timeStamp + 280;
1226
+ },
1227
+ { passive: false }
1228
+ );
1229
+ }
1230
+ function arrowKeys(ctx, el, { step, activate, accept }) {
1231
+ ctx.on(el, "keydown", (e) => {
1232
+ if (accept && !accept(e)) return;
1233
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") step(e.key === "ArrowRight" ? 1 : -1);
1234
+ else if (!activate || e.key !== "Enter" && e.key !== " " || activate(e) === false) return;
1235
+ e.preventDefault();
1236
+ });
1237
+ }
1238
+ function flip(el, from, options) {
1239
+ if (!from || !from.width || typeof el.animate !== "function") return null;
1240
+ const box = el.getBoundingClientRect();
1241
+ if (!box.height) return null;
1242
+ 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})` };
1243
+ const home = { transform: "none" };
1244
+ return el.animate(options.back ? [home, away] : [away, home], { duration: options.duration, easing: options.easing, fill: "both" });
1245
+ }
1246
+ function loadImage(doc, image) {
1247
+ const img = doc.createElement("img");
1248
+ if (image.srcset) img.srcset = image.srcset;
1249
+ img.src = image.src;
1250
+ return typeof img.decode === "function" ? img.decode().then(() => img, () => img) : Promise.resolve(img);
1251
+ }
1252
+ function putImage(img, image) {
1253
+ if (!img || !image || !image.src || img.getAttribute("src") === image.src) return;
1254
+ if (image.srcset) img.setAttribute("srcset", image.srcset);
1255
+ else img.removeAttribute("srcset");
1256
+ img.setAttribute("src", image.src);
1257
+ }
1258
+ function swapText(el, text2, still) {
1259
+ if (!el || el.textContent === text2) return;
1260
+ if (still || typeof el.animate !== "function") {
1261
+ el.textContent = text2;
1262
+ return;
1263
+ }
1264
+ const out = el.animate([{ opacity: 1 }, { opacity: 0, transform: "translateY(-6px)" }], { duration: 140, fill: "forwards" });
1265
+ out.onfinish = () => {
1266
+ el.textContent = text2;
1267
+ out.cancel();
1268
+ el.animate([{ opacity: 0, transform: "translateY(8px)" }, { opacity: 1, transform: "none" }], { duration: 220, easing: "cubic-bezier(.2,.7,.2,1)" });
1269
+ };
1270
+ }
1271
+ function readProduct(el) {
1272
+ try {
1273
+ const p = JSON.parse(el.getAttribute("data-gem-product") || "null");
1274
+ return p && p.image && p.image.src ? p : null;
1275
+ } catch {
1276
+ return null;
1277
+ }
1278
+ }
1279
+ function formatMoney(cents, format) {
1280
+ const value = (Number(cents) || 0) / 100;
1281
+ const group = (str, sep) => str.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
1282
+ const [int, dec] = value.toFixed(2).split(".");
1283
+ return (format || "${{amount}}").replace(/\{\{\s*(\w+)\s*\}\}/, (_, key) => {
1284
+ if (key === "amount_no_decimals") return group(String(Math.round(value)), ",");
1285
+ if (key === "amount_with_comma_separator") return `${group(int, ".")},${dec}`;
1286
+ if (key === "amount_no_decimals_with_comma_separator") return group(String(Math.round(value)), ".");
1287
+ return `${group(int, ",")}.${dec}`;
1288
+ });
1289
+ }
1290
+ function priceText(p, el, which) {
1291
+ const compare = which === "compare";
1292
+ const words = compare ? p.compare_at_text : p.price_text;
1293
+ if (words != null) return String(words);
1294
+ const cents = compare ? p.compare_at : p.price;
1295
+ if (cents == null || cents === "") return "";
1296
+ if (typeof cents !== "number") return String(cents);
1297
+ const holder = el && el.closest ? el.closest("[data-gem-money-format]") : null;
1298
+ return formatMoney(cents, holder ? holder.getAttribute("data-gem-money-format") : void 0);
1299
+ }
1300
+
1301
+ // ../../packages/gem-framework/src/motion/orbit.js
1302
+ var pad = (v) => String(v).padStart(2, "0");
1303
+ function mountOrbit(root, ctx) {
1304
+ const items = [];
1305
+ const products = [];
1306
+ const hits = [];
1307
+ const imgs = [];
1308
+ root.querySelectorAll("[data-gem-orbit-item]").forEach((item) => {
1309
+ const product = readProduct(item);
1310
+ if (!product) return;
1311
+ items.push(item);
1312
+ products.push(product);
1313
+ hits.push(item.querySelector(".gem-orbit__hit") || item);
1314
+ imgs.push(item.querySelector("img"));
1315
+ });
1316
+ const n = items.length;
1317
+ if (!n) return false;
1318
+ const part = (name) => root.querySelector(".gem-orbit__" + name);
1319
+ const stage = part("stage") || root;
1320
+ const track = part("track");
1321
+ const style = ctx.renderer.style;
1322
+ let s = ctx.settings;
1323
+ const start = Math.min(n - 1, s.start);
1324
+ let pos = start;
1325
+ let target2 = start;
1326
+ let dragFrom = 0;
1327
+ let dragging = false;
1328
+ let rendered = false;
1329
+ let front = -1;
1330
+ let ring = null;
1331
+ let slotPx = 60;
1332
+ const full = () => ctx.tier() === "full";
1333
+ const layout = () => {
1334
+ s = ctx.reread();
1335
+ const w = stage.clientWidth;
1336
+ ring = {
1337
+ rx: w * s.rx,
1338
+ ry: stage.clientHeight * s.tilt,
1339
+ back: s.back,
1340
+ fade: s.fade,
1341
+ blur: full() ? s.blur : 0,
1342
+ lean: s.lean,
1343
+ spread: (s.spread || 360 / n) * Math.PI / 180
1344
+ };
1345
+ slotPx = Math.max(60, ring.rx * ring.spread / 1.2);
1346
+ };
1347
+ const setTint = (tint) => {
1348
+ if (!s.tint) return;
1349
+ if (tint) root.style.setProperty("--gem-motion-tint", tint);
1350
+ else root.style.removeProperty("--gem-motion-tint");
1351
+ };
1352
+ const frontIndex = () => mod(Math.round(pos), n);
1353
+ const indexOf = (el) => items.findIndex((item) => item === el || item.contains(el));
1354
+ const updateFront = (initial) => {
1355
+ const i = frontIndex();
1356
+ if (i === front) return;
1357
+ front = i;
1358
+ const p = products[i];
1359
+ items.forEach((el, k) => {
1360
+ if (k === i) el.setAttribute("aria-current", "true");
1361
+ else el.removeAttribute("aria-current");
1362
+ hits[k].tabIndex = k === i ? 0 : -1;
1363
+ });
1364
+ if (!initial) {
1365
+ const still = ctx.tier() === "static";
1366
+ const price = priceText(p, root);
1367
+ swapText(part("name"), p.title || "", still);
1368
+ swapText(part("price"), price, still);
1369
+ const count = part("count");
1370
+ if (count) count.textContent = pad(i + 1) + " / " + pad(n);
1371
+ const bar = part("bar");
1372
+ if (bar) bar.style.transform = "scaleX(" + (i + 1) / n + ")";
1373
+ const live = part("live");
1374
+ const words = { index: i + 1, count: n, title: p.title || "", price };
1375
+ if (live) live.textContent = s.live.replace(/\{(\w+)\}/g, (m, k) => Object.hasOwn(words, k) ? String(words[k]) : m);
1376
+ setTint(p.tint);
1377
+ }
1378
+ ctx.emit("gem:motion:front", { index: i, product: p });
1379
+ };
1380
+ const frame = (dt, time) => {
1381
+ let moving = dragging;
1382
+ if (!dragging) {
1383
+ const d = target2 - pos;
1384
+ if (ctx.tier() === "static" || Math.abs(d) < 8e-4) pos = target2;
1385
+ else {
1386
+ pos += d * follow(dt, s.ease);
1387
+ moving = true;
1388
+ }
1389
+ }
1390
+ const drift = s.drift && full();
1391
+ for (let i = 0; i < n; i++) {
1392
+ const p = ringPose(i, n, pos, ring);
1393
+ const bob = drift ? Math.sin(time / 1100 + i * 1.9) * 5 * (1 - p.depth) : 0;
1394
+ const el = items[i];
1395
+ 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)`);
1396
+ style(el, "opacity", p.opacity.toFixed(3));
1397
+ style(el, "z-index", String(p.z));
1398
+ const blur = Math.round(p.blur * 2) / 2;
1399
+ style(el, "filter", blur > 0 ? `blur(${blur}px)` : "none");
1400
+ style(el, "visibility", p.hidden ? "hidden" : "visible");
1401
+ }
1402
+ rendered = true;
1403
+ updateFront(false);
1404
+ return moving || drift;
1405
+ };
1406
+ const refresh = () => {
1407
+ layout();
1408
+ if (rendered) task.kick();
1409
+ };
1410
+ const task = frameTask(ctx, root, frame, { onDegrade: refresh });
1411
+ const goTo = (index) => {
1412
+ const from = Math.round(target2);
1413
+ target2 = from + ringDelta(from, mod(index, n), n);
1414
+ task.kick();
1415
+ };
1416
+ hits.forEach((hit, i) => {
1417
+ ctx.on(hit, "click", (event) => {
1418
+ if (i === frontIndex()) return;
1419
+ event.preventDefault();
1420
+ goTo(i);
1421
+ });
1422
+ });
1423
+ root.querySelectorAll("[data-gem-orbit-step]").forEach((button) => {
1424
+ ctx.on(button, "click", () => goTo(target2 + (Number(button.getAttribute("data-gem-orbit-step")) || 0)));
1425
+ });
1426
+ if (track) {
1427
+ ctx.on(track, "click", (event) => {
1428
+ const r = track.getBoundingClientRect();
1429
+ if (r.width) goTo(Math.round((event.clientX - r.left) / r.width * (n - 1)));
1430
+ });
1431
+ }
1432
+ horizontalDrag(ctx, stage, {
1433
+ start: () => {
1434
+ dragFrom = pos;
1435
+ },
1436
+ move: (dx) => {
1437
+ dragging = true;
1438
+ root.setAttribute("data-gem-state", "dragging");
1439
+ pos = dragFrom - dx / slotPx;
1440
+ task.kick();
1441
+ },
1442
+ end: (vx, moved) => {
1443
+ if (!dragging) return;
1444
+ dragging = false;
1445
+ root.setAttribute("data-gem-state", "ready");
1446
+ target2 = moved < 4 ? Math.round(pos) : throwTarget(pos, -vx / slotPx, s.carry, Math.min(3, Math.ceil(n / 2)));
1447
+ task.kick();
1448
+ }
1449
+ });
1450
+ wheelSteps(ctx, root, (d) => goTo(target2 + d));
1451
+ arrowKeys(ctx, root, {
1452
+ accept: (e) => e.target === root || hits.includes(e.target),
1453
+ step: (d) => goTo(target2 + d),
1454
+ activate: (e) => e.target === root ? hits[frontIndex()].click() : false
1455
+ });
1456
+ layout();
1457
+ observeSize(ctx, root, refresh);
1458
+ root.gemQuickViewHost = {
1459
+ key: "orbit",
1460
+ products,
1461
+ indexOf,
1462
+ opens: (el) => indexOf(el) === frontIndex(),
1463
+ imageFor: (i) => imgs[i] || null,
1464
+ triggerFor: (i) => hits[i] || null,
1465
+ goTo,
1466
+ setOpen: (open) => root.setAttribute("data-gem-state", open ? "open" : "ready"),
1467
+ setImage: (i, image) => putImage(imgs[i], image),
1468
+ setTint
1469
+ };
1470
+ root.setAttribute("data-gem-quick-view-host", "orbit");
1471
+ root.setAttribute("data-gem-state", "ready");
1472
+ updateFront(true);
1473
+ if (s.drift && full()) task.kick();
1474
+ return () => {
1475
+ delete root.gemQuickViewHost;
1476
+ root.removeAttribute("data-gem-quick-view-host");
1477
+ root.removeAttribute("data-gem-state");
1478
+ };
1479
+ }
1480
+ var orbitEffect = defineEffect({
1481
+ key: "orbit",
1482
+ hook: "[data-gem-orbit]",
1483
+ load: "viewport",
1484
+ renderer: "dom",
1485
+ tier: "static",
1486
+ fallback: "markup",
1487
+ budget: { gzip: 6144 },
1488
+ settings: {
1489
+ 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)" },
1490
+ 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)" },
1491
+ 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)" },
1492
+ back: { type: "number", min: 0.1, max: 1, step: 0.01, default: 0.42, css: true, label: "Scale at the back" },
1493
+ fade: { type: "number", min: 0, max: 1, step: 0.01, default: 0.35, css: true, label: "Opacity at the back" },
1494
+ blur: { type: "number", min: 0, max: 8, step: 0.5, default: 2.5, css: true, label: "Blur at the back (px)" },
1495
+ lean: { type: "number", min: 0, max: 20, step: 1, default: 5, css: true, label: "Lean at the sides (degrees)" },
1496
+ spread: { type: "number", min: 0, max: 180, step: 1, default: 0, css: true, label: "Degrees between pieces (0: round the full ring)" },
1497
+ start: { type: "integer", min: 0, default: 0, css: true, label: "Piece in front at first" },
1498
+ ease: { type: "number", min: 40, max: 600, step: 10, default: 140, label: "Settle time (ms)" },
1499
+ carry: { type: "number", min: 0, max: 800, step: 10, default: 240, label: "How far a throw carries (ms)" },
1500
+ tint: { type: "boolean", default: true, label: "Tint the band with the front piece's colour" },
1501
+ drift: { type: "boolean", default: false, label: "Idle float (full tier only)" },
1502
+ live: { type: "text", default: "{index} / {count}: {title}, {price}", label: "What a screen reader hears when the front piece changes" }
1503
+ },
1504
+ mount: mountOrbit
1505
+ });
1506
+
1507
+ // ../../packages/gem-framework/src/motion/rail.js
1508
+ var COMPACT_BELOW = 700;
1509
+ var ROD_TOP = 14;
1510
+ function createTurn(el) {
1511
+ if (!el) return null;
1512
+ const side = el.querySelector(".gem-turn__side");
1513
+ const front = el.querySelector(".gem-turn__front");
1514
+ const sheets = el.querySelectorAll(".gem-turn__sheet");
1515
+ const frames = Math.floor(Number(el.getAttribute("data-gem-turn-frames"))) || 0;
1516
+ const cols = Math.floor(Number(el.getAttribute("data-gem-turn-cols"))) || 0;
1517
+ const mode = sheets.length > 1 && frames > 1 && cols > 0 ? "sprite" : side && front ? "pair" : front ? "flip" : null;
1518
+ if (!mode) return null;
1519
+ const rows = Math.ceil(frames / Math.max(1, cols)) || 1;
1520
+ const place = (sheet, f) => setStyle(sheet, "transform", `translate(${(-(f % cols) * 100 / cols).toFixed(3)}%, ${(-Math.floor(f / cols) * 100 / rows).toFixed(3)}%)`);
1521
+ let last = -1;
1522
+ return {
1523
+ mode,
1524
+ /** what the quick view flies out of */
1525
+ image: mode === "sprite" ? el : front,
1526
+ set(t) {
1527
+ const q = Math.round(t * 1e3) / 1e3;
1528
+ if (q === last) return;
1529
+ last = q;
1530
+ if (mode === "pair") {
1531
+ setStyle(side, "transform", `perspective(900px) rotateY(${(-82 * q).toFixed(1)}deg)`);
1532
+ setStyle(side, "opacity", (1 - smoothstep(0.42, 0.78, q)).toFixed(3));
1533
+ setStyle(front, "opacity", smoothstep(0.45, 0.82, q).toFixed(3));
1534
+ setStyle(front, "transform", `scale(${lerp(0.9, 1, q).toFixed(3)})`);
1535
+ } else if (mode === "sprite") {
1536
+ const f = q * (frames - 1);
1537
+ const a = Math.floor(f);
1538
+ place(sheets[0], a);
1539
+ place(sheets[1], Math.min(frames - 1, a + 1));
1540
+ setStyle(sheets[1], "opacity", (f - a).toFixed(3));
1541
+ } else {
1542
+ setStyle(front, "transform", `perspective(900px) rotateY(${lerp(-74, 0, q).toFixed(1)}deg) scale(${lerp(0.86, 1, q).toFixed(3)})`);
1543
+ }
1544
+ },
1545
+ /** Another colour of the same piece (picked in the quick view). A sprite keeps its sheet. */
1546
+ setImages(image, sideImage) {
1547
+ if (mode === "sprite") return;
1548
+ putImage(front, image);
1549
+ putImage(side, sideImage);
1550
+ }
1551
+ };
1552
+ }
1553
+ function mountRail(root, ctx) {
1554
+ const items = [];
1555
+ const products = [];
1556
+ const hits = [];
1557
+ const bodies = [];
1558
+ const turns = [];
1559
+ root.querySelectorAll("[data-gem-rail-item]").forEach((item) => {
1560
+ const product = readProduct(item);
1561
+ if (!product) return;
1562
+ const hit = item.querySelector(".gem-rail__hit") || item;
1563
+ items.push(item);
1564
+ products.push(product);
1565
+ hits.push(hit);
1566
+ bodies.push(item.querySelector(".gem-rail__body") || hit);
1567
+ turns.push(createTurn(item.querySelector(".gem-turn")));
1568
+ });
1569
+ const n = items.length;
1570
+ if (!n) return false;
1571
+ const stage = root.querySelector(".gem-rail__stage") || root;
1572
+ const track = root.querySelector(".gem-rail__track") || stage;
1573
+ const caption = root.querySelector(".gem-rail__caption");
1574
+ const rod = root.querySelector(".gem-rail__rod path");
1575
+ const style = ctx.renderer.style;
1576
+ const turn = items.map(() => 0);
1577
+ const hangers = items.map(() => ({ x: 0, v: 0 }));
1578
+ let s = ctx.settings;
1579
+ let goal = items.map(() => 0);
1580
+ let focused = -1;
1581
+ let compact = false;
1582
+ let scrolled = false;
1583
+ let rendered = false;
1584
+ let w = 0;
1585
+ let h = 0;
1586
+ let lastX = 0;
1587
+ let lastT = 0;
1588
+ let dpx = 0;
1589
+ const still = () => ctx.tier() === "static";
1590
+ const rodPath = () => `M0 ${ROD_TOP} Q50 ${Math.round(ROD_TOP * 10 + s.sag * 2e3) / 10} 100 ${ROD_TOP}`;
1591
+ if (rod && rod.getAttribute("d") !== rodPath()) rod.setAttribute("d", rodPath());
1592
+ const layout = () => {
1593
+ s = ctx.reread();
1594
+ const was = compact;
1595
+ compact = root.clientWidth < COMPACT_BELOW;
1596
+ w = stage.clientWidth;
1597
+ h = stage.clientHeight;
1598
+ if (compact && !was) items.forEach((el) => style(el, "transform", ""));
1599
+ };
1600
+ const rodY = (x) => {
1601
+ const t = clamp(x / Math.max(1, w), 0, 1);
1602
+ return h * ROD_TOP / 100 + 4 * s.sag * h * t * (1 - t);
1603
+ };
1604
+ const slots = () => {
1605
+ const weights = turn.map((t) => 1 + s.grow * t * t);
1606
+ const unit = w * 0.92 / weights.reduce((a, b) => a + b, 0);
1607
+ let acc = w * 0.04;
1608
+ return weights.map((x) => {
1609
+ const c = acc + x * unit / 2;
1610
+ acc += x * unit;
1611
+ return { c, width: x * unit };
1612
+ });
1613
+ };
1614
+ const nearest = (x) => {
1615
+ let best = -1;
1616
+ let bestD = Infinity;
1617
+ slots().forEach(({ c, width }, i) => {
1618
+ const d = Math.abs(c - x);
1619
+ if (d < bestD && d < width * 1.2) {
1620
+ best = i;
1621
+ bestD = d;
1622
+ }
1623
+ });
1624
+ return best;
1625
+ };
1626
+ const setTint = (tint) => {
1627
+ if (!s.tint) return;
1628
+ if (tint) root.style.setProperty("--gem-motion-tint", tint);
1629
+ else root.style.removeProperty("--gem-motion-tint");
1630
+ };
1631
+ const setFocus = (i, initial) => {
1632
+ if (i === focused) return;
1633
+ focused = i;
1634
+ goal = goal.map((_, k) => k === i ? 1 : 0);
1635
+ const p = products[i];
1636
+ if (initial) {
1637
+ turn[i] = 1;
1638
+ if (turns[i]) turns[i].set(1);
1639
+ return;
1640
+ }
1641
+ if (caption) caption.textContent = p ? [p.title, priceText(p, root)].filter(Boolean).join(" \xB7 ") : "";
1642
+ if (p) {
1643
+ setTint(p.tint);
1644
+ ctx.emit("gem:motion:front", { index: i, product: p });
1645
+ }
1646
+ task.kick();
1647
+ };
1648
+ const frame = (dt) => {
1649
+ const k = follow(dt, s.ease);
1650
+ let moving = false;
1651
+ if (compact && scrolled) {
1652
+ const mid = track.scrollLeft + track.clientWidth / 2;
1653
+ let best = 0;
1654
+ items.forEach((el, i) => {
1655
+ if (Math.abs(el.offsetLeft + el.offsetWidth / 2 - mid) < Math.abs(items[best].offsetLeft + items[best].offsetWidth / 2 - mid)) best = i;
1656
+ });
1657
+ if (best !== focused) setFocus(best);
1658
+ }
1659
+ for (let i = 0; i < n; i++) {
1660
+ const d = goal[i] - turn[i];
1661
+ turn[i] = still() || Math.abs(d) < 2e-3 ? goal[i] : turn[i] + d * k;
1662
+ if (turn[i] !== goal[i]) moving = true;
1663
+ const hanger = hangers[i];
1664
+ if (still()) hanger.x = hanger.v = 0;
1665
+ else {
1666
+ spring(hanger, 0, dt, 0.02, 0.09);
1667
+ hanger.x = clamp(hanger.x, -9, 9);
1668
+ if (Math.abs(hanger.x) > 0.02 || Math.abs(hanger.v) > 0.02) moving = true;
1669
+ else hanger.x = hanger.v = 0;
1670
+ }
1671
+ if (turns[i]) turns[i].set(turn[i]);
1672
+ style(bodies[i], "transform", `rotate(${hanger.x.toFixed(2)}deg)`);
1673
+ }
1674
+ if (!compact) {
1675
+ slots().forEach(({ c }, i) => {
1676
+ style(items[i], "transform", `translate3d(${c.toFixed(1)}px, ${rodY(c).toFixed(1)}px, 0)`);
1677
+ style(items[i], "z-index", String(Math.round(10 + turn[i] * 10)));
1678
+ });
1679
+ }
1680
+ rendered = true;
1681
+ return moving;
1682
+ };
1683
+ const refresh = () => {
1684
+ layout();
1685
+ if (rendered) task.kick();
1686
+ };
1687
+ const task = frameTask(ctx, root, frame, { onDegrade: refresh });
1688
+ hits.forEach((hit, i) => ctx.on(hit, "focus", () => setFocus(i)));
1689
+ ctx.on(stage, "click", (event) => {
1690
+ if (compact || hits.some((hit) => hit.contains(event.target))) return;
1691
+ const i = nearest(event.clientX - stage.getBoundingClientRect().left);
1692
+ if (i >= 0) hits[i].click();
1693
+ });
1694
+ ctx.on(stage, "pointermove", (event) => {
1695
+ if (compact || event.pointerType !== "mouse") return;
1696
+ const x = event.clientX - stage.getBoundingClientRect().left;
1697
+ dpx = clamp((x - lastX) / Math.max(1, event.timeStamp - lastT), -3, 3);
1698
+ lastX = x;
1699
+ lastT = event.timeStamp;
1700
+ if (!still() && s.swing) {
1701
+ slots().forEach(({ c, width }, i) => {
1702
+ hangers[i].v += dpx * 0.35 * s.swing * clamp(1 - Math.abs(c - x) / (width * 2.5), 0, 1);
1703
+ });
1704
+ }
1705
+ setFocus(nearest(x));
1706
+ task.kick();
1707
+ });
1708
+ ctx.on(stage, "pointerleave", () => {
1709
+ if (!compact) setFocus(-1);
1710
+ });
1711
+ ctx.on(track, "keydown", (event) => {
1712
+ if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
1713
+ event.preventDefault();
1714
+ hits[clamp(Math.max(0, focused) + (event.key === "ArrowRight" ? 1 : -1), 0, n - 1)].focus();
1715
+ });
1716
+ ctx.on(
1717
+ track,
1718
+ "scroll",
1719
+ () => {
1720
+ if (!compact) return;
1721
+ scrolled = true;
1722
+ task.kick();
1723
+ },
1724
+ { passive: true }
1725
+ );
1726
+ layout();
1727
+ observeSize(ctx, root, refresh);
1728
+ if (compact) setFocus(0, true);
1729
+ const goTo = (index) => {
1730
+ const i = (index % n + n) % n;
1731
+ if (compact && typeof track.scrollTo === "function") {
1732
+ track.scrollTo({ left: items[i].offsetLeft - (track.clientWidth - items[i].offsetWidth) / 2, behavior: still() ? "auto" : "smooth" });
1733
+ }
1734
+ setFocus(i);
1735
+ };
1736
+ root.gemQuickViewHost = {
1737
+ key: "rail",
1738
+ products,
1739
+ indexOf: (el) => items.findIndex((item) => item === el || item.contains(el)),
1740
+ opens: () => true,
1741
+ imageFor: (i) => (turns[i] ? turns[i].image : items[i] && items[i].querySelector("img")) || null,
1742
+ triggerFor: (i) => hits[i] || null,
1743
+ goTo,
1744
+ setOpen: (open) => root.setAttribute("data-gem-state", open ? "open" : "ready"),
1745
+ setImage: (i, image, side) => turns[i] && turns[i].setImages(image, side),
1746
+ setTint
1747
+ };
1748
+ root.setAttribute("data-gem-quick-view-host", "rail");
1749
+ root.setAttribute("data-gem-state", "ready");
1750
+ return () => {
1751
+ delete root.gemQuickViewHost;
1752
+ root.removeAttribute("data-gem-quick-view-host");
1753
+ root.removeAttribute("data-gem-state");
1754
+ };
1755
+ }
1756
+ var railEffect = defineEffect({
1757
+ key: "rail",
1758
+ hook: "[data-gem-rail]",
1759
+ load: "viewport",
1760
+ renderer: "dom",
1761
+ tier: "static",
1762
+ fallback: "markup",
1763
+ budget: { gzip: 6144 },
1764
+ settings: {
1765
+ 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)" },
1766
+ 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)" },
1767
+ grow: { type: "number", min: 0, max: 3, step: 0.1, default: 1.5, label: "How much wider a turned piece gets" },
1768
+ swing: { type: "number", min: 0, max: 2, step: 0.1, default: 1, label: "Hanger swing (0: off)" },
1769
+ ease: { type: "number", min: 40, max: 600, step: 10, default: 120, label: "Turn time (ms)" },
1770
+ tint: { type: "boolean", default: true, label: "Tint the band with the turned piece's colour" }
1771
+ },
1772
+ mount: mountRail
1773
+ });
1774
+
1775
+ // ../../packages/gem-framework/src/motion/quick-view.js
1776
+ var EASE_OUT = "cubic-bezier(.2,.75,.15,1)";
1777
+ var LABELS = {
1778
+ add: "Add to bag",
1779
+ adding: "Adding\u2026",
1780
+ added: "Added",
1781
+ added_status: "added to bag",
1782
+ failed: "Could not add to bag",
1783
+ choose: "Choose an option",
1784
+ sold_out: "Sold out",
1785
+ details: "Full details",
1786
+ previous: "Previous product",
1787
+ next: "Next product",
1788
+ close: "Close",
1789
+ colour: "Colour",
1790
+ size: "Size"
1791
+ };
1792
+ var panelCount = 0;
1793
+ var pad2 = (v) => String(v).padStart(2, "0");
1794
+ var text = (v) => v == null ? "" : String(v);
1795
+ function cardHost(trigger) {
1796
+ const own = trigger.closest("[data-gem-product]");
1797
+ const group = trigger.closest("[data-gem-quick-view-group]");
1798
+ const products = [];
1799
+ const sources = [];
1800
+ let index = -1;
1801
+ for (const el of group ? group.querySelectorAll("[data-gem-product]") : own ? [own] : []) {
1802
+ const product = readProduct(el);
1803
+ if (!product) continue;
1804
+ if (el === own) index = products.length;
1805
+ products.push(product);
1806
+ sources.push(el);
1807
+ }
1808
+ return {
1809
+ index,
1810
+ host: {
1811
+ key: "card",
1812
+ products,
1813
+ imageFor: (i) => sources[i] ? sources[i].querySelector("img") : null,
1814
+ triggerFor: (i) => sources[i] ? sources[i].matches("[data-gem-quick-view-trigger]") ? sources[i] : sources[i].querySelector("[data-gem-quick-view-trigger]") : null,
1815
+ goTo: () => {
1816
+ },
1817
+ setOpen: () => {
1818
+ }
1819
+ }
1820
+ };
1821
+ }
1822
+ function sessionOf(ctx) {
1823
+ if (ctx.session.quickView) return ctx.session.quickView;
1824
+ const shared = { panels: /* @__PURE__ */ new Map(), pending: null };
1825
+ ctx.session.quickView = shared;
1826
+ ctx.on(ctx.window, "gem:cart:added", () => shared.pending && shared.pending(true));
1827
+ ctx.on(ctx.window, "gem:cart:add-failed", () => shared.pending && shared.pending(false));
1828
+ ctx.cleanup(() => {
1829
+ shared.panels.forEach((panel) => panel.destroy());
1830
+ shared.panels.clear();
1831
+ });
1832
+ return shared;
1833
+ }
1834
+ function createPanel(parent, ctx, shared) {
1835
+ const doc = parent.ownerDocument;
1836
+ const win = ctx.window;
1837
+ const still = () => ctx.tier() === "static";
1838
+ const make = (tag, className, attrs) => {
1839
+ const node = doc.createElement(tag);
1840
+ node.className = className;
1841
+ for (const [k, v] of Object.entries(attrs || {})) node.setAttribute(k, v);
1842
+ return node;
1843
+ };
1844
+ panelCount += 1;
1845
+ const titleId = "gem-quick-view-title-" + panelCount;
1846
+ const shell = make("div", "gem-popup-shell gem-quick-view__shell", {
1847
+ "data-gem-quick-view-panel": "",
1848
+ "data-gem-popup-desktop": "split-left",
1849
+ "data-gem-popup-mobile": "sheet",
1850
+ "data-gem-popup-media": "top",
1851
+ "data-gem-popup-align": "start"
1852
+ });
1853
+ const dialog = make("dialog", "gem-popup-shell__dialog gem-quick-view__dialog", { "aria-labelledby": titleId });
1854
+ const frame = make("div", "gem-popup-shell__frame gem-quick-view__frame");
1855
+ const surface = make("div", "gem-quick-view__surface", { "aria-hidden": "true" });
1856
+ const close = make("button", "gem-popup-shell__close gem-quick-view__close", { type: "button" });
1857
+ const media = make("div", "gem-popup-shell__media gem-quick-view__media");
1858
+ const img = make("img", "gem-quick-view__image", { alt: "" });
1859
+ const imgNext = make("img", "gem-quick-view__image gem-quick-view__image--next", { alt: "", "aria-hidden": "true" });
1860
+ const body = make("div", "gem-popup-shell__panel gem-quick-view__panel");
1861
+ const bar = make("div", "gem-quick-view__bar");
1862
+ const prev = make("button", "gem-quick-view__icon", { type: "button" });
1863
+ const next = make("button", "gem-quick-view__icon", { type: "button" });
1864
+ const counter = make("span", "gem-quick-view__count");
1865
+ const eyebrow = make("p", "gem-quick-view__eyebrow");
1866
+ const heading = make("h2", "gem-quick-view__title", { id: titleId });
1867
+ const price = make("p", "gem-quick-view__price");
1868
+ const compare = make("s", "gem-quick-view__compare");
1869
+ const swatches = make("div", "gem-quick-view__swatches", { role: "radiogroup" });
1870
+ const sizes = make("div", "gem-quick-view__sizes", { role: "radiogroup" });
1871
+ const add = make("button", "gem-button gem-button--primary gem-quick-view__add", { type: "button" });
1872
+ const status = make("p", "gem-quick-view__status", { role: "status" });
1873
+ const desc = make("p", "gem-quick-view__description");
1874
+ const details = make("a", "gem-quick-view__details");
1875
+ close.textContent = "\xD7";
1876
+ prev.textContent = "\u2039";
1877
+ next.textContent = "\u203A";
1878
+ media.append(img, imgNext);
1879
+ bar.append(prev, counter, next);
1880
+ body.append(bar, eyebrow, heading, price, compare, swatches, sizes, add, status, desc, details);
1881
+ frame.append(close, media, body);
1882
+ dialog.append(surface, frame);
1883
+ shell.append(dialog);
1884
+ parent.append(shell);
1885
+ const chosen = /* @__PURE__ */ new Map();
1886
+ let host = null;
1887
+ let list = [];
1888
+ let index = 0;
1889
+ let color2 = 0;
1890
+ let size = -1;
1891
+ let source = null;
1892
+ let returnTo = null;
1893
+ let priceAt = null;
1894
+ let labels = LABELS;
1895
+ let closing = false;
1896
+ let resetTimer = 0;
1897
+ const product = () => list[index];
1898
+ const animate = (el, frames, options) => typeof el.animate === "function" ? el.animate(frames, options) : null;
1899
+ const cancelAll = (el) => {
1900
+ if (el.getAnimations) el.getAnimations().forEach((a) => a.cancel());
1901
+ };
1902
+ const hideSource = (hide) => {
1903
+ if (!source) return;
1904
+ if (hide) source.setAttribute("data-gem-quick-view-source", "");
1905
+ else source.removeAttribute("data-gem-quick-view-source");
1906
+ };
1907
+ const moveSource = () => {
1908
+ const target2 = host.imageFor(index);
1909
+ if (target2 === source) return;
1910
+ hideSource(false);
1911
+ source = target2;
1912
+ if (dialog.open) hideSource(true);
1913
+ };
1914
+ const setTint = (tint) => {
1915
+ if (host.setTint) host.setTint(tint);
1916
+ else if (tint) shell.style.setProperty("--gem-motion-tint", tint);
1917
+ else shell.style.removeProperty("--gem-motion-tint");
1918
+ };
1919
+ const showImage = (image, fade) => {
1920
+ img.alt = image.alt || text(product().title);
1921
+ if (!fade || still() || typeof imgNext.animate !== "function") return putImage(img, image);
1922
+ putImage(imgNext, image);
1923
+ const swap = () => {
1924
+ const settle = () => {
1925
+ putImage(img, image);
1926
+ imgNext.removeAttribute("src");
1927
+ imgNext.removeAttribute("srcset");
1928
+ };
1929
+ const run = imgNext.animate([{ opacity: 0, transform: "scale(.97)" }, { opacity: 1, transform: "none" }], { duration: 280, easing: EASE_OUT });
1930
+ if (run && run.finished) run.finished.then(settle, settle);
1931
+ else settle();
1932
+ };
1933
+ if (typeof imgNext.decode === "function") imgNext.decode().then(swap, swap);
1934
+ else swap();
1935
+ };
1936
+ const variant = () => {
1937
+ const p = product();
1938
+ const c = p.colors && p.colors[color2];
1939
+ const z = p.sizes && p.sizes[size];
1940
+ if (!Array.isArray(p.variants)) return null;
1941
+ return p.variants.find((v) => (!c || (v.options || []).includes(c.name)) && (!z || (v.options || []).includes(z.name))) || null;
1942
+ };
1943
+ const variantId = () => {
1944
+ const p = product();
1945
+ const match = variant();
1946
+ if (match) return match.available === false ? null : match.id;
1947
+ const c = p.colors && p.colors[color2];
1948
+ const z = p.sizes && p.sizes[size];
1949
+ if (z && z.variant_id != null) return z.variant_id;
1950
+ if (c && c.variant_ids && c.variant_ids.length) return c.variant_ids[0];
1951
+ return p.variant_id != null ? p.variant_id : null;
1952
+ };
1953
+ const priced = () => {
1954
+ const v = variant();
1955
+ return v && (v.price_text != null || v.price != null) ? v : product();
1956
+ };
1957
+ const showPrice = (fade) => {
1958
+ const words = priceText(priced(), priceAt);
1959
+ if (fade) swapText(price, words, still());
1960
+ else price.textContent = words;
1961
+ compare.textContent = priceText(priced(), priceAt, "compare");
1962
+ compare.hidden = !compare.textContent;
1963
+ };
1964
+ const updateAdd = () => {
1965
+ const p = product();
1966
+ const z = p.sizes && p.sizes[size];
1967
+ const soldOut = p.sold_out === true || Boolean(p.sizes && p.sizes.length) && size < 0;
1968
+ add.disabled = soldOut;
1969
+ add.textContent = soldOut ? labels.sold_out : [labels.add, z ? z.name : "", priceText(priced(), priceAt)].filter(Boolean).join(" \xB7 ");
1970
+ };
1971
+ const render = (fade) => {
1972
+ const p = product();
1973
+ const colors = Array.isArray(p.colors) ? p.colors : [];
1974
+ color2 = Math.min(chosen.get(p.id) || 0, Math.max(0, colors.length - 1));
1975
+ size = (p.sizes || []).findIndex((z) => z.available !== false);
1976
+ const c = colors[color2];
1977
+ counter.textContent = pad2(index + 1) + " / " + pad2(list.length);
1978
+ bar.hidden = list.length < 2;
1979
+ const put = (el, value) => {
1980
+ if (fade) swapText(el, value, still());
1981
+ else el.textContent = value;
1982
+ };
1983
+ put(eyebrow, text(p.eyebrow));
1984
+ put(heading, text(p.title));
1985
+ showPrice(fade);
1986
+ eyebrow.hidden = !p.eyebrow;
1987
+ desc.textContent = text(p.description);
1988
+ desc.hidden = !p.description;
1989
+ details.textContent = labels.details;
1990
+ if (p.url) details.setAttribute("href", p.url);
1991
+ details.hidden = !p.url;
1992
+ showImage(c && c.image || p.image, fade);
1993
+ setTint(c && c.tint || p.tint);
1994
+ swatches.replaceChildren(
1995
+ ...colors.map((col, i) => {
1996
+ const b = make("button", "gem-quick-view__swatch", { type: "button", role: "radio", "aria-checked": String(i === color2), "aria-label": col.name, title: col.name, "data-gem-quick-view-color": String(i) });
1997
+ if (col.hex) b.style.setProperty("--gem-quick-view-swatch", col.hex);
1998
+ return b;
1999
+ })
2000
+ );
2001
+ swatches.hidden = colors.length < 2;
2002
+ sizes.replaceChildren(
2003
+ ...(p.sizes || []).map((z, i) => {
2004
+ const b = make("button", "gem-quick-view__size", { type: "button", role: "radio", "aria-checked": String(i === size), "data-gem-quick-view-size": String(i) });
2005
+ b.textContent = z.name;
2006
+ if (z.available === false) {
2007
+ b.disabled = true;
2008
+ b.setAttribute("aria-label", z.name + ", " + labels.sold_out);
2009
+ }
2010
+ return b;
2011
+ })
2012
+ );
2013
+ sizes.hidden = !(p.sizes && p.sizes.length);
2014
+ status.textContent = "";
2015
+ updateAdd();
2016
+ moveSource();
2017
+ for (const k of [index - 1, index + 1]) if (list.length > 1) loadImage(doc, list[(k + list.length) % list.length].image);
2018
+ };
2019
+ const step = (delta) => {
2020
+ if (list.length < 2) return;
2021
+ index = (index + delta + list.length) % list.length;
2022
+ host.goTo(index);
2023
+ render(true);
2024
+ };
2025
+ const finish = (ok, before) => {
2026
+ shared.pending = null;
2027
+ add.disabled = false;
2028
+ add.textContent = ok ? labels.added : before;
2029
+ status.textContent = ok ? text(product().title) + ": " + labels.added_status : labels.failed;
2030
+ if (resetTimer) win.clearTimeout(resetTimer);
2031
+ resetTimer = ok ? win.setTimeout(updateAdd, 1600) : 0;
2032
+ };
2033
+ const addToCart = () => {
2034
+ const id = variantId();
2035
+ if (id == null || id === "") {
2036
+ status.textContent = labels.choose;
2037
+ return;
2038
+ }
2039
+ const before = add.textContent;
2040
+ add.disabled = true;
2041
+ add.textContent = labels.adding;
2042
+ add.dispatchEvent(new win.CustomEvent("gem:cart:add", { bubbles: true, composed: true, detail: { variantId: String(id), quantity: 1 } }));
2043
+ if (win.__gemCartAddBridge) shared.pending = (ok) => finish(ok, before);
2044
+ else finish(true, before);
2045
+ };
2046
+ const pick = (group, i) => {
2047
+ Array.from(group.children).forEach((el, k) => el.setAttribute("aria-checked", String(k === i)));
2048
+ if (group === sizes) size = i;
2049
+ else {
2050
+ const p = product();
2051
+ const c = p.colors && p.colors[i];
2052
+ if (!c) return;
2053
+ color2 = i;
2054
+ chosen.set(p.id, i);
2055
+ if (c.image) {
2056
+ showImage(c.image, true);
2057
+ if (host.setImage) host.setImage(index, c.image, c.side);
2058
+ }
2059
+ setTint(c.tint || p.tint);
2060
+ }
2061
+ showPrice(false);
2062
+ updateAdd();
2063
+ };
2064
+ const done = (reason) => {
2065
+ dialog.removeAttribute("data-gem-state");
2066
+ cancelAll(img);
2067
+ if (dialog.open) dialog.close();
2068
+ hideSource(false);
2069
+ host.setOpen(false);
2070
+ closing = false;
2071
+ const back = host.triggerFor(index) || returnTo;
2072
+ if (back && typeof back.focus === "function") back.focus();
2073
+ ctx.api.emit("gem:quick-view:close", { source: "gem-runtime", reason, productId: product().id });
2074
+ };
2075
+ const shut = (reason) => {
2076
+ if (!dialog.open || closing) return;
2077
+ closing = true;
2078
+ moveSource();
2079
+ const to = source && source.getBoundingClientRect ? source.getBoundingClientRect() : null;
2080
+ const flight = still() || !to ? null : flip(img, to, { back: true, duration: 440, easing: "cubic-bezier(.4,0,.2,1)" });
2081
+ if (!flight || !flight.finished) return done(reason);
2082
+ dialog.setAttribute("data-gem-state", "closing");
2083
+ for (const el of [body, surface, close]) animate(el, [{ opacity: 1 }, { opacity: 0 }], { duration: 220, fill: "forwards" });
2084
+ host.setOpen(false);
2085
+ const end = () => {
2086
+ [body, surface, close].forEach(cancelAll);
2087
+ done(reason);
2088
+ };
2089
+ flight.finished.then(end, end);
2090
+ };
2091
+ ctx.on(close, "click", () => shut("close-control"));
2092
+ ctx.on(prev, "click", () => step(-1));
2093
+ ctx.on(next, "click", () => step(1));
2094
+ ctx.on(add, "click", addToCart);
2095
+ for (const [group, attr] of [[swatches, "data-gem-quick-view-color"], [sizes, "data-gem-quick-view-size"]]) {
2096
+ ctx.on(group, "click", (event) => {
2097
+ const b = event.target.closest && event.target.closest(`[${attr}]`);
2098
+ if (b && !b.disabled) pick(group, Number(b.getAttribute(attr)));
2099
+ });
2100
+ }
2101
+ ctx.on(dialog, "cancel", (event) => {
2102
+ event.preventDefault();
2103
+ shut("escape");
2104
+ });
2105
+ ctx.on(dialog, "click", (event) => {
2106
+ if (event.target === dialog) shut("backdrop");
2107
+ });
2108
+ ctx.on(dialog, "keydown", (event) => {
2109
+ if (event.target.closest && event.target.closest(".gem-quick-view__swatches, .gem-quick-view__sizes")) return;
2110
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") step(event.key === "ArrowRight" ? 1 : -1);
2111
+ });
2112
+ return {
2113
+ shell,
2114
+ open(nextHost, at, trigger, nextLabels) {
2115
+ if (dialog.open) return;
2116
+ cancelAll(img);
2117
+ host = nextHost;
2118
+ list = host.products;
2119
+ index = at;
2120
+ labels = nextLabels;
2121
+ returnTo = trigger;
2122
+ priceAt = trigger;
2123
+ source = null;
2124
+ close.setAttribute("aria-label", labels.close);
2125
+ prev.setAttribute("aria-label", labels.previous);
2126
+ next.setAttribute("aria-label", labels.next);
2127
+ swatches.setAttribute("aria-label", labels.colour);
2128
+ sizes.setAttribute("aria-label", labels.size);
2129
+ render(false);
2130
+ source = host.imageFor(index);
2131
+ const from = source && source.getBoundingClientRect ? source.getBoundingClientRect() : null;
2132
+ dialog.showModal();
2133
+ dialog.setAttribute("data-gem-state", "open");
2134
+ host.setOpen(true);
2135
+ hideSource(true);
2136
+ ctx.api.emit("gem:quick-view:open", { source: "gem-runtime", productId: product().id, productUrl: product().url || null, index, count: list.length, host: host.key });
2137
+ if (still()) return;
2138
+ flip(img, from, { duration: 560, easing: EASE_OUT });
2139
+ animate(surface, [{ opacity: 0, transform: "scale(.97)" }, { opacity: 1, transform: "none" }], { duration: 360, easing: EASE_OUT });
2140
+ Array.from(body.children).forEach(
2141
+ (el, i) => animate(el, [{ opacity: 0, transform: "translateY(12px)" }, { opacity: 1, transform: "none" }], { duration: 380, delay: 120 + i * 40, easing: EASE_OUT, fill: "backwards" })
2142
+ );
2143
+ },
2144
+ /** Unmount: close at once (no flight) and leave the root as the server drew it. */
2145
+ destroy() {
2146
+ if (dialog.open) done("unmount");
2147
+ shell.remove();
2148
+ }
2149
+ };
2150
+ }
2151
+ function mountQuickView(trigger, ctx) {
2152
+ const shared = sessionOf(ctx);
2153
+ ctx.on(trigger, "click", (event) => {
2154
+ if (event.defaultPrevented) return;
2155
+ const hostEl = trigger.closest("[data-gem-quick-view-host]");
2156
+ const host = hostEl && hostEl.gemQuickViewHost;
2157
+ if (host && !host.opens(trigger)) return;
2158
+ const card = host ? null : cardHost(trigger);
2159
+ const index = host ? host.indexOf(trigger) : card.index;
2160
+ if (index < 0 || !(host || card.host).products[index]) return;
2161
+ event.preventDefault();
2162
+ const parent = host && hostEl || trigger.closest("[data-gem-quick-view-group]") || trigger.closest(".gem-section, [data-gem-section-id]") || trigger.parentElement;
2163
+ let panel = shared.panels.get(parent);
2164
+ if (!panel || !panel.shell.isConnected) shared.panels.set(parent, panel = createPanel(parent, ctx, shared));
2165
+ const own = ctx.reread().labels;
2166
+ panel.open(host || card.host, index, trigger, { ...LABELS, ...own && typeof own === "object" ? own : {} });
2167
+ });
2168
+ }
2169
+ var quickViewEffect = defineEffect({
2170
+ key: "quick-view",
2171
+ hook: "[data-gem-quick-view-trigger]",
2172
+ load: "interaction",
2173
+ renderer: "dom",
2174
+ tier: "static",
2175
+ fallback: "markup",
2176
+ budget: { gzip: 6144 },
2177
+ settings: {
2178
+ labels: {
2179
+ type: "json",
2180
+ default: {},
2181
+ inherit: true,
2182
+ 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"
2183
+ }
2184
+ },
2185
+ mount: mountQuickView
2186
+ });
2187
+
2188
+ // ../../packages/gem-framework/src/motion-engine.ts
2189
+ var GEM_ORBIT_BASE_CSS_LINES = [
2190
+ ".gem-orbit { display: block; position: relative; background-color: var(--gem-motion-tint, transparent); }",
2191
+ ".gem-orbit:focus-visible { outline: var(--gem-focus-ring); outline-offset: calc(-1 * var(--gem-focus-offset)); }",
2192
+ ".gem-orbit__stage { position: relative; height: var(--gem-orbit-stage-height, clamp(340px, 58vh, 620px)); overflow: hidden; container-type: size; }",
2193
+ ".gem-orbit__ring { list-style: none; margin: 0; padding: 0; position: absolute; left: 50%; top: 66%; width: 0; height: 0; }",
2194
+ ".gem-orbit__item {",
2195
+ " --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);",
2196
+ " --gem-orbit-a: calc(var(--gem-orbit-k) * var(--gem-orbit-spread, calc(360 / var(--gem-orbit-n, 1))) * 1deg);",
2197
+ " --gem-orbit-d: calc((1 - cos(var(--gem-orbit-a))) / 2);",
2198
+ " position: absolute; left: calc(var(--gem-orbit-size, 0.62) * -50cqh); top: calc(var(--gem-orbit-size, 0.62) * -50cqh);",
2199
+ " width: calc(var(--gem-orbit-size, 0.62) * 100cqh); height: calc(var(--gem-orbit-size, 0.62) * 100cqh);",
2200
+ " 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)",
2201
+ " 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));",
2202
+ " opacity: calc(1 + (var(--gem-orbit-fade, 0.35) - 1) * var(--gem-orbit-d));",
2203
+ " z-index: calc((1 - var(--gem-orbit-d)) * 100);",
2204
+ " filter: blur(calc(var(--gem-orbit-blur, 2.5) * var(--gem-orbit-d) * 1px));",
2205
+ "}",
2206
+ ".gem-orbit__item::after {",
2207
+ " content: ''; position: absolute; left: 18%; right: 18%; bottom: -2%; height: 7%; z-index: -1; border-radius: var(--gem-radius-pill);",
2208
+ " background: radial-gradient(closest-side, color-mix(in srgb, var(--gem-color-scrim, #000) 18%, transparent), transparent);",
2209
+ "}",
2210
+ // A hit is a <button> or an <a href> to the product (still a link on a page without the engine).
2211
+ ".gem-orbit__hit { display: block; width: 100%; height: 100%; padding: 0; border: 0; color: inherit; background: transparent; text-decoration: none; cursor: pointer; }",
2212
+ ".gem-orbit__hit:focus-visible { outline: var(--gem-focus-ring); outline-offset: var(--gem-focus-offset); border-radius: var(--gem-radius-md); }",
2213
+ ".gem-orbit__img { display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; }",
2214
+ ".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; }",
2215
+ ".gem-orbit__name { margin: 0; font-family: var(--gem-font-heading); font-size: var(--gem-h4); font-weight: 600; line-height: 1.2; }",
2216
+ ".gem-orbit__price { margin: 0; color: var(--gem-color-muted); font-variant-numeric: tabular-nums; }",
2217
+ ".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; }",
2218
+ ".gem-orbit__arrow {",
2219
+ " display: inline-grid; place-items: center; width: var(--gem-tap-min); height: var(--gem-tap-min); padding: 0; cursor: pointer;",
2220
+ " border: var(--gem-stroke, 1px) solid currentColor; border-radius: var(--gem-radius-pill); color: inherit; background: transparent; font: inherit; line-height: 1;",
2221
+ "}",
2222
+ ".gem-orbit__arrow:hover { color: var(--gem-color-background); background: var(--gem-color-text); border-color: var(--gem-color-text); }",
2223
+ ".gem-orbit__arrow:focus-visible { outline: var(--gem-focus-ring); outline-offset: var(--gem-focus-offset); }",
2224
+ ".gem-orbit__count { min-width: 7ch; text-align: center; font-variant-numeric: tabular-nums; }",
2225
+ ".gem-orbit__live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }",
2226
+ ".gem-orbit__track { position: relative; width: min(180px, 30vw); height: var(--gem-stroke-thick, 2px); background: color-mix(in srgb, currentColor 18%, transparent); }",
2227
+ ".gem-orbit__track::before { content: ''; position: absolute; inset: calc(-1 * var(--gem-space-sm)) 0; }",
2228
+ ".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))); }",
2229
+ // The light and static tiers draw no blur (a phone, reduced motion): the first paint already matches what they draw.
2230
+ "@media (pointer: coarse), (prefers-reduced-motion: reduce) { .gem-orbit__item { filter: none; } }"
2231
+ ];
2232
+ var GEM_RAIL_BASE_CSS_LINES = [
2233
+ ".gem-rail { display: block; position: relative; container-type: inline-size; background-color: var(--gem-motion-tint, transparent); }",
2234
+ ".gem-rail__stage { position: relative; height: var(--gem-rail-stage-height, clamp(380px, 62vh, 640px)); overflow: hidden; container-type: size; }",
2235
+ // The rod is drawn in a 0..100 viewBox with preserveAspectRatio="none": it fits any stage without being redrawn.
2236
+ ".gem-rail__rod { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }",
2237
+ ".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; }",
2238
+ ".gem-rail__track { list-style: none; margin: 0; padding: 0; position: absolute; inset: 0; }",
2239
+ // At rest slot i of n is centred at 4% + 92% · (i + ½) / n of the width and hangs from the rod's curve there.
2240
+ ".gem-rail__item {",
2241
+ " --gem-rail-t: calc(0.04 + 0.92 * (var(--gem-rail-i, 0) + 0.5) / var(--gem-rail-n, 1));",
2242
+ " position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 10;",
2243
+ " 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);",
2244
+ "}",
2245
+ ".gem-rail__hit {",
2246
+ " 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);",
2247
+ " padding: 0; border: 0; color: inherit; background: transparent; text-decoration: none; cursor: pointer; transform: translateX(-50%);",
2248
+ "}",
2249
+ ".gem-rail__hit:focus-visible { outline: var(--gem-focus-ring); outline-offset: var(--gem-focus-offset); border-radius: var(--gem-radius-md); }",
2250
+ ".gem-rail__body { position: absolute; inset: 0; display: block; transform-origin: 50% 0; }",
2251
+ ".gem-rail__hook {",
2252
+ " position: absolute; left: calc(50% - 8px); top: 0; width: 16px; height: 22px; opacity: 0.8;",
2253
+ " 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;",
2254
+ "}",
2255
+ ".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); }",
2256
+ ".gem-rail__tag {",
2257
+ " position: absolute; left: calc(50% + 14px); top: 18px; z-index: 2; padding: var(--gem-space-2xs) var(--gem-space-xs); transform: rotate(4deg);",
2258
+ " font-family: var(--gem-font-mono); font-size: var(--gem-text-xs); font-weight: 600; line-height: 1; font-variant-numeric: tabular-nums;",
2259
+ " color: var(--gem-color-text); background: var(--gem-color-background); border-radius: var(--gem-radius-sm); box-shadow: var(--gem-shadow-sm);",
2260
+ "}",
2261
+ ".gem-rail .gem-turn { position: absolute; inset: 26px 0 0; width: auto; height: auto; }",
2262
+ ".gem-rail__caption { margin: var(--gem-space-xs) 0 0; min-height: 1.5em; text-align: center; font-variant-numeric: tabular-nums; }",
2263
+ // Under 700 px (the effect's breakpoint, on the same width): a swipe row with snap points; the centred piece turns.
2264
+ "@container (max-width: 699px) {",
2265
+ " .gem-rail__stage { height: auto; overflow: visible; container-type: normal; }",
2266
+ " .gem-rail__rod { display: none; }",
2267
+ " .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; }",
2268
+ " .gem-rail__track::-webkit-scrollbar { display: none; }",
2269
+ " .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); }",
2270
+ " .gem-rail__hit { left: 50%; width: 100%; height: 100%; }",
2271
+ // The row rests with its first piece centred (the track's padding) and facing the shopper; the plugin settles there.
2272
+ " .gem-rail__item:first-child .gem-turn__side { opacity: 0; transform: perspective(900px) rotateY(-82deg); }",
2273
+ " .gem-rail__item:first-child .gem-turn:has(> .gem-turn__side) > .gem-turn__front { opacity: 1; transform: scale(1); }",
2274
+ " .gem-rail__item:first-child .gem-turn:not(:has(> .gem-turn__side)) > .gem-turn__front { transform: perspective(900px) rotateY(0deg) scale(1); }",
2275
+ " .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)))); }",
2276
+ "}",
2277
+ // The turn rests on its side view: pair (side + front photo), flip (front only), sprite (two copies of a frame sheet).
2278
+ ".gem-turn { position: relative; display: block; width: 100%; height: 100%; }",
2279
+ ".gem-turn[data-gem-turn-frames] { overflow: hidden; }",
2280
+ ".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; }",
2281
+ ".gem-turn__side { transform: perspective(900px) rotateY(0deg); backface-visibility: hidden; }",
2282
+ ".gem-turn:has(> .gem-turn__side) > .gem-turn__front { opacity: 0; transform: scale(0.9); }",
2283
+ ".gem-turn:not(:has(> .gem-turn__side)) > .gem-turn__front { transform: perspective(900px) rotateY(-74deg) scale(0.86); }",
2284
+ ".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%); }",
2285
+ ".gem-turn__sheet + .gem-turn__sheet { opacity: 0; }"
2286
+ ];
2287
+ function scopeMotionCss(css, scope) {
2288
+ const roots = /^\.gem-(?:orbit|rail)(?![\w-])/;
2289
+ const prefix = (list) => list.split(/,(?![^(]*\))/).map((one) => one.trim()).flatMap((one) => roots.test(one) ? [`${scope} ${one}`, `${scope}${one}`] : [`${scope} ${one}`]).join(", ");
2290
+ let out = "";
2291
+ let prelude = "";
2292
+ const stack = [];
2293
+ for (const ch of css) {
2294
+ if (ch === "{") {
2295
+ const head = prelude.trim();
2296
+ const atRule = head.startsWith("@");
2297
+ out += prelude.slice(0, prelude.length - prelude.trimStart().length) + (atRule ? head : prefix(head)) + " {";
2298
+ stack.push(atRule);
2299
+ prelude = "";
2300
+ } else if (ch === "}") {
2301
+ out += prelude + "}";
2302
+ prelude = "";
2303
+ stack.pop();
2304
+ } else if (stack.length && stack[stack.length - 1] === false) {
2305
+ out += ch;
2306
+ } else {
2307
+ prelude += ch;
2308
+ }
2309
+ }
2310
+ return out + prelude;
2311
+ }
2312
+ var GEM_ORBIT_BASE_CSS = GEM_ORBIT_BASE_CSS_LINES.join("\n") + "\n";
2313
+ var GEM_RAIL_BASE_CSS = GEM_RAIL_BASE_CSS_LINES.join("\n") + "\n";
2314
+ var gemOrbitBaseCss = (scope) => scopeMotionCss(GEM_ORBIT_BASE_CSS, scope);
2315
+ var gemRailBaseCss = (scope) => scopeMotionCss(GEM_RAIL_BASE_CSS, scope);
2316
+ var GEM_ORBIT_RUNTIME_CSS = [
2317
+ ".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); }",
2318
+ ".gem-orbit[data-gem-state] .gem-orbit__stage { touch-action: pan-y; cursor: grab; }",
2319
+ ".gem-orbit[data-gem-state='dragging'] .gem-orbit__stage { cursor: grabbing; }",
2320
+ ".gem-orbit[data-gem-state] .gem-orbit__item { will-change: transform; }",
2321
+ ".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); }",
2322
+ ".gem-orbit[data-gem-state] .gem-orbit__bar { transition: transform var(--gem-motion-slow, 0.4s) var(--gem-ease-soft, ease); }",
2323
+ ".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; }",
2324
+ ".gem-orbit[data-gem-state='open'] .gem-orbit__item:not([aria-current='true']) .gem-orbit__hit { opacity: 0; transform: scale(0.92); }",
2325
+ "@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; } }"
2326
+ ].join("\n") + "\n";
2327
+ var GEM_RAIL_RUNTIME_CSS = [
2328
+ ".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); }",
2329
+ ".gem-rail[data-gem-state] .gem-rail__item { will-change: transform; }",
2330
+ // Unturned neighbours' hit boxes overlap on the rail: once bound, a click on the stage goes to the piece whose slot it is in
2331
+ // (motion/rail.js), so the hits take no pointer events; keyboard focus and Enter stay on them. The phone row keeps its own.
2332
+ ".gem-rail[data-gem-state] .gem-rail__stage { cursor: pointer; }",
2333
+ ".gem-rail[data-gem-state] .gem-rail__hit { pointer-events: none; transition: opacity var(--gem-motion-base, 0.35s) var(--gem-ease-soft, ease); }",
2334
+ "@container (max-width: 699px) { .gem-rail[data-gem-state] .gem-rail__hit { pointer-events: auto; } }",
2335
+ ".gem-rail[data-gem-state='open'] .gem-rail__hit { opacity: 0.15; }",
2336
+ "@media (prefers-reduced-motion: reduce) { .gem-rail, .gem-rail[data-gem-state] .gem-rail__hit { transition: none; } }"
2337
+ ].join("\n") + "\n";
2338
+ var GEM_QUICK_VIEW_CSS = [
2339
+ "[data-gem-quick-view-trigger] { --gem-quick-view-css: 1; }",
2340
+ ".gem-quick-view__shell { display: contents; }",
2341
+ ".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; }",
2342
+ ".gem-quick-view__surface {",
2343
+ " position: absolute; inset: 0; z-index: -1; border-radius: inherit; box-shadow: var(--gem-shadow-md);",
2344
+ " background: color-mix(in srgb, var(--gem-motion-tint, var(--gem-color-background)) 45%, var(--gem-color-background));",
2345
+ " transition: background-color var(--gem-motion-slow, 0.5s) var(--gem-ease-soft, ease);",
2346
+ "}",
2347
+ ".gem-quick-view__shell .gem-quick-view__media { overflow: visible; background: transparent; min-height: 0; }",
2348
+ ".gem-quick-view__image { position: absolute; inset: 6% 4%; width: 92%; height: 88%; object-fit: contain; transform-origin: 50% 50%; will-change: transform; }",
2349
+ ".gem-quick-view__image--next { opacity: 0; pointer-events: none; }",
2350
+ ".gem-quick-view__image--next:not([src]) { display: none; }",
2351
+ ".gem-quick-view__shell .gem-quick-view__panel { justify-content: flex-start; gap: var(--gem-space-sm); }",
2352
+ ".gem-quick-view__shell [hidden] { display: none; }",
2353
+ ".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; }",
2354
+ ".gem-quick-view__icon {",
2355
+ " display: inline-grid; place-items: center; width: var(--gem-tap-min); height: var(--gem-tap-min); padding: 0; cursor: pointer;",
2356
+ " border: var(--gem-stroke, 1px) solid color-mix(in srgb, currentColor 25%, transparent); border-radius: var(--gem-radius-pill);",
2357
+ " color: inherit; background: transparent; font: inherit; font-size: var(--gem-h5); line-height: 1;",
2358
+ "}",
2359
+ ".gem-quick-view__icon:hover { border-color: currentColor; }",
2360
+ ".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); }",
2361
+ ".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; }",
2362
+ ".gem-quick-view__title { margin: 0; font-family: var(--gem-font-heading); font-size: var(--gem-h2); line-height: 1.05; }",
2363
+ ".gem-quick-view__price { margin: 0; font-size: var(--gem-h5); font-variant-numeric: tabular-nums; }",
2364
+ ".gem-quick-view__compare { margin: calc(-1 * var(--gem-space-xs)) 0 0; color: var(--gem-color-muted); font-size: var(--gem-text-sm); }",
2365
+ ".gem-quick-view__swatches, .gem-quick-view__sizes { display: flex; flex-wrap: wrap; gap: var(--gem-space-xs); }",
2366
+ ".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; }",
2367
+ ".gem-quick-view__swatch::before {",
2368
+ " content: ''; position: absolute; inset: 8px; border-radius: var(--gem-radius-pill); background: var(--gem-quick-view-swatch, var(--gem-color-surface));",
2369
+ " box-shadow: inset 0 0 0 var(--gem-stroke, 1px) color-mix(in srgb, var(--gem-color-text) 15%, transparent);",
2370
+ "}",
2371
+ ".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); }",
2372
+ ".gem-quick-view__size {",
2373
+ " 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;",
2374
+ " border: var(--gem-stroke, 1px) solid color-mix(in srgb, currentColor 22%, transparent); border-radius: var(--gem-radius-pill);",
2375
+ " color: inherit; background: transparent; font: inherit; font-size: var(--gem-text-sm);",
2376
+ "}",
2377
+ ".gem-quick-view__size[aria-checked='true'] { color: var(--gem-color-background); background: var(--gem-color-text); border-color: var(--gem-color-text); }",
2378
+ ".gem-quick-view__size:disabled { opacity: 0.4; text-decoration: line-through; cursor: not-allowed; }",
2379
+ ".gem-quick-view__add { width: 100%; margin: var(--gem-space-xs) 0 0; font-variant-numeric: tabular-nums; }",
2380
+ ".gem-quick-view__status { margin: 0; min-height: 1.2em; color: var(--gem-color-muted); font-size: var(--gem-text-sm); }",
2381
+ ".gem-quick-view__description { margin: 0; max-width: 46ch; font-size: var(--gem-text-sm); line-height: 1.55; }",
2382
+ ".gem-quick-view__details { color: inherit; font-size: var(--gem-text-sm); text-underline-offset: 3px; }",
2383
+ // The photo the panel flew out of stays hidden in the page while the panel shows it.
2384
+ "[data-gem-quick-view-source] { visibility: hidden; }",
2385
+ "@keyframes gem-quick-view-fade { from { opacity: 0; } to { opacity: 1; } }",
2386
+ ".gem-quick-view__dialog[data-gem-state='open']::backdrop { animation: gem-quick-view-fade var(--gem-motion-base, 0.35s) ease both; }",
2387
+ ".gem-quick-view__dialog[data-gem-state='closing']::backdrop { animation: gem-quick-view-fade var(--gem-motion-base, 0.35s) ease reverse both; }",
2388
+ "@media (max-width: 749px) { .gem-quick-view__shell .gem-quick-view__media { height: 34dvh; } }",
2389
+ "@media (prefers-reduced-motion: reduce) { .gem-quick-view__dialog::backdrop { animation: none; } .gem-quick-view__surface { transition: none; } }"
2390
+ ].join("\n") + "\n";
2391
+ var GEM_MOTION_CLASSES = [
2392
+ ...["", "__stage", "__ring", "__item", "__hit", "__img", "__caption", "__name", "__price", "__dock", "__arrow", "__count", "__track", "__bar", "__live"].map((part) => `gem-orbit${part}`),
2393
+ ...["", "__stage", "__rod", "__track", "__item", "__hit", "__body", "__hook", "__tag", "__caption"].map((part) => `gem-rail${part}`),
2394
+ ...["", "__side", "__front", "__sheet"].map((part) => `gem-turn${part}`),
2395
+ ...[
2396
+ "shell",
2397
+ "dialog",
2398
+ "frame",
2399
+ "surface",
2400
+ "close",
2401
+ "media",
2402
+ "image",
2403
+ "image--next",
2404
+ "panel",
2405
+ "bar",
2406
+ "icon",
2407
+ "count",
2408
+ "eyebrow",
2409
+ "title",
2410
+ "price",
2411
+ "compare",
2412
+ "swatches",
2413
+ "swatch",
2414
+ "sizes",
2415
+ "size",
2416
+ "add",
2417
+ "status",
2418
+ "description",
2419
+ "details"
2420
+ ].map((part) => `gem-quick-view__${part}`)
2421
+ ];
2422
+ var GEM_MOTION_RUNTIME_FILES = ["motion/core.js", "motion/orbit.js", "motion/rail.js", "motion/quick-view.js"];
2423
+ var gemMotionEffectList = [orbitEffect, railEffect, quickViewEffect];
2424
+ function gemMotionEffectManifest() {
2425
+ return gemMotionEffectList.map(({ key, hook, load, renderer, tier, fallback, budget, settings }) => ({
2426
+ key,
2427
+ hook,
2428
+ load,
2429
+ renderer,
2430
+ tier,
2431
+ fallback,
2432
+ budget,
2433
+ settings: Object.fromEntries(Object.entries(settings).map(([name, setting]) => [name, { ...setting, attribute: `data-gem-${key}-${name}`, ...setting.css ? { variable: `--gem-${key}-${name}` } : {} }]))
2434
+ }));
2435
+ }
2436
+ var compactCss = (css) => css.replace(/\s+/g, " ").replace(/\s*([{};])\s*/g, "$1").replace(/:\s+/g, ":").replace(/,\s+/g, ",").replace(/;}/g, "}").trim();
2437
+ var effectPluginLines = (effect, name, css, before = [], detail = "count") => [
2438
+ ` registerPlugin('${effect.key}', ({ api }) => {`,
2439
+ ...before,
2440
+ " const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };",
2441
+ ` const count = mountEffects(${name}, { window, document, on, mark, api, css: ${JSON.stringify(compactCss(css))} }, onUnmount);`,
2442
+ ` api.emit('gem:${effect.key}:ready', { source: 'gem-runtime', ${detail} });`,
2443
+ " });"
2444
+ ];
2445
+ var effectImports = (file, name) => ['import { mountEffects } from "../motion/core.js";', `import { ${name} } from "../motion/${file}.js";`];
2446
+ function gemMotionChunks(legacyQuickView) {
2447
+ return {
2448
+ orbit: { hook: orbitEffect.hook, when: orbitEffect.load, imports: effectImports("orbit", "orbitEffect"), body: effectPluginLines(orbitEffect, "orbitEffect", GEM_ORBIT_RUNTIME_CSS) },
2449
+ rail: { hook: railEffect.hook, when: railEffect.load, imports: effectImports("rail", "railEffect"), body: effectPluginLines(railEffect, "railEffect", GEM_RAIL_RUNTIME_CSS) },
2450
+ "quick-view": {
2451
+ hook: `${quickViewEffect.hook}, ${legacyQuickView.hook}`,
2452
+ when: quickViewEffect.load,
2453
+ imports: effectImports("quick-view", "quickViewEffect"),
2454
+ body: effectPluginLines(quickViewEffect, "quickViewEffect", GEM_QUICK_VIEW_CSS, legacyQuickView.lines, legacyQuickView.readyDetail)
2455
+ }
2456
+ };
2457
+ }
2458
+
1053
2459
  // ../../packages/gem-framework/src/manifest.ts
1054
2460
  var gemFrameworkPrimitiveGroups = [
1055
2461
  {
@@ -1245,6 +2651,12 @@ var gemFrameworkPrimitiveGroups = [
1245
2651
  "gem-popup-shell__teaser"
1246
2652
  ]
1247
2653
  },
2654
+ {
2655
+ // The motion engine (motion-engine.ts, 0.19.0): the orbit, rail and turn markup a section draws, and the quick view panel
2656
+ // the quick-view plugin builds. Styled by their own base / runtime stylesheets, never by gem-framework.css.
2657
+ group: "motion",
2658
+ classes: [...GEM_MOTION_CLASSES]
2659
+ },
1248
2660
  {
1249
2661
  group: "sections",
1250
2662
  classes: [
@@ -1399,9 +2811,11 @@ var gemFrameworkRuntimePlugins = [
1399
2811
  events: ["gem:hero-slideshow:ready", "gem:hero-slideshow:change"]
1400
2812
  },
1401
2813
  {
2814
+ // 0.19.0: a lazy chunk with the motion engine's quick view; idle schedules only gem-runtime.js's stub, which imports
2815
+ // the chunk on the first interaction with a trigger (or at idle), never on a page without one.
1402
2816
  key: "quick-view",
1403
- load_strategy: "interaction",
1404
- events: ["gem:quick-view:open", "gem:quick-view:close"]
2817
+ load_strategy: "idle",
2818
+ events: ["gem:quick-view:ready", "gem:quick-view:open", "gem:quick-view:close", "gem:cart:add"]
1405
2819
  },
1406
2820
  {
1407
2821
  key: "promo-popup",
@@ -1462,6 +2876,29 @@ var gemFrameworkRuntimePlugins = [
1462
2876
  key: "reviews",
1463
2877
  load_strategy: "idle",
1464
2878
  events: ["gem:reviews:ready", "gem:reviews:render", "gem:review:vote"]
2879
+ },
2880
+ {
2881
+ key: "bundle",
2882
+ load_strategy: "idle",
2883
+ events: ["gem:bundle:ready", "gem:bundle:change", "gem:bundle:complete"]
2884
+ },
2885
+ {
2886
+ // idle, not interaction: the button it shows has to be there before anyone interacts.
2887
+ key: "size-guide",
2888
+ load_strategy: "idle",
2889
+ events: ["gem:size-guide:ready", "gem:size-guide:open"]
2890
+ },
2891
+ // The motion engine's effects (0.19.0): lazy chunks. idle schedules gem-runtime.js's stub, which imports the chunk when a
2892
+ // hook comes near the viewport and never on a page without one.
2893
+ {
2894
+ key: "orbit",
2895
+ load_strategy: "idle",
2896
+ events: ["gem:orbit:ready", "gem:motion:front", "gem:motion:fallback"]
2897
+ },
2898
+ {
2899
+ key: "rail",
2900
+ load_strategy: "idle",
2901
+ events: ["gem:rail:ready", "gem:motion:front", "gem:motion:fallback"]
1465
2902
  }
1466
2903
  ];
1467
2904
  var gemFrameworkValidationRules = [
@@ -1567,6 +3004,24 @@ var GEM_POPUP_SHELL_CSS_LINES = [
1567
3004
  " .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); }",
1568
3005
  " .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; }",
1569
3006
  "}",
3007
+ // 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
3008
+ // 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
3009
+ // 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ừ đó
3010
+ // (--gem-motion-base / --gem-ease-enter, như ngăn giỏ của framework). Chỉ là bố cục desktop: mobile vẫn takeover / sheet.
3011
+ "@keyframes gem-popup-drawer-in { from { translate: calc(100% * var(--gem-popup-drawer-dir, 1)) 0; } }",
3012
+ ".gem-popup-shell__dialog:dir(rtl) { --gem-popup-drawer-dir: -1; }",
3013
+ "@media (min-width: 750px) {",
3014
+ " .gem-popup-shell[data-gem-popup-desktop='drawer-end'] .gem-popup-shell__dialog {",
3015
+ " width: min(var(--gem-popup-drawer-width, 640px), 100vw); max-width: 100vw; height: 100dvh; max-height: 100dvh;",
3016
+ " margin-block: 0; margin-inline: auto 0; border-radius: 0;",
3017
+ " animation: gem-popup-drawer-in var(--gem-motion-base, 180ms) var(--gem-ease-enter, ease-out);",
3018
+ " }",
3019
+ " .gem-popup-shell[data-gem-popup-desktop='drawer-end'] .gem-popup-shell__frame { grid-template-columns: 1fr; align-content: start; min-height: 100%; }",
3020
+ " .gem-popup-shell[data-gem-popup-desktop='drawer-end'] .gem-popup-shell__media { max-height: 240px; }",
3021
+ " .gem-popup-shell[data-gem-popup-desktop='drawer-end'] .gem-popup-shell__panel { justify-content: flex-start; align-items: stretch; text-align: start; }",
3022
+ "}",
3023
+ // 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).
3024
+ "@media (prefers-reduced-motion: reduce) { .gem-popup-shell[data-gem-popup-desktop='drawer-end'] .gem-popup-shell__dialog { animation: none; } }",
1570
3025
  "@media (max-width: 749px) {",
1571
3026
  // Mobile: takeover is the whole screen; a sheet rises from the bottom to at most 88% of it. The image
1572
3027
  // sits under the text (top when media=top), capped at a third of the screen; with no image or the image
@@ -1602,6 +3057,9 @@ var GEM_POPUP_SHELL_CSS_LINES = [
1602
3057
  "@media (min-width: 750px) {",
1603
3058
  " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] { padding: 0; }",
1604
3059
  " :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; }",
3060
+ // 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).
3061
+ " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='drawer-end'] { padding-inline: 0; }",
3062
+ " :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; }",
1605
3063
  "}",
1606
3064
  "@media (max-width: 749px) {",
1607
3065
  " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] { padding: 0; }",
@@ -1797,7 +3255,7 @@ var GEM_INLINE_MARK_CSS_LINES = [
1797
3255
  ":where(.gem-mark--alert) { color: var(--gem-color-danger, #b42318); }",
1798
3256
  ":where(.gem-mark--highlight.gem-mark--accent), :where(.gem-mark--highlight.gem-mark--alert) { color: var(--gem-mark-highlight-ink, #1f1d18); }",
1799
3257
  ".gem-mark-link { color: var(--gem-color-accent); text-decoration-line: underline; text-decoration-thickness: from-font; text-underline-offset: 0.18em; }",
1800
- ".gem-mark-link:hover { text-decoration-thickness: 2px; }",
3258
+ ".gem-mark-link:hover { text-decoration-thickness: var(--gem-underline-thickness, 2px); }",
1801
3259
  // 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
1802
3260
  // 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).
1803
3261
  ".gem-mark-link.gem-mark--highlight, .gem-mark--highlight .gem-mark-link { color: var(--gem-mark-highlight-ink, #1f1d18); }"
@@ -2023,6 +3481,14 @@ function createPrimitiveCss() {
2023
3481
  " cursor: not-allowed;",
2024
3482
  " opacity: 0.55;",
2025
3483
  "}",
3484
+ // Button shape of ONE section (GemFactory gap #69, 22/10/2026): matching a source, a section needs square, softly rounded
3485
+ // or pill buttons whatever the preset's button radius is, and writing border-radius on .gem-button takes the button
3486
+ // away from the preset for good (GemFactory's button_token_shadowed). `data-gem-button-shape` on the section root (or on
3487
+ // one button) sets the radius the button already reads; with no attribute the preset decides, as before. Square and
3488
+ // pill are shapes, so they do not scale; soft is a size, so it follows the preset's radius scale (--gem-style-radius).
3489
+ "[data-gem-button-shape='square'] { --gem-radius-button: 0px; }",
3490
+ "[data-gem-button-shape='soft'] { --gem-radius-button: var(--gem-radius-sm); }",
3491
+ "[data-gem-button-shape='pill'] { --gem-radius-button: 999px; }",
2026
3492
  ".gem-input:disabled, .gem-select:disabled, .gem-textarea:disabled, .gem-quantity-input__button:disabled {",
2027
3493
  " cursor: not-allowed;",
2028
3494
  " opacity: 0.6;",
@@ -3400,8 +4866,8 @@ function createPrimitiveCss() {
3400
4866
  function createGemSectionRuntimeContractCss(selector = "[data-gem-section-library-id]", options = {}) {
3401
4867
  const background = options.backgroundVar ?? "var(--gem-color-background)";
3402
4868
  const surface = options.surfaceVar ?? "var(--gem-color-surface)";
3403
- const text = options.textVar ?? "var(--gem-color-text)";
3404
- const mutedText = options.mutedTextVar ?? `color-mix(in srgb, ${text} 72%, transparent)`;
4869
+ const text2 = options.textVar ?? "var(--gem-color-text)";
4870
+ const mutedText = options.mutedTextVar ?? `color-mix(in srgb, ${text2} 72%, transparent)`;
3405
4871
  const accent = options.accentVar ?? "var(--gem-color-accent)";
3406
4872
  const accentText = options.accentTextVar ?? "var(--gem-color-accent-text, #ffffff)";
3407
4873
  const border = options.borderVar ?? "var(--gem-color-border)";
@@ -3411,7 +4877,7 @@ function createGemSectionRuntimeContractCss(selector = "[data-gem-section-librar
3411
4877
  `${selector} {`,
3412
4878
  " box-sizing: border-box;",
3413
4879
  ` background: ${background};`,
3414
- ` color: ${text};`,
4880
+ ` color: ${text2};`,
3415
4881
  "}",
3416
4882
  `${selector} *,`,
3417
4883
  `${selector} *::before,`,
@@ -3470,7 +4936,7 @@ function createGemSectionRuntimeContractCss(selector = "[data-gem-section-librar
3470
4936
  " border-radius: var(--gem-radius-pill, 999px);",
3471
4937
  " padding: 6px 10px;",
3472
4938
  ` background: ${surface};`,
3473
- ` color: ${text};`,
4939
+ ` color: ${text2};`,
3474
4940
  " font-size: 0.8125rem;",
3475
4941
  " font-weight: 700;",
3476
4942
  "}",
@@ -4828,8 +6294,8 @@ function resolveGift(gift, totalAmount) {
4828
6294
  const remaining = `${symbol}${remainingAmount.toFixed(2)}`;
4829
6295
  const percent = gift.threshold > 0 ? Math.max(0, Math.min(100, Math.round(totalAmount / gift.threshold * 100))) : 100;
4830
6296
  const template = unlocked ? gift.unlocked_text ?? "\u{1F381} {label} unlocked!" : gift.locked_text ?? "Add {remaining} more to unlock {label}";
4831
- const text = template.replace("{remaining}", remaining).replace("{label}", gift.label);
4832
- return { unlocked, remaining, remaining_amount: remainingAmount, percent, text };
6297
+ const text2 = template.replace("{remaining}", remaining).replace("{label}", gift.label);
6298
+ return { unlocked, remaining, remaining_amount: remainingAmount, percent, text: text2 };
4833
6299
  }
4834
6300
  function matches(when, sel) {
4835
6301
  return Object.keys(when).every((k) => sel[k] === when[k]);
@@ -5445,7 +6911,7 @@ function reviewsEngineRuntimeScript() {
5445
6911
  const b = document.createElement('button');
5446
6912
  b.type = 'button'; b.className = strip.getAttribute('data-rv-thumb-class') || ''; b.setAttribute('data-rv-lb-thumb', String(k));
5447
6913
  b.setAttribute('aria-label', (strip.getAttribute('data-rv-thumb-label') || 'Media {n} of {total}').replace('{n}', String(k + 1)).replace('{total}', String(lbSet.length)));
5448
- const img = document.createElement('img'); img.src = x.thumb; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
6914
+ const img = document.createElement('img'); img.loading = 'lazy'; img.decoding = 'async'; img.alt = ''; img.src = x.thumb;
5449
6915
  b.appendChild(img); strip.appendChild(b);
5450
6916
  });
5451
6917
  strip.hidden = lbSet.length <= 1;
@@ -5570,6 +7036,386 @@ function reviewsEngineRuntimeScript() {
5570
7036
  api.emit('gem:reviews:ready', { source: 'gem-runtime', count: boundReviews });`;
5571
7037
  }
5572
7038
 
7039
+ // ../../packages/gem-framework/src/bundle-engine.ts
7040
+ function bundleEngineRuntimeScript() {
7041
+ return `let boundBundles = 0;
7042
+ const BB_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
7043
+ document.querySelectorAll('[data-bb-root]').forEach((root) => {
7044
+ if (!mark(root, 'bundle')) return;
7045
+ boundBundles += 1;
7046
+ const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };
7047
+ const isEl = (t) => t instanceof window.Element;
7048
+ const parse = (attr, fallback) => { try { const v = JSON.parse(root.getAttribute(attr) || 'null'); return v == null ? fallback : v; } catch (e) { return fallback; } };
7049
+ let rules = parse('data-bb-rules', {});
7050
+ const text = root.hasAttribute('data-bb-messages') ? parse('data-bb-messages', {}) : parse('data-bb-labels', {});
7051
+ const viMoney = root.getAttribute('data-bb-money') === 'vi';
7052
+ const currency = root.getAttribute('data-bb-currency') || (viMoney ? 'VND' : 'USD');
7053
+ let nf = null;
7054
+ if (!viMoney) { try { nf = new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: currency === 'VND' ? 0 : 2 }); } catch (e) { nf = null; } }
7055
+ 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));
7056
+ // A strike price as stores show it: a whole number drops its cents ("$42", not "$42.00").
7057
+ const moneyShort = (n) => { const s = money(n); return Math.abs(n - Math.round(n)) < 0.005 ? s.replace(/[.,]00(?=\\D*$)/, '') : s; };
7058
+ const fill = (tpl, vars) => String(tpl || '').replace(/\\{(\\w+)\\}/g, (m, k) => (Object.hasOwn(vars, k) ? String(vars[k]) : m));
7059
+ // A sentence with {reward}: the reward is a <strong>, the rest plain text. Never innerHTML.
7060
+ const say = (el, tpl, vars) => {
7061
+ if (!el) return;
7062
+ const parts = String(tpl || '').split('{reward}');
7063
+ el.replaceChildren();
7064
+ parts.forEach((part, i) => {
7065
+ if (part) el.append(document.createTextNode(fill(part, vars)));
7066
+ if (i < parts.length - 1 && vars.reward) { const s = document.createElement('strong'); s.textContent = vars.reward; el.append(s); }
7067
+ });
7068
+ };
7069
+ let min = Math.max(1, Number(rules.min) || 1);
7070
+ let cap = Number(rules.max) > 0 ? Number(rules.max) : Infinity;
7071
+ const per = Number(rules.per) > 0 ? Number(rules.per) : Infinity;
7072
+ let tiers = Array.isArray(rules.tiers) ? rules.tiers : [];
7073
+ let goal = tiers.length ? tiers[tiers.length - 1] : null;
7074
+ // The catalog: one entry per item, plus one per extra variant of a card (<key>~<index>).
7075
+ const catalog = new Map();
7076
+ const cards = new Map();
7077
+ root.querySelectorAll('[data-bb-item]').forEach((el) => {
7078
+ const key = el.getAttribute('data-bb-item');
7079
+ const base = {
7080
+ el,
7081
+ variantId: el.getAttribute('data-bb-variant') || '',
7082
+ price: Number(el.getAttribute('data-bb-price')) || 0,
7083
+ compare: Number(el.getAttribute('data-bb-compare')) || 0,
7084
+ title: el.getAttribute('data-bb-title') || '',
7085
+ subtitle: el.getAttribute('data-bb-subtitle') || '',
7086
+ image: el.getAttribute('data-bb-image') || '',
7087
+ group: el.getAttribute('data-bb-group') || '',
7088
+ step: el.getAttribute('data-bb-step') || '',
7089
+ short: el.getAttribute('data-bb-short') || '',
7090
+ available: el.getAttribute('data-available') !== 'false',
7091
+ };
7092
+ catalog.set(key, base);
7093
+ let vs = [];
7094
+ try { vs = JSON.parse(el.getAttribute('data-bb-variants') || '[]'); } catch (e) { vs = []; }
7095
+ if (!Array.isArray(vs)) vs = [];
7096
+ vs.forEach((v, i) => {
7097
+ if (!i) return;
7098
+ 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 || '' });
7099
+ });
7100
+ if (vs[0]) base.variantTitle = vs[0].title || '';
7101
+ cards.set(key, { el, variants: vs, gift: el.hasAttribute('data-bb-gift') });
7102
+ });
7103
+ const gifts = Array.from(cards.entries()).filter(([, c]) => c.gift).map(([k]) => k);
7104
+ // The variant a card shows now (data-bb-current), as the key an action picks.
7105
+ 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; };
7106
+ let picks = [];
7107
+ let plan = 'one_time';
7108
+ const planOf = (el) => (el.type === 'checkbox' ? (el.checked ? 'subscribe' : 'one_time') : el.getAttribute('data-bb-plan') || 'one_time');
7109
+ const planInputs = Array.from(root.querySelectorAll('input[data-bb-plan]'));
7110
+ const checkedPlan = planInputs.find((el) => el.checked);
7111
+ if (checkedPlan) plan = planOf(checkedPlan);
7112
+ const tierFor = (n) => tiers.filter((t) => n >= t.min).pop() || null;
7113
+ const pctOf = (t, p) => (!t ? 0 : p === 'subscribe' ? Number(t.sub) || Number(t.pct) || 0 : Number(t.pct) || 0);
7114
+ // 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.
7115
+ const rewardOf = (t, p) => (!t ? '' : pctOf(t, p) + '% off');
7116
+ const qtyOf = (key) => picks.filter((p) => p === key).length;
7117
+ const canAdd = (key) => picks.length < cap && (rules.repeat !== false || !picks.includes(key)) && qtyOf(key) < per;
7118
+
7119
+ // Box sizes, when the root declares them (data-bb-sizes): choosing a size sets floor = ceiling = its item count. "trim":
7120
+ // a smaller box drops the LAST picks; "queue": they wait, in order, and come back when there is room again.
7121
+ const sizeMode = root.getAttribute('data-bb-sizes');
7122
+ const sizes = Array.isArray(rules.sizes) ? rules.sizes : [];
7123
+ let size = null;
7124
+ const waiting = [];
7125
+ const refill = () => { while (sizeMode === 'queue' && picks.length < cap && waiting.length) picks.push(waiting.shift()); };
7126
+ const applySize = (n) => {
7127
+ const s = sizes.find((x) => Number(x.items) === n);
7128
+ if (!s) return false;
7129
+ size = s; min = Number(s.items); cap = Number(s.items);
7130
+ goal = tierFor(min) || (tiers.length ? tiers[tiers.length - 1] : null);
7131
+ if (picks.length > cap) { const out = picks.splice(cap); if (sizeMode === 'queue') waiting.unshift(...out); }
7132
+ refill();
7133
+ return true;
7134
+ };
7135
+ if (sizeMode) { const s0 = sizes.find((s) => Number(s.items) === Number(rules.size)) || sizes[0] || null; if (s0) applySize(Number(s0.items)); }
7136
+
7137
+ // Kits (rules.kits): each keeps its own picks and steps; a kit's own rules replace the bundle's while it is chosen.
7138
+ // A kit with steps fills each step's slots in order and moves on by itself; optional steps open after the required ones.
7139
+ const kits = Array.isArray(rules.kits) && rules.kits.length ? rules.kits : null;
7140
+ const stateOf = new Map();
7141
+ (kits || []).forEach((k) => {
7142
+ const steps = Array.isArray(k.steps) ? k.steps : [];
7143
+ const slots = {};
7144
+ steps.forEach((s) => { slots[s.id] = Array.from({ length: Math.max(1, Number(s.pick) || 1) }, () => null); });
7145
+ stateOf.set(k.id, { kit: k, steps, fill: slots, picks: [], current: steps.length ? steps[0].id : null });
7146
+ });
7147
+ let st = kits ? stateOf.get(kits[0].id) : { kit: null, steps: [], fill: {}, picks, current: null };
7148
+ const useKit = (id) => {
7149
+ if (!kits) return;
7150
+ st = stateOf.get(id) || stateOf.get(kits[0].id);
7151
+ const k = st.kit;
7152
+ rules = { ...rules, min: k.min, max: k.max, tiers: k.tiers, repeat: k.repeat, price_mode: k.price_mode, fixed_price: k.fixed_price };
7153
+ min = Math.max(1, Number(k.min) || 1);
7154
+ cap = Number(k.max) > 0 ? Number(k.max) : Infinity;
7155
+ tiers = Array.isArray(k.tiers) ? k.tiers : [];
7156
+ goal = tiers.length ? tiers[tiers.length - 1] : null;
7157
+ picks = st.picks;
7158
+ };
7159
+ if (kits) useKit(kits[0].id);
7160
+ const stepsMode = () => st.steps.length > 0;
7161
+ const required = () => st.steps.filter((s) => !s.optional);
7162
+ const isFull = (s) => st.fill[s.id].every(Boolean);
7163
+ // picks (in step order) are rebuilt from the slots after every change: price and cart read picks.
7164
+ const syncPicks = () => { picks.length = 0; st.steps.forEach((s) => st.fill[s.id].forEach((k) => { if (k) picks.push(k); })); };
7165
+ const openRequired = () => required().find((s) => !isFull(s)) || null;
7166
+ const nextOpen = () => openRequired() || st.steps.find((s) => s.optional && !isFull(s)) || null;
7167
+ const doneCount = () => required().filter(isFull).length;
7168
+ const isComplete = () => (stepsMode() ? doneCount() === required().length : picks.length >= min);
7169
+ const reachable = (s) => { const open = openRequired(); return !open || st.steps.indexOf(s) <= st.steps.indexOf(open); };
7170
+ const slotRef = (idx) => {
7171
+ let n = 0;
7172
+ for (const s of st.steps) for (let j = 0; j < st.fill[s.id].length; j++) { if (n === idx) return [s, j]; n++; }
7173
+ return null;
7174
+ };
7175
+ // Into the card's step: its first empty slot; a full step swaps its last pick (a full optional step refuses).
7176
+ const addToStep = (key) => {
7177
+ const it = catalog.get(key);
7178
+ const step = (it && st.steps.find((s) => s.id === it.step)) || st.steps.find((s) => s.id === st.current);
7179
+ if (!step || (rules.repeat === false && picks.includes(key))) return false;
7180
+ const row = st.fill[step.id];
7181
+ let i = row.indexOf(null);
7182
+ if (i < 0) { if (step.optional) return false; i = row.length - 1; }
7183
+ row[i] = key;
7184
+ syncPicks();
7185
+ if (isFull(step)) { const n = nextOpen(); if (n) st.current = n.id; }
7186
+ return true;
7187
+ };
7188
+ // "Edit" on a required step empties the slot, closes the gap and goes back to that step; on an optional step it only removes.
7189
+ const editSlot = (idx) => {
7190
+ const ref = slotRef(idx);
7191
+ if (!ref) return;
7192
+ const [s, j] = ref;
7193
+ const row = st.fill[s.id];
7194
+ row.splice(j, 1); row.push(null);
7195
+ syncPicks();
7196
+ if (!s.optional) st.current = s.id;
7197
+ };
7198
+ const clearAll = () => {
7199
+ st.steps.forEach((s) => { st.fill[s.id] = st.fill[s.id].map(() => null); });
7200
+ picks.length = 0; waiting.length = 0;
7201
+ st.current = st.steps.length ? st.steps[0].id : null;
7202
+ };
7203
+
7204
+ // Prices. totals(): the bundle price at the tier the bundle WILL reach once it has its minimum (stores show the bundle
7205
+ // price from the first pick), plus a container's base price. totalsNow(): at the tier reached now, rounded to the cent
7206
+ // first so total + saved add up.
7207
+ let basePrice = 0;
7208
+ let cartHead = null;
7209
+ const sumOf = () => {
7210
+ let sum = 0; let compare = 0;
7211
+ picks.forEach((k) => { const it = catalog.get(k); if (!it) return; sum += it.price; compare += Math.max(it.compare, it.price); });
7212
+ return { sum, compare };
7213
+ };
7214
+ const totals = (p) => {
7215
+ const { sum, compare } = sumOf();
7216
+ if (rules.price_mode === 'fixed' && !basePrice) {
7217
+ // A box size with a fixed one-time price; or a kit with one price whatever is picked (no strike price then).
7218
+ if (size && Number(size.fixed) > 0) { if (p !== 'subscribe') return { sum, compare, total: Number(size.fixed) }; }
7219
+ else if (Number(rules.fixed_price) > 0) return { sum, compare: 0, total: Number(rules.fixed_price) };
7220
+ }
7221
+ const t = tierFor(Math.max(picks.length, min)) || goal;
7222
+ return { sum, compare, total: basePrice + sum * (1 - pctOf(t, p) / 100) };
7223
+ };
7224
+ const totalsNow = (p) => {
7225
+ const { sum, compare } = sumOf();
7226
+ return { sum, compare, total: Math.round(sum * (1 - pctOf(tierFor(picks.length), p) / 100) * 100 + 1e-6) / 100 };
7227
+ };
7228
+ // One item's price on its card at the bundle's current tier, counting at least one item.
7229
+ const unitNow = (it, p) => it.price * (1 - pctOf(tierFor(Math.max(picks.length, 1)), p) / 100);
7230
+
7231
+ const frequency = () => root.querySelector('[data-bb-frequency]');
7232
+ const planId = () => {
7233
+ if (plan !== 'subscribe') return '';
7234
+ const f = frequency();
7235
+ if (!f) return '';
7236
+ if (f.matches('select')) return f.value;
7237
+ const c = f.querySelector('input:checked');
7238
+ return c ? c.value : '';
7239
+ };
7240
+ const bundleName = () => {
7241
+ const n = (st.kit && st.kit.label) || (root.querySelector('[data-bb-name]') || {}).textContent || 'Bundle';
7242
+ return String(n).trim();
7243
+ };
7244
+ const toCart = () => {
7245
+ const counts = new Map();
7246
+ picks.concat(gifts).forEach((k) => { const it = catalog.get(k); if (it && it.variantId) counts.set(it.variantId, (counts.get(it.variantId) || 0) + 1); });
7247
+ const ruleKey = root.getAttribute('data-bb-discount-key') || '';
7248
+ const id = (root.getAttribute('data-gem-section-id') || 'bundle') + '-' + Date.now().toString(36);
7249
+ const props = [{ key: '_bundle_id', value: id }, { key: '_bundle', value: bundleName() }].concat(ruleKey ? [{ key: '_bundle_rule', value: ruleKey }] : []);
7250
+ const pid = planId();
7251
+ const lines = Array.from(counts.entries()).map(([variantId, quantity]) => ({ variantId, quantity, properties: props.slice(), ...(pid ? { sellingPlanId: pid } : {}) }));
7252
+ // A container line (a gift box) goes first and carries the section's own properties (lid, gift message).
7253
+ const head = cartHead ? cartHead() : null;
7254
+ if (head) {
7255
+ const hp = props.concat(Array.isArray(head.properties) ? head.properties : []);
7256
+ if (head.variantId) lines.unshift({ variantId: head.variantId, quantity: 1, properties: hp, ...(pid ? { sellingPlanId: pid } : {}) });
7257
+ else if (lines[0]) lines[0].properties = hp;
7258
+ }
7259
+ if (lines.length && typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:cart:add-many', { detail: { lines, after: 'drawer' } }));
7260
+ return lines.length;
7261
+ };
7262
+
7263
+ let busy = false;
7264
+ let wasComplete = false;
7265
+ const state = () => {
7266
+ const n = picks.length;
7267
+ const counts = {};
7268
+ picks.forEach((k) => { counts[k] = (counts[k] || 0) + 1; });
7269
+ return {
7270
+ picks, count: n, counts, min, cap, max: cap === Infinity ? null : cap, per, rules, text, tiers, goal, plan, size, sizes, waiting,
7271
+ catalog, cards, gifts, reached: tierFor(n), next: tiers.find((t) => n < t.min) || null, left: Math.max(0, min - n),
7272
+ kit: st.kit, kits, steps: st.steps, fill: st.fill, current: st.current, stepsMode: stepsMode(), done: doneCount(),
7273
+ required: required(), complete: isComplete(), busy, base: basePrice,
7274
+ };
7275
+ };
7276
+ const outs = () => root.querySelectorAll('[data-bb-out]');
7277
+ const emit = (action, extra) => {
7278
+ const n = picks.length;
7279
+ const complete = isComplete();
7280
+ root.setAttribute('data-bb-count', String(n));
7281
+ root.setAttribute('data-bb-state', n === 0 ? 'empty' : n >= cap ? 'full' : complete ? 'complete' : 'partial');
7282
+ if (outs().length) {
7283
+ const t = totals(plan); const reached = tierFor(n);
7284
+ 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) : '' };
7285
+ outs().forEach((el) => { const k = el.getAttribute('data-bb-out'); if (Object.hasOwn(v, k)) el.textContent = v[k]; });
7286
+ }
7287
+ 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 };
7288
+ if (typeof window.CustomEvent === 'function') {
7289
+ root.dispatchEvent(new window.CustomEvent('gem:bundle:change', { bubbles: true, detail }));
7290
+ if (complete && !wasComplete) root.dispatchEvent(new window.CustomEvent('gem:bundle:complete', { bubbles: true, detail }));
7291
+ }
7292
+ wasComplete = complete;
7293
+ };
7294
+
7295
+ // Actions: the handlers below call these, and so may a section's own controls (root.gemBundle.add(key)\u2026).
7296
+ const add = (key) => {
7297
+ const before = st.current;
7298
+ if (root.hasAttribute('data-bb-add-toggle') && picks.includes(key)) { picks.splice(picks.indexOf(key), 1); emit('remove', { key }); return true; }
7299
+ if (stepsMode()) { if (!addToStep(key)) return false; }
7300
+ else { if (!canAdd(key)) return false; picks.push(key); }
7301
+ emit('add', { key, from: before });
7302
+ return true;
7303
+ };
7304
+ const removeAt = (idx) => { if (stepsMode()) editSlot(idx); else { picks.splice(idx, 1); refill(); } emit('remove', { index: idx }); };
7305
+ const dec = (key) => { const at = picks.lastIndexOf(key); if (at < 0) return false; picks.splice(at, 1); refill(); emit('dec', { key }); return true; };
7306
+ 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 }); };
7307
+ const clear = () => { clearAll(); emit('clear'); };
7308
+ // A typed quantity: exactly k picks of that item, within the bundle's ceiling, the item's own and no-repeat.
7309
+ const setQty = (key, want) => {
7310
+ if (!catalog.has(key)) return;
7311
+ const has = qtyOf(key);
7312
+ const room = cap === Infinity ? Infinity : cap - (picks.length - has);
7313
+ const k = Math.max(0, Math.min(Math.floor(Number(want)) || 0, room, per, rules.repeat === false ? 1 : Infinity));
7314
+ for (let i = picks.length - 1; i >= 0; i--) if (picks[i] === key) picks.splice(i, 1);
7315
+ for (let i = 0; i < k; i++) picks.push(key);
7316
+ emit('qty', { key });
7317
+ };
7318
+ const setPlan = (p) => { plan = p === 'subscribe' ? 'subscribe' : 'one_time'; emit('plan'); };
7319
+ const setSize = (n) => {
7320
+ if (!sizeMode || !applySize(Number(n))) return false;
7321
+ // A box size may name its default selling plan.
7322
+ const f = frequency();
7323
+ if (f && f.matches('select') && size && size.plan && Array.from(f.options).some((o) => o.value === size.plan)) f.value = size.plan;
7324
+ emit('size');
7325
+ return true;
7326
+ };
7327
+ // An item's variant radio (a pack size): the whole row changes variant and price, its count stays.
7328
+ const applyVariant = (radio) => {
7329
+ const it = catalog.get(radio.getAttribute('data-bb-size'));
7330
+ if (!it) return false;
7331
+ it.variantId = radio.getAttribute('data-bb-variant') || '';
7332
+ it.price = Number(radio.getAttribute('data-bb-price')) || 0;
7333
+ it.compare = Number(radio.getAttribute('data-bb-compare')) || 0;
7334
+ it.el.setAttribute('data-bb-variant', it.variantId);
7335
+ it.el.setAttribute('data-bb-price', String(it.price));
7336
+ it.el.setAttribute('data-bb-compare', it.compare ? String(it.compare) : '');
7337
+ return true;
7338
+ };
7339
+ const selectKit = (id) => { if (!kits) return; useKit(id); emit('kit'); };
7340
+ 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; };
7341
+ const finish = (ok) => {
7342
+ busy = false;
7343
+ root.querySelectorAll('[data-bb-cta]').forEach((c) => { c.removeAttribute('data-state'); c.removeAttribute('aria-busy'); });
7344
+ if (ok) clearAll();
7345
+ emit(ok ? 'added' : 'add-failed');
7346
+ };
7347
+ const checkout = () => {
7348
+ if (!isComplete() || busy || !picks.length) return false;
7349
+ if (!toCart()) return false;
7350
+ // With gemsite's cart bridge: wait for its answer before clearing (a failure keeps the bundle to try again).
7351
+ if (window.__gemCartAddBridge) {
7352
+ busy = true;
7353
+ const box = st.kit ? root.querySelector('[data-bb-kit-box="' + st.kit.id + '"]') : null;
7354
+ (box || root).querySelectorAll('[data-bb-cta]').forEach((c) => { c.setAttribute('data-state', 'loading'); c.setAttribute('aria-busy', 'true'); });
7355
+ emit('checkout');
7356
+ } else finish(true);
7357
+ return true;
7358
+ };
7359
+
7360
+ on(root, 'click', (event) => {
7361
+ const t = isEl(event.target) ? event.target : null;
7362
+ if (!t) return;
7363
+ const addEl = t.closest('[data-bb-add], [data-bb-inc]');
7364
+ if (addEl) { add(currentKey(addEl.getAttribute('data-bb-add') || addEl.getAttribute('data-bb-inc'))); return; }
7365
+ const decEl = t.closest('[data-bb-dec], [data-bb-minus]');
7366
+ if (decEl) { dec(currentKey(decEl.getAttribute('data-bb-dec') || decEl.getAttribute('data-bb-minus'))); return; }
7367
+ const un = t.closest('[data-bb-uncheck]');
7368
+ if (un) { uncheck(currentKey(un.getAttribute('data-bb-uncheck'))); return; }
7369
+ const rm = t.closest('[data-bb-remove]');
7370
+ if (rm) { removeAt(Number(rm.getAttribute('data-bb-remove')) || 0); return; }
7371
+ if (t.closest('[data-bb-clear]')) { clear(); return; }
7372
+ const nav = t.closest('[data-bb-step-nav]');
7373
+ if (nav) { if (!nav.disabled) goStep(nav.getAttribute('data-bb-step-nav')); return; }
7374
+ const tab = t.closest('[data-bb-kit-tab]');
7375
+ if (tab) { selectKit(tab.getAttribute('data-bb-kit-tab')); return; }
7376
+ if (t.closest('[data-bb-skip]')) { checkout(); return; }
7377
+ const cta = t.closest('[data-bb-cta]');
7378
+ if (cta) { event.preventDefault(); if (cta.getAttribute('aria-disabled') !== 'true') checkout(); }
7379
+ });
7380
+ on(root, 'change', (event) => {
7381
+ const t = isEl(event.target) ? event.target : null;
7382
+ if (!t) return;
7383
+ const p = t.closest('input[data-bb-plan]');
7384
+ if (p) { if (p.type === 'checkbox' || p.checked) setPlan(planOf(p)); return; }
7385
+ const s = t.closest('input[data-bb-size]');
7386
+ 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; }
7387
+ const q = t.closest('input[data-bb-count]');
7388
+ if (q) setQty(q.getAttribute('data-bb-count'), q.value);
7389
+ });
7390
+ // A typed quantity applies as soon as it is a number (an emptied box waits for "change" to go back to 0).
7391
+ on(root, 'input', (event) => {
7392
+ const q = isEl(event.target) ? event.target.closest('input[data-bb-count]') : null;
7393
+ if (q && q.value !== '' && Number.isFinite(Number(q.value))) setQty(q.getAttribute('data-bb-count'), q.value);
7394
+ });
7395
+ on(window, 'gem:cart:added', () => { if (busy) finish(true); });
7396
+ on(window, 'gem:cart:add-failed', () => { if (busy) finish(false); });
7397
+
7398
+ // A page brought back from history restores its form: read back the variant radios and typed quantities in view.
7399
+ root.querySelectorAll('input[data-bb-size][data-bb-variant]').forEach((r) => { if (r.checked) applyVariant(r); });
7400
+ root.querySelectorAll('input[data-bb-count]').forEach((inp) => {
7401
+ const v = Math.floor(Number(inp.value)) || 0;
7402
+ const key = inp.getAttribute('data-bb-count');
7403
+ 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); }
7404
+ });
7405
+
7406
+ root.gemBundle = {
7407
+ state, money, moneyShort, fill, say, words: BB_WORDS, tierFor, pctOf, rewardOf, canAdd, qtyOf, currentKey, totals, totalsNow, unitNow,
7408
+ reachable, isFull, slotRef, required, add, removeAt, dec, uncheck, clear, setQty, setPlan, setSize, selectKit, goStep, checkout,
7409
+ setBase: (n) => { basePrice = Number(n) || 0; emit('base'); },
7410
+ setCartHead: (fn) => { cartHead = typeof fn === 'function' ? fn : null; },
7411
+ refresh: () => emit('refresh'),
7412
+ };
7413
+ root.setAttribute('data-bb-ready', '');
7414
+ emit('init');
7415
+ });
7416
+ api.emit('gem:bundle:ready', { source: 'gem-runtime', count: boundBundles });`;
7417
+ }
7418
+
5573
7419
  // ../../packages/gem-framework/src/runtime.ts
5574
7420
  var runtimePluginReadyEvents = {
5575
7421
  "cart-drawer": "gem:cart:ready",
@@ -5594,7 +7440,11 @@ var runtimePluginReadyEvents = {
5594
7440
  "seasonal-effects": "gem:seasonal:ready",
5595
7441
  "context-adapter": "gem:context:ready",
5596
7442
  "offer-engine": "gem:offer:ready",
5597
- reviews: "gem:reviews:ready"
7443
+ reviews: "gem:reviews:ready",
7444
+ bundle: "gem:bundle:ready",
7445
+ "size-guide": "gem:size-guide:ready",
7446
+ orbit: "gem:orbit:ready",
7447
+ rail: "gem:rail:ready"
5598
7448
  };
5599
7449
  var cartDrawerPluginRegistrationLines = [
5600
7450
  " registerPlugin('cart-drawer', ({ api }) => {",
@@ -6088,8 +7938,7 @@ var heroSlideshowPluginRegistrationLines = [
6088
7938
  " api.emit('gem:hero-slideshow:ready', { source: 'gem-runtime', count: bound });",
6089
7939
  " });"
6090
7940
  ];
6091
- var quickViewPluginRegistrationLines = [
6092
- " registerPlugin('quick-view', ({ api }) => {",
7941
+ var legacyQuickViewLines = [
6093
7942
  ` const triggers = document.querySelectorAll('[data-gem-quick-view], [data-gem-action="quick-view"]');`,
6094
7943
  ` const surface = document.querySelector('[data-gem-quick-view-surface], .gem-quick-view, .gem-modal[data-gem-component="quick-view"]');`,
6095
7944
  " let bound = 0;",
@@ -6139,10 +7988,15 @@ var quickViewPluginRegistrationLines = [
6139
7988
  " });",
6140
7989
  " if (canListen(surface)) surface.addEventListener('keydown', (event) => { if (event.key === 'Escape') close('escape'); });",
6141
7990
  " }",
6142
- " api.quickView = { open: (detail = {}) => openSurface({ ...detail }), close };",
6143
- " api.emit('gem:quick-view:ready', { source: 'gem-runtime', count: bound, hasSurface: Boolean(surface) });",
6144
- " });"
7991
+ // A mount on one late trigger (gem-runtime.js binds a section added after boot on its own) finds no surface: it keeps the
7992
+ // page's api.quickView.
7993
+ " if (surface || !api.quickView) api.quickView = { open: (detail = {}) => openSurface({ ...detail }), close };"
6145
7994
  ];
7995
+ var runtimeChunks = gemMotionChunks({
7996
+ hook: '[data-gem-quick-view], [data-gem-action="quick-view"]',
7997
+ lines: legacyQuickViewLines,
7998
+ readyDetail: "count: bound + count, hasSurface: Boolean(surface)"
7999
+ });
6146
8000
  var promoPopupPluginRegistrationLines = [
6147
8001
  " registerPlugin('promo-popup', ({ api }) => {",
6148
8002
  // Only the explicit hook opts in. Every promo-popup section carries the archetype class .gem-promo-popup, and
@@ -6677,6 +8531,35 @@ var reviewsPluginRegistrationLines = [
6677
8531
  " " + reviewsEngineRuntimeScript(),
6678
8532
  " });"
6679
8533
  ];
8534
+ var bundlePluginRegistrationLines = [
8535
+ " registerPlugin('bundle', ({ api }) => {",
8536
+ " " + bundleEngineRuntimeScript(),
8537
+ " });"
8538
+ ];
8539
+ var sizeGuidePluginRegistrationLines = [
8540
+ " registerPlugin('size-guide', ({ api }) => {",
8541
+ " const page = document.documentElement && typeof document.documentElement.querySelectorAll === 'function' ? document.documentElement : document;",
8542
+ " const guides = Array.prototype.slice.call(page.querySelectorAll('[data-gem-size-guide]'));",
8543
+ " const opens = (target) => guides.some((guide) => !target || guide.getAttribute('data-gem-size-guide') === target || guide.id === target);",
8544
+ " let bound = 0;",
8545
+ " document.querySelectorAll('[data-gem-size-guide-trigger]').forEach((trigger) => {",
8546
+ " const target = trigger.getAttribute('data-gem-size-guide-trigger') || '';",
8547
+ " if (!opens(target)) { trigger.hidden = true; return; }",
8548
+ " if (!mark(trigger, 'size-guide')) return;",
8549
+ " trigger.hidden = false;",
8550
+ " if (!canListen(trigger)) return;",
8551
+ " trigger.addEventListener('click', (event) => {",
8552
+ " event.preventDefault && event.preventDefault();",
8553
+ " const holder = trigger.closest ? trigger.closest('[data-product-id]') : null;",
8554
+ " const detail = { source: 'gem-runtime', sectionId: target || null, productId: holder ? holder.getAttribute('data-product-id') : null };",
8555
+ " if (typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:size-guide:open', { detail }));",
8556
+ " api.emit('gem:size-guide:open', detail);",
8557
+ " });",
8558
+ " bound += 1;",
8559
+ " });",
8560
+ " api.emit('gem:size-guide:ready', { source: 'gem-runtime', count: bound, guides: guides.length });",
8561
+ " });"
8562
+ ];
6680
8563
  var runtimePluginSourceLines = [
6681
8564
  ["cart-drawer", cartDrawerPluginRegistrationLines],
6682
8565
  ["mobile-menu", mobileMenuPluginRegistrationLines],
@@ -6689,7 +8572,7 @@ var runtimePluginSourceLines = [
6689
8572
  ["media-gallery", mediaGalleryPluginRegistrationLines],
6690
8573
  ["carousel", carouselPluginRegistrationLines],
6691
8574
  ["hero-slideshow", heroSlideshowPluginRegistrationLines],
6692
- ["quick-view", quickViewPluginRegistrationLines],
8575
+ ["quick-view", runtimeChunks["quick-view"].body],
6693
8576
  ["promo-popup", promoPopupPluginRegistrationLines],
6694
8577
  ["sticky-atc", stickyAtcPluginRegistrationLines],
6695
8578
  ["before_after", beforeAfterPluginRegistrationLines],
@@ -6699,16 +8582,95 @@ var runtimePluginSourceLines = [
6699
8582
  ["recently-viewed", recentlyViewedPluginRegistrationLines],
6700
8583
  ["runtime-slot-loader", runtimeSlotLoaderPluginRegistrationLines],
6701
8584
  ["offer-engine", offerEnginePluginRegistrationLines],
6702
- ["reviews", reviewsPluginRegistrationLines]
8585
+ ["reviews", reviewsPluginRegistrationLines],
8586
+ ["bundle", bundlePluginRegistrationLines],
8587
+ ["size-guide", sizeGuidePluginRegistrationLines],
8588
+ ["orbit", runtimeChunks.orbit.body],
8589
+ ["rail", runtimeChunks.rail.body]
6703
8590
  ];
6704
8591
  var noopPluginRegistrationLine = (key, readyEvent) => ` registerNoopPlugin(${JSON.stringify(key)}, ${JSON.stringify(readyEvent)});`;
8592
+ var chunkStubLine = (key, chunk) => ` registerPlugin(${JSON.stringify(key)}, ({ api }) => loadChunk(${JSON.stringify(key)}, ${JSON.stringify(chunk.hook)}, ${JSON.stringify(chunk.when)}, api));`;
8593
+ var chunkLoaderLines = [
8594
+ " const chunkBase = (document.currentScript && document.currentScript.src) || '';",
8595
+ " const chunks = {};",
8596
+ " let chunkWatch = null;",
8597
+ " const loadChunkModule = (chunk) => {",
8598
+ " if (chunk.state) return;",
8599
+ " chunk.state = 1;",
8600
+ " Promise.resolve().then(() => {",
8601
+ " const url = new URL('plugins/' + chunk.key + '.js', chunk.api.pluginBase || chunkBase || document.baseURI).href;",
8602
+ " return chunk.api.importChunk ? chunk.api.importChunk(url) : import(url);",
8603
+ " }).then((module) => {",
8604
+ " chunk.module = module;",
8605
+ " module.mount(document, { api: chunk.api, window: target });",
8606
+ " chunk.state = 2;",
8607
+ " if (chunk.replay) chunk.replay.click();",
8608
+ " chunk.replay = null;",
8609
+ " }, (error) => { chunk.state = 0; chunk.replay = null; chunk.api.emit('gem:plugin:error', { key: chunk.key, error: String(error) }); });",
8610
+ " };",
8611
+ " const armChunk = (chunk, hooks) => {",
8612
+ " const load = () => loadChunkModule(chunk);",
8613
+ " if (chunk.when === 'viewport') {",
8614
+ " if (!target.IntersectionObserver) { load(); return; }",
8615
+ " chunk.io = chunk.io || new target.IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) { chunk.io.disconnect(); load(); } }, { rootMargin: '200px' });",
8616
+ " hooks.forEach((hookEl) => chunk.io.observe(hookEl));",
8617
+ " return;",
8618
+ " }",
8619
+ " hooks.forEach((hookEl) => {",
8620
+ " ['pointerover', 'focusin', 'touchstart'].forEach((type) => hookEl.addEventListener(type, load, { once: true, passive: true }));",
8621
+ " hookEl.addEventListener('click', (event) => {",
8622
+ " if (chunk.state === 2 || event.defaultPrevented) return;",
8623
+ " const host = hookEl.closest && hookEl.closest('[data-gem-quick-view-host]');",
8624
+ " if (host && host.gemQuickViewHost && !host.gemQuickViewHost.opens(hookEl)) return;",
8625
+ " event.preventDefault();",
8626
+ " chunk.replay = hookEl;",
8627
+ " load();",
8628
+ " });",
8629
+ " });",
8630
+ " if (!chunk.idle) { chunk.idle = true; onIdle(load); }",
8631
+ " };",
8632
+ " const scanChunk = (chunk, scope) => {",
8633
+ " const hooks = Array.prototype.slice.call(scope.querySelectorAll ? scope.querySelectorAll(chunk.hook) : []);",
8634
+ " if (scope !== document && scope.matches && scope.matches(chunk.hook)) hooks.unshift(scope);",
8635
+ " if (!hooks.length) return false;",
8636
+ " if (chunk.state === 2) hooks.forEach((hookEl) => chunk.module.mount(hookEl, { api: chunk.api, window: target }));",
8637
+ " else armChunk(chunk, hooks);",
8638
+ " return true;",
8639
+ " };",
8640
+ " const watchChunks = () => {",
8641
+ " if (chunkWatch || !target.MutationObserver) return;",
8642
+ " let added = [];",
8643
+ " let timer = 0;",
8644
+ " const flush = () => {",
8645
+ " timer = 0;",
8646
+ " const nodes = added;",
8647
+ " added = [];",
8648
+ " nodes.forEach((node) => { if (node.isConnected) Object.keys(chunks).forEach((key) => scanChunk(chunks[key], node)); });",
8649
+ " };",
8650
+ " chunkWatch = new target.MutationObserver((records) => {",
8651
+ " records.forEach((record) => record.addedNodes.forEach((node) => { if (node.nodeType === 1) added.push(node); }));",
8652
+ " if (added.length && !timer) timer = setTimeout(flush, 50);",
8653
+ " });",
8654
+ " chunkWatch.observe(document.documentElement, { childList: true, subtree: true });",
8655
+ " if (canListen(document)) document.addEventListener('shopify:section:load', (event) => { if (event.target && event.target.nodeType === 1) Object.keys(chunks).forEach((key) => scanChunk(chunks[key], event.target)); });",
8656
+ " };",
8657
+ " const loadChunk = (key, hook, when, api) => {",
8658
+ " const chunk = (chunks[key] = { key, hook, when, api, state: 0, module: null, replay: null });",
8659
+ " watchChunks();",
8660
+ " if (!scanChunk(chunk, document)) api.emit('gem:' + key + ':ready', { source: 'gem-runtime', count: 0 });",
8661
+ " };"
8662
+ ];
6705
8663
  function gemRuntimePluginSources() {
6706
- const withBody = runtimePluginSourceLines.map(([key, lines]) => ({
6707
- key,
6708
- readyEvent: runtimePluginReadyEvents[key] ?? `gem:${key}:ready`,
6709
- hasBody: true,
6710
- lines
6711
- }));
8664
+ const withBody = runtimePluginSourceLines.map(([key, lines]) => {
8665
+ const chunk = runtimeChunks[key];
8666
+ return {
8667
+ key,
8668
+ readyEvent: runtimePluginReadyEvents[key] ?? `gem:${key}:ready`,
8669
+ hasBody: true,
8670
+ lines: chunk ? [chunkStubLine(key, chunk)] : lines,
8671
+ ...chunk ? { chunk } : {}
8672
+ };
8673
+ });
6712
8674
  const noop = Object.entries(runtimePluginReadyEvents).filter(([key]) => !runtimePluginSourceLines.some(([sourceKey]) => sourceKey === key)).map(([key, readyEvent]) => ({ key, readyEvent, hasBody: false, lines: [noopPluginRegistrationLine(key, readyEvent)] }));
6713
8675
  return [...withBody, ...noop];
6714
8676
  }
@@ -6843,7 +8805,12 @@ function createGemRuntimeBootstrap(configArg) {
6843
8805
  " if (readyEvent) api.emit(readyEvent, { source: 'gem-runtime' });",
6844
8806
  " });",
6845
8807
  " };",
6846
- ...runtimePluginSourceLines.flatMap(([key, lines]) => enabledPluginKeys.has(key) ? lines : []),
8808
+ ...runtimePluginSourceLines.some(([key]) => runtimeChunks[key] && enabledPluginKeys.has(key)) ? chunkLoaderLines : [],
8809
+ ...runtimePluginSourceLines.flatMap(([key, lines]) => {
8810
+ if (!enabledPluginKeys.has(key)) return [];
8811
+ const chunk = runtimeChunks[key];
8812
+ return chunk ? [chunkStubLine(key, chunk)] : lines;
8813
+ }),
6847
8814
  ...pluginRegistrationLines,
6848
8815
  // `data-gem-bound-<key>` is a CONTRACT, not private bookkeeping: markup may carry it to claim an element for its
6849
8816
  // own behavior (promo-popup.email-capture.v1 renders data-gem-bound-promo-popup="section"). Any value counts as
@@ -6939,17 +8906,18 @@ function gemRuntimePluginModuleKeys() {
6939
8906
  function createGemRuntimePluginModule(key) {
6940
8907
  const source = gemRuntimePluginSources().find((candidate) => candidate.key === key);
6941
8908
  if (!source) throw new Error(`gem-framework: no runtime plugin "${key}"`);
6942
- const body = routeListenersThroughLedger(source.lines.join("\n"), key);
8909
+ const body = routeListenersThroughLedger((source.chunk ? source.chunk.body : source.lines).join("\n"), key);
6943
8910
  const guard = PLUGIN_GUARDS[key];
6944
8911
  return [
6945
8912
  `// @gemboss/gem-framework/plugins/${key}: the gem-runtime plugin "${key}" as an ES module. Generated from`,
6946
- "// packages/gem-framework/src/runtime.ts, the same lines gem-runtime.js carries; do not edit here.",
8913
+ source.chunk ? "// packages/gem-framework/src/runtime.ts (a lazy chunk: gem-runtime.js carries only its stub); do not edit here." : "// packages/gem-framework/src/runtime.ts, the same lines gem-runtime.js carries; do not edit here.",
6947
8914
  'import { mountPlugin } from "../runtime-core.js";',
8915
+ ...source.chunk ? source.chunk.imports : [],
6948
8916
  "",
6949
8917
  `export const key = ${JSON.stringify(key)};`,
6950
8918
  `export const readyEvent = ${JSON.stringify(source.readyEvent)};`,
6951
8919
  "",
6952
- "function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin }) {",
8920
+ "function install({ document, window, target, mark, canListen, listen: __gemListen, registerPlugin, registerNoopPlugin, onUnmount }) {",
6953
8921
  body,
6954
8922
  "}",
6955
8923
  "",
@@ -6989,7 +8957,7 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
6989
8957
  const namespace = group === "typography" ? "font" : group === "spacing" ? "space" : group;
6990
8958
  return keys.map((key) => `--gem-${namespace}-${slugify(key)}`);
6991
8959
  }),
6992
- // The governed type ramp (--gem-h1…h6, --gem-body, --gem-text-sm, --gem-type-base/-scale). The
8960
+ // The governed type ramp (--gem-h1…h6, --gem-body, --gem-text-sm, --gem-text-xs, --gem-type-base/-scale). The
6993
8961
  // framework stylesheet declares it on every page, but it is not a token GROUP, so it was missing here
6994
8962
  // and a section sizing its heading with `var(--gem-h2)` got `unknown_gem_css_variable`. Read from the
6995
8963
  // function that emits it, so the ramp and this list cannot drift.
@@ -7008,7 +8976,12 @@ var canonicalCssVariableNames = /* @__PURE__ */ new Set([
7008
8976
  // The row primitive's variables (primitives.ts GEM_ROW_CSS). Sections SET these on their grid.
7009
8977
  ...GEM_ROW_CSS_VARIABLES,
7010
8978
  // The form set's variables (form-set.ts): set from a plan's `tokens.form`, read by fields under [data-gem-form-set].
7011
- ...GEM_FORM_CSS_VARIABLES
8979
+ ...GEM_FORM_CSS_VARIABLES,
8980
+ // The motion engine's (motion-engine.ts, 0.19.0), all of them: a section copies the base CSS VERBATIM into its own CSS,
8981
+ // internals (--gem-orbit-a, --gem-rail-t) included, and sets --gem-orbit-i / -n, the settings and --gem-motion-tint.
8982
+ ...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) ?? []),
8983
+ // …and every setting an effect reads from a CSS variable (`css: true`): a section sets it in its stylesheet, per breakpoint.
8984
+ ...gemMotionEffectList.flatMap((effect) => Object.entries(effect.settings).filter(([, setting]) => setting.css).map(([name]) => `--gem-${effect.key}-${name}`))
7012
8985
  ]);
7013
8986
  var TOKENIZED_PROPERTIES = [
7014
8987
  "background",
@@ -7120,14 +9093,25 @@ function validateGemFrameworkMarkup(markup, artifactPath, options = {}) {
7120
9093
  }
7121
9094
  return result(issues);
7122
9095
  }
9096
+ var NAMESPACE_URL = String.raw`https?:\/\/www\.w3\.org\/[\w.\/#:~%+-]*`;
9097
+ var SHARE_NETWORK = "facebook|twitter|x|pinterest|whatsapp|threads";
9098
+ var AS_A_LINK = String.raw`(?<=(?:<a\s[^<>]{0,400}?(?<![\w-])href=\{?|[{,]\s{0,40}["']?(?:${SHARE_NETWORK})["']?\s{0,8}:\s{0,8})["'\x60])`;
9099
+ 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)\?`;
9100
+ var NEVER_FETCHED_URL = new RegExp(`${NAMESPACE_URL}|${AS_A_LINK}${SHARE_INTENT}`, "gi");
9101
+ var JSON_LD = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>[\s\S]*?<\/script>/gi;
9102
+ function namesExternalDependency(withoutJsonLd) {
9103
+ return /https?:\/\/|\/\/cdn\./i.test(withoutJsonLd.replace(NEVER_FETCHED_URL, ""));
9104
+ }
9105
+ function hasExternalDependency(content) {
9106
+ return namesExternalDependency(content.replace(JSON_LD, ""));
9107
+ }
7123
9108
  function validateSharedSafety(content, artifactPath) {
7124
9109
  const issues = [];
7125
- const contentWithoutJsonLd = content.replace(/<script\b[^>]*type=["']application\/ld\+json["'][^>]*>[\s\S]*?<\/script>/gi, "");
7126
- const contentWithoutNamespaces = contentWithoutJsonLd.replace(/https?:\/\/www\.w3\.org\/[^\s"'<>]*/gi, "");
7127
- if (/<script\b/i.test(contentWithoutJsonLd)) {
9110
+ const withoutJsonLd = content.replace(JSON_LD, "");
9111
+ if (/<script\b/i.test(withoutJsonLd)) {
7128
9112
  issues.push(ruleIssue("inline_script", "Gem Framework artifacts must not include inline script tags.", artifactPath));
7129
9113
  }
7130
- if (/https?:\/\/|\/\/cdn\./i.test(contentWithoutNamespaces)) {
9114
+ if (namesExternalDependency(withoutJsonLd)) {
7131
9115
  issues.push(ruleIssue("external_dependency", "Gem Framework artifacts must not include external runtime or CDN dependencies.", artifactPath));
7132
9116
  }
7133
9117
  return issues;
@@ -7156,6 +9140,9 @@ function gemFrameworkPublicCssVariables() {
7156
9140
  String(createSectionCss()),
7157
9141
  String(createShellCss()),
7158
9142
  String(createGemSectionRuntimeContractCss()),
9143
+ // popup-shell.css (its own file since 0.8.3, so not in createPrimitiveCss): a popup section sets
9144
+ // --gem-popup-drawer-width (0.18.17, drawer-end) and must not be told the framework's own variable is unknown.
9145
+ GEM_POPUP_SHELL_CSS,
7159
9146
  // The button case rule only exists when a token sets it; a section may still read its variables (a CTA rule
7160
9147
  // beats the zero-specificity rule, so opting in means `text-transform: var(--gem-font-button-transform, none)`).
7161
9148
  gemButtonCaseCss({ typography: { buttonTransform: "none", buttonLetterSpacing: "normal" } })
@@ -7166,11 +9153,13 @@ function gemFrameworkPublicCssVariables() {
7166
9153
  }
7167
9154
  var ROOT_SELECTOR = /^\s*(?::root|html|:host)(?:\[[^\]]*\]|:[a-z-]+(?:\([^()]*\))?)*\s*$/;
7168
9155
  var EDITION_ROOT_SELECTOR = /^\s*:where\(\s*\[data-gem-edition=(["'])\d+\1\](?:\s*,\s*\[data-gem-edition=(["'])\d+\2\])*\s*\)\s*$/;
9156
+ var KNOB_SCOPE_SELECTOR = /^\s*\[data-gem-[a-z0-9-]+(?:=(["'])[^"']*\1)?\]\s*$/;
7169
9157
  function gemPublicCssVariablesFrom(stylesheet) {
7170
9158
  const css = stylesheet.replace(/\/\*[\s\S]*?\*\//g, " ");
7171
9159
  const declaredOnRoot = /* @__PURE__ */ new Set();
7172
9160
  const declaredElsewhere = /* @__PURE__ */ new Set();
7173
9161
  for (const [, selector, body] of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
9162
+ if (selector.split(",").every((part) => KNOB_SCOPE_SELECTOR.test(part))) continue;
7174
9163
  const onRoot = EDITION_ROOT_SELECTOR.test(selector) || selector.split(",").every((part) => ROOT_SELECTOR.test(part));
7175
9164
  for (const [, name] of body.matchAll(/(--gem-[a-z0-9-]+)\s*:/g)) (onRoot ? declaredOnRoot : declaredElsewhere).add(name);
7176
9165
  }
@@ -7369,9 +9358,9 @@ function renderGemShellHeader(plan) {
7369
9358
  header.show_cart ? `<a class="gem-shell-header__action gem-shell-header__action--cart" href="#" aria-label="Cart">${ICON_CART}<span class="gem-shell-header__cart-count cart-count-bubble">0</span></a>` : ""
7370
9359
  ].filter(Boolean).join("");
7371
9360
  const bars = Array.isArray(header.bars) ? header.bars.filter((b) => (b?.text ?? "").trim()) : [];
7372
- const barMarkup = (text, bg, fg, segments) => {
9361
+ const barMarkup = (text2, bg, fg, segments) => {
7373
9362
  const style = [bg ? `background:${bg}` : "", fg ? `color:${fg}` : ""].filter(Boolean).join(";");
7374
- const body = (segments ?? []).length > 1 ? `<span class="gem-announcement-bar__segments">${(segments ?? []).map((x) => `<span>${escapeHtml(x)}</span>`).join("")}</span>` : `<span>${escapeHtml(text)}</span>`;
9363
+ const body = (segments ?? []).length > 1 ? `<span class="gem-announcement-bar__segments">${(segments ?? []).map((x) => `<span>${escapeHtml(x)}</span>`).join("")}</span>` : `<span>${escapeHtml(text2)}</span>`;
7375
9364
  return `<div class="announcement-bar" role="region" aria-label="Announcement"${style ? ` style="${escapeAttribute(style)}"` : ""}><p class="announcement-bar__message">${body}</p></div>`;
7376
9365
  };
7377
9366
  const announcement = bars.length ? bars.map((b) => barMarkup(b.text ?? "", b.bg, b.fg, b.segments)).join("") : header.announcement_text?.trim() ? barMarkup(header.announcement_text.trim(), header.announcement_bg, header.announcement_fg) : "";
@@ -8356,10 +10345,18 @@ export {
8356
10345
  GEM_IMG_IN_CSS,
8357
10346
  GEM_INLINE_MARK_CSS,
8358
10347
  GEM_LINE_HEIGHT_TOKEN_KEYS,
10348
+ GEM_MOTION_CLASSES,
8359
10349
  GEM_MOTION_REDUCED,
10350
+ GEM_MOTION_RUNTIME_FILES,
8360
10351
  GEM_MOTION_SCALE_DEFAULTS,
10352
+ TIERS as GEM_MOTION_TIERS,
10353
+ GEM_ORBIT_BASE_CSS,
10354
+ GEM_ORBIT_RUNTIME_CSS,
8361
10355
  GEM_PART_CLASS,
8362
10356
  GEM_POPUP_SHELL_CSS,
10357
+ GEM_QUICK_VIEW_CSS,
10358
+ GEM_RAIL_BASE_CSS,
10359
+ GEM_RAIL_RUNTIME_CSS,
8363
10360
  GEM_REVEAL_CSS,
8364
10361
  GEM_ROW_CSS,
8365
10362
  GEM_ROW_CSS_VARIABLES,
@@ -8376,6 +10373,10 @@ export {
8376
10373
  aaLabelColor,
8377
10374
  aaTextColor,
8378
10375
  auditGemFrameworkClassName,
10376
+ bundleEngineRuntimeScript,
10377
+ capabilityTier,
10378
+ checkEffect,
10379
+ compactCss,
8379
10380
  createGemBaseCss,
8380
10381
  createGemFrameworkManifest,
8381
10382
  createGemRuntimeBootstrap,
@@ -8395,7 +10396,9 @@ export {
8395
10396
  createThemeProfileCss,
8396
10397
  defaultGemTokens,
8397
10398
  defaultSelection,
10399
+ defineEffect,
8398
10400
  deriveFormSetId,
10401
+ follow,
8399
10402
  gemBandSchemeColors,
8400
10403
  gemBandSchemeCssVars,
8401
10404
  gemBandSchemeDeclarations,
@@ -8416,17 +10419,23 @@ export {
8416
10419
  gemFrameworkValidationRules,
8417
10420
  gemFrameworkVersion,
8418
10421
  gemMobileTokenCssVars,
10422
+ gemMotionChunks,
10423
+ gemMotionEffectList,
10424
+ gemMotionEffectManifest,
8419
10425
  gemMotionScaleCssVars,
10426
+ gemOrbitBaseCss,
8420
10427
  gemPartClass,
8421
10428
  gemPhoneTokenTarget,
8422
10429
  gemPlanFormSet,
8423
10430
  gemPlanShapeKnobs,
10431
+ gemRailBaseCss,
8424
10432
  gemRuntimePluginModuleKeys,
8425
10433
  gemRuntimePluginSources,
8426
10434
  gemSectionDividerResponsiveStyle,
8427
10435
  gemSectionDividerStyle,
8428
10436
  gemSectionDividers,
8429
10437
  getGemFrameworkValidationRule,
10438
+ hasExternalDependency,
8430
10439
  hexContrastRatio,
8431
10440
  hexRelativeLuminance,
8432
10441
  isModernMineralFamilyLineagePlan,
@@ -8439,7 +10448,10 @@ export {
8439
10448
  normalizeHex,
8440
10449
  offerEngineRuntimeScript,
8441
10450
  optionalGemTokenKeys,
10451
+ orbitEffect,
8442
10452
  planToRuntimeBootstrapConfig,
10453
+ quickViewEffect,
10454
+ railEffect,
8443
10455
  renderGemFrameworkManifest,
8444
10456
  renderGemPreviewDocument,
8445
10457
  renderGemSectionMarkup,
@@ -8451,7 +10463,13 @@ export {
8451
10463
  resolveGift,
8452
10464
  resolveOffer,
8453
10465
  reviewsEngineRuntimeScript,
10466
+ ringDelta,
10467
+ ringPose,
10468
+ scopeMotionCss,
10469
+ scrollProgress,
8454
10470
  slugify,
10471
+ spring,
10472
+ throwTarget,
8455
10473
  toCssVars,
8456
10474
  toMotionCssVars,
8457
10475
  validateGemFrameworkArtifact,