¡ª those keep /
pinned at
// `overflow:hidden`, so window.scrollY never changes and the icon would be
// stuck at its default color forever. `scrollEl` tracks whichever element
// actually receives scroll events; falls back to window when nothing else does.
var scrollEl = null;
function currentScrollTop() {
// Never trust scrollEl exclusively ¡ª if it was ever mis-detected (a
// transient element present only at page-load) its scrollTop stays stuck
// at 0 forever and would otherwise permanently override a perfectly good
// window.scrollY reading on themes where the window itself scrolls
// normally. Taking whichever is larger means a wrong guess can never
// suppress the real value, on any theme.
var winTop = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
var elTop = scrollEl ? scrollEl.scrollTop : 0;
return Math.max(winTop, elTop);
}
// Best-effort seed in case the page loads already scrolled inside a
// non-window container ¡ª the capture-phase listener below self-corrects
// for everything after the first real scroll, this just avoids a flash
// of the wrong color before that happens.
function detectScrollContainer() {
var all = document.body.querySelectorAll('*');
for (var i = 0; i < all.length; i++) {
var el = all[i];
var cs = getComputedStyle(el);
if (cs.overflowY !== 'auto' && cs.overflowY !== 'scroll') continue;
if (el.scrollHeight <= el.clientHeight + 50) continue;
if (el.clientHeight <= window.innerHeight * 0.5) continue;
// Reject hidden/off-canvas elements (e.g. a closed mobile nav submenu) ¡ª
// they can satisfy the size checks above while never actually being the
// thing the shopper scrolls, and unlike a mis-detected *visible* element
// they never self-correct via a real scroll event either.
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
var r = el.getBoundingClientRect();
if (r.right <= 0 || r.left >= window.innerWidth || r.bottom <= 0 || r.top >= window.innerHeight) continue;
return el;
}
return null;
}
function isHomePage() {
// Set server-side from Liquid's request.page_type ¡ª reliable regardless of
// locale-prefixed URLs (e.g. /en, /fr) where pathname/body-class checks fail.
if (root.dataset.isHomePage) return root.dataset.isHomePage === 'true';
if (window.Shopify && window.Shopify.template) return window.Shopify.template === 'index';
if (document.body.classList.contains('template-index')) return true;
var p = window.location.pathname;
return p === '/' || p === '';
}
function isMenuOpen() {
// Many themes force the header to a solid background while a nav
// dropdown/mega-menu is open (via CSS reacting to aria-expanded),
// independent of actual scroll position ¡ª without this check the icon
// is left in its transparent-header color, invisible against the
// now-solid backdrop.
return !!document.querySelector('header [aria-expanded="true"], nav [aria-expanded="true"], [role="navigation"] [aria-expanded="true"]');
}
// Composited bg behind el. null = no signal -> scrollY fallback. 'fading'
// = early fade (<50% opaque) before an out-of-flow ancestor/hop limit ->
// can't see the real backdrop, hold not-scrolled rather than guess.
// Confirmed live: treating ANY partial opacity as "no signal" let scrollY
// flip early over a still-visible photo (icon went grey right after
// scrolling). >=50% trusts the accumulated color already rather than
// waiting for exactly 100%, which held white through the fade's near-
// solid tail too -- a shorter version of the same "invisible" complaint.
function wlBgDone(a,r,g,b) { return a>=0.5 ? {r:r/a,g:g/a,b:b/a} : (a>0.02 ? 'fading' : null); }
function wlBg(el) {
try {
var r=0,g=0,b=0,a=0,node=el,hops=0;
while (node && node.nodeType===1 && hops<12) {
var cs=getComputedStyle(node);
if (cs.backgroundImage!=='none') return wlBgDone(a,r,g,b);
var m=/rgba?\(([^)]+)\)/.exec(cs.backgroundColor||'');
if (m) {
var p=m[1].split(',').map(parseFloat), ca=p.length>3?p[3]:1;
if (ca>0) {
r+=(1-a)*ca*p[0]; g+=(1-a)*ca*p[1]; b+=(1-a)*ca*p[2]; a+=(1-a)*ca;
if (a>0.999) return {r:r/a,g:g/a,b:b/a};
}
}
if (cs.position==='fixed' || cs.position==='sticky' || cs.position==='absolute') return wlBgDone(a,r,g,b);
node=node.parentElement; hops++;
}
return wlBgDone(a,r,g,b); // walk exhausted without reaching full opacity
} catch (e) { return null; }
}
function wlHex(v) {
v=(v||'').trim().replace('#','');
if (v.length===3) v=v.replace(/(.)/g,'$1$1');
return /^[0-9a-f]{6}$/i.test(v) ? {r:parseInt(v.slice(0,2),16),g:parseInt(v.slice(2,4),16),b:parseInt(v.slice(4,6),16)} : null;
}
function wlLum(c) { return 0.2126*c.r+0.7152*c.g+0.0722*c.b; }
// Throttled ~12/s (plenty for a 0.2s transition); true/false = better-contrast
// candidate, null = bg unreliable -> caller falls back to scrollY threshold.
var cdT=0, cdV=null;
function contrastScrolled() {
var t=Date.now();
if (t-cdT<80) return cdV;
cdT=t;
var bg=wlBg(root);
// A fade in progress is positive evidence the header hasn't reached its
// final solid state yet ¡ª hold the not-scrolled color rather than let
// the scrollY threshold guess early. Once the fade completes, wlBg()
// resolves normally and this re-evaluates against the real result.
if (bg==='fading') return cdV=false;
if (!bg) return cdV=null;
var rcs=getComputedStyle(document.documentElement);
var c1=wlHex(rcs.getPropertyValue('--wh-icon-color'));
var c2=wlHex(rcs.getPropertyValue('--wh-scrolled-icon-color'));
// Same icon color both states (merchant only customized scrolled text/badge,
// not the icon) -> no real signal either way; false here would permanently
// block .wh-scrolled via scroll, silently breaking those other colors too.
if (!c1 || !c2 || (c1.r===c2.r && c1.g===c2.g && c1.b===c2.b)) return cdV=null;
var lb=wlLum(bg);
return cdV = Math.abs(wlLum(c2)-lb) > Math.abs(wlLum(c1)-lb);
}
function updateScrollState() {
// Non-home pages: only pin to the static otherPagesColor choice when the
// merchant restricted scroll-based switching to the home page.
if (scrollHomePageOnly && !isHomePage()) {
root.classList.toggle('wh-scrolled', otherPagesColor === 'scrolled');
return;
}
// Otherwise (home page, or homePageOnly is off): switch based on real scroll position.
var cd = contrastScrolled();
var scrolled = (cd !== null ? cd : currentScrollTop() > scrollThreshold) || isMenuOpen();
root.classList.toggle('wh-scrolled', scrolled);
}
// Confirmed live: last scroll event may not land at rest position, and
// the 80ms throttle can serve a stale cd -> icon stuck grey after
// scrolling back to transparent. Force one fresh check once settled.
var scrollSettleTimer;
function handleScroll(e) {
// Capturing on document catches descendant scroll containers too. Must
// require genuine vertical overflow, not just a scroll event ¡ª a
// horizontal-only carousel fires 'scroll' too and would otherwise hijack
// scrollEl and permanently strand the icon in its not-scrolled color.
var target = e && e.target;
if (target && target !== document && target !== window &&
target !== document.documentElement && target !== document.body &&
typeof target.scrollTop === 'number' &&
target.scrollHeight > target.clientHeight + 50) {
scrollEl = target;
}
updateScrollState();
clearTimeout(scrollSettleTimer);
scrollSettleTimer = setTimeout(function () { cdT = 0; updateScrollState(); }, 150);
}
if (scrollColorsEnabled) {
scrollEl = detectScrollContainer();
window.addEventListener('scroll', handleScroll, { passive: true });
document.addEventListener('scroll', handleScroll, { passive: true, capture: true });
updateScrollState();
// Re-check when any nav item's expanded state changes (mega-menu open/close)
new MutationObserver(updateScrollState).observe(document.body, {
attributes: true,
attributeFilter: ['aria-expanded'],
subtree: true,
});
}
// ©¤©¤ INIT ©¤©¤
// Launch mode is confirmed by Liquid server-side (wh_launch_mode == 'Header'),
// so no API fetch or async mode check is needed here.
function init() {
root.style.display = '';
fetchCount();
// True once the icon has been relocated into its header slot.
function isPlaced() {
var slot = root.closest('[data-wl-slot]');
return !!(slot && document.contains(root));
}
// Safe to call repeatedly. Once already placed, relocateIcon() itself
// would no-op ¡ª but refineExistingPlacement() still runs on every
// scheduled retry, since a same-row neighbor's true glyph (e.g. a custom
// element's Shadow DOM content) may not have existed yet the first time
// we placed the icon. See refineExistingPlacement()'s own comment.
function place() {
if (!isPlaced()) {
relocateIcon();
} else {
// Still re-validate on every retry, not just once isPlaced() flips
// true ¡ª deriveContainer() can resolve to a different (more
// correct) container once the theme's layout has settled, and
// refineExistingPlacement() alone can't move the icon between
// containers, only adjust its margin within whichever one it's
// already in. See deriveContainer()'s own comment for why the
// first attempt can land in the wrong one on some themes.
var cartEl = findCartLink();
var derived = cartEl && deriveContainer(cartEl);
var currentSlot = root.closest('[data-wl-slot]');
if (derived && currentSlot && currentSlot.parentElement !== derived.container) {
relocateIcon();
} else {
refineExistingPlacement();
}
}
if (scrollColorsEnabled) updateScrollState();
}
// Fast attempts, plus later backstops. On slow devices/networks the theme
// header (e.g. Prestige's
custom element) may not be laid out
// until well after 2.5s. Without the later retries the icon is stranded at
// the bottom of and never appears in the header ¡ª which is why it
// showed up on some phones but not others.
function scheduleAttempts() {
place();
[400, 1000, 2500, 4000, 8000, 15000].forEach(function (ms) {
setTimeout(place, ms);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scheduleAttempts);
} else {
scheduleAttempts();
}
// Resilient watcher:
// ? re-inject if the theme removes our icon (SPA nav / header re-render), and
// ? keep hunting for the cart icon until we are actually placed ¡ª this is what
// makes the header icon appear reliably regardless of how slowly the device
// renders the header.
var debounce = null;
var stopHuntAt = Date.now() + 20000; // stop actively hunting after 20s
new MutationObserver(function () {
if (!document.contains(root)) {
// Icon was removed by a theme re-render ¡ª put it back.
clearTimeout(debounce);
debounce = setTimeout(place, 100);
} else if (!isPlaced() && Date.now() < stopHuntAt) {
// Still stranded (cart wasn't ready during the timed attempts) ¡ª retry as
// the header mutates its way into existence.
clearTimeout(debounce);
debounce = setTimeout(place, 120);
}
}).observe(document.body, { childList: true, subtree: true });
}
init();
})();