PageSourceSearch

https://vertisourcehr.com/wp-content/themes/vshr-sage/public/hero-device.js?v=1790314192

js vertisourcehr.com collected 2026-10-04 03:32:01 UTC 17,841 bytes, 311 lines download raw bytes

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.