[object Object]

← back to Designer Wallcoverings

Stage infinite-scroll hardening patch (DTD verdict B: live already works, hold for post-mailer)

c1902e87af0c0686249bed54cf35c08e3686894e · 2026-06-25 11:01:04 -0700 · Steve (Claude)

Files touched

Diff

commit c1902e87af0c0686249bed54cf35c08e3686894e
Author: Steve (Claude) <steve@designerwallcoverings.com>
Date:   Thu Jun 25 11:01:04 2026 -0700

    Stage infinite-scroll hardening patch (DTD verdict B: live already works, hold for post-mailer)
---
 .../infinite-scroll-hardening-2026-06-25/README.md |  31 ++
 .../boost-sd-custom.LIVE-BACKUP.js                 | 317 ++++++++++++++++++++
 .../boost-sd-custom.PATCHED.js                     | 327 +++++++++++++++++++++
 3 files changed, 675 insertions(+)

diff --git a/pending-approval/infinite-scroll-hardening-2026-06-25/README.md b/pending-approval/infinite-scroll-hardening-2026-06-25/README.md
new file mode 100644
index 00000000..2bd2ff1e
--- /dev/null
+++ b/pending-approval/infinite-scroll-hardening-2026-06-25/README.md
@@ -0,0 +1,31 @@
+# Infinite-scroll hardening — STAGED (DTD verdict B: do NOT ship before the noon mailer)
+
+## Status: STAGED, not deployed. Live infinite scroll already works.
+
+### Finding (2026-06-25)
+Infinite scroll **functionally works on LIVE** (Boost SD collection grids):
+- Desktop: 24 → 312 on scroll. Mobile (iPhone, real touch-style scroll): 24 → 144.
+- Live shows no numbered pager; per-page "Display: 24" control hidden; infinite container active.
+- The PRIMARY mechanism `snippets/boost-infinite-override.liquid` is wired into live
+  theme.liquid and flips Boost to `paginationType: infinite_scroll` before init.
+
+The "stuck at 24" only reproduces with a synthetic instant `scrollTo(bottom)` teleport,
+which never crosses the IntersectionObserver sentinel — a **headless artifact**, not real
+user behavior. Real finger/momentum scroll crosses the sentinel and loads.
+
+### Why staged, not shipped (DTD committed verdict B, no dissent — 2026-06-25 ~11:25am)
+With a noon mailer minutes away, priority = zero regression. The hardened patch adds a
+near-bottom pump calling `sentinel.scrollIntoView({block:'center'})`, which can YANK the
+viewport on a page that already auto-loads. Not worth the jank risk on live traffic with
+no confirmed live defect. Deploy ONLY if a real on-device stall is reported.
+
+### What's here
+- `boost-sd-custom.PATCHED.js` — full hardened asset (current Boost selectors
+  `.boost-sd__pagination-infinite-scroll-container-button/-target`, re-arming
+  IntersectionObserver, near-bottom-only latch-guarded pump). Syntax-checked.
+- `boost-sd-custom.LIVE-BACKUP.js` — the live asset as of 2026-06-25, for diff/rollback.
+
+### To deploy IF a real live stall appears (role==main theme, Steve-gated)
+PUT `assets/boost-sd-custom.js` = boost-sd-custom.PATCHED.js to the role==main theme
+(resolve dynamically; SHOPIFY_THEME_TOKEN in secrets-manager/.env, API 2024-10).
+Back up the live value first. Re-test on mobile with CONTINUOUS scroll (not teleport).
diff --git a/pending-approval/infinite-scroll-hardening-2026-06-25/boost-sd-custom.LIVE-BACKUP.js b/pending-approval/infinite-scroll-hardening-2026-06-25/boost-sd-custom.LIVE-BACKUP.js
new file mode 100644
index 00000000..3602c078
--- /dev/null
+++ b/pending-approval/infinite-scroll-hardening-2026-06-25/boost-sd-custom.LIVE-BACKUP.js
@@ -0,0 +1,317 @@
+/*********************** Custom JS for Boost AI Search & Discovery  ************************/
+
+(function() {
+  'use strict';
+
+  // ── Vendor Handle Map (vendor name → collection handle) ────────────────
+  // Built from the brands page. Used to make vendor names clickable.
+  var VENDOR_HANDLES = {
+    '1838 Wallpapers': '1838-wallpapers',
+    'Andrew Martin': 'andrew-martin-wallpaper-collection',
+    'Anna French': 'anna-french',
+    'Anthology': 'anthology-wallcoverings',
+    'Armani/Casa': 'armani-casa-wallcoverings',
+    'Arte': 'arte',
+    'Borastapeter': 'borastapeter-scandinavian-wallpapers',
+    'Brewster': 'brewster-wallcoverings',
+    'Brunschwig & Fils': 'brunschwig-wallpapers',
+    'Carnegie': 'carnegie-wallcoverings',
+    'China Seas': 'china-seas-wallpaper',
+    'Christian Lacroix': 'christian-lacroix-wallcoverings',
+    'Clarke & Clarke': 'clarke-clarke',
+    'Cole & Son': 'cole-and-son',
+    'Colefax and Fowler': 'colefax-and-fowler',
+    'Cowtan & Tout': 'cowtan-tout-wallpaper-collection',
+    'de Gournay': 'de-gournay-wallcoverings',
+    'Designers Guild': 'designers-guild-wallcoverings',
+    'Donghia': 'donghia-wallcoverings',
+    'Elitis': 'elitis-wallcoverings',
+    'Fabricut': 'fabricut-wallcoverings',
+    'Farrow & Ball': 'farrow-ball-wallcoverings',
+    'Fornasetti': 'fornasetti',
+    'Franquemont London': 'franquemont-london',
+    'Fromental': 'fromental-wallcoverings',
+    'Gaston y Daniela': 'gaston-y-daniela',
+    'Graham & Brown': 'graham-and-brown-exclusive-wallpaper',
+    'Groundworks': 'groundworks-wallcoverings',
+    'Harlequin': 'harlequin-wallcoverings',
+    'Hygge & West': 'hygge-and-west',
+    'Innovations': 'innovations-usa',
+    'Koroseal': 'koroseal-wallpaper-collection',
+    'Kravet': 'kravet',
+    'Lee Jofa': 'lee-jofa',
+    'Maharam': 'maharam-wallcoverings',
+    'Matthew Williamson': 'matthew-williamson-wallcoverings',
+    'Maya Romanoff': 'maya-romanoff',
+    'MDC': 'mdc-wallcoverings',
+    'Milton & King': 'milton-and-king',
+    'Mind the Gap': 'mind-the-gap',
+    'Missoni Home': 'missoni-home',
+    'Mr Perswall': 'mr-perswall-wallcoverings',
+    'Mulberry': 'mulberry-wallcoverings',
+    'Nina Campbell': 'nina-campbell-wallcoverings',
+    'Nobilis': 'nobilis-wallcoverings',
+    'Osborne & Little': 'osborne-little-wallpapers',
+    'Phillip Jeffries': 'phillip-jeffries-wallcoverings',
+    'Pierre Frey': 'pierre-frey-wallcoverings',
+    'Philippe Romano Fabrics': 'philippe-romano-fabrics',
+    'Ralph Lauren': 'ralph-lauren-wallpaper',
+    'Rebel Walls': 'rebel-walls-murals',
+    'Roberto Cavalli': 'roberto-cavalli-wallpaper',
+    'Romo': 'romo-europe-wallpapaper-collections',
+    'Sanderson': 'sanderson-wallcoverings',
+    'Schumacher': 'schumacher-wallpaper',
+    'Stout Textiles': 'stout-wallcoverings',
+    'Stroheim': 'stroheim-wallcoverings',
+    'The Graduate Collection': 'the-graduate-collection',
+    'Thibaut': 'thibaut-wallcoverings',
+    'Threads': 'threads-wallcoverings',
+    'Versace': 'versace-wallpaper',
+    'Wolf Gordon': 'wolf-gordon-wallcoverings',
+    'York Wallcoverings': 'york-wallcoverings',
+    'Zoffany': 'zoffany-wallcoverings',
+    'Zuber': 'zuber-wallcoverings'
+  };
+
+  // Slugify vendor name as fallback if not in map
+  function vendorToHandle(vendor) {
+    if (VENDOR_HANDLES[vendor]) return VENDOR_HANDLES[vendor];
+    // Fallback: slugify
+    return vendor.toLowerCase()
+      .replace(/[&]/g, 'and')
+      .replace(/[^a-z0-9]+/g, '-')
+      .replace(/^-|-$/g, '');
+  }
+
+  // ── Enhance Product Cards ─────────────────────────────────────────────
+  function enhanceProductCards() {
+    // Find vendor elements: <p> elements with class containing "vendor"
+    // inside the Boost filter block
+    var container = document.querySelector('.boost-sd__filter-block');
+    if (!container) return;
+
+    // Strategy: find all <p> elements whose className contains "vendor"
+    var allP = container.querySelectorAll('p[class*="vendor"]');
+
+    for (var i = 0; i < allP.length; i++) {
+      var vendorEl = allP[i];
+
+      // Skip if already enhanced
+      if (vendorEl.getAttribute('data-dw-linked')) continue;
+      vendorEl.setAttribute('data-dw-linked', '1');
+
+      var vendorName = vendorEl.textContent.trim();
+      if (!vendorName) continue;
+
+      var handle = vendorToHandle(vendorName);
+      var link = document.createElement('a');
+      link.href = '/collections/' + handle;
+      link.textContent = vendorName;
+      link.className = 'dw-vendor-link';
+      link.title = 'Browse all ' + vendorName;
+      link.addEventListener('click', function(e) {
+        e.stopPropagation(); // Don't trigger product card click
+      });
+
+      vendorEl.textContent = '';
+      vendorEl.appendChild(link);
+    }
+
+    // Add zoom-on-hover to product image wrappers that don't have a second image
+    var imgWrappers = container.querySelectorAll('[class*="product-image-wrapper"]');
+    for (var j = 0; j < imgWrappers.length; j++) {
+      var wrapper = imgWrappers[j];
+      if (wrapper.getAttribute('data-dw-hover')) continue;
+      wrapper.setAttribute('data-dw-hover', '1');
+
+      // If no second image, add zoom class
+      var hasSecond = wrapper.className.indexOf('has-second-image') > -1;
+      if (!hasSecond) {
+        wrapper.classList.add('dw-hover-zoom');
+      }
+    }
+  }
+
+  // ── Run on Boost render + MutationObserver ─────────────────────────────
+  // Boost renders async via React, so we watch for DOM changes
+  var observer = new MutationObserver(function(mutations) {
+    var shouldEnhance = false;
+    for (var i = 0; i < mutations.length; i++) {
+      if (mutations[i].addedNodes.length > 0) {
+        shouldEnhance = true;
+        break;
+      }
+    }
+    if (shouldEnhance) {
+      // Debounce slightly
+      clearTimeout(observer._timer);
+      observer._timer = setTimeout(enhanceProductCards, 150);
+    }
+  });
+
+  function startObserving() {
+    var target = document.querySelector('.boost-sd__filter-block');
+    if (target) {
+      observer.observe(target, { childList: true, subtree: true });
+      // Also run once immediately in case cards already rendered
+      enhanceProductCards();
+    } else {
+      // Boost hasn't loaded yet, wait
+      setTimeout(startObserving, 500);
+    }
+  }
+
+  // Start when DOM is ready
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', startObserving);
+  } else {
+    startObserving();
+  }
+
+})();
+
+
+/* Infinite Scroll belt-and-suspenders for Boost AI Collection/Search grids.
+   The PRIMARY mechanism is snippets/boost-infinite-override.liquid, which forces
+   window.boostSDAppConfig.paginationType -> "infinite_scroll" before Boost inits,
+   so Boost itself appends on scroll / renders a Load-More button. This block is
+   the fallback: it auto-fires the Load-More / next-page control on scroll and
+   hides ONLY the numbered pager (never Boost's own Load-More button).
+   Hardened 2026-06-24: no permanent allLoaded latch; recovers between Boost
+   re-renders; distinguishes numbered pager from load-more. */
+(function() {
+  'use strict';
+  var loading = false;
+
+  // Boost's "load more" button (infinite/load-more mode) takes priority; fall
+  // back to a numbered "next" link only if no load-more control exists.
+  function getLoadMore() {
+    return document.querySelector('.boost-sd__load-more-button:not([disabled])') ||
+           document.querySelector('button[class*="load-more"]:not([disabled])') ||
+           document.querySelector('.boost-sd__pagination-next:not(.boost-sd__pagination--disabled)') ||
+           document.querySelector('[class*="pagination"] a[rel="next"]');
+  }
+
+  function loadMore() {
+    if (loading) return;
+    var btn = getLoadMore();
+    if (btn) {
+      loading = true;
+      try { btn.click(); } catch (e) {}
+      setTimeout(function() { loading = false; }, 1200);
+      return;
+    }
+    // No button (Boost's mobile/infinite mode uses an IntersectionObserver
+    // sentinel). A fling-to-bottom can overshoot the sentinel without it ever
+    // crossing the viewport, so nudge up-then-down to force it to re-cross.
+    loading = true;
+    var y = window.scrollY;
+    try {
+      window.scrollTo(0, Math.max(0, y - 400));
+      setTimeout(function () {
+        window.scrollTo(0, document.documentElement.scrollHeight);
+        setTimeout(function () { loading = false; }, 600);
+      }, 120);
+    } catch (e) { loading = false; }
+  }
+
+  function onScroll() {
+    var scrollBottom = window.innerHeight + window.scrollY;
+    var docHeight = document.documentElement.scrollHeight;
+    if (docHeight - scrollBottom < 900) loadMore();
+  }
+
+  function init() {
+    var check = setInterval(function() {
+      if (document.querySelector('.boost-sd__product-list') ||
+          document.querySelector('.boost-sd__filter-block')) {
+        clearInterval(check);
+        window.addEventListener('scroll', onScroll, { passive: true });
+        // Hide ONLY the numbered pager (pages list), not the load-more button.
+        var s = document.createElement('style');
+        s.textContent =
+          '.boost-sd__pagination-list,' +
+          '.boost-sd__pagination-page-list,' +
+          '.boost-sd__pagination .boost-sd__pagination-item{display:none!important;}';
+        document.head.appendChild(s);
+      }
+    }, 400);
+    setTimeout(function() { clearInterval(check); }, 15000);
+  }
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', init);
+  } else { init(); }
+})();
+
+
+/* ============================================================================
+   DW card hover label (2026-06-22) — Boost product grid cards.
+   Default: clean image-only grid (full SEO title hidden).
+   On hover: reveal "Pattern, Color" (full title minus "wallcovering(s) | vendor")
+   + the vendor on a smaller line. Full title stays in DOM for SEO.
+   Re-applies to infinite-scroll-appended / Boost-rerendered cards via observer.
+   ========================================================================== */
+(function () {
+  if (window.__dwCardHover) return;
+  window.__dwCardHover = true;
+
+  var STYLE_ID = 'dw-card-hover-style';
+  if (!document.getElementById(STYLE_ID)) {
+    var st = document.createElement('style');
+    st.id = STYLE_ID;
+    st.textContent =
+      '.boost-sd__product-item .boost-sd__product-title,' +
+      '.boost-sd__product-item .boost-sd__product-vendor{opacity:0;transition:opacity .15s;}' +
+      '.boost-sd__product-item:hover .dw-hover-label{opacity:1;}' +
+      '.dw-hover-label{opacity:0;transition:opacity .15s;padding:6px 2px 0;pointer-events:none;}' +
+      '.dw-hover-label .pat{font-size:13px;font-weight:600;color:#1a1a1a;line-height:1.2;}' +
+      '.dw-hover-label .ven{font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:#888;margin-top:2px;}';
+    document.head.appendChild(st);
+  }
+
+  function titleCase(s) {
+    return s.replace(/\w\S*/g, function (w) { return w.charAt(0).toUpperCase() + w.slice(1); });
+  }
+
+  function labelCard(card) {
+    if (card.querySelector('.dw-hover-label')) return;
+    var tEl = card.querySelector('.boost-sd__product-title');
+    var vEl = card.querySelector('.boost-sd__product-vendor');
+    if (!tEl) return;
+    var vendor = (vEl ? vEl.textContent : '').trim();
+    var name = tEl.textContent.split('|')[0].trim()        // drop "| vendor"
+      .replace(/\s*wallcoverings?\b/ig, '')                // drop "wallcovering(s)"
+      .replace(/[,\s]+$/, '').trim();
+    var lab = document.createElement('div');
+    lab.className = 'dw-hover-label';
+    lab.innerHTML = '<div class="pat">' + titleCase(name) + '</div>' +
+      (vendor ? '<div class="ven">' + titleCase(vendor) + '</div>' : '');
+    tEl.parentElement.appendChild(lab);
+  }
+
+  function run() {
+    var cards = document.querySelectorAll('.boost-sd__product-item');
+    for (var i = 0; i < cards.length; i++) labelCard(cards[i]);
+  }
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', run);
+  } else {
+    run();
+  }
+
+  // Re-apply when Boost renders a grid or infinite scroll appends more cards.
+  var t;
+  var obs = new MutationObserver(function (muts) {
+    for (var i = 0; i < muts.length; i++) {
+      if (muts[i].addedNodes && muts[i].addedNodes.length) {
+        clearTimeout(t);
+        t = setTimeout(run, 80);
+        return;
+      }
+    }
+  });
+  obs.observe(document.body, { childList: true, subtree: true });
+})();
+
diff --git a/pending-approval/infinite-scroll-hardening-2026-06-25/boost-sd-custom.PATCHED.js b/pending-approval/infinite-scroll-hardening-2026-06-25/boost-sd-custom.PATCHED.js
new file mode 100644
index 00000000..e5a38b90
--- /dev/null
+++ b/pending-approval/infinite-scroll-hardening-2026-06-25/boost-sd-custom.PATCHED.js
@@ -0,0 +1,327 @@
+/*********************** Custom JS for Boost AI Search & Discovery  ************************/
+
+(function() {
+  'use strict';
+
+  // ── Vendor Handle Map (vendor name → collection handle) ────────────────
+  // Built from the brands page. Used to make vendor names clickable.
+  var VENDOR_HANDLES = {
+    '1838 Wallpapers': '1838-wallpapers',
+    'Andrew Martin': 'andrew-martin-wallpaper-collection',
+    'Anna French': 'anna-french',
+    'Anthology': 'anthology-wallcoverings',
+    'Armani/Casa': 'armani-casa-wallcoverings',
+    'Arte': 'arte',
+    'Borastapeter': 'borastapeter-scandinavian-wallpapers',
+    'Brewster': 'brewster-wallcoverings',
+    'Brunschwig & Fils': 'brunschwig-wallpapers',
+    'Carnegie': 'carnegie-wallcoverings',
+    'China Seas': 'china-seas-wallpaper',
+    'Christian Lacroix': 'christian-lacroix-wallcoverings',
+    'Clarke & Clarke': 'clarke-clarke',
+    'Cole & Son': 'cole-and-son',
+    'Colefax and Fowler': 'colefax-and-fowler',
+    'Cowtan & Tout': 'cowtan-tout-wallpaper-collection',
+    'de Gournay': 'de-gournay-wallcoverings',
+    'Designers Guild': 'designers-guild-wallcoverings',
+    'Donghia': 'donghia-wallcoverings',
+    'Elitis': 'elitis-wallcoverings',
+    'Fabricut': 'fabricut-wallcoverings',
+    'Farrow & Ball': 'farrow-ball-wallcoverings',
+    'Fornasetti': 'fornasetti',
+    'Franquemont London': 'franquemont-london',
+    'Fromental': 'fromental-wallcoverings',
+    'Gaston y Daniela': 'gaston-y-daniela',
+    'Graham & Brown': 'graham-and-brown-exclusive-wallpaper',
+    'Groundworks': 'groundworks-wallcoverings',
+    'Harlequin': 'harlequin-wallcoverings',
+    'Hygge & West': 'hygge-and-west',
+    'Innovations': 'innovations-usa',
+    'Koroseal': 'koroseal-wallpaper-collection',
+    'Kravet': 'kravet',
+    'Lee Jofa': 'lee-jofa',
+    'Maharam': 'maharam-wallcoverings',
+    'Matthew Williamson': 'matthew-williamson-wallcoverings',
+    'Maya Romanoff': 'maya-romanoff',
+    'MDC': 'mdc-wallcoverings',
+    'Milton & King': 'milton-and-king',
+    'Mind the Gap': 'mind-the-gap',
+    'Missoni Home': 'missoni-home',
+    'Mr Perswall': 'mr-perswall-wallcoverings',
+    'Mulberry': 'mulberry-wallcoverings',
+    'Nina Campbell': 'nina-campbell-wallcoverings',
+    'Nobilis': 'nobilis-wallcoverings',
+    'Osborne & Little': 'osborne-little-wallpapers',
+    'Phillip Jeffries': 'phillip-jeffries-wallcoverings',
+    'Pierre Frey': 'pierre-frey-wallcoverings',
+    'Philippe Romano Fabrics': 'philippe-romano-fabrics',
+    'Ralph Lauren': 'ralph-lauren-wallpaper',
+    'Rebel Walls': 'rebel-walls-murals',
+    'Roberto Cavalli': 'roberto-cavalli-wallpaper',
+    'Romo': 'romo-europe-wallpapaper-collections',
+    'Sanderson': 'sanderson-wallcoverings',
+    'Schumacher': 'schumacher-wallpaper',
+    'Stout Textiles': 'stout-wallcoverings',
+    'Stroheim': 'stroheim-wallcoverings',
+    'The Graduate Collection': 'the-graduate-collection',
+    'Thibaut': 'thibaut-wallcoverings',
+    'Threads': 'threads-wallcoverings',
+    'Versace': 'versace-wallpaper',
+    'Wolf Gordon': 'wolf-gordon-wallcoverings',
+    'York Wallcoverings': 'york-wallcoverings',
+    'Zoffany': 'zoffany-wallcoverings',
+    'Zuber': 'zuber-wallcoverings'
+  };
+
+  // Slugify vendor name as fallback if not in map
+  function vendorToHandle(vendor) {
+    if (VENDOR_HANDLES[vendor]) return VENDOR_HANDLES[vendor];
+    // Fallback: slugify
+    return vendor.toLowerCase()
+      .replace(/[&]/g, 'and')
+      .replace(/[^a-z0-9]+/g, '-')
+      .replace(/^-|-$/g, '');
+  }
+
+  // ── Enhance Product Cards ─────────────────────────────────────────────
+  function enhanceProductCards() {
+    // Find vendor elements: <p> elements with class containing "vendor"
+    // inside the Boost filter block
+    var container = document.querySelector('.boost-sd__filter-block');
+    if (!container) return;
+
+    // Strategy: find all <p> elements whose className contains "vendor"
+    var allP = container.querySelectorAll('p[class*="vendor"]');
+
+    for (var i = 0; i < allP.length; i++) {
+      var vendorEl = allP[i];
+
+      // Skip if already enhanced
+      if (vendorEl.getAttribute('data-dw-linked')) continue;
+      vendorEl.setAttribute('data-dw-linked', '1');
+
+      var vendorName = vendorEl.textContent.trim();
+      if (!vendorName) continue;
+
+      var handle = vendorToHandle(vendorName);
+      var link = document.createElement('a');
+      link.href = '/collections/' + handle;
+      link.textContent = vendorName;
+      link.className = 'dw-vendor-link';
+      link.title = 'Browse all ' + vendorName;
+      link.addEventListener('click', function(e) {
+        e.stopPropagation(); // Don't trigger product card click
+      });
+
+      vendorEl.textContent = '';
+      vendorEl.appendChild(link);
+    }
+
+    // Add zoom-on-hover to product image wrappers that don't have a second image
+    var imgWrappers = container.querySelectorAll('[class*="product-image-wrapper"]');
+    for (var j = 0; j < imgWrappers.length; j++) {
+      var wrapper = imgWrappers[j];
+      if (wrapper.getAttribute('data-dw-hover')) continue;
+      wrapper.setAttribute('data-dw-hover', '1');
+
+      // If no second image, add zoom class
+      var hasSecond = wrapper.className.indexOf('has-second-image') > -1;
+      if (!hasSecond) {
+        wrapper.classList.add('dw-hover-zoom');
+      }
+    }
+  }
+
+  // ── Run on Boost render + MutationObserver ─────────────────────────────
+  // Boost renders async via React, so we watch for DOM changes
+  var observer = new MutationObserver(function(mutations) {
+    var shouldEnhance = false;
+    for (var i = 0; i < mutations.length; i++) {
+      if (mutations[i].addedNodes.length > 0) {
+        shouldEnhance = true;
+        break;
+      }
+    }
+    if (shouldEnhance) {
+      // Debounce slightly
+      clearTimeout(observer._timer);
+      observer._timer = setTimeout(enhanceProductCards, 150);
+    }
+  });
+
+  function startObserving() {
+    var target = document.querySelector('.boost-sd__filter-block');
+    if (target) {
+      observer.observe(target, { childList: true, subtree: true });
+      // Also run once immediately in case cards already rendered
+      enhanceProductCards();
+    } else {
+      // Boost hasn't loaded yet, wait
+      setTimeout(startObserving, 500);
+    }
+  }
+
+  // Start when DOM is ready
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', startObserving);
+  } else {
+    startObserving();
+  }
+
+})();
+
+
+/* Infinite Scroll belt-and-suspenders for Boost AI Collection/Search grids.
+   PRIMARY = snippets/boost-infinite-override.liquid (forces paginationType=
+   infinite_scroll). This fallback GUARANTEES the next page loads even when
+   Boost's native IntersectionObserver sentinel stalls — notably on MOBILE,
+   where a fling-to-bottom OVERSHOOTS the sentinel (it ends up just above the
+   fold) so Boost never fires and the grid froze after one page.
+   Hardened 2026-06-25: the reliable trigger is the sentinel
+   (.boost-sd__pagination-infinite-scroll-container-target) ENTERING the
+   viewport — Boost's empty container "button" is structural and clicking it
+   does nothing — so we bring the sentinel into view when the shopper is near
+   the bottom. A latch-guarded, count-aware pump stops at the true end of list
+   (and resumes the instant growth resumes) so it can neither strand mid-list
+   nor jitter past the end. */
+(function () {
+  'use strict';
+  var loading = false, lastCount = -1, idle = 0, MAXIDLE = 8;
+
+  function count() { return document.querySelectorAll('.boost-sd__product-item').length; }
+  function sentinel() { return document.querySelector('.boost-sd__pagination-infinite-scroll-container-target'); }
+  function getNextButton() {
+    return document.querySelector('.boost-sd__load-more-button:not([disabled])') ||
+           document.querySelector('button[class*="load-more"]:not([disabled])') ||
+           document.querySelector('.boost-sd__pagination-button--next:not([disabled]):not(.boost-sd__pagination-button--disabled)') ||
+           document.querySelector('[class*="pagination"] a[rel="next"]');
+  }
+
+  // Trigger Boost's own loader. Primary = bring the sentinel into the viewport
+  // (handles the mobile fling overshoot). Fallback = click a real next button.
+  function pull() {
+    if (loading) return;
+    var s = sentinel();
+    if (s) {
+      loading = true;
+      try { s.scrollIntoView({ block: 'center' }); } catch (e) {}
+      setTimeout(function () { loading = false; }, 700);
+      return;
+    }
+    var b = getNextButton();
+    if (b) {
+      loading = true;
+      try { b.click(); } catch (e) {}
+      setTimeout(function () { loading = false; }, 700);
+    }
+  }
+
+  function nearBottom() {
+    return (document.documentElement.scrollHeight - (window.innerHeight + window.scrollY)) < 1500;
+  }
+  function onScroll() { if (nearBottom()) pull(); }
+
+  function init() {
+    var check = setInterval(function () {
+      if (document.querySelector('.boost-sd__product-list') ||
+          document.querySelector('.boost-sd__filter-block')) {
+        clearInterval(check);
+        window.addEventListener('scroll', onScroll, { passive: true });
+        // Hide ONLY the numbered pager, never Boost's own controls.
+        var st = document.createElement('style');
+        st.textContent =
+          '.boost-sd__pagination-list,' +
+          '.boost-sd__pagination-page-list,' +
+          '.boost-sd__pagination .boost-sd__pagination-item{display:none!important;}';
+        document.head.appendChild(st);
+        // Pump: only act when the shopper is near the bottom. Track product
+        // count; after MAXIDLE consecutive near-bottom ticks with no growth,
+        // assume end-of-list and stop pulling (resets the moment growth
+        // resumes), so it neither strands mid-list nor jitters at the end.
+        setInterval(function () {
+          if (!nearBottom()) { idle = 0; return; }
+          var c = count();
+          if (c !== lastCount) { lastCount = c; idle = 0; } else { idle++; }
+          if (idle <= MAXIDLE) pull();
+        }, 800);
+      }
+    }, 400);
+    setTimeout(function () { clearInterval(check); }, 15000);
+  }
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', init);
+  } else { init(); }
+})();
+
+
+/* ============================================================================
+   DW card hover label (2026-06-22) — Boost product grid cards.
+   Default: clean image-only grid (full SEO title hidden).
+   On hover: reveal "Pattern, Color" (full title minus "wallcovering(s) | vendor")
+   + the vendor on a smaller line. Full title stays in DOM for SEO.
+   Re-applies to infinite-scroll-appended / Boost-rerendered cards via observer.
+   ========================================================================== */
+(function () {
+  if (window.__dwCardHover) return;
+  window.__dwCardHover = true;
+
+  var STYLE_ID = 'dw-card-hover-style';
+  if (!document.getElementById(STYLE_ID)) {
+    var st = document.createElement('style');
+    st.id = STYLE_ID;
+    st.textContent =
+      '.boost-sd__product-item .boost-sd__product-title,' +
+      '.boost-sd__product-item .boost-sd__product-vendor{opacity:0;transition:opacity .15s;}' +
+      '.boost-sd__product-item:hover .dw-hover-label{opacity:1;}' +
+      '.dw-hover-label{opacity:0;transition:opacity .15s;padding:6px 2px 0;pointer-events:none;}' +
+      '.dw-hover-label .pat{font-size:13px;font-weight:600;color:#1a1a1a;line-height:1.2;}' +
+      '.dw-hover-label .ven{font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:#888;margin-top:2px;}';
+    document.head.appendChild(st);
+  }
+
+  function titleCase(s) {
+    return s.replace(/\w\S*/g, function (w) { return w.charAt(0).toUpperCase() + w.slice(1); });
+  }
+
+  function labelCard(card) {
+    if (card.querySelector('.dw-hover-label')) return;
+    var tEl = card.querySelector('.boost-sd__product-title');
+    var vEl = card.querySelector('.boost-sd__product-vendor');
+    if (!tEl) return;
+    var vendor = (vEl ? vEl.textContent : '').trim();
+    var name = tEl.textContent.split('|')[0].trim()        // drop "| vendor"
+      .replace(/\s*wallcoverings?\b/ig, '')                // drop "wallcovering(s)"
+      .replace(/[,\s]+$/, '').trim();
+    var lab = document.createElement('div');
+    lab.className = 'dw-hover-label';
+    lab.innerHTML = '<div class="pat">' + titleCase(name) + '</div>' +
+      (vendor ? '<div class="ven">' + titleCase(vendor) + '</div>' : '');
+    tEl.parentElement.appendChild(lab);
+  }
+
+  function run() {
+    var cards = document.querySelectorAll('.boost-sd__product-item');
+    for (var i = 0; i < cards.length; i++) labelCard(cards[i]);
+  }
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', run);
+  } else {
+    run();
+  }
+
+  // Re-apply when Boost renders a grid or infinite scroll appends more cards.
+  var t;
+  var obs = new MutationObserver(function (muts) {
+    for (var i = 0; i < muts.length; i++) {
+      if (muts[i].addedNodes && muts[i].addedNodes.length) {
+        clearTimeout(t);
+        t = setTimeout(run, 80);
+        return;
+      }
+    }
+  });
+  obs.observe(document.body, { childList: true, subtree: true });
+})();
+

← e4435faa cadence: add Anna French config with per-row product type (A  ·  back to Designer Wallcoverings  ·  auto-save: 2026-06-25T11:03:45 (4 files) — pending-approval/ 3ba83190 →