1/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2 hero-device.js â VSHR homepage hero device cycler (v4, 2026-05-31). 3 Ported from hero-reframe-all-demo-v3.html. Replaces the legacy 4 §3 tab-cycle in home-page.js AND the inline choreographer in 5 template-home.blade.php (both removed / guarded). 6 7 - Scoped to #vshHero (no global selectors â other .pane/.tab on the page 8 are never touched). 9 - Sequential zero-overlap beat crossfade (CSS drives the motion). 10 - Drives the hero H1 word (#heroCycleWrap .hero-cycle-word) in sync. 11 - Container-based zoom-fit (the device lives in a ~560â740px column, NOT 12 the full window) â fits width AND height of its slot. 13 - a11y: prefers-reduced-motion starts static (no auto-advance); pause on 14 hover/focus; â/â keyboard on tabs; pause when tab backgrounded. 15 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 16(function () { 17 'use strict'; 18 var root = document.getElementById('vshHero'); 19 if (!root) return; 20 21 /* ââ Live device clock â reflects the VISITOR'S real local time & date, 22 like a real phone. Status bar clock, lockscreen clock, lockscreen 23 date, and the Same-Day-Pay deposit date all stay current (deposit = 24 today, because Same Day Pay means paid today). Ticks every 20s so the 25 minute rolls over. Pure text updates â unaffected by reduced-motion. */ 26 (function liveClock() { 27 var sbTime = root.querySelector('.vsh-sb-time'); 28 var lockHour = root.querySelector('.vsh-lock-hour'); 29 var lockDay = root.querySelector('.vsh-lock-day'); 30 var payDate = root.querySelector('.vsh-pay-date'); 31 var finMonth = root.querySelector('.vsh-fin-month'); 32 if (!sbTime && !lockHour && !lockDay && !payDate && !finMonth) return; 33 var DAY_L = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; 34 var DAY_S = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; 35 var MON_L = ['January','February','March','April','May','June','July','August','September','October','November','December']; 36 var MON_S = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; 37 function fmtTime(d) { 38 var h = d.getHours() % 12; if (h === 0) h = 12; 39 var m = d.getMinutes(); if (m < 10) m = '0' + m; 40 return h + ':' + m; 41 } 42 function tick() { 43 var d = new Date(), t = fmtTime(d); 44 if (sbTime) sbTime.textContent = t; 45 if (lockHour) lockHour.textContent = t; 46 if (lockDay) lockDay.textContent = DAY_L[d.getDay()] + ', ' + MON_L[d.getMonth()] + ' ' + d.getDate(); 47 if (payDate) payDate.textContent = DAY_S[d.getDay()] + ' ' + MON_S[d.getMonth()] + ' ' + d.getDate(); 48 /* Finances pane: "<last completed month> books closed" â you close the 49 prior month's books, so this tracks current month minus one. */ 50 if (finMonth) finMonth.textContent = MON_L[(d.getMonth() + 11) % 12]; 51 } 52 tick(); 53 setInterval(tick, 20000); 54 })(); 55 56 var PANE_BEATS = [2, 2, 2, 1, 3, 2]; 57 // Per-STEP dwell (ms). NOTE: each beat spends ~1.5-2s of this revealing its 58 // content (staggered cells, checklist/sparkline draws, payslip items), so the 59 // ACTUAL read time = dwell â reveal. Raised 2026-06-08 for human reading speed 60 // (some beats were ~3s of read time, too fast); dense beats get the most. 61 // Manual prev/next + clickable dots + pause-on-hover let readers self-pace. 62 // Order matches `steps` below: 63 // P0b0(run), P0b1(Same Day Pay), B1b0, B1b1(wallet), HR2b0, HR2b1(checklist), 64 // Time3b0, WC4b0(claim), WC4b1(sparkline), WC4b2(training), Fin5b0, Fin5b1(dupe). 65 var DWELL = [6500, 7500, 6800, 8200, 6800, 8500, 7000, 7200, 8000, 8500, 6800, 8200]; 66 // Hero H1 word per service pane (kept in sync with the device). 67 var WORDS = ['payroll.', 'benefits.', 'people.', 'time.', "workers' comp.", 'finances.']; 68 69 var panes = [].slice.call(root.querySelectorAll('.vsh-pane')); 70 var beatsOf = panes.map(function (p) { return [].slice.call(p.querySelectorAll('.vsh-beat')); }); 71 var tabs = [].slice.call(root.querySelectorAll('.vsh-tab')); 72 var dotsEl = root.querySelector('#vshDots'); 73 var live = root.querySelector('#vshLive'); 74 var device = root.querySelector('.vsh-device'); 75 var ipad = root.querySelector('.vsh-ipad'); 76 var scaler = root.querySelector('.vsh-scaler'); 77 if (!panes.length || !device || !ipad || !scaler) return; 78 79 var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; 80 81 var steps = []; 82 var stepBase = []; // first step index of each pane (for dot/tab -> step mapping) 83 for (var p = 0; p < PANE_BEATS.length; p++) { 84 stepBase[p] = steps.length; 85 for (var b = 0; b < PANE_BEATS[p]; b++) steps.push([p, b]); 86 } 87 var idx = 0, timer = null, paused = false, lastPane = -1; 88 89 /* ââ Hero H1 word (lives OUTSIDE #vshHero, in .hero-left) âââââââââââââââââ */ 90 var cycleWrap = document.getElementById('heroCycleWrap'); 91 var cycleWord = cycleWrap ? cycleWrap.querySelector('.hero-cycle-word') : null; 92 // Lock the wrap to the widest word so cycling never reflows. 93 // 94 // Bug fixed 2026-06-08 (left-side hero "shake" on every word swap): the wrap 95 // is box-sizing:border-box with padding-right:2px, and the old lock set 96 // min-width = the widest WORD's offsetWidth (integer-rounded). The widest word 97 // ("workers' comp." â 604.44px) then exceeded the border-box content area 98 // (min-width 604 â 2px padding = 602px) and grew the inline-block to ~606.4px, 99 // while every narrower word sat at 604px â a ~2.4px width wobble each cycle. 100 // Fix: measure sub-pixel (getBoundingClientRect), add the wrap's own padding, 101 // round up + 1px buffer, and set a FIXED width so the box can't shrink-wrap or 102 // grow. Words are left-anchored, so a late re-lock never shifts visible text. 103 function lockCycleWrapSize() { 104 if (!cycleWrap || !cycleWord) return; 105 var probe = document.createElement('span'); 106 probe.style.cssText = 'visibility:hidden;position:absolute;white-space:nowrap;font:inherit;'; 107 cycleWrap.appendChild(probe); 108 var maxW = 0; 109 WORDS.forEach(function (w) { probe.textContent = w; var x = probe.getBoundingClientRect().width; if (x > maxW) maxW = x; }); 110 cycleWrap.removeChild(probe); 111 if (!maxW) return; 112 var cs = getComputedStyle(cycleWrap); 113 var padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
114 var lockW = Math.ceil(maxW + padX) + 1; 115 cycleWrap.style.width = lockW + 'px'; 116 cycleWrap.style.minWidth = lockW + 'px'; 117 } 118 function swapWord(newText) { 119 if (!cycleWord || cycleWord.textContent.trim() === newText) return; 120 if (reduced) { cycleWord.textContent = newText; return; } 121 var done = false; 122 function finishExit() { 123 if (done) return; done = true; 124 cycleWord.removeEventListener('animationend', finishExit); 125 cycleWord.textContent = newText; 126 cycleWord.classList.remove('cycle-exit'); 127 cycleWord.classList.add('cycle-enter'); 128 var ed = false; 129 function finishEnter() { if (ed) return; ed = true; cycleWord.removeEventListener('animationend', finishEnter); cycleWord.classList.remove('cycle-enter'); } 130 cycleWord.addEventListener('animationend', finishEnter); 131 setTimeout(finishEnter, 600); 132 } 133 cycleWord.classList.add('cycle-exit'); 134 cycleWord.addEventListener('animationend', finishExit); 135 setTimeout(finishExit, 500); 136 } 137 138 /* ââ Count-up on numeric stat values âââââââââââââââââââââââââââââââââââââ */ 139 function countUp(beat) { 140 if (reduced) return; 141 [].slice.call(beat.querySelectorAll('.vsh-val,.vsh-amount')).forEach(function (el) { 142 var raw = el.getAttribute('data-raw') || el.textContent; el.setAttribute('data-raw', raw); 143 var m = raw.match(/^(\$?)(\d[\d,]*)(.*)$/); if (!m) return; 144 var pre = m[1], target = parseInt(m[2].replace(/,/g, ''), 10), suf = m[3]; 145 if (isNaN(target) || target < 10) return; 146 // The Finances ".vsh-amount" ($1,247) sits in a flex row beside text, so 147 // as the count-up adds digits ($0 -> $1,247) its width grows and shoves 148 // that text ("Duplicate vendor charge flagged / Westfield Supply..."). At 149 // this point textContent is still the final value (static markup / prior 150 // raw), so reserve that width up-front and right-align the digits â the 151 // neighbouring text then never reflows. (.vsh-val sits centred in equal 152 // flex cells, so it can't shift its neighbours and is left alone.) 153 if (el.classList.contains('vsh-amount')) { 154 el.style.minWidth = Math.ceil(el.getBoundingClientRect().width) + 'px'; 155 el.style.textAlign = 'right'; 156 } 157 var start = performance.now(), dur = 820; 158 (function tick(now) { 159 var t = Math.min(1, (now - start) / dur); var e = t >= 1 ? 1 : 1 - Math.pow(2, -10 * t); 160 el.textContent = pre + Math.round(target * e).toLocaleString() + suf; 161 if (t < 1) requestAnimationFrame(tick); else el.textContent = raw; 162 })(performance.now()); 163 }); 164 } 165 166 function renderDots(pi, bi) { 167 if (!dotsEl) return; 168 var n = PANE_BEATS[pi]; dotsEl.innerHTML = ''; 169 if (n < 2) return; /* empty slot keeps the CTA pinned right via space-between */ 170 for (var k = 0; k < n; k++) { 171 var i = document.createElement('i'); 172 if (k === bi) i.className = 'vsh-on'; 173 // Plain <i> (NOT role=button) â role=button matched the theme's global 174 // touch-target rule (min-width/min-height: var(--touch-min)=48px) and blew 175 // the 6px dots up to 48px. Mouse-click still works; keyboard users have the 176 // real <button> prev/next controls. 177 (function (target) { i.addEventListener('click', function () { goToStep(target); }); })(stepBase[pi] + k); 178 dotsEl.appendChild(i); 179 } 180 } 181 182 function render() { 183 var s = steps[idx], pi = s[0], bi = s[1]; 184 panes.forEach(function (pn, j) { var on = (j === pi); pn.classList.toggle('vsh-active', on); pn.setAttribute('aria-hidden', on ? 'false' : 'true'); });
185 tabs.forEach(function (t, j) { var on = (j === pi); t.classList.toggle('vsh-active', on); t.setAttribute('aria-selected', on ? 'true' : 'false'); t.setAttribute('tabindex', on ? '0' : '-1'); }); 186 /* clear .vsh-show from EVERY beat (panes aren't display:none, so a stale 187 .vsh-show on another pane would stack/ghost), then show the one. */ 188 beatsOf.forEach(function (arr) { arr.forEach(function (bt) { bt.classList.remove('vsh-show'); }); }); 189 var activeBeat = beatsOf[pi][bi]; 190 activeBeat.classList.add('vsh-show'); 191 // dark-mode the CTA bar while the full-bleed lockscreen beat is showing 192 root.classList.toggle('vsh-lockon', activeBeat.classList.contains('vsh-lockbeat')); 193 renderDots(pi, bi); 194 if (pi !== lastPane) { swapWord(WORDS[pi] || ''); if (live) live.textContent = (WORDS[pi] || '').replace('.', '') + ' overview'; lastPane = pi; } 195 countUp(beatsOf[pi][bi]); 196 } 197 198 function arm() { stop(); if (!paused && !reduced) timer = setTimeout(advance, DWELL[idx] || 5500); } 199 function advance() { idx = (idx + 1) % steps.length; render(); arm(); } 200 function stop() { if (timer) { clearTimeout(timer); timer = null; } } 201 202 // Manual navigation: jump to any step and restart the dwell timer so the 203 // reader gets a full read at their chosen beat (wraps around both ends). 204 function goToStep(n) { 205 idx = ((n % steps.length) + steps.length) % steps.length; 206 render(); arm(); 207 } 208 function goToPane(pi) { goToStep(stepBase[pi]); } 209
210 tabs.forEach(function (t, ti) { 211 t.addEventListener('click', function () { goToPane(ti); }); 212 t.addEventListener('keydown', function (e) { 213 if (e.key === 'ArrowRight') { e.preventDefault(); var n = tabs[(ti + 1) % tabs.length]; n.focus(); goToPane((ti + 1) % tabs.length); } 214 else if (e.key === 'ArrowLeft') { e.preventDefault(); var pv = (ti - 1 + tabs.length) % tabs.length; tabs[pv].focus(); goToPane(pv); } 215 }); 216 }); 217 218 // Prev / Next manual pacing controls (bottom of the screen). 219 var prevBtn = root.querySelector('#vshPrev'); 220 var nextBtn = root.querySelector('#vshNext'); 221 if (prevBtn) prevBtn.addEventListener('click', function () { goToStep(idx - 1); }); 222 if (nextBtn) nextBtn.addEventListener('click', function () { goToStep(idx + 1); }); 223 224 // Pause on hover/focus (WCAG 2.2.2 â pausable auto-updating content) 225 device.addEventListener('mouseenter', function () { paused = true; stop(); }); 226 device.addEventListener('mouseleave', function () { paused = false; arm(); }); 227 device.addEventListener('focusin', function () { paused = true; stop(); }); 228 device.addEventListener('focusout', function () { paused = false; arm(); }); 229 document.addEventListener('visibilitychange', function () { if (document.hidden) stop(); else arm(); }); 230 231 // Mouse 3D tilt (hover-capable, motion-allowed only) 232 if (window.matchMedia && window.matchMedia('(hover:hover)').matches && !reduced) { 233 var ticking = false, rx = 0, ry = 0; 234 device.addEventListener('mousemove', function (e) { 235 var r = ipad.getBoundingClientRect(); 236 var px = (e.clientX - r.left) / r.width - .5, py = (e.clientY - r.top) / r.height - .5; 237 ry = px * 7; rx = -py * 5; 238 if (!ticking) { 239 ticking = true; 240 requestAnimationFrame(function () { ipad.classList.add('vsh-tilting'); ipad.style.transform = 'rotateX(' + rx.toFixed(2) + 'deg) rotateY(' + ry.toFixed(2) + 'deg)'; ticking = false; }); 241 } 242 }); 243 device.addEventListener('mouseleave', function () { ipad.classList.remove('vsh-tilting'); ipad.style.transform = ''; }); 244 } 245 246 /* ââ Container-based zoom-fit ââââââââââââââââââââââââââââââââââââââââââââââ 247 The device lives in .hero-scene (~740Ã680 desktop, full-width on mobile), 248 NOT the full window. Fit to the host's width AND height. */ 249 // The device's natural layout size (400 à ~555) is constant, so measure it ONCE 250 // and cache it. This avoids toggling zoomâ1 on every fit() call (which churned 251 // the ResizeObserver) and makes the scaler loop-safe: fit() now only reads the 252 // column width + viewport, both independent of the zoom it sets. 253 var natW = 0, natH = 0; 254 function fit() { 255 var availW = root.clientWidth; // device column width (independent of zoom) 256 if (!availW) return; 257 if (!natW) { 258 scaler.style.zoom = '1'; 259 natW = scaler.offsetWidth; natH = scaler.offsetHeight; 260 if (!natW || !natH) { natW = 0; return; } 261 } 262 var hdr = document.querySelector('header'); 263 var headerH = (hdr ? hdr.offsetHeight : 0) || 76; 264 // UNIVERSAL SCALER. Two ceilings, take the smaller: 265 // ⢠height â ~86% of the VISIBLE fold (viewport minus the sticky header). 266 // Driving off the viewport (NOT the hero's content height) is the whole 267 // fix: the device lands at the SAME proportion of the screen everywhere â 268 // it can't be shrunk by a short copy column ("too small on 15in") nor 269 // balloon past the fold on a short laptop ("too big on 13in"). 270 // ⢠width â 98% of its column, so it never overflows into the copy. 271 // Max zoom capped at 1.25 (2026-06-04 · Ryan): on big/tall monitors the 272 // fold-height ceiling drove the device to ~1.5â2.2à ("significantly larger 273 // than the text"). 1.25 keeps it a substantial but balanced size near the 274 // copy block; laptops never reach the cap, so their tuning is untouched. 275 var availH = Math.max(320, window.innerHeight - headerH - 28); 276 var f = Math.min((availW * 0.98) / natW, (availH * 0.92) / natH); 277 f = Math.max(0.62, Math.min(1.25, f)); 278 scaler.style.zoom = String(f); 279 } 280 if (window.ResizeObserver) { new ResizeObserver(fit).observe(root); } 281 else { window.addEventListener('resize', fit); } 282 283 // First paint: show Payroll (already server-rendered with .vsh-show), then run. 284 render(); 285 fit(); 286 // Reveal AFTER the device is measured & zoom-fitted, so the entrance plays at 287 // the final size â kills the "load small â pop big" growth. (No-JS stays visible 288 // via the <noscript> reveal in the partial; reduced-motion shows instantly.) 289 requestAnimationFrame(function () { root.classList.add('vsh-ready'); }); 290 if (document.fonts && document.fonts.ready) { 291 document.fonts.ready.then(function () { lockCycleWrapSize(); fit(); }); 292 } 293 // Re-lock only when the word's OWN intrinsic size changes: the webfont 294 // swapping in (can land after fonts.ready resolves) or the responsive h1 295 // font-size changing on viewport resize. The lock sets a FIXED width and 296 // never clears it, so this is idempotent â it can only correct the width to 297 // the right (into transparent padding, behind the left-anchored word), never 298 // collapse the box mid-cycle. (Clearing + timed re-locks were what made the 299 // box snap on the Payroll beat â removed.) Observes the word, not the wrap, 300 // so re-locking the wrap can't feed back into the observer. 301 if (window.ResizeObserver && cycleWord) { 302 var roPending = false;
303 new ResizeObserver(function () { 304 if (roPending) return; 305 roPending = true; 306 requestAnimationFrame(function () { roPending = false; lockCycleWrapSize(); }); 307 }).observe(cycleWord); 308 } 309 lockCycleWrapSize(); 310 arm(); // no-op under reduced-motion (stays on Payroll, tabs still work) 311})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.