/* ==========================================================================
   Augusta Pediatric Associates — redesign layer
   Setup for the split copy panels.

   Preparation only. No event listener drives the animation — that is all CSS
   on :hover / :focus-within — so there is no state here and nothing to tear
   down. Per row:

     1. split the paragraph into the lines the browser laid out, each one a
        <span class="apa-line" --apa-i:N> masking a <span class="apa-line-in">
     2. wrap the title in <span class="apa-title">
     3. tag the two columns .apa-copy / .apa-photo, and the row with which
        side the copy is on
     4. panel rows only: drop a <div class="apa-ground"> into the copy column,
        the layer that scales to make the panel look like it expands. Bleed
        rows have nothing behind their copy, so they get no element at all
        and are tagged .apa-bleed instead.
     5. measure the paragraph and publish its height as --apa-lift, which is
        how far the icon and the title travel — down the panel at rest on a
        panel row, down out of the way on hover on a bleed row

   Marking the row `.apa-split-row` is what switches the CSS on, so if any of
   this fails that section stays the 50/50 it was captured as.
   ========================================================================== */

(function () {
  'use strict';

  /* One entry per service row, keyed by its call to action rather than by the
     row's own WPBakery class — because those are not unique. Well Checks and
     Immunizations were built from the same saved row, so both carry
     `.vc_custom_1603396833444`; keying on it prepares Well Checks and leaves
     Immunizations exactly as captured, without erroring.

     The button container class alone is not enough either: a second
     `.buttonone` further down the page is the ALL PHYSICIANS button. So each
     selector pins the destination as well, which is unique per row.

     Which side the copy sits on is read from the markup, not configured here.
     The rows alternate, and Immunizations mirrors Well Checks while Prenatal
     mirrors Sick Visits.

     `bleed` picks which of the two treatments the row gets.

     false — the original: the photograph keeps its own column, the copy sits
     on an opaque white panel, and hovering grows that panel from 30% to 50%.

     true  — the photograph leaves the flex flow and becomes its panel's own
     ground, with the copy sitting straight on it and no panel behind the type
     at all.

     `duo` additionally lifts the row out of the page's vertical stack. Its
     value names a band, and every row sharing that name is moved, in order,
     into one flex wrapper so they sit side by side as a single band that
     resizes on hover. Each band measures itself independently — they are
     different heights because their blurbs are — except for the button width,
     which is settled once across all of them so all four match. */
  var ROWS = [
    { cta: '.buttontwo   a[href="/service/well-checks/"]',    bleed: true, duo: 'services' },
    { cta: '.buttonone   a[href="/service/sick-child/"]',     bleed: true, duo: 'services' },
    { cta: '.buttonthree a[href="/service/immunizations/"]',  bleed: true, duo: 'care' },
    { cta: '.buttonfour  a[href="/prenatal-consultations/"]', bleed: true, duo: 'care' }
  ];

  /* how much of the band the copy block occupies. It is the measure the type
     was always set to, and it is also the width of the squeezed panel — so at
     30% the block exactly fills its half and never has to re-wrap. */
  var MEASURE = 0.30;

  var measured = [];

  /* one entry per `duo` name in ROWS: the rows that belong to that band, the
     records for them, and the wrapper once it has been built */
  var bands = {};

  function band(key) {
    if (!bands[key]) bands[key] = { rows: [], records: [], wrap: null };
    return bands[key];
  }

  function escapeHtml(s) {
    return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  }

  /* The paragraph gets rebuilt twice. Here into bare word spans, which are
     only scaffolding — they exist so the browser can be asked where it put its
     own line breaks — and then in groupLines() into the line boxes that
     actually animate.

     The original text is kept on the node because every regroup has to start
     from flat words again: breaks move with the viewport and the webfont, and
     measuring already-grouped markup would just return the previous answer. */
  function splitWords(p) {
    var text = p.getAttribute('data-apa-text');

    if (text === null) {
      text = p.textContent.trim().replace(/\s+/g, ' ');
      p.setAttribute('data-apa-text', text);
    }

    var words = text.split(/\s+/);
    if (!words.length) return;

    p.innerHTML = words.map(function (word) {
      return '<span class="apa-word">' + escapeHtml(word) + '</span>';
    }).join(' ');
  }

  /* Group the words into the lines the browser actually laid out, and give
     each line an overflow box of its own so it can rise into view from
     underneath, one after the next.

     The lines are read out of the DOM rather than worked out here, because
     only the browser knows where its own breaks fall — and the copy is the
     client's words, which we do not get to re-break. offsetTop is the grouping
     key: every word sharing a visual line shares it. The comparison carries a
     tolerance of half a word's height so that anything sitting slightly off
     the baseline does not start a line of its own.

     This runs on every remeasure rather than once. The copy block is a fixed
     pixel width, so hovering never re-wraps it — but the viewport and the
     webfont still can, and a stale grouping would animate lines that no longer
     match where the text breaks. */
  function groupLines(p) {
    splitWords(p);                 /* back to flat words — breaks must be re-read */

    var words = p.querySelectorAll('.apa-word');
    if (!words.length) return;

    var tol   = Math.max(4, words[0].offsetHeight / 2);
    var lines = [];
    var top   = null;

    for (var i = 0; i < words.length; i++) {
      var t = words[i].offsetTop;

      if (top === null || Math.abs(t - top) > tol) {
        lines.push([]);
        top = t;
      }
      lines[lines.length - 1].push(words[i].textContent);
    }

    p.innerHTML = lines.map(function (line, i) {
      return '<span class="apa-line" style="--apa-i:' + i + '">' +
             '<span class="apa-line-in">' + escapeHtml(line.join(' ')) +
             '</span></span>';
    /* joined with a space, not butted together: the line spans are block
       boxes so it renders nothing, but without it the paragraph textContent
       runs the last word of each line into the first of the next — which is
       what a screen reader announces and what a reader copies. */
    }).join(' ');
  }

  /* How far the icon and title sit below their open position: the distance
     from the bottom of the heading to the bottom of the paragraph. Translating
     the pair down by that lands the heading exactly where the paragraph ended,
     i.e. tight above the button.

     offsetTop/offsetHeight, not getBoundingClientRect: the heading is already
     carrying the previous lift as a transform, and a client rect would include
     it — measuring the answer a little smaller on every re-measure. Offsets
     are layout values, so transforms never touch them, and both elements share
     an offsetParent so the subtraction is valid. */
  function measureLift(entry) {
    var lift = (entry.p.offsetTop  + entry.p.offsetHeight) -
               (entry.h5.offsetTop + entry.h5.offsetHeight);

    entry.box.style.setProperty('--apa-lift', (lift > 0 ? lift : 0) + 'px');
  }

  function prepare(entry) {
    var cta = document.querySelector(entry.cta);
    if (!cta) return;

    /* `:not(.vc_inner)` is load-bearing: the button sits inside a nested
       WPBakery row, and that inner row is not the two-column split. */
    var row = cta.closest('.row.wpb_row:not(.vc_inner)');
    if (!row || row.classList.contains('apa-split-row')) return;

    /* the copy column is whichever of the row's own two children holds the
       iconbox — not `querySelector`, which would happily reach into a nested
       .wpb_column instead */
    var columns = Array.prototype.slice.call(row.children);
    var copyCol = null;

    for (var i = 0; i < columns.length; i++) {
      if (columns[i].querySelector('.thb-iconbox')) { copyCol = columns[i]; break; }
    }
    if (!copyCol || columns.length !== 2) return;

    var photoCol = (copyCol === columns[0]) ? columns[1] : columns[0];

    var box     = copyCol.querySelector('.thb-iconbox');
    var content = box.querySelector('.iconbox-content');
    var h5      = content && content.querySelector('h5');
    var p       = content && content.querySelector('p');

    if (!h5 || !p) return;

    splitWords(p);
    content.setAttribute('data-apa-split', '');

    /* the title rides in its own span — the theme's scroll-reveal tweens the
       h5 itself with an inline transform, which would win over the lift */
    h5.innerHTML = '<span class="apa-title">' +
                   escapeHtml(h5.textContent.trim()) + '</span>';

    /* Only the panel treatment needs a ground to scale. A full-bleed row has
       nothing painted behind its copy, so it gets no element rather than an
       invisible one. */
    if (entry.bleed) {
      row.classList.add('apa-bleed');
      if (entry.duo) band(entry.duo).rows.push(row);
    } else {
      var ground = document.createElement('div');
      ground.className = 'apa-ground';
      ground.setAttribute('aria-hidden', 'true');
      copyCol.insertBefore(ground, copyCol.firstChild);
    }

    copyCol.classList.add('apa-copy');
    photoCol.classList.add('apa-photo');

    row.classList.add('apa-split-row');
    row.classList.add(copyCol === columns[1] ? 'apa-copy-right' : 'apa-copy-left');

    var record = { row: row, box: box, h5: h5, p: p, duo: entry.duo || null };
    measured.push(record);
    if (record.duo) band(record.duo).records.push(record);
    measureLift(record);
  }

  /* The copy block's width, in pixels, taken from the band rather than from
     the panel the block sits in. That is the whole point: the panels resize
     on hover, and a percentage would resize the block with them and re-wrap
     the type mid-transition. */
  function measureBand(band) {
    var w = band.wrap.getBoundingClientRect().width;
    band.wrap.style.setProperty('--apa-measure', Math.round(w * MEASURE) + 'px');
  }

  /* Move each band's rows out of the page's vertical stack and into a flex
     wrapper of their own, in the order they appear. Anything that sat between
     them — the empty mobile-only spacer rows do — is left where it was, below
     its band.

     Deliberately unguarded by a media query: the DOM is restructured once and
     the CSS decides what to do with it, so below the breakpoint .apa-duo is
     simply not a flex container and each pair stacks exactly as captured. */
  function pairDuos() {
    for (var key in bands) {
      if (!bands.hasOwnProperty(key)) continue;
      var band = bands[key];
      if (band.rows.length < 2) continue;

      band.wrap = document.createElement('div');
      band.wrap.className = 'apa-duo';
      band.rows[0].parentNode.insertBefore(band.wrap, band.rows[0]);

      for (var i = 0; i < band.rows.length; i++) {
        band.rows[i].classList.add('apa-panel');
        band.wrap.appendChild(band.rows[i]);
      }

      measureBand(band);
    }
  }

  /* Every button on the page settles to one width, unlike everything else
     here, which is measured per band. Four service buttons at two different
     widths reads as an accident rather than a decision, so the longest label
     anywhere sets the width and all of them take it. */
  function equalizeButtons() {
    var i, cta, width = 0, all = [];

    for (i = 0; i < measured.length; i++)
      if (measured[i].duo) all.push(measured[i]);
    if (!all.length) return;

    for (i = 0; i < all.length; i++) {
      cta = all[i].row.querySelector('a.apa-cta');
      if (!cta) continue;
      cta.style.width = 'auto';
      width = Math.max(width, cta.getBoundingClientRect().width);
      cta.style.width = '';
    }

    for (var key in bands)
      if (bands.hasOwnProperty(key) && bands[key].wrap)
        bands[key].wrap.style.setProperty('--apa-btn', Math.ceil(width) + 'px');
  }

  /* The two halves of a band have to be identical when they are closed — same
     card, same size, buttons level — and they are not identical by nature:
     Well Checks runs to four lines against Sick Visits' two, and the two icons
     are different drawings at different sizes.

     Each of those differences feeds through the card's height and pushes the
     buttons off level, so each is equalised to the larger of the pair. */
  function equalizeBand(band) {
    var pair = band.records, i;
    if (!band.wrap || pair.length < 2) return;

    /* Everything below resets a value and reads the layout straight back, and
       several of those values are transitioned — so without this the reset is
       still animating when it is read (and in a background tab, where
       transitions are frozen outright, it never lands at all). */
    band.wrap.classList.add('apa-measuring');

    /* let the blurbs and icons fall back to their natural heights before
       reading them, or each pass would measure the box the last pass imposed */
    band.wrap.style.setProperty('--apa-para', '0px');
    band.wrap.style.setProperty('--apa-icon', '0px');
    band.wrap.style.setProperty('--apa-h5',   '0px');

    /* the headings can differ by a whole line — IMMUNIZATIONS sits on one,
       FREE PRENATAL CONSULTATIONS wraps to two — and that put those two
       buttons 16px apart. Same treatment as the blurb: equal box, larger of
       the pair, the shorter heading centred in the space. */
    var head = 0;
    for (i = 0; i < pair.length; i++)
      head = Math.max(head, Math.ceil(pair[i].h5.getBoundingClientRect().height));
    band.wrap.style.setProperty('--apa-h5', head + 'px');

    /* the icons differ by a couple of pixels — 99×114 against 95×116 in the
       first band — and that was the whole of the remaining asymmetry: it fed
       straight through the card height into the buttons sitting 1px apart.
       The taller sets the box and both are centred in it, so neither drawing
       is scaled to match the other. */
    var icon = 0, im;
    for (i = 0; i < pair.length; i++) {
      im = pair[i].box.querySelector('.iconbox-image');
      if (im) icon = Math.max(icon, Math.ceil(im.getBoundingClientRect().height));
    }
    band.wrap.style.setProperty('--apa-icon', icon + 'px');

    var para = 0;
    for (i = 0; i < pair.length; i++)
      para = Math.max(para, Math.ceil(pair[i].p.getBoundingClientRect().height));
    band.wrap.style.setProperty('--apa-para', para + 'px');

    /* with both blurbs in the same box the two lifts come out equal anyway;
       taking the larger means a rounding difference can never desynchronise
       the two clusters by a pixel */
    var lift = 0;
    for (i = 0; i < pair.length; i++) {
      var l = (pair[i].p.offsetTop  + pair[i].p.offsetHeight) -
              (pair[i].h5.offsetTop + pair[i].h5.offsetHeight);
      if (l > lift) lift = l;
    }

    for (i = 0; i < pair.length; i++)
      pair[i].box.style.setProperty('--apa-lift', (lift > 0 ? lift : 0) + 'px');

    measureDrop(band);

    band.wrap.classList.remove('apa-measuring');
  }

  /* A closed card is shorter than the box it lives in: the blurb's reserved
     space is empty and sits above the button, so the icon and title come down
     into it and the card ends up hanging off the bottom of the panel — around
     60px low, with twice as much air above it as below.

     So measure where the visible card — icon top to button bottom — actually
     sits against the panel's centre, and publish the error. The CSS lifts the
     block by that much while closed and releases it on hover, where the open
     card is already within a few pixels of centre on its own.

     Skipped while a panel is hovered: the measurement only means anything in
     the closed state, and a resize mid-hover would otherwise record the open
     card's geometry and bake it in. */
  function measureDrop(band) {
    var pair = band.records, i;

    for (i = 0; i < pair.length; i++)
      if (pair[i].row.querySelector('.apa-copy:hover')) return;

    band.wrap.style.setProperty('--apa-drop', '0px');

    var drop = 0;
    for (i = 0; i < pair.length; i++) {
      var icon = pair[i].row.querySelector('.iconbox-image');
      var cta  = pair[i].row.querySelector('a.apa-cta');
      if (!icon || !cta) return;

      var pb = pair[i].row.getBoundingClientRect();
      var d  = ((icon.getBoundingClientRect().top +
                 cta.getBoundingClientRect().bottom) / 2) -
               (pb.top + pb.height / 2);
      if (d > drop) drop = d;
    }

    band.wrap.style.setProperty('--apa-drop', Math.round(drop) + 'px');
  }

  function remeasure() {
    var key;

    for (key in bands)
      if (bands.hasOwnProperty(key) && bands[key].wrap) measureBand(bands[key]);

    /* Regrouped before anything is measured, and after the band widths are
       published: the grouping depends on how wide the copy block is, and the
       lift and the card heights are in turn read off the grouped paragraph. */
    for (var li = 0; li < measured.length; li++) groupLines(measured[li].p);

    for (var i = 0; i < measured.length; i++)
      if (!measured[i].duo) measureLift(measured[i]);

    /* width first: it changes how wide the buttons are, which the card
       heights and the drop are both measured against */
    equalizeButtons();

    for (key in bands)
      if (bands.hasOwnProperty(key)) equalizeBand(bands[key]);

    /* last: the bands must be at their settled heights before they are summed */
    measureStack();
  }

  /* ======================================================================
     The band buttons — COVID-19 / APPOINTMENTS / RESOURCES

     The three white pills on the pink checkerboard, directly under where the
     dated "Sunday Hours" banner used to sit. They react to the cursor: the
     pill leans toward it, a soft glow tracks it across the face, and the
     label leads the pill slightly so the two read as different depths.

     The script writes finished pixel values, not normalised ones, so these
     three numbers are the only place the distances are defined — the CSS
     just consumes them. That also keeps `tx`/`ty` below exactly equal to
     what is on screen, which the measurement depends on.
     ====================================================================== */

  var LEAN   = 7;   /* px the pill leans toward the cursor — same on both  */
  var LEAN_Y = 7;   /* axes, so it follows the cursor rather than sliding   */
  var LABEL  = 3;   /* px the label leads the pill by, for a little depth   */

  /* verified unique in the markup — unlike the service rows' classes */
  var BAND = '.vc_custom_1605025265775';

  function clamp(n) { return n < -1 ? -1 : (n > 1 ? 1 : n); }

  function trackButton(el) {
    var tx = 0, ty = 0;   /* the lean currently on screen, in px */

    el.addEventListener('pointermove', function (e) {
      /* The rect includes the lean already applied, so subtract it back out
         before working out where the cursor is inside the pill. Skip that
         and every move measures the cursor slightly closer to the centre
         than it really is, and the lean settles short of where it should.
         Same trap as measureLift, arrived at from the other direction. */
      var r  = el.getBoundingClientRect();
      var x  = e.clientX - (r.left - tx);
      var y  = e.clientY - (r.top  - ty);
      var nx = clamp((x / r.width)  * 2 - 1);
      var ny = clamp((y / r.height) * 2 - 1);

      tx = nx * LEAN;
      ty = ny * LEAN_Y;

      el.style.setProperty('--apa-tx', tx + 'px');
      el.style.setProperty('--apa-ty', ty + 'px');
      el.style.setProperty('--apa-lx', (nx * LABEL) + 'px');
      el.style.setProperty('--apa-ly', (ny * LABEL) + 'px');
      el.style.setProperty('--apa-mx', (x / r.width  * 100) + '%');
      el.style.setProperty('--apa-my', (y / r.height * 100) + '%');
    });

    el.addEventListener('pointerleave', function () {
      tx = ty = 0;
      el.style.setProperty('--apa-tx', '0px');
      el.style.setProperty('--apa-ty', '0px');
      el.style.setProperty('--apa-lx', '0px');
      el.style.setProperty('--apa-ly', '0px');
      /* the glow fades where it was rather than snapping back to centre */
    });
  }

  function prepareBand() {
    var band = document.querySelector(BAND);
    if (!band || band.classList.contains('apa-band')) return;

    var buttons = band.querySelectorAll('.vc_btn3-container > a.vc_btn3');
    if (!buttons.length) return;

    var mq     = window.matchMedia;
    var fine   = mq ? mq('(hover: hover)').matches : false;
    var reduce = mq ? mq('(prefers-reduced-motion: reduce)').matches : false;

    for (var i = 0; i < buttons.length; i++) {
      var el = buttons[i];

      /* the label rides in its own span: it has to sit above the glow, and
         it moves a little further than the pill underneath it */
      el.innerHTML = '<span class="apa-band-label">' + el.innerHTML + '</span>';
      el.classList.add('apa-band-cta');

      /* the styling is worth having either way; only the cursor tracking
         needs a real pointer and a visitor who has not asked for less */
      if (fine && !reduce) trackButton(el);
    }

    band.classList.add('apa-band');
  }

  /* ======================================================================
     The stack — the vertical half of the accordion

     The two bands are moved into one column-flex wrapper so a hover can move
     them against each other, exactly the way the two halves of a band already
     move. Hovering any card now widens its own column, narrows the card beside
     it, grows its band, and shrinks the other band. All four react.

     The height has to be a definite number for the percentage bases to
     resolve, so it is measured — the two bands at their natural heights,
     summed — and pinned. That also means the section never changes height on
     hover: the space is redistributed, not added, so nothing below it moves.
     ====================================================================== */

  var stackEl = null;

  /* How much of the bands' natural summed height the section actually takes —
     the one knob for "make these shorter". 1 is the old full height (403px a
     band); this gives ~371.

     Do not push it much below this without re-checking every panel while it is
     open. The floor is NOT the closed card: opening one lifts the icon by a
     further 10px and spreads the block out to a 356px run, so the crop starts
     eating the icon and the button while the resting state still looks fine.
     Measured: at 0.8 the bands came out 322px and immunisations lost 22px off
     its icon and 12px off its button.

     Trying to derive that floor automatically is harder than it looks — the
     open geometry is three separate transforms deep, so a measurement taken at
     rest reports a 270px run and happily under-sizes the band. Verify by
     hovering, not by measuring at rest.

     The room to go lower is not there to be found, either: the 46px between
     the paragraph and the button is the reserve the reveal needs — closed, the
     title translates DOWN over exactly that space to hide the paragraph. Take
     it away and the closed state breaks. Shorter panels than this need the
     copy or the reveal rethought, not a smaller number here. */
  var STACK_SCALE = 0.94;


  function measureStack() {
    if (!stackEl) return;

    /* only meaningful with nothing hovered — mid-hover the bands are already
       redistributed and summing them would bake that in */
    if (stackEl.querySelector('.apa-panel:hover')) return;

    stackEl.classList.add('apa-measuring-stack');

    var h = 0, kids = stackEl.children;
    for (var i = 0; i < kids.length; i++)
      h += kids[i].getBoundingClientRect().height;

    stackEl.classList.remove('apa-measuring-stack');

    /* The sum is the bands at their NATURAL height, which is taller than the
       card needs: most of it is empty reserve above and below the copy block,
       and `overflow:hidden` on the band crops exactly that. Scaling the pin
       down therefore shortens the panels without touching the card inside.

       But only up to a point, and the point is not the closed card — it is the
       card's full content run, icon through button, which is the same height
       in both states (the open/closed difference is transforms, not layout).
       Scale alone got that wrong: at 0.8 the bands came out 322px against a
       356px content run, which clipped 22px off the immunisation icon and 12px
       off its button. So the scale is a request and this is the floor. */
    if (h > 0) stackEl.style.setProperty('--apa-stack-h',
      Math.round(h * STACK_SCALE) + 'px');
  }


  function buildStack() {
    var wraps = [], key;
    for (key in bands)
      if (bands.hasOwnProperty(key) && bands[key].wrap) wraps.push(bands[key].wrap);
    if (wraps.length < 2) return;

    stackEl = document.createElement('div');
    stackEl.className = 'apa-stack';
    wraps[0].parentNode.insertBefore(stackEl, wraps[0]);

    for (var i = 0; i < wraps.length; i++) stackEl.appendChild(wraps[i]);

    measureStack();
  }

  /* ======================================================================
     The drifting checkerboard behind the band buttons
     ====================================================================== */

  var driftInner = null;

  /* The distance one tile occupies on screen, which is how far the animation
     has to travel before it can start again without a jump. The tile is
     pinned to the inner's height, so the width follows from the artwork's own
     proportions — 2560x523 at whatever height the band happens to be. */
  function measureDrift() {
    if (!driftInner || !driftInner._apaRatio) return;
    var h = driftInner.getBoundingClientRect().height;
    driftInner.style.setProperty('--apa-tile', (h * driftInner._apaRatio) + 'px');
  }

  /* The row paints the pattern, but WPBakery lays `.vc_parallax-inner` over
     the top of it and that is the copy anyone actually sees — so the drift
     has to go on the inner or it is animating a layer nobody can see.

     The inner does not exist yet at DOMContentLoaded; the parallax builds it
     later. Hence the retry, rather than an ordering guess. */
  function prepareDrift() {
    var tries = 0;

    function attempt() {
      var band = document.querySelector(BAND);
      if (!band) return true;                 /* nothing here — stop trying */

      var inner = band.querySelector('.vc_parallax-inner');
      if (!inner) return false;               /* not built yet — come back */

      var url = (getComputedStyle(inner).backgroundImage || '')
                  .match(/url\("?([^")]+)/);
      if (!url) return true;

      var im = new Image();
      im.onload = function () {
        if (!im.naturalHeight) return;
        driftInner = inner;
        inner._apaRatio = im.naturalWidth / im.naturalHeight;
        measureDrift();
        inner.classList.add('apa-drift');
      };
      im.src = url[1];

      return true;
    }

    if (attempt()) return;

    var timer = setInterval(function () {
      if (attempt() || ++tries > 40) clearInterval(timer);
    }, 250);
  }


  function init() {
    for (var i = 0; i < ROWS.length; i++) prepare(ROWS[i]);
    prepareBand();          /* before the early return — it measures nothing */
    prepareDrift();

    /* pairing moves rows into a half-width wrapper, so every lift taken
       during prepare() is stale the moment it happens. Measure again. */
    pairDuos();
    buildStack();
    if (!measured.length) return;
    remeasure();

    /* the paragraphs no longer re-wrap on hover, so one measurement holds —
       but the viewport and the webfont can still change them */
    if (document.fonts && document.fonts.ready) {
      document.fonts.ready.then(remeasure);
    }

    var pending;
    window.addEventListener('resize', function () {
      clearTimeout(pending);
      pending = setTimeout(function () {
        remeasure();
        measureDrift();
      }, 150);
    });
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();
