@gemboss/gem-framework 0.18.11 → 0.18.15

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.
@@ -432,6 +432,8 @@ function gemMotionScaleCssVars(motion) {
432
432
  return toMotionCssVars(scale).filter((line) => !declared.has(cssVarName(line)));
433
433
  }
434
434
  var optionalGemTokenKeys = {
435
+ // Lớp phủ tối (GemFactory gap #17): xem GEM_SCRIM_FALLBACK / gemScrim bên dưới.
436
+ color: ["scrim"],
435
437
  typography: [...GEM_TYPE_RAMP_KEYS, ...GEM_LINE_HEIGHT_TOKEN_KEYS, ...GEM_BUTTON_TOKEN_KEYS.typography],
436
438
  spacing: [...Object.keys(GEM_DERIVED_SPACE_STEPS), ...GEM_PHONE_TOKEN_TARGETS.spacing.map((k) => `${k}Mobile`)],
437
439
  layout: GEM_PHONE_TOKEN_TARGETS.layout.map((k) => `${k}Mobile`),
@@ -439,6 +441,8 @@ var optionalGemTokenKeys = {
439
441
  // Khoá motion ngoài duration / easing: nền ambient (đã phát từ trước nhưng chưa khai ở đây) và thang "Gọn" (#32).
440
442
  motion: ["easingSoft", "ambientSlow", "ambientMid", "ambientFast", "ambientAmp", ...Object.keys(GEM_MOTION_SCALE_DEFAULTS)]
441
443
  };
444
+ var GEM_SCRIM_FALLBACK = "#000";
445
+ var gemScrim = (percent) => `color-mix(in srgb, var(--gem-color-scrim, ${GEM_SCRIM_FALLBACK}) ${percent}%, transparent)`;
442
446
  var GEM_HEADING_SIZE_CSS_VARS = [
443
447
  "--gem-section-heading-size: calc(var(--gem-h2) * var(--gem-typography-heading-scale, 1));",
444
448
  "--gem-hero-heading-size: calc(var(--gem-h1) * var(--gem-typography-heading-scale, 1));",
@@ -462,7 +466,9 @@ var defaultGemTokens = {
462
466
  // GemFactory sections set `color: var(--gem-color-warning)` on words; white text on a warning badge was 3.64:1 too.
463
467
  // #9a661a keeps the hue at 4.52:1 / 4.90:1. default-colours-meet-aa.test.ts holds every foreground default to 4.5:1.
464
468
  warning: "#9a661a",
465
- danger: "#d72c0d"
469
+ danger: "#d72c0d",
470
+ // Lớp phủ tối ở độ đậm tối đa (gap #17, khoá tuỳ chọn): mỗi lớp phủ lấy N% của nó qua gemScrim.
471
+ scrim: "#000"
466
472
  },
467
473
  typography: {
468
474
  body: "Inter, system-ui, sans-serif",
@@ -1034,7 +1040,7 @@ var GEM_POPUP_SHELL_CSS_LINES = [
1034
1040
  " width: min(880px, 92vw); max-width: none; max-height: min(640px, 92vh); overflow: hidden;",
1035
1041
  " border-radius: var(--gem-radius-lg); box-shadow: var(--gem-shadow-md);",
1036
1042
  "}",
1037
- ".gem-popup-shell__dialog::backdrop { background: rgb(0 0 0 / 0.55); }",
1043
+ `.gem-popup-shell__dialog::backdrop { background: ${gemScrim(55)}; }`,
1038
1044
  ".gem-popup-shell__frame { position: relative; display: grid; grid-template-columns: 1fr 1fr; min-height: 520px; height: 100%; }",
1039
1045
  ".gem-popup-shell__media { position: relative; min-width: 0; overflow: hidden; background: var(--gem-color-surface); }",
1040
1046
  ".gem-popup-shell__image { display: block; width: 100%; height: 100%; object-fit: cover; }",
@@ -1061,7 +1067,7 @@ var GEM_POPUP_SHELL_CSS_LINES = [
1061
1067
  "}",
1062
1068
  // Image behind the text: light text over a dark wash, every size. A card over the image has its own
1063
1069
  // background, so on desktop it keeps the preset's text colour.
1064
- ".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__media::after { content: ''; position: absolute; inset: 0; background: linear-gradient(rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.6)); }",
1070
+ `.gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__media::after { content: ''; position: absolute; inset: 0; background: linear-gradient(${gemScrim(35)}, ${gemScrim(60)}); }`,
1065
1071
  ".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__panel,",
1066
1072
  ".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__close { color: #fff; }",
1067
1073
  ".gem-popup-shell__close {",
@@ -1125,10 +1131,10 @@ var GEM_POPUP_SHELL_CSS_LINES = [
1125
1131
  // Dialog định vị ĐÚNG như modal của trình duyệt (position: fixed; inset: 0; margin: auto), nên mọi bố cục (fullscreen,
1126
1132
  // thẻ, takeover, sheet kéo tới 88dvh) ra cùng khung chữ nhật với popup mở thật; nền chỉ thay ::backdrop (không có ở
1127
1133
  // dialog không-modal).
1128
- ":root:not(:has([data-gem-canvas])) .gem-popup-shell[data-gem-popup-preview='true'] { position: fixed; inset: 0; z-index: 1000; padding: 0; background: rgb(0 0 0 / 0.55); }",
1134
+ `:root:not(:has([data-gem-canvas])) .gem-popup-shell[data-gem-popup-preview='true'] { position: fixed; inset: 0; z-index: 1000; padding: 0; background: ${gemScrim(55)}; }`,
1129
1135
  ":root:not(:has([data-gem-canvas])) .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { position: fixed; inset: 0; display: block; }",
1130
1136
  ":root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { position: relative; display: block; inset: auto; }",
1131
- ":root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] { padding: var(--gem-space-xl) var(--gem-layout-gutter); background: rgb(0 0 0 / 0.55); }",
1137
+ `:root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] { padding: var(--gem-space-xl) var(--gem-layout-gutter); background: ${gemScrim(55)}; }`,
1132
1138
  "@media (min-width: 750px) {",
1133
1139
  " :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] { padding: 0; }",
1134
1140
  " :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; }",
@@ -1262,6 +1268,23 @@ var GEM_FORM_SET_CSS_LINES = [
1262
1268
  " inline-size: var(--gem-checkbox-size);",
1263
1269
  " block-size: var(--gem-checkbox-size);",
1264
1270
  " accent-color: var(--gem-checkbox-accent);",
1271
+ "}",
1272
+ // THE QUANTITY STEPPER IS A FIELD OF THE SET (20/10/2026): its box takes the set's height, edge and corner (capped at
1273
+ // 22px, so a pill set draws a round stepper, not a 999px lozenge), its -/+ and number the set's fill and text size, and
1274
+ // the underline shape paints no cell, as it paints no field. The storefront carried these (gemsite #1001/#1007, with its
1275
+ // own element stepper in the :is() lists); the package did not, so every section the kho previewed drew a square 44px
1276
+ // stepper beside 48px pill fields (GemFactory Framework › Controls, measured 20/10/2026).
1277
+ "[data-gem-form-set] :is(.gem-quantity-input) {",
1278
+ " min-height: var(--gem-input-height);",
1279
+ " border: var(--gem-input-border-width) solid var(--gem-input-border-color);",
1280
+ " border-radius: min(var(--gem-input-radius), 22px);",
1281
+ "}",
1282
+ "[data-gem-form-set] :is(.gem-quantity-input__button, .gem-quantity-input__field) {",
1283
+ " background: var(--gem-input-bg);",
1284
+ " font-size: var(--gem-input-font-size);",
1285
+ "}",
1286
+ '[data-gem-form-set][data-gem-field-shape="underline"] :is(.gem-quantity-input__button, .gem-quantity-input__field) {',
1287
+ " background: transparent;",
1265
1288
  "}"
1266
1289
  ];
1267
1290
  var GEM_FORM_SET_CSS = GEM_FORM_SET_CSS_LINES.join("\n");
@@ -1703,8 +1726,9 @@ function createPrimitiveCss() {
1703
1726
  " width: auto;",
1704
1727
  " min-width: min(10rem, 50%);",
1705
1728
  "}",
1706
- // A wrapped field gets the join's height too: the dawn-parity profile sets .gem-input { min-height: 4.5rem }, and
1707
- // only the direct-child rule above outranked it, so a wrapped input stood 72px next to a 44px one (02/10/2026).
1729
+ // A wrapped field gets the join's height too: the dawn-parity profile's 4.5rem floor named .gem-input until 0.18.12, and
1730
+ // only the direct-child rule above outranked it, so a wrapped input stood 72px next to a 44px one (02/10/2026). The
1731
+ // floor no longer names it; this keeps a wrapped field at the join's height whatever a profile floors it at.
1708
1732
  ".gem-field-join > .gem-form-field > :is(.gem-input, .gem-select) {",
1709
1733
  " width: 100%;",
1710
1734
  " height: 100%;",
@@ -1742,6 +1766,32 @@ function createPrimitiveCss() {
1742
1766
  " accent-color: var(--gem-color-accent);",
1743
1767
  "}",
1744
1768
  ...GEM_FORM_SET_CSS_LINES,
1769
+ // HOVER AND ERROR (§9.2 state matrix, 20/10/2026): GemFactory Framework › Controls measured both INERT, so a field a
1770
+ // shopper points at, or one a form marked invalid, looked like any other. Written once for every field, with or
1771
+ // without a form set.
1772
+ // HOVER thickens the edge by 1px in the field's own ink, as Dawn does (`box-shadow: 0 0 0 calc(border + .1rem)`): a
1773
+ // colour change alone was invisible under every form set, whose edge is already the ink (measured #1f2224 → #202223).
1774
+ // The ring follows the box and its corner; an underline or filled field has only a bottom rule, so only that thickens.
1775
+ // ERROR (`aria-invalid="true"`, set by the form) takes the danger colour mixed 70/30 with that ink, which keeps the
1776
+ // edge at 3:1 or more on a white field (9.7:1), an underline field on a dark band (4.2:1) and on an accent band
1777
+ // (3.0:1); plain #b42318 there was 2.4:1 and 1.8:1. Only the edge colour changes, so every shape keeps its edges.
1778
+ // Hover skips a disabled or invalid field (an error must not lose its colour under the pointer), a focused one (the
1779
+ // focus halo on a dark or accent band is a box-shadow, and a click focuses a text field as :focus-visible, so the
1780
+ // pointer still on it would wipe the halo: review) and touch screens, where a tap would leave it stuck.
1781
+ // Specificity: hover (0,6,0; 0,8,0 per shape) outranks the set's field rule (0,3,0); error (0,3,0) ties it and wins by
1782
+ // coming later in the sheet.
1783
+ "@media (hover: hover) {",
1784
+ ' :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)):hover:not(:disabled):not([aria-invalid="true"]):not(:focus-visible) {',
1785
+ " border-color: currentColor;",
1786
+ " box-shadow: 0 0 0 1px currentColor;",
1787
+ " }",
1788
+ ' [data-gem-form-set]:is([data-gem-field-shape="underline"], [data-gem-field-shape="filled"]) :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)):hover:not(:disabled):not([aria-invalid="true"]):not(:focus-visible) {',
1789
+ " box-shadow: 0 1px 0 0 currentColor;",
1790
+ " }",
1791
+ "}",
1792
+ ':is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input))[aria-invalid="true"] {',
1793
+ " border-color: color-mix(in srgb, var(--gem-color-danger, #b42318) 70%, currentColor);",
1794
+ "}",
1745
1795
  ".gem-submit {",
1746
1796
  " cursor: pointer;",
1747
1797
  "}",
@@ -2301,6 +2351,12 @@ function createPrimitiveCss() {
2301
2351
  " color: var(--gem-color-text);",
2302
2352
  " text-align: center;",
2303
2353
  "}",
2354
+ // The number in the middle of its cell, as the -/+ beside it (a <button> centres its label; an <output> is inline and
2355
+ // put the digit 8-9px above the middle; Framework › Controls, 20/10/2026).
2356
+ ".gem-quantity-input__field {",
2357
+ " display: grid;",
2358
+ " place-items: center;",
2359
+ "}",
2304
2360
  ".gem-quantity-input__button {",
2305
2361
  " font-weight: 750;",
2306
2362
  "}",
@@ -2599,7 +2655,7 @@ function createPrimitiveCss() {
2599
2655
  ".gem-cart-drawer__overlay {",
2600
2656
  " position: fixed;",
2601
2657
  " inset: 0;",
2602
- " background: rgba(0, 0, 0, 0.36);",
2658
+ ` background: ${gemScrim(36)};`,
2603
2659
  "}",
2604
2660
  // Opening motion (gemfactory request #13, 01/10/2026): 28 stored drawers each declared their own
2605
2661
  // `@keyframes …-in { from { transform: translateX(24px); opacity: 0 } }`. OPT-IN on the drawer root,
@@ -3391,7 +3447,7 @@ function createDawnParityCss() {
3391
3447
  ".gem-hero-banner--default { position: relative; overflow: hidden; padding: 0; color: white; place-items: stretch; }",
3392
3448
  ".gem-hero-banner--default .gem-hero-banner__inner { position: static; width: 100%; min-height: 720px; height: 100%; display: grid; align-items: end; justify-items: center; padding-bottom: 0; }",
3393
3449
  ".gem-hero-banner--default .gem-hero-banner__media { position: absolute; inset: 0; z-index: 0; }",
3394
- ".gem-hero-banner--default .gem-hero-banner__media::after { content: ''; position: absolute; inset: 0; background: rgb(0 0 0 / 32%); }",
3450
+ `.gem-hero-banner--default .gem-hero-banner__media::after { content: ''; position: absolute; inset: 0; background: ${gemScrim(32)}; }`,
3395
3451
  ".gem-hero-banner--default .gem-hero-banner__image, .gem-hero-banner--default .gem-hero-banner__placeholder { width: 100%; height: 100%; object-fit: cover; display: block; }",
3396
3452
  ".gem-hero-banner--default .gem-hero-banner__copy { position: absolute; z-index: 1; left: 50%; bottom: 50px; transform: translateX(-50%); width: min(702px, 100%); margin: 0; padding: 40px 0; text-align: center; display: block; }",
3397
3453
  ".gem-hero-banner--default .gem-section__heading { margin: 0; font-size: 52px; line-height: 67.6px; color: currentColor; }",
@@ -3897,7 +3953,10 @@ function createShellCss() {
3897
3953
  ".newsletter-form__field-wrapper, .field {",
3898
3954
  " position: relative;",
3899
3955
  "}",
3900
- ".field__input, .field input, .gem-input {",
3956
+ // Dawn's field floor, for Dawn's own field classes. It named .gem-input too, and 4.5rem is Dawn's 45px only on Dawn's
3957
+ // 10px root: on this 16px root a .gem-input stood 72px beside a 44px select and stepper whenever no form set ruled it
3958
+ // (GemFactory Framework › Controls, 20/10/2026). A .gem-input takes the primitive's floor, as every field does.
3959
+ ".field__input, .field input {",
3901
3960
  " min-height: 4.5rem;",
3902
3961
  " border-radius: var(--inputs-radius, 0);",
3903
3962
  "}",
@@ -4331,6 +4390,662 @@ function offerEngineRuntimeScript() {
4331
4390
  })();`;
4332
4391
  }
4333
4392
 
4393
+ // ../../packages/gem-framework/src/reviews-engine.ts
4394
+ function reviewsEngineRuntimeScript() {
4395
+ return `let boundReviews = 0;
4396
+ document.querySelectorAll('[data-rv-root]').forEach((root) => {
4397
+ if (!mark(root, 'reviews')) return;
4398
+ boundReviews += 1;
4399
+ const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };
4400
+ const all = (sel) => Array.from(root.querySelectorAll(sel));
4401
+ const isEl = (t) => t instanceof window.Element;
4402
+ const list = root.querySelector('[data-rv-list]');
4403
+ const items = list ? Array.from(list.querySelectorAll('[data-rv-item]')) : [];
4404
+ let pageSize = Math.max(1, parseInt((list && list.getAttribute('data-rv-page-size')) || '5', 10) || 5);
4405
+ const moreSize = () => Math.max(1, parseInt((list && list.getAttribute('data-rv-more-size')) || '', 10) || pageSize);
4406
+ const collapsedN = Math.max(0, parseInt((list && list.getAttribute('data-rv-collapsed')) || '0', 10) || 0);
4407
+ const st = { sort: '', star: 0, media: false, topic: '', q: '', country: '', page: 1, shown: pageSize, collapsed: collapsedN > 0, staged: 0 };
4408
+ const section = root.getAttribute('data-gem-section-library-id') || undefined;
4409
+ const sectionId = root.getAttribute('data-gem-section-id') || root.id || undefined;
4410
+ const pagesNavs = all('[data-rv-pages]');
4411
+ const pagesMq = pagesNavs.length && window.matchMedia ? window.matchMedia(pagesNavs[0].getAttribute('data-rv-pages-media') || 'all') : null;
4412
+ const usesPagesNow = () => pagesNavs.length > 0 && (!pagesMq || pagesMq.matches);
4413
+ const prevPages = all('[data-rv-prev]');
4414
+ const nextPages = all('[data-rv-next]');
4415
+ const none = root.querySelector('[data-rv-none]');
4416
+ const more = root.querySelector('[data-rv-more]');
4417
+ const expand = root.querySelector('[data-rv-expand]');
4418
+ const num = (el, a) => Number(el.getAttribute(a) || 0);
4419
+ const isOn = (el, a) => el.getAttribute(a) === '1';
4420
+ const byOrder = (a, b) => num(a, 'data-rv-order') - num(b, 'data-rv-order');
4421
+ const newest = (a, b) => (a.hasAttribute('data-rv-recent') ? num(a, 'data-rv-recent') - num(b, 'data-rv-recent') : byOrder(a, b));
4422
+ const leastHelpful = (a, b) => num(a, 'data-rv-helpful') - num(b, 'data-rv-helpful') || byOrder(a, b);
4423
+ const SORTS = {
4424
+ relevant: byOrder,
4425
+ featured: byOrder,
4426
+ recent: newest,
4427
+ oldest: (a, b) => (a.hasAttribute('data-rv-recent') ? num(b, 'data-rv-recent') - num(a, 'data-rv-recent') : byOrder(b, a)),
4428
+ verified: (a, b) => isOn(b, 'data-rv-verified') - isOn(a, 'data-rv-verified') || byOrder(a, b),
4429
+ highest: (a, b) => num(b, 'data-rv-rating') - num(a, 'data-rv-rating') || byOrder(a, b),
4430
+ lowest: (a, b) => num(a, 'data-rv-rating') - num(b, 'data-rv-rating') || byOrder(a, b),
4431
+ helpful: (a, b) => num(b, 'data-rv-helpful') - num(a, 'data-rv-helpful') || byOrder(a, b),
4432
+ leasthelpful: leastHelpful,
4433
+ unhelpful: leastHelpful,
4434
+ media: (a, b) => isOn(b, 'data-rv-media') - isOn(a, 'data-rv-media') || byOrder(a, b),
4435
+ 'media-only': newest,
4436
+ videos: (a, b) => isOn(b, 'data-rv-video') - isOn(a, 'data-rv-video') || newest(a, b),
4437
+ };
4438
+ const matches = (it) => {
4439
+ if (st.star && num(it, 'data-rv-rating') !== st.star) return false;
4440
+ if ((st.media || st.sort === 'media-only') && !isOn(it, 'data-rv-media')) return false;
4441
+ if (st.country && it.getAttribute('data-rv-country-code') !== st.country) return false;
4442
+ if (st.topic) {
4443
+ const tags = (it.getAttribute('data-rv-topics') || '').toLowerCase().split('|').filter(Boolean);
4444
+ if (tags.length ? !tags.includes(st.topic) : !(it.textContent || '').toLowerCase().includes(st.topic)) return false;
4445
+ }
4446
+ if (st.q && !(it.textContent || '').toLowerCase().includes(st.q)) return false;
4447
+ return true;
4448
+ };
4449
+ const filterKeys = ['star', 'media', 'topic', 'q', 'country'];
4450
+ const only = (k) => filterKeys.every((x) => x === k || !st[x]);
4451
+ // Counts as written ("5284") unless the list names a locale: data-rv-locale="en-US" \u2192 5,284; "auto" \u2192 the page's lang.
4452
+ const locale = list ? list.getAttribute('data-rv-locale') : null;
4453
+ const fmtCount = (n) => {
4454
+ const v = Number(n) || 0;
4455
+ if (!locale) return String(v);
4456
+ try { return v.toLocaleString(locale === 'auto' ? (document.documentElement && document.documentElement.lang) || undefined : locale); } catch (err) { return String(v); }
4457
+ };
4458
+
4459
+ // Read more: the button opens the body of its scope; short text hides the button. Measured after every draw, on
4460
+ // resize and once the theme's fonts are in, because a review on a later page (or under a filter) measures 0.
4461
+ const moreScope = (b) => b.closest('[data-rv-more-scope]') || b.closest('[data-rv-item], [data-rv-scope]') || root;
4462
+ const syncReadMore = () => all('[data-rv-readmore]').forEach((b) => {
4463
+ const it = b.closest('[data-rv-item]');
4464
+ if ((it && it.hidden) || b.hasAttribute('data-rv-keep')) return;
4465
+ const body = moreScope(b).querySelector('[data-rv-body]');
4466
+ if (!body || !body.getClientRects().length) return;
4467
+ b.hidden = !body.hasAttribute('data-rv-open') && body.scrollHeight <= body.clientHeight + 2;
4468
+ });
4469
+
4470
+ // Search (and, with data-rv-mark-topic, topic) words marked in [data-rv-hl]; the original nodes come back on every draw.
4471
+ const hlOrig = new Map();
4472
+ const markClass = (list && list.getAttribute('data-rv-mark-class')) || '';
4473
+ const highlight = () => {
4474
+ const terms = [st.q, list && list.hasAttribute('data-rv-mark-topic') ? st.topic : ''].filter((x) => x && x.length > 1);
4475
+ const re = terms.length ? new RegExp('(' + terms.map((x) => x.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&')).join('|') + ')', 'gi') : null;
4476
+ all('[data-rv-hl]').forEach((el) => {
4477
+ if (!hlOrig.has(el)) { if (!re) return; hlOrig.set(el, Array.from(el.childNodes).map((n) => n.cloneNode(true))); }
4478
+ else el.replaceChildren(...hlOrig.get(el).map((n) => n.cloneNode(true)));
4479
+ if (!re) return;
4480
+ const walker = document.createTreeWalker(el, 4);
4481
+ const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
4482
+ nodes.forEach((n) => {
4483
+ const parts = (n.nodeValue || '').split(re);
4484
+ if (parts.length < 2) return;
4485
+ const frag = document.createDocumentFragment();
4486
+ parts.forEach((p, i) => { if (i % 2 === 0) { if (p) frag.append(p); return; } const m = document.createElement('mark'); m.className = markClass; m.textContent = p; frag.append(m); });
4487
+ n.replaceWith(frag);
4488
+ });
4489
+ });
4490
+ };
4491
+
4492
+ const drawPages = (nav, pages) => {
4493
+ const cur = st.page;
4494
+ const win = Number(nav.getAttribute('data-rv-page-window') || 0);
4495
+ let seq;
4496
+ if (win > 0) {
4497
+ // A window of N numbers around the current page, no \u2026 (Yotpo: 1 2 3 4 5).
4498
+ const start = Math.max(1, Math.min(cur - Math.floor(win / 2), pages - win + 1));
4499
+ seq = Array.from({ length: Math.min(win, pages) }, (_, k) => start + k);
4500
+ } else {
4501
+ // 1 \u2026 n around the current page (Judge.me: 1 2 3 4 \u2026 18).
4502
+ const want = new Set([1, pages, cur - 1, cur, cur + 1].filter((p) => p >= 1 && p <= pages));
4503
+ if (cur <= 3) [2, 3, 4].forEach((p) => { if (p <= pages) want.add(p); });
4504
+ seq = Array.from(want).sort((a, b) => a - b);
4505
+ }
4506
+ const tpl = nav.querySelector('[data-rv-page-tpl]');
4507
+ const anchor = nextPages.find((b) => nav.contains(b)) || null;
4508
+ nav.querySelectorAll('[data-rv-page]:not([data-rv-page-tpl]), [data-rv-gap]').forEach((e) => e.remove());
4509
+ let last = 0;
4510
+ seq.forEach((p) => {
4511
+ if (!win && p - last > 1) {
4512
+ const g = document.createElement('span');
4513
+ g.setAttribute('data-rv-gap', ''); g.setAttribute('aria-hidden', 'true');
4514
+ g.className = (tpl && tpl.getAttribute('data-rv-gap-class')) || ''; g.textContent = '\u2026';
4515
+ nav.insertBefore(g, anchor);
4516
+ }
4517
+ const b = document.createElement('button');
4518
+ b.type = 'button'; b.className = tpl ? tpl.className : ''; b.textContent = String(p);
4519
+ b.setAttribute('data-rv-page', String(p)); b.setAttribute('aria-label', 'Page ' + p);
4520
+ if (p === cur) b.setAttribute('aria-current', 'page');
4521
+ nav.insertBefore(b, anchor);
4522
+ last = p;
4523
+ });
4524
+ };
4525
+
4526
+ // Active-filter chips: one per filter in force, a click removes that filter.
4527
+ const chipsBox = root.querySelector('[data-rv-chips]');
4528
+ const chipTpl = root.querySelector('[data-rv-chip-tpl]');
4529
+ const starText = (v) => { const e = root.querySelector('[data-rv-star="' + v + '"][data-rv-star-text]'); return e ? e.getAttribute('data-rv-star-text') || '' : ''; };
4530
+ const countryText = (c) => { const e = root.querySelector('[data-rv-country="' + c + '"][data-rv-country-text]'); return e ? e.getAttribute('data-rv-country-text') || c : c; };
4531
+ const drawChips = () => {
4532
+ if (!chipsBox || !chipTpl) return;
4533
+ chipsBox.replaceChildren();
4534
+ const add = (kind, label, star) => {
4535
+ const b = chipTpl.cloneNode(true);
4536
+ b.removeAttribute('data-rv-chip-tpl'); b.hidden = false;
4537
+ const t = b.querySelector('[data-rv-chip-text]'); if (t) t.textContent = label;
4538
+ const s = b.querySelector('[data-rv-chip-star]'); if (s) s.hidden = !star;
4539
+ const v = b.querySelector('[data-rv-chip-value]'); if (v) v.textContent = star || '';
4540
+ b.setAttribute('data-rv-chip', kind);
4541
+ b.setAttribute('aria-label', ((b.getAttribute('data-rv-remove') || 'Remove') + ' ' + label + ' ' + (star || '')).trim());
4542
+ chipsBox.appendChild(b);
4543
+ };
4544
+ if (st.star) add('star', chipsBox.getAttribute('data-rv-chip-rating') || '', starText(st.star) || String(st.star));
4545
+ if (st.media) add('media', chipsBox.getAttribute('data-rv-chip-media') || '', '');
4546
+ if (st.country) add('country', countryText(st.country), '');
4547
+ chipsBox.hidden = !chipsBox.childElementCount;
4548
+ };
4549
+
4550
+ const render = (focusFirstNew, scroll) => {
4551
+ if (!list) return;
4552
+ const sorted = st.sort && SORTS[st.sort] ? items.slice().sort(SORTS[st.sort]) : items;
4553
+ if (st.sort) sorted.forEach((it) => list.appendChild(it));
4554
+ const hit = sorted.filter(matches);
4555
+ const hitSet = new Set(hit);
4556
+ const total = hit.length;
4557
+ const usesPages = !st.collapsed && usesPagesNow();
4558
+ const pages = Math.max(1, Math.ceil(total / pageSize));
4559
+ if (st.page > pages) st.page = pages;
4560
+ const from = usesPages ? (st.page - 1) * pageSize : 0;
4561
+ const to = st.collapsed ? collapsedN : usesPages ? from + pageSize : st.shown;
4562
+ let firstNew = null;
4563
+ items.forEach((it) => { if (!hitSet.has(it)) it.hidden = true; });
4564
+ hit.forEach((it, i) => {
4565
+ const show = i >= from && i < to;
4566
+ if (show && it.hidden && !firstNew && focusFirstNew) firstNew = it;
4567
+ it.hidden = !show;
4568
+ });
4569
+ if (none) none.hidden = total > 0;
4570
+ if (more) more.hidden = st.collapsed || usesPages || to >= total;
4571
+ if (expand) expand.hidden = !st.collapsed || total <= collapsedN;
4572
+ if (list.hasAttribute('data-rv-collapsed')) root.toggleAttribute('data-rv-expanded', !st.collapsed);
4573
+ const filtered = filterKeys.some((k) => !!st[k]);
4574
+ // Found: the store's own count when nothing filters; the star row's (or topic chip's) count when only that filters
4575
+ // (the breakdown of the WHOLE store, as review widgets show it); otherwise what matched on the page.
4576
+ const starEl = st.star ? root.querySelector('[data-rv-star="' + st.star + '"][data-rv-star-count]') : null;
4577
+ const topicEl = st.topic ? all('[data-rv-topic][data-rv-topic-count]').find((x) => (x.getAttribute('data-rv-topic') || '').toLowerCase() === st.topic) : null;
4578
+ const found = !filtered ? Number(list.getAttribute('data-rv-total') || total)
4579
+ : starEl && only('star') ? Number(starEl.getAttribute('data-rv-star-count'))
4580
+ : topicEl && only('topic') ? Number(topicEl.getAttribute('data-rv-topic-count'))
4581
+ : total;
4582
+ const shown = Math.max(0, Math.min(to, total) - from);
4583
+ root.toggleAttribute('data-rv-filtered', filtered);
4584
+ root.setAttribute('data-rv-f-star', String(st.star || ''));
4585
+ root.setAttribute('data-rv-f-topic', st.topic);
4586
+ root.setAttribute('data-rv-f-country', st.country);
4587
+ root.toggleAttribute('data-rv-f-media', st.media);
4588
+ all('[data-rv-star-value]').forEach((e) => (e.querySelector('.gem-stars') || e).style.setProperty('--gem-stars-value', String(st.star || 0)));
4589
+ all('[data-rv-star-text]:not([data-rv-star])').forEach((e) => { e.textContent = String(st.star || ''); });
4590
+ all('[data-rv-star-label]').forEach((e) => { e.textContent = st.star ? starText(st.star) : ''; });
4591
+ all('[data-rv-country-label]').forEach((e) => { e.textContent = st.country ? countryText(st.country) : ''; });
4592
+ all('[data-rv-filter-count]').forEach((e) => { const n = (st.star ? 1 : 0) + (st.media ? 1 : 0) + (st.topic ? 1 : 0) + (st.country ? 1 : 0); e.textContent = String(n); e.hidden = n === 0; });
4593
+ all('[data-rv-count-label]').forEach((e) => { e.textContent = ((found === 1 && e.getAttribute('data-rv-count-one')) || e.getAttribute('data-rv-count-label') || '').replace('{count}', fmtCount(found)); });
4594
+ all('[data-rv-status]').forEach((s) => {
4595
+ const tpl = (found === 1 && s.getAttribute('data-rv-status-one')) || s.getAttribute('data-rv-status-template') || '';
4596
+ s.textContent = tpl && filtered ? tpl.replace('{count}', fmtCount(found)).replace('{shown}', String(shown)).replace('{total}', String(total)) : '';
4597
+ });
4598
+ all('[data-rv-hl-topic]').forEach((m) => m.toggleAttribute('data-rv-hl-on', !!st.topic && (m.getAttribute('data-rv-hl-topic') || '').toLowerCase() === st.topic));
4599
+ highlight();
4600
+ drawChips();
4601
+ pagesNavs.forEach((nav) => {
4602
+ if (usesPages && nav.querySelector('[data-rv-page-tpl]')) drawPages(nav, pages);
4603
+ nav.hidden = !usesPages || pages <= 1;
4604
+ });
4605
+ all('[data-rv-page-label]').forEach((l) => { l.textContent = (l.getAttribute('data-rv-page-template') || '{page}').replace('{page}', String(st.page)).replace('{pages}', String(pages)); });
4606
+ prevPages.forEach((b) => { b.toggleAttribute('disabled', st.page <= 1); b.setAttribute('aria-disabled', String(st.page <= 1)); });
4607
+ nextPages.forEach((b) => { b.toggleAttribute('disabled', st.page >= pages); b.setAttribute('aria-disabled', String(st.page >= pages)); });
4608
+ syncReadMore();
4609
+ if (firstNew) {
4610
+ const f = firstNew.querySelector('h3, [tabindex], a, button') || firstNew;
4611
+ if (f === firstNew && !f.hasAttribute('tabindex')) f.setAttribute('tabindex', '-1');
4612
+ f.focus({ preventScroll: false });
4613
+ }
4614
+ if (scroll) { const t = root.querySelector('[data-rv-scroll-to]') || list; if (t.scrollIntoView) t.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
4615
+ const detail = { source: 'gem-runtime', section, sectionId, total, found, shown, page: st.page, pages, filtered, star: st.star, sort: st.sort };
4616
+ if (typeof window.CustomEvent === 'function') root.dispatchEvent(new window.CustomEvent('gem:reviews:render', { detail }));
4617
+ api.emit('gem:reviews:render', detail);
4618
+ };
4619
+ const reset = () => { st.page = 1; st.shown = pageSize; };
4620
+
4621
+ // Menus: one open at a time; Escape, a click outside, or choosing an item closes it.
4622
+ const menus = all('[data-rv-menu]');
4623
+ const panelOf = (b) => root.querySelector('[data-rv-menu-panel="' + b.getAttribute('data-rv-menu') + '"]');
4624
+ const closeMenus = (except) => menus.forEach((b) => {
4625
+ if (b === except) return;
4626
+ b.setAttribute('aria-expanded', 'false');
4627
+ const p = panelOf(b); if (p) p.hidden = true;
4628
+ });
4629
+ // A choice made inside a menu panel closes it, and focus goes back to the menu's button (the section behaviors left
4630
+ // it on a now hidden item, so it fell to <body>). A filter row in a panel with data-rv-menu-keep stays open where
4631
+ // that media query matches; sort and Show N always close.
4632
+ const closeAfterChoice = (el, always) => {
4633
+ const p = el.closest('[data-rv-menu-panel]');
4634
+ if (!p) { if (always) closeMenus(); return; }
4635
+ const keep = p.getAttribute('data-rv-menu-keep');
4636
+ if (!always && keep && window.matchMedia && window.matchMedia(keep).matches) return;
4637
+ const owner = menus.find((b) => panelOf(b) === p);
4638
+ closeMenus();
4639
+ if (owner && p.contains(document.activeElement)) owner.focus({ preventScroll: true });
4640
+ };
4641
+ menus.forEach((b) => on(b, 'click', (e) => {
4642
+ e.preventDefault();
4643
+ const open = b.getAttribute('aria-expanded') !== 'true';
4644
+ closeMenus(b);
4645
+ b.setAttribute('aria-expanded', String(open));
4646
+ const p = panelOf(b);
4647
+ if (p) { p.hidden = !open; if (open) { const f = p.querySelector('[aria-checked=true], [aria-current=true], button, a, input') || p; if (f.focus) f.focus({ preventScroll: true }); } }
4648
+ }));
4649
+ if (menus.length) {
4650
+ on(window, 'keydown', (e) => {
4651
+ if (e.key !== 'Escape') return;
4652
+ const openMenu = menus.find((x) => x.getAttribute('aria-expanded') === 'true');
4653
+ if (openMenu) { closeMenus(); openMenu.focus(); }
4654
+ });
4655
+ on(window, 'click', (e) => {
4656
+ if (!(e.target instanceof window.Node)) return;
4657
+ if (menus.some((b) => b.contains(e.target)) || all('[data-rv-menu-panel]').some((p) => p.contains(e.target))) return;
4658
+ closeMenus();
4659
+ });
4660
+ }
4661
+
4662
+ // Every filter control reads its state from st (buttons, menu items, chips, Clear).
4663
+ const syncStars = () => all('[data-rv-star]').forEach((x) => {
4664
+ const yes = Number(x.getAttribute('data-rv-star')) === st.star;
4665
+ if (x.hasAttribute('aria-checked')) x.setAttribute('aria-checked', String(yes));
4666
+ else if (x.hasAttribute('aria-current')) x.setAttribute('aria-current', String(yes && st.star > 0));
4667
+ else x.setAttribute('aria-pressed', String(yes && st.star > 0));
4668
+ });
4669
+ const syncControls = () => {
4670
+ syncStars();
4671
+ all('[data-rv-media-filter]').forEach((x) => { if (x.matches('input')) x.checked = st.media; else x.setAttribute('aria-pressed', String(st.media)); });
4672
+ all('[data-rv-country]').forEach((x) => x.setAttribute('aria-checked', String((x.getAttribute('data-rv-country') || '') === st.country)));
4673
+ all('[data-rv-topic]').forEach((x) => x.setAttribute('aria-pressed', String(!!st.topic && (x.getAttribute('data-rv-topic') || '').toLowerCase() === st.topic)));
4674
+ };
4675
+
4676
+ // Toggles: an inline panel / region / collapse opened by its button, not closed by a click outside. Closing a region
4677
+ // that has data-rv-region-clear drops the filters inside it (closing the filter row = no star filter; closing the
4678
+ // search box = no search), as Judge.me does.
4679
+ const PANEL_ATTRS = ['data-rv-toggle-panel', 'data-rv-region', 'data-rv-collapse-panel'];
4680
+ const togglePanels = (id) => all('[data-rv-toggle-panel], [data-rv-region], [data-rv-collapse-panel]').filter((p) => PANEL_ATTRS.some((a) => p.hasAttribute(a) && p.getAttribute(a) === id));
4681
+ const toggleId = (t) => (t.hasAttribute('data-rv-toggle') ? t.getAttribute('data-rv-toggle') : t.getAttribute('data-rv-collapse')) || '';
4682
+ const setToggle = (id, open) => {
4683
+ const ps = togglePanels(id);
4684
+ if (!ps.length) return;
4685
+ const was = ps.some((p) => !p.hidden);
4686
+ ps.forEach((p) => { p.hidden = !open; });
4687
+ all('[data-rv-toggle], [data-rv-collapse]').forEach((t) => { if (toggleId(t) === id) t.setAttribute('aria-expanded', String(open)); });
4688
+ const r = !open && was ? ps.find((p) => p.hasAttribute('data-rv-region-clear')) : null;
4689
+ if (!r) return;
4690
+ let changed = false;
4691
+ if (r.querySelector('[data-rv-star]') && st.star) { st.star = 0; changed = true; syncControls(); }
4692
+ r.querySelectorAll('input[data-rv-search]').forEach((x) => { if (x.value || st.q) { x.value = ''; st.q = ''; changed = true; } });
4693
+ if (changed) { reset(); render(); }
4694
+ };
4695
+ all('[data-rv-toggle], [data-rv-collapse]').forEach((t) => on(t, 'click', (e) => {
4696
+ e.preventDefault();
4697
+ const ps = togglePanels(toggleId(t));
4698
+ setToggle(toggleId(t), ps.length ? ps.every((p) => p.hidden) : t.getAttribute('aria-expanded') !== 'true');
4699
+ }));
4700
+
4701
+ const setSort = (key, label) => {
4702
+ st.sort = key; reset();
4703
+ all('[data-rv-sort-option]').forEach((o) => o.setAttribute('aria-checked', String(o.getAttribute('data-rv-sort-option') === key)));
4704
+ all('select[data-rv-sort]').forEach((s) => { if (s.value !== key) s.value = key; });
4705
+ if (label) {
4706
+ all('[data-rv-sort-label]').forEach((l) => { l.textContent = label; });
4707
+ all('[data-rv-menu][data-rv-sort-aria]').forEach((b) => b.setAttribute('aria-label', (b.getAttribute('data-rv-sort-aria') || '').replace('{label}', label)));
4708
+ }
4709
+ render();
4710
+ };
4711
+ all('select[data-rv-sort]').forEach((s) => on(s, 'change', () => setSort(s.value, '')));
4712
+ all('[data-rv-sort-option]').forEach((o) => on(o, 'click', (e) => {
4713
+ e.preventDefault();
4714
+ setSort(o.getAttribute('data-rv-sort-option') || '', (o.getAttribute('data-rv-sort-text') || o.textContent || '').trim());
4715
+ closeAfterChoice(o, true);
4716
+ }));
4717
+ const starKeep = root.getAttribute('data-rv-star-keep');
4718
+ all('[data-rv-star]').forEach((b) => on(b, 'click', (e) => {
4719
+ e.preventDefault();
4720
+ const v = Number(b.getAttribute('data-rv-star')) || 0;
4721
+ const keep = starKeep !== null && (starKeep !== 'menu' || !!b.closest('[data-rv-menu-panel]'));
4722
+ st.star = keep ? v : st.star === v ? 0 : v; reset();
4723
+ syncControls();
4724
+ if (b.hasAttribute('data-rv-open-region')) setToggle(b.getAttribute('data-rv-open-region') || '', true);
4725
+ closeAfterChoice(b);
4726
+ render();
4727
+ }));
4728
+ all('[data-rv-media-filter]').forEach((m) => on(m, m.matches('input') ? 'change' : 'click', (e) => {
4729
+ if (!m.matches('input')) e.preventDefault();
4730
+ st.media = m.matches('input') ? m.checked : !st.media; reset();
4731
+ syncControls();
4732
+ render();
4733
+ }));
4734
+ all('[data-rv-country]').forEach((c) => on(c, 'click', (e) => {
4735
+ e.preventDefault();
4736
+ st.country = c.getAttribute('data-rv-country') || ''; reset();
4737
+ syncControls();
4738
+ closeAfterChoice(c);
4739
+ render();
4740
+ }));
4741
+ all('[data-rv-topic]').forEach((t) => on(t, 'click', (e) => {
4742
+ e.preventDefault();
4743
+ const v = (t.getAttribute('data-rv-topic') || '').toLowerCase();
4744
+ st.topic = st.topic === v ? '' : v; reset();
4745
+ syncControls();
4746
+ render();
4747
+ }));
4748
+ all('input[data-rv-search]').forEach((s) => {
4749
+ let wait = 0;
4750
+ const apply = () => { st.q = s.value.trim().toLowerCase(); reset(); render(); };
4751
+ on(s, 'input', () => { window.clearTimeout(wait); wait = window.setTimeout(apply, 180); });
4752
+ on(s.form || s, 'submit', (e) => { e.preventDefault(); apply(); });
4753
+ });
4754
+ // Clear: data-rv-clear="star" | "topic" drops that filter only (the "\xD7 Clear" beside a label); empty drops them all.
4755
+ all('[data-rv-clear]').forEach((c) => on(c, 'click', (e) => {
4756
+ e.preventDefault();
4757
+ const scope = c.getAttribute('data-rv-clear') || '';
4758
+ if (!scope || scope === 'star') st.star = 0;
4759
+ if (!scope || scope === 'topic') st.topic = '';
4760
+ if (!scope) { st.media = false; st.q = ''; st.country = ''; all('input[data-rv-search]').forEach((x) => { x.value = ''; }); }
4761
+ reset();
4762
+ syncControls();
4763
+ render();
4764
+ }));
4765
+ on(chipsBox, 'click', (e) => {
4766
+ const b = isEl(e.target) ? e.target.closest('[data-rv-chip]') : null;
4767
+ if (!b) return;
4768
+ e.preventDefault();
4769
+ const k = b.getAttribute('data-rv-chip');
4770
+ if (k === 'star') st.star = 0;
4771
+ if (k === 'media') st.media = false;
4772
+ if (k === 'country') st.country = '';
4773
+ reset(); syncControls(); render();
4774
+ });
4775
+ // A filter sheet with Apply: its star chips only stage a choice, Apply filters, Reset un-stages.
4776
+ const syncStage = () => all('[data-rv-stage]').forEach((c) => c.setAttribute('aria-pressed', String(Number(c.getAttribute('data-rv-stage')) === st.staged)));
4777
+ all('[data-rv-stage]').forEach((c) => on(c, 'click', (e) => { e.preventDefault(); st.staged = Number(c.getAttribute('data-rv-stage')) || 0; syncStage(); }));
4778
+ all('[data-rv-reset]').forEach((c) => on(c, 'click', (e) => { e.preventDefault(); st.staged = 0; syncStage(); }));
4779
+ all('[data-rv-apply]').forEach((c) => on(c, 'click', (e) => {
4780
+ e.preventDefault();
4781
+ st.star = st.staged; reset(); syncControls(); render();
4782
+ const d = c.closest('dialog'); if (d && d.open) d.close();
4783
+ }));
4784
+
4785
+ on(more, 'click', (e) => { e.preventDefault(); st.shown += moreSize(); render(true); });
4786
+ on(expand, 'click', (e) => { e.preventDefault(); st.collapsed = false; st.page = 1; render(); });
4787
+ pagesNavs.forEach((nav) => on(nav, 'click', (e) => {
4788
+ const b = isEl(e.target) ? e.target.closest('[data-rv-page]') : null;
4789
+ if (!b || b.hasAttribute('data-rv-page-tpl')) return;
4790
+ e.preventDefault(); st.page = Number(b.getAttribute('data-rv-page')) || 1; render(false, true);
4791
+ }));
4792
+ prevPages.forEach((b) => on(b, 'click', (e) => { e.preventDefault(); if (st.page > 1) { st.page -= 1; render(false, true); } }));
4793
+ nextPages.forEach((b) => on(b, 'click', (e) => { e.preventDefault(); st.page += 1; render(false, true); }));
4794
+ all('[data-rv-per-page-option]').forEach((o) => on(o, 'click', (e) => {
4795
+ e.preventDefault();
4796
+ pageSize = Math.max(1, Number(o.getAttribute('data-rv-per-page-option')) || pageSize); reset();
4797
+ all('[data-rv-per-page-option]').forEach((x) => x.setAttribute('aria-checked', String(x === o)));
4798
+ all('[data-rv-per-page-label]').forEach((l) => { l.textContent = String(pageSize); });
4799
+ closeAfterChoice(o, true);
4800
+ render();
4801
+ }));
4802
+ if (pagesMq) on(pagesMq, 'change', () => { reset(); render(); });
4803
+
4804
+ all('[data-rv-readmore]').forEach((b) => on(b, 'click', (e) => {
4805
+ e.preventDefault();
4806
+ const body = moreScope(b).querySelector('[data-rv-body]');
4807
+ const open = b.getAttribute('aria-expanded') !== 'true';
4808
+ b.setAttribute('aria-expanded', String(open));
4809
+ if (body) body.toggleAttribute('data-rv-open', open);
4810
+ const less = b.getAttribute('data-rv-less'); const moreText = b.getAttribute('data-rv-more-text');
4811
+ if (less && moreText) b.textContent = open ? less : moreText;
4812
+ }));
4813
+ let resizeWait = 0;
4814
+ on(window, 'resize', () => { window.clearTimeout(resizeWait); resizeWait = window.setTimeout(syncReadMore, 150); });
4815
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(syncReadMore).catch(() => {});
4816
+ on(root, 'gem:reviews:refresh', () => render());
4817
+
4818
+ // Tracks (a strip of photos or cards): back / forward one card; the buttons switch off at either end. A looping
4819
+ // track (data-rv-loop) is the section's own carousel and is left to it.
4820
+ all('[data-rv-track]').forEach((track) => {
4821
+ if (track.hasAttribute('data-rv-loop')) return;
4822
+ const scope = track.closest('[data-rv-carousel]') || root;
4823
+ const prev = scope.querySelector('[data-rv-slide-prev]'); const next = scope.querySelector('[data-rv-slide-next]');
4824
+ const step = () => { const c = track.firstElementChild; const gap = parseFloat(window.getComputedStyle(track).columnGap || '0') || 0; return c ? c.getBoundingClientRect().width + gap : track.clientWidth; };
4825
+ const sync = () => { const max = track.scrollWidth - track.clientWidth - 2; if (prev) prev.toggleAttribute('disabled', track.scrollLeft <= 2); if (next) next.toggleAttribute('disabled', track.scrollLeft >= max); };
4826
+ on(prev, 'click', (e) => { e.preventDefault(); track.scrollBy({ left: -step(), behavior: 'smooth' }); });
4827
+ on(next, 'click', (e) => { e.preventDefault(); track.scrollBy({ left: step(), behavior: 'smooth' }); });
4828
+ on(track, 'scroll', sync, { passive: true });
4829
+ sync();
4830
+ });
4831
+
4832
+ // Filter sheet (phones): a button opens dialog[data-rv-sheet], delegated on the root so copies made after binding
4833
+ // (an inline card a section repeats) open it too; closing returns focus to that button.
4834
+ let sheetFrom = null;
4835
+ on(root, 'click', (e) => {
4836
+ const b = isEl(e.target) ? e.target.closest('[data-rv-sheet-open]') : null;
4837
+ if (!b) return;
4838
+ const d = root.querySelector('dialog[data-rv-sheet="' + b.getAttribute('data-rv-sheet-open') + '"]');
4839
+ if (!d || typeof d.showModal !== 'function') return;
4840
+ e.preventDefault(); sheetFrom = b; st.staged = st.star; syncStage(); closeMenus(); if (!d.open) d.showModal();
4841
+ });
4842
+ all('dialog[data-rv-sheet]').forEach((d) => {
4843
+ d.querySelectorAll('[data-rv-sheet-close]').forEach((c) => on(c, 'click', (e) => {
4844
+ e.preventDefault(); if (d.open) d.close();
4845
+ if (c.hasAttribute('data-rv-scroll-list') && list) { sheetFrom = null; list.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
4846
+ }));
4847
+ on(d, 'click', (e) => {
4848
+ if (e.target !== d || !d.hasAttribute('data-rv-backdrop-close')) return;
4849
+ const r = d.getBoundingClientRect();
4850
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) d.close();
4851
+ });
4852
+ on(d, 'close', () => { const opener = sheetFrom && sheetFrom.isConnected ? sheetFrom : null; sheetFrom = null; if (opener) opener.focus({ preventScroll: true }); });
4853
+ });
4854
+
4855
+ // Helpful: once per review (or per [data-rv-vote-scope]: a question, an answer), counted in place.
4856
+ all('[data-rv-vote]').forEach((b) => on(b, 'click', (e) => {
4857
+ e.preventDefault();
4858
+ const scope = b.closest('[data-rv-item], [data-rv-vote-scope]');
4859
+ if (!scope || scope.hasAttribute('data-rv-voted')) return;
4860
+ scope.setAttribute('data-rv-voted', b.getAttribute('data-rv-vote') || '');
4861
+ b.setAttribute('aria-pressed', 'true');
4862
+ const c = b.querySelector('[data-rv-vote-count]'); if (c) c.textContent = String((Number(c.textContent) || 0) + 1);
4863
+ scope.querySelectorAll('[data-rv-vote]').forEach((x) => x.setAttribute('disabled', ''));
4864
+ const detail = { source: 'gem-runtime', section, sectionId, review: scope.id, value: b.getAttribute('data-rv-vote') };
4865
+ api.emit('gem:review:vote', detail);
4866
+ if (typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:review:vote', { detail }));
4867
+ }));
4868
+
4869
+ // Lightbox. The set it walks: every [data-rv-zoom] of the section in page order (data-rv-lb-dedupe: one entry per
4870
+ // photo), the opener's [data-rv-group], the opener's review (data-rv-lb-scope="review"), or the opener's own
4871
+ // data-rv-media-list. Ends: hide the arrow (default), disable it, or wrap. The review the photo belongs to is copied
4872
+ // beside it from [data-rv-lb-part]; Escape closes (native dialog) and focus returns to the opener.
4873
+ const lb = root.querySelector('dialog[data-rv-lightbox]');
4874
+ const zooms = all('[data-rv-zoom]');
4875
+ const lbScoped = !!lb && lb.getAttribute('data-rv-lb-scope') === 'review';
4876
+ const lbEnds = lb ? lb.getAttribute('data-rv-lb-ends') || (lbScoped ? 'disable' : 'hide') : 'hide';
4877
+ const lbVotes = !lb || lb.getAttribute('data-rv-lb-votes') !== 'off';
4878
+ const entryOf = (z) => {
4879
+ const img = z.querySelector('img');
4880
+ return { el: z, src: z.getAttribute('data-rv-src') || '', kind: z.getAttribute('data-rv-kind') || '', poster: z.getAttribute('data-rv-poster') || '',
4881
+ alt: z.getAttribute('data-rv-alt') || '', thumb: z.getAttribute('data-rv-thumb') || (img && img.getAttribute('src')) || z.getAttribute('data-rv-src') || '',
4882
+ review: z.getAttribute('data-rv-review') || '' };
4883
+ };
4884
+ const lbAll = [];
4885
+ const seenSrc = new Set();
4886
+ zooms.forEach((z) => {
4887
+ const en = entryOf(z);
4888
+ if (lb && lb.hasAttribute('data-rv-lb-dedupe')) { if (seenSrc.has(en.src)) return; seenSrc.add(en.src); }
4889
+ lbAll.push(en);
4890
+ });
4891
+ const setFor = (z) => {
4892
+ const raw = z.getAttribute('data-rv-media-list');
4893
+ if (raw !== null) {
4894
+ let parsed = [];
4895
+ try { parsed = JSON.parse(raw || '[]'); } catch (err) { parsed = []; }
4896
+ return (Array.isArray(parsed) ? parsed : []).filter((m) => m && m.src).map((m) => ({ el: null, src: String(m.src), kind: m.kind || '', poster: m.poster || '', alt: m.alt || '', thumb: m.poster || String(m.src), review: z.getAttribute('data-rv-review') || '' }));
4897
+ }
4898
+ const g = z.getAttribute('data-rv-group');
4899
+ if (g) return lbAll.filter((x) => x.el.getAttribute('data-rv-group') === g);
4900
+ if (lbScoped) {
4901
+ const r = z.getAttribute('data-rv-review') || '';
4902
+ const own = lbAll.filter((x) => x.review === r && !x.el.closest('[data-rv-carousel]'));
4903
+ return own.length ? own : [entryOf(z)];
4904
+ }
4905
+ return lbAll;
4906
+ };
4907
+ const indexIn = (set, z) => {
4908
+ const i = set.findIndex((x) => x.el === z);
4909
+ if (i >= 0) return i;
4910
+ const src = z.getAttribute('data-rv-src');
4911
+ const j = set.findIndex((x) => x.src === src && x.review === (z.getAttribute('data-rv-review') || ''));
4912
+ return Math.max(0, j >= 0 ? j : set.findIndex((x) => x.src === src));
4913
+ };
4914
+ let lbSet = lbAll; let lbAt = 0; let lbFrom = null; let stripFor = null;
4915
+ const strip = lb ? lb.querySelector('[data-rv-lb-thumbs]') : null;
4916
+ const buildStrip = () => {
4917
+ if (!strip || stripFor === lbSet) return;
4918
+ stripFor = lbSet;
4919
+ strip.replaceChildren();
4920
+ if (lbSet.length > 1) lbSet.forEach((x, k) => {
4921
+ const b = document.createElement('button');
4922
+ b.type = 'button'; b.className = strip.getAttribute('data-rv-thumb-class') || ''; b.setAttribute('data-rv-lb-thumb', String(k));
4923
+ b.setAttribute('aria-label', (strip.getAttribute('data-rv-thumb-label') || 'Media {n} of {total}').replace('{n}', String(k + 1)).replace('{total}', String(lbSet.length)));
4924
+ const img = document.createElement('img'); img.src = x.thumb; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
4925
+ b.appendChild(img); strip.appendChild(b);
4926
+ });
4927
+ strip.hidden = lbSet.length <= 1;
4928
+ };
4929
+ const lbShow = (i) => {
4930
+ if (!lb) return;
4931
+ const n = lbSet.length;
4932
+ lbAt = lbEnds === 'wrap' && n ? ((i % n) + n) % n : Math.max(0, Math.min(n - 1, i));
4933
+ const x = lbSet[lbAt];
4934
+ lb.toggleAttribute('data-rv-lb-empty', !n);
4935
+ lb.toggleAttribute('data-rv-lb-multi', n > 1);
4936
+ const media = lb.querySelector('[data-rv-lb-media]');
4937
+ if (media) {
4938
+ media.replaceChildren();
4939
+ if (x && x.kind === 'video') { const v = document.createElement('video'); v.src = x.src; if (x.poster) v.setAttribute('poster', x.poster); v.controls = true; v.playsInline = true; v.autoplay = true; media.appendChild(v); }
4940
+ else if (x) { const img = document.createElement('img'); img.src = x.src; img.alt = x.alt; media.appendChild(img); }
4941
+ }
4942
+ const stage = lb.querySelector('[data-rv-lb-stage]');
4943
+ if (stage && x) {
4944
+ const img = x.el && x.el.querySelector('img');
4945
+ let bg = img ? img.currentSrc || img.src : '';
4946
+ if (!bg) { try { bg = new URL(x.src, window.location.href).href; } catch (err) { bg = x.src; } }
4947
+ stage.style.setProperty('--rv-lb-bg', 'url("' + String(bg).replace(/"/g, '%22') + '")');
4948
+ }
4949
+ const panel = lb.querySelector('[data-rv-lb-review]');
4950
+ if (panel) {
4951
+ panel.replaceChildren();
4952
+ // A review with no photos (an empty media list) still shows its review: the opener names it.
4953
+ const rid = x ? x.review : (lbFrom && lbFrom.getAttribute && lbFrom.getAttribute('data-rv-review')) || '';
4954
+ const item = rid ? items.find((it) => it.id === rid) : null;
4955
+ if (item) item.querySelectorAll('[data-rv-lb-part]').forEach((part) => {
4956
+ const copy = part.cloneNode(true);
4957
+ copy.removeAttribute('id'); copy.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
4958
+ copy.querySelectorAll('input, label[for], [data-rv-readmore]').forEach((el) => el.remove());
4959
+ copy.querySelectorAll('[data-rv-zoom]').forEach((t) => { t.removeAttribute('data-rv-zoom'); t.setAttribute('tabindex', '-1'); if (t.getAttribute('data-rv-src') === x.src) t.setAttribute('aria-current', 'true'); });
4960
+ copy.querySelectorAll('[data-rv-zoom-alias]').forEach((t) => { if (lbSet === lbAll && t.getAttribute('data-rv-zoom-alias') === String(lbAt)) t.setAttribute('aria-current', 'true'); });
4961
+ if (!lbVotes) copy.querySelectorAll('button').forEach((el) => { el.setAttribute('tabindex', '-1'); el.setAttribute('disabled', ''); });
4962
+ panel.appendChild(copy);
4963
+ });
4964
+ panel.hidden = !item;
4965
+ lb.toggleAttribute('data-rv-lb-has-review', !!item);
4966
+ }
4967
+ const peek = lb.querySelector('[data-rv-lb-peek]');
4968
+ if (peek) {
4969
+ peek.replaceChildren();
4970
+ const nx = lbSet[lbAt + 1];
4971
+ const url = nx ? nx.poster || (nx.kind === 'video' ? '' : nx.src) : '';
4972
+ if (url) { const img = document.createElement('img'); img.src = url; img.alt = ''; peek.appendChild(img); }
4973
+ peek.hidden = !nx;
4974
+ }
4975
+ const count = lb.querySelector('[data-rv-lb-count]'); if (count) count.textContent = n ? (lbAt + 1) + ' / ' + n : '';
4976
+ const pv = lb.querySelector('[data-rv-lb-prev]'); const nxb = lb.querySelector('[data-rv-lb-next]');
4977
+ [[pv, lbAt <= 0], [nxb, lbAt >= n - 1]].forEach(([b, atEnd]) => {
4978
+ if (!b) return;
4979
+ if (lbEnds === 'wrap') { b.hidden = n < 2; b.disabled = false; }
4980
+ else if (lbEnds === 'disable') { b.hidden = n < 2; b.disabled = atEnd; }
4981
+ else { b.hidden = atEnd; b.disabled = false; }
4982
+ });
4983
+ if (strip) Array.from(strip.children).forEach((t, k) => {
4984
+ if (k !== lbAt) { t.removeAttribute('aria-current'); return; }
4985
+ t.setAttribute('aria-current', 'true');
4986
+ if (strip.scrollTo) strip.scrollTo({ left: t.offsetLeft - (strip.clientWidth - t.offsetWidth) / 2, behavior: lb.open ? 'smooth' : 'auto' });
4987
+ });
4988
+ };
4989
+ const lbOpen = (e, set, at, opener) => {
4990
+ if (!lb || typeof lb.showModal !== 'function') return;
4991
+ e.preventDefault();
4992
+ closeMenus();
4993
+ lbFrom = opener; lbSet = set; buildStrip();
4994
+ lb.querySelectorAll('[data-rv-toggle]').forEach((t) => setToggle(toggleId(t), false));
4995
+ lbShow(at);
4996
+ if (!lb.open) lb.showModal();
4997
+ };
4998
+ zooms.forEach((z) => {
4999
+ const open = (e) => { const set = setFor(z); lbOpen(e, set, z.hasAttribute('data-rv-media-list') ? 0 : indexIn(set, z), z); };
5000
+ on(z, 'click', open);
5001
+ on(z, 'keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') open(e); });
5002
+ });
5003
+ all('[data-rv-zoom-alias]').forEach((z) => on(z, 'click', (e) => {
5004
+ if (!lb || !lbAll.length) return;
5005
+ e.stopPropagation();
5006
+ lbOpen(e, lbAll, Number(z.getAttribute('data-rv-zoom-alias')) || 0, z);
5007
+ }));
5008
+ if (lb) {
5009
+ on(lb.querySelector('[data-rv-lb-prev]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt - 1); });
5010
+ on(lb.querySelector('[data-rv-lb-next]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt + 1); });
5011
+ on(lb.querySelector('[data-rv-lb-close]'), 'click', (e) => { e.preventDefault(); lb.close(); });
5012
+ on(lb.querySelector('[data-rv-lb-peek]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt + 1); });
5013
+ on(strip, 'click', (e) => {
5014
+ const t = isEl(e.target) ? e.target.closest('[data-rv-lb-thumb]') : null;
5015
+ if (!t) return;
5016
+ e.preventDefault(); lbShow(Number(t.getAttribute('data-rv-lb-thumb')) || 0);
5017
+ });
5018
+ on(lb, 'keydown', (e) => { if (e.key === 'ArrowLeft') lbShow(lbAt - 1); if (e.key === 'ArrowRight') lbShow(lbAt + 1); });
5019
+ on(lb, 'click', (e) => { if (e.target === lb) lb.close(); });
5020
+ // Swipe on the photo (some widgets show no arrows on phones).
5021
+ const swipe = lb.querySelector('[data-rv-lb-stage]') || lb.querySelector('[data-rv-lb-media]');
5022
+ let sx = 0; let sy = 0; let tracking = false;
5023
+ on(swipe, 'touchstart', (e) => { const t = e.touches[0]; if (!t) return; sx = t.clientX; sy = t.clientY; tracking = true; }, { passive: true });
5024
+ on(swipe, 'touchend', (e) => {
5025
+ if (!tracking) return; tracking = false;
5026
+ const t = e.changedTouches[0]; if (!t) return;
5027
+ const dx = t.clientX - sx; const dy = t.clientY - sy;
5028
+ if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) lbShow(lbAt + (dx < 0 ? 1 : -1));
5029
+ });
5030
+ // Helpful from inside the lightbox: the copy's button presses the real one, then the copy redraws with the count.
5031
+ on(lb.querySelector('[data-rv-lb-review]'), 'click', (e) => {
5032
+ const v = lbVotes && isEl(e.target) ? e.target.closest('[data-rv-vote]') : null;
5033
+ if (!v) return;
5034
+ e.preventDefault();
5035
+ const x = lbSet[lbAt];
5036
+ const item = x && x.review ? items.find((it) => it.id === x.review) : null;
5037
+ const real = item && item.querySelector('[data-rv-vote="' + v.getAttribute('data-rv-vote') + '"]');
5038
+ if (real) real.click();
5039
+ lbShow(lbAt);
5040
+ });
5041
+ on(lb, 'close', () => { const vid = lb.querySelector('video'); if (vid) vid.pause(); if (lbFrom && lbFrom.focus) lbFrom.focus({ preventScroll: true }); });
5042
+ }
5043
+
5044
+ render();
5045
+ });
5046
+ api.emit('gem:reviews:ready', { source: 'gem-runtime', count: boundReviews });`;
5047
+ }
5048
+
4334
5049
  // ../../packages/gem-framework/src/runtime.ts
4335
5050
  var COUNTDOWN_END_RUNTIME_LINES = [
4336
5051
  " const COUNTDOWN_ISO = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})?)?$/i;",
@@ -4523,6 +5238,11 @@ var offerEnginePluginRegistrationLines = [
4523
5238
  " api.emit('gem:offer:ready', { source: 'gem-runtime' });",
4524
5239
  " });"
4525
5240
  ];
5241
+ var reviewsPluginRegistrationLines = [
5242
+ " registerPlugin('reviews', ({ api }) => {",
5243
+ " " + reviewsEngineRuntimeScript(),
5244
+ " });"
5245
+ ];
4526
5246
 
4527
5247
  // ../../packages/gem-framework/src/validation.ts
4528
5248
  var validationRulesByCode = new Map(gemFrameworkValidationRules.map((rule) => [rule.code, rule]));
@@ -4620,7 +5340,7 @@ function validateGemFrameworkCss(css, artifactPath, options = {}) {
4620
5340
  if (!TOKENIZED_PROPERTIES.includes(property)) continue;
4621
5341
  if (isAllowedUntokenizedValue(property, value)) continue;
4622
5342
  if (!value.includes("var(--gem-") && hasRawVisualValue(value)) {
4623
- issues.push(ruleIssue("untokenized_visual_value", `${property}: ${value.trim()} should prefer semantic --gem-* tokens.`, artifactPath, {
5343
+ issues.push(ruleIssue("untokenized_visual_value", `${property}: ${value.trim()} should prefer semantic --gem-* tokens.${scrimHint(value)}`, artifactPath, {
4624
5344
  property,
4625
5345
  value: value.trim()
4626
5346
  }));
@@ -4628,6 +5348,12 @@ function validateGemFrameworkCss(css, artifactPath, options = {}) {
4628
5348
  }
4629
5349
  return result(issues);
4630
5350
  }
5351
+ function scrimHint(value) {
5352
+ const m = value.match(/rgba?\(\s*0[\s,]+0[\s,]+0\s*(?:,|\/)\s*([0-9.]+)(%?)\s*\)/i);
5353
+ if (!m) return "";
5354
+ const pct = Math.round(m[2] ? Number(m[1]) : Number(m[1]) * 100);
5355
+ return ` A dark overlay reads the scrim token: color-mix(in srgb, var(--gem-color-scrim, #000) ${pct}%, transparent).`;
5356
+ }
4631
5357
  function validateGemFrameworkMarkup(markup, artifactPath, options = {}) {
4632
5358
  const issues = validateSharedSafety(markup, artifactPath);
4633
5359
  if (/\bgs-/.test(markup)) {