// ── Sprinkle background ────────────────────────────
(function () {
  const canvas  = document.getElementById('bgCanvas');
  const ctx     = canvas.getContext('2d');

  const COLORS  = ['#ffd84d', '#8cbcff', '#ff9fca', '#a9e76c', '#b99cff', '#ff9d57'];
  const INK     = '#171717';
  const COUNT   = 1000;   // number of sprinkles
  const MIN_LEN = 12;    // px
  const MAX_LEN = 18;   // px
  const MIN_W   = 4;
  const MAX_W   = 6;

  let W, H, sprinkles;

  function rand(a, b) { return a + Math.random() * (b - a); }

  function resize() {
    W = canvas.width  = window.innerWidth;
    H = canvas.height = window.innerHeight;
    if (!sprinkles) init();
    // Redistribute any sprinkles that landed outside new bounds
    sprinkles.forEach(s => {
      if (s.x > W) s.x = rand(0, W);
      if (s.y > H) s.y = rand(0, H);
    });
    // Resizing clears the canvas; with no animation loop running, repaint it
    if (reduceMotion.matches) {
      cancelAnimationFrame(rafId);
      rafId = requestAnimationFrame(draw);
    }
  }

  function init() {
    sprinkles = [];
    for (let i = 0; i < COUNT; i++) {
      sprinkles.push(makeSprinkle());
    }
  }

  function makeSprinkle() {
    return {
      x:      rand(0, W),
      y:      rand(0, H),
      len:    rand(MIN_LEN, MAX_LEN),
      width:  rand(MIN_W, MAX_W),
      color:  COLORS[Math.floor(Math.random() * COLORS.length)],
      angle:  rand(0, Math.PI * 2),
      // very slow drift
      vx:     rand(-0.1, 0.1),
      vy:     rand(-0.1, 0.1),
      va:     rand(-0.005, 0.005),   // angular velocity
      alpha:  1,
    };
  }

  function draw() {
    ctx.clearRect(0, 0, W, H);

    sprinkles.forEach(s => {
      ctx.save();
      ctx.translate(s.x, s.y);
      ctx.rotate(s.angle);
      ctx.beginPath();
      ctx.moveTo(0, -s.len / 2);
      ctx.lineTo(0,  s.len / 2);
      ctx.lineCap     = 'round';
      ctx.globalAlpha = s.alpha;
      // Ink outline under the colored fill (sticker look): same path, two strokes
      ctx.strokeStyle = INK;
      ctx.lineWidth   = s.width + 3;
      ctx.stroke();
      ctx.strokeStyle = s.color;
      ctx.lineWidth   = s.width;
      ctx.stroke();
      ctx.restore();

      // Reduced motion: sprinkles stay where they are
      if (reduceMotion.matches) return;

      // Drift
      s.x     += s.vx;
      s.y     += s.vy;
      if (s.vx > 0.1 || s.vx < -0.1) {
        s.vx *= 0.98;
      }
      if (s.vy > 0.1 || s.vy < -0.1) {
        s.vy *= 0.98;
      }
      s.angle += s.va;

      // Repel from cursor
      const dx = s.x - mouseX;
      const dy = s.y - mouseY;
      const dist = Math.sqrt(dx * dx + dy * dy);
      const repelRadius = 80;
      if (dist < repelRadius && dist > 0) {
        const force = (repelRadius - dist) / repelRadius;
        s.vx += (dx / dist) * force * 1.0;
        s.vy += (dy / dist) * force * 1.0;
      }

      // Wrap: when a sprinkle drifts off the screen, reappear on the other side
      if (s.y < -MAX_LEN) s.y = H + MAX_LEN;
      if (s.y > H + MAX_LEN) s.y = -MAX_LEN
      if (s.x < -MAX_LEN)  s.x = W + MAX_LEN;
      if (s.x > W + MAX_LEN) s.x = -MAX_LEN;
    });

    if (!reduceMotion.matches) rafId = requestAnimationFrame(draw);
  }

  // With reduced motion the sprinkles render once as a static backdrop
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
  let rafId;
  reduceMotion.addEventListener('change', () => {
    cancelAnimationFrame(rafId);
    rafId = requestAnimationFrame(draw);
  });

  window.addEventListener('resize', resize, { passive: true });
  resize();
  let mouseX = -9999, mouseY = -9999;
  window.addEventListener('mousemove', (e) => {
    mouseX = e.clientX;
    mouseY = e.clientY;
  }, { passive: true });
  rafId = requestAnimationFrame(draw);
})();


// ── Nav: scroll state & active link ───────────────────────────
(function () {
  const header    = document.getElementById('header');
  const links     = document.querySelectorAll('.nav-link');
  const sections  = document.querySelectorAll('section[id]');
  const burger    = document.getElementById('burger');

  window.addEventListener('scroll', () => {
    header.classList.toggle('scrolled', window.scrollY > 24 || burger.classList.contains('open'));

    let current = '';
    sections.forEach(s => {
      if (window.scrollY >= s.offsetTop - 100) current = s.id;
    });
    links.forEach(l => {
      l.classList.toggle('active', l.getAttribute('href') === '#' + current);
    });
  }, { passive: true });
})();


// ── Mobile burger ──────────────────────────────────────────────
(function () {
  const burger    = document.getElementById('burger');
  const navLinks  = document.getElementById('navLinks');
  const header    = document.getElementById('header');
  burger.addEventListener('click', () => {
    burger.classList.toggle('open');
    navLinks.classList.toggle('open');
    header.classList.toggle('scrolled', burger.classList.contains('open') || window.scrollY > 24);
  });
  navLinks.querySelectorAll('a').forEach(a => {
    a.addEventListener('click', () => {
      burger.classList.remove('open');
      navLinks.classList.remove('open');
      header.classList.toggle('scrolled', window.scrollY > 24);
    });
  });
})();


// ── Avatar spin on click ──────────────────────────────────────
(function () {
  const avatar = document.querySelector('.avatar-ring');

  avatar.addEventListener('click', () => {
    if (avatar.classList.contains('spinning')) return;
    avatar.classList.add('spinning');
  });
  avatar.addEventListener('animationend', () => {
    avatar.classList.remove('spinning');
  });
})();


// ── Tag pill tooltips: tap toggle (touch devices only) ────────
(function () {
  // Hover-capable devices already get the tooltips via :hover
  if (window.matchMedia('(hover: hover)').matches) return;

  const pills = [...document.querySelectorAll('[data-tip]')];

  function closeAll() {
    pills.forEach(p => {
      p.classList.remove('tip-open');
      p.setAttribute('aria-expanded', 'false');
    });
  }

  pills.forEach(pill => {
    pill.setAttribute('tabindex', '0');
    pill.setAttribute('role', 'button');
    pill.setAttribute('aria-expanded', 'false');
    // Pseudo-element tooltips are invisible to screen readers
    pill.setAttribute('aria-description', pill.dataset.tip);

    pill.addEventListener('click', (e) => {
      e.stopPropagation();
      const wasOpen = pill.classList.contains('tip-open');
      closeAll();
      if (!wasOpen) {
        pill.classList.add('tip-open');
        pill.setAttribute('aria-expanded', 'true');
      }
    });
    pill.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        pill.click();
      }
    });
  });

  document.addEventListener('click', closeAll);
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') closeAll();
  });
})();



// ── Pepsi gallery: shuffled on load, swipe cycles through on click ─
(function () {
  const COUNT = 15;
  const img = document.querySelector('.pepsi-placeholder img');
  if (!img) return;
  const box = img.parentElement;
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
  const src = (n) => `Pepsi/Pepsi-${n}.jpg`;

  // Fisher–Yates shuffle of [1..COUNT]; clicks cycle through this order
  const order = Array.from({ length: COUNT }, (_, i) => i + 1);
  for (let i = order.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [order[i], order[j]] = [order[j], order[i]];
  }
  let pos = 0;
  img.src = src(order[pos]);

  let busy = false;
  box.addEventListener('click', () => {
    if (busy) return;
    pos = (pos + 1) % order.length;
    const next = order[pos];

    if (reduceMotion.matches) {
      img.src = src(next);
      return;
    }

    busy = true;
    const incoming = new Image();
    incoming.src = src(next);
    incoming.alt = img.alt;
    incoming.draggable = false;
    incoming.className = 'pepsi-incoming';
    // Decode before animating so the swipe never reveals a half-loaded frame
    incoming.decode().then(() => {
      box.appendChild(incoming);
      const opts = { duration: 300, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' };
      img.animate(
        [{ transform: 'translateX(0)' }, { transform: 'translateX(-100%)' }],
        opts
      );
      incoming.animate(
        [{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }],
        opts
      ).finished.then(() => {
        img.src = incoming.src;   // already decoded, swaps instantly
        incoming.remove();
        busy = false;
      });
    }).catch(() => { busy = false; });
  });
})();


// ── Scroll hint: appears after idling at the top of the page ──
(function () {
  const hint = document.getElementById('scrollHint');
  if (!hint) return;

  const timer = setTimeout(() => {
    if (window.scrollY <= 24) hint.classList.add('visible');
  }, 3000);

  // Any scroll dismisses it for the rest of the visit
  window.addEventListener('scroll', () => {
    clearTimeout(timer);
    hint.classList.remove('visible');
  }, { passive: true, once: true });
})();


// ── Job title slot machine: click the role pill ───────────────
(function () {
  const pill = document.getElementById('heroRole');
  if (!pill) return;

  const WORDS = [
    ['Software', 'Backend', 'Data', 'Computer', 'Frontend', 'Full-Stack', 'Tech'],
    ['Developer', 'Scientist', 'Engineer', 'Enthusiast', 'Wizard'],
  ];
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
  const slots = [...pill.querySelectorAll('.slot')];

  // Width of a word in this mono font: 1ch + letter-spacing per character.
  // Slots hug their current word at rest and expand to their widest word
  // while spinning (the CSS width transition animates both moves).
  const widthFor = (word) => `calc(${word.length}ch + ${word.length * 0.08}em)`;
  slots.forEach((slot) => {
    slot.style.width = widthFor(slot.textContent.trim());
  });

  let busy = false;
  pill.addEventListener('click', () => {
    if (busy) return;
    busy = true;

    const spins = slots.map((slot, i) => {
      const list = WORDS[i];
      const reel = slot.querySelector('.reel');
      const from = Math.max(list.indexOf(reel.textContent.trim()), 0);
      // Random pick that's never the word already showing
      let target = Math.floor(Math.random() * (list.length - 1));
      if (target >= from) target++;

      if (reduceMotion.matches) {
        reel.innerHTML = `<span>${list[target]}</span>`;
        slot.style.width = widthFor(list[target]);
        return Promise.resolve();
      }

      // Expand to fit the reel's widest word for the duration of the spin
      const longest = list.reduce((a, b) => (b.length > a.length ? b : a));
      slot.style.width = widthFor(longest);

      // Walk the reel in list order: a couple of full revolutions, then
      // land on the target (the second reel spins one revolution longer)
      const steps = ((target - from + list.length) % list.length) + list.length * (2 + i);

      // Build the strip top-down ending at the current word, so the column
      // slides downward and words enter from above. One extra word sits
      // above the target to back the overshoot.
      reel.innerHTML = '';
      for (let k = 0; k <= steps + 1; k++) {
        const w = document.createElement('span');
        w.textContent = list[(from + steps + 1 - k) % list.length];
        reel.appendChild(w);
      }

      const h = reel.firstChild.getBoundingClientRect().height;
      const land = -h;              // row where the target word sits
      const over = land + h * 0.35; // spin runs slightly past the stop
      const anim = reel.animate(
        [
          { transform: `translateY(${-(steps + 1) * h}px)` },
          { transform: `translateY(${over}px)` },
        ],
        { duration: 900 + i * 500, easing: 'cubic-bezier(0.15, 0.85, 0.25, 1)', fill: 'forwards' }
      );
      return anim.finished.then(() => {
        const settle = reel.animate(
          [{ transform: `translateY(${over}px)` }, { transform: `translateY(${land}px)` }],
          { duration: 200, easing: 'ease', fill: 'forwards' }
        );
        return settle.finished.then(() => {
          reel.innerHTML = `<span>${list[target]}</span>`;
          settle.cancel();
          anim.cancel();   // drop the forward-filled transforms after the swap
          slot.style.width = widthFor(list[target]);   // shrink back to fit
        });
      });
    });

    Promise.all(spins).finally(() => { busy = false; });
  });
})();


// ── Pronoun pill: non-binary flag sweep on click ──────────────
(function () {
  const pill = document.querySelector('.hero-pronouns');
  if (!pill) return;
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

  pill.addEventListener('click', () => {
    if (reduceMotion.matches) return;
    if (pill.classList.contains('waving')) return;
    pill.classList.add('waving');
  });
  pill.addEventListener('animationend', () => {
    pill.classList.remove('waving');
  });
})();


// ── Delete website (footer): flag in localStorage, serve the 404 ─
(function () {
  const button  = document.getElementById('deleteSite');
  const dialog  = document.getElementById('deleteDialog');
  const confirm = document.getElementById('deleteConfirm');
  const cancel  = document.getElementById('deleteCancel');
  if (!button || !dialog) return;

  button.addEventListener('click', () => dialog.showModal());
  cancel.addEventListener('click', () => dialog.close());
  confirm.addEventListener('click', () => {
    try { localStorage.setItem('siteDeleted', '1'); } catch (e) {}
    location.replace('404.html');
  });
  // Click on the backdrop closes too
  dialog.addEventListener('click', (e) => {
    if (e.target === dialog) dialog.close();
  });
})();


// ── Icon circles spin on click, same move as the avatar ───────
(function () {
  const icons = document.querySelectorAll('.skill-card-icon, .project-icon, .edu-icon, .nav-logo');
  icons.forEach((icon) => {
    icon.addEventListener('click', () => {
      if (icon.classList.contains('spinning')) return;
      icon.classList.add('spinning');
    });
    icon.addEventListener('animationend', () => {
      icon.classList.remove('spinning');
    });
  });
})();


// ── D20 on "Dungeons & Dragons": a 3D die built from matrix3d ─
(function () {
  const anchor = document.querySelector('.dnd-roll');
  if (!anchor) return;
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

  // Icosahedron in CSS coordinates (x right, y down, z toward viewer)
  const R = 44;   // circumradius, px
  const PHI = (1 + Math.sqrt(5)) / 2;
  const RAW = [
    [-1, PHI, 0], [1, PHI, 0], [-1, -PHI, 0], [1, -PHI, 0],
    [0, -1, PHI], [0, 1, PHI], [0, -1, -PHI], [0, 1, -PHI],
    [PHI, 0, -1], [PHI, 0, 1], [-PHI, 0, -1], [-PHI, 0, 1],
  ];
  const FACES = [
    [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
    [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
    [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
    [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
  ];
  // The standard d20 layout (per Alea Kybos' dice configuration catalog),
  // embedded onto the face list above: 20 bordered by 2/8/14, opposite
  // faces sum to 21, evens on 20's hemisphere and odds on 1's
  const NUMBERS = [20, 2, 12, 10, 8, 18, 14, 16, 15, 17, 11, 9, 19, 1, 13, 6, 4, 3, 7, 5];
  // Theme pastels the die can be cast from, one picked per roll
  const PASTELS = [
    [255, 216, 77], [140, 188, 255], [255, 159, 202],
    [169, 231, 108], [185, 156, 255], [255, 157, 87],
  ];
  const scale = R / Math.hypot(1, PHI);
  const V = RAW.map(([x, y, z]) => [x * scale, -y * scale, z * scale]);

  const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
  const cross = (a, b) => [
    a[1] * b[2] - a[2] * b[1],
    a[2] * b[0] - a[0] * b[2],
    a[0] * b[1] - a[1] * b[0],
  ];
  const norm = (a) => {
    const l = Math.hypot(a[0], a[1], a[2]);
    return [a[0] / l, a[1] / l, a[2] / l];
  };
  const mat3d = ([X, Y, Z], t) =>
    `matrix3d(${X[0]},${X[1]},${X[2]},0,${Y[0]},${Y[1]},${Y[2]},0,` +
    `${Z[0]},${Z[1]},${Z[2]},0,${t[0]},${t[1]},${t[2]},1)`;

  let scene = null;
  let die = null;
  const bases = [];
  const faceEls = [];

  function build() {
    scene = document.createElement('span');
    scene.className = 'die-scene';
    die = document.createElement('span');
    die.className = 'die';
    scene.appendChild(die);

    const light = norm([-0.35, -0.5, 0.78]);
    FACES.forEach((f, idx) => {
      let [A, B, C] = f.map((i) => V[i]);
      const G = [
        (A[0] + B[0] + C[0]) / 3,
        (A[1] + B[1] + C[1]) / 3,
        (A[2] + B[2] + C[2]) / 3,
      ];
      let n = norm(cross(sub(B, A), sub(C, A)));
      if (dot(n, G) < 0) {           // ensure the normal points outward
        [B, C] = [C, B];
        n = [-n[0], -n[1], -n[2]];
      }
      const X = norm(sub(B, A));     // face-local right
      const Y = norm(cross(n, X));   // face-local down (X × Y = n, no mirroring)
      bases.push([X, Y, n]);

      const D = Math.hypot(...sub(B, A));   // edge length
      const face = document.createElement('span');
      face.className = 'die-face';
      face.textContent = NUMBERS[idx];
      // Real dice underline the ambiguous digits
      if (NUMBERS[idx] === 6 || NUMBERS[idx] === 9) face.classList.add('die-face--mark');
      const pts = [A, B, C].map((P) => {
        const p = sub(P, G);
        return `${dot(p, X) + D / 2}px ${dot(p, Y) + D / 2}px`;
      });
      face.style.width = face.style.height = D + 'px';
      face.style.margin = -D / 2 + 'px';
      face.style.fontSize = D * 0.3 + 'px';
      face.style.clipPath = `polygon(${pts.join(', ')})`;
      // Static lighting factor, applied to the roll's color when it's cast
      faceEls.push({ el: face, shade: 0.6 + 0.4 * Math.max(0, dot(n, light)) });
      face.style.transform = mat3d([X, Y, n], G);
      die.appendChild(face);
    });
    anchor.appendChild(scene);
  }

  // Rotation that lands face i flat toward the viewer, number upright
  function landing(i) {
    const [X, Y, Z] = bases[i];
    return `matrix3d(${X[0]},${Y[0]},${Z[0]},0,${X[1]},${Y[1]},${Z[1]},0,` +
      `${X[2]},${Y[2]},${Z[2]},0,0,0,0,1)`;
  }

  let busy = false;
  anchor.addEventListener('click', () => {
    if (busy) return;
    busy = true;
    if (!scene) build();
    scene.style.display = '';
    scene.style.opacity = '1';

    const result = Math.floor(Math.random() * 20) + 1;
    const final = landing(NUMBERS.indexOf(result));

    // Cast the die in one of the theme pastels for this roll
    const [pr, pg, pb] = PASTELS[Math.floor(Math.random() * PASTELS.length)];
    faceEls.forEach(({ el, shade }) => {
      el.style.background =
        `rgb(${Math.round(pr * shade)}, ${Math.round(pg * shade)}, ${Math.round(pb * shade)})`;
    });

    // Nat 20: DOM sprinkles burst from the die — unlike the canvas ones,
    // these render above the cards, so the effect survives any backdrop
    const burst = () => {
      const r = scene.getBoundingClientRect();
      const cx = r.left + r.width / 2;
      const cy = r.top + r.height / 2;
      for (let i = 0; i < 24; i++) {
        const bit = document.createElement('span');
        bit.className = 'burst-bit';
        const [br, bg, bb] = PASTELS[Math.floor(Math.random() * PASTELS.length)];
        bit.style.background = `rgb(${br}, ${bg}, ${bb})`;
        bit.style.left = cx + 'px';
        bit.style.top = cy + 'px';
        document.body.appendChild(bit);
        const a = Math.random() * Math.PI * 2;
        const d = 60 + Math.random() * 90;
        bit.animate(
          [
            {
              transform: `translate(-50%, -50%) rotate(${Math.random() * 360}deg)`,
              opacity: 1,
            },
            {
              transform:
                `translate(calc(-50% + ${(Math.cos(a) * d).toFixed(1)}px), ` +
                `calc(-50% + ${(Math.sin(a) * d).toFixed(1)}px)) ` +
                `rotate(${((Math.random() - 0.5) * 720).toFixed(0)}deg)`,
              opacity: 0,
            },
          ],
          { duration: 700 + Math.random() * 300, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)', fill: 'forwards' }
        ).finished.then(() => bit.remove());
      }
    };

    const finish = () => {
      if (result === 20 && !reduceMotion.matches) burst();
      setTimeout(() => {
        scene.style.opacity = '0';
        setTimeout(() => {
          scene.style.display = 'none';
          busy = false;
        }, 320);
      }, 1100);
    };

    if (reduceMotion.matches) {
      die.style.transform = final;
      finish();
      return;
    }
    // One continuous decelerating roll from a fully random direction: the
    // die tumbles end-over-end along its travel (axis perpendicular to the
    // motion, in-plane) with a slower barrel roll around the travel axis.
    // Both extra rotations unwind to exactly zero on top of the landing
    // matrix, so it settles on the result with no correcting jerk.
    const ang = Math.random() * Math.PI * 2;
    const dx = Math.cos(ang);
    const dy = Math.sin(ang);
    const roll = 810 + Math.floor(Math.random() * 3) * 72;
    const wobble = 240 + Math.random() * 240;
    const tumbleAxis = `${dy.toFixed(4)},${(-dx).toFixed(4)},0`;
    const travelAxis = `${dx.toFixed(4)},${dy.toFixed(4)},0`;
    const anim = die.animate(
      [
        {
          transform:
            `translate(${(-dx * 150).toFixed(1)}px, ${(-dy * 150).toFixed(1)}px) ` +
            `rotate3d(${tumbleAxis},${roll}deg) rotate3d(${travelAxis},${-wobble}deg) ${final}`,
        },
        {
          transform:
            `translate(0px, 0px) rotate3d(${tumbleAxis},0deg) rotate3d(${travelAxis},0deg) ${final}`,
        },
      ],
      { duration: 1400, easing: 'cubic-bezier(0.16, 0.6, 0.28, 1)', fill: 'forwards' }
    );
    anim.finished.then(() => {
      die.style.transform = final;
      anim.cancel();
      finish();
    });
  });
})();


// ── Name anagram: clicks cycle through rearrangements ─────────
(function () {
  const h1 = document.querySelector('.hero-name');
  if (!h1) return;
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

  const NAME = 'Nikolaj Gade';
  const CYCLE = [NAME, 'a joking deal', NAME, 'naked oil jag'];

  // The visual letters rearrange, but assistive tech always gets the name
  h1.setAttribute('aria-label', NAME);

  // Split into persistent per-letter spans; the exact glyphs get reused,
  // so the capitals travel (see "a jokiNG deal")
  const letters = [];
  h1.textContent = '';
  for (const ch of NAME) {
    if (ch === ' ') {
      h1.appendChild(document.createTextNode(' '));
      continue;
    }
    const s = document.createElement('span');
    s.className = 'ltr';
    s.textContent = ch;
    h1.appendChild(s);
    letters.push({ el: s, ch: ch.toLowerCase() });
  }

  function arrange(phrase) {
    // Pick a distinct source glyph for every letter of the target
    const pool = [...letters];
    const seq = [];
    for (const ch of phrase) {
      if (ch === ' ') {
        seq.push(null);
        continue;
      }
      const i = pool.findIndex((l) => l.ch === ch.toLowerCase());
      seq.push(pool.splice(i, 1)[0].el);
    }

    // FLIP: measure, reorder the DOM, measure again, then animate each
    // glyph from its old position into its new one
    const first = new Map(letters.map((l) => [l.el, l.el.getBoundingClientRect()]));
    h1.textContent = '';
    seq.forEach((el) => h1.appendChild(el || document.createTextNode(' ')));
    if (reduceMotion.matches) return Promise.resolve();

    return Promise.all(
      letters.map((l, k) => {
        const a = first.get(l.el);
        const b = l.el.getBoundingClientRect();
        return l.el.animate(
          [
            { transform: `translate(${(a.left - b.left).toFixed(1)}px, ${(a.top - b.top).toFixed(1)}px)` },
            { transform: 'translate(0, 0)' },
          ],
          {
            duration: 650,
            delay: k * 18,
            fill: 'backwards',
            easing: 'cubic-bezier(0.3, 0.9, 0.3, 1)',
          }
        ).finished;
      })
    );
  }

  let pos = 0;
  let busy = false;
  h1.addEventListener('click', () => {
    if (busy) return;
    busy = true;
    pos = (pos + 1) % CYCLE.length;
    arrange(CYCLE[pos]).finally(() => { busy = false; });
  });
})();
