// Services section: four service names, one open at a time, each name carrying
// a GSAP ornament that illustrates what the service actually does.
//
// Structure follows initProjectDashboard in home.jsx: React renders static
// markup once, and an imperative init owns all interaction. That is deliberate:
// the ornaments are measured off live glyph metrics and one of them has SplitText
// rewrite its heading, so driving the active item through React state would
// re-measure (and re-split) on every tick of the rotation.
//
// Wrapped in an IIFE so it contributes NO top-level bindings; these files share
// one global scope. Exported as window.Services.
(function () {
const { useEffect, useRef } = React;

// Ornament tuning. Geometry math lives in services-geometry.js; these are the
// dials that decide how it looks.
const CFG = {
  // Pacing is derived per panel from its own word count rather than fixed. The
  // copy now runs to four paragraphs on the first item, and the old flat 7s
  // dwell rotated it away long before anyone could finish reading -- which also
  // made a reading-time emphasis delay impossible, since it would never fire
  // before the panel closed. See panelPacing in services-geometry.js.
  read: { wpm: 260,
          dwellAt: 0.5, tail: 2.5, dwellMin: 8, dwellMax: 20,
          emAt: 0.6, emMin: 2.5, emTail: 4 },
  em: { from: 400, to: 600, dur: 0.55, stagger: 0.1 },
  hold: 0.34,    // s before an ornament starts, so it reads as a response

  trace:     { width: 3.0, lift: 0.46, amp: 0.17, noise: 0.42, cycles: 2.2, steps: 64, seed: 1337 },
  neurons:   { boldTo: 700, satScale: 0.40, gap: 0.14,
               sats: [{ x: 1.45, y: -1.45 }, { x: 1.45, y: 1.45 }] },
  // "sample" reads as a probability distribution being drawn from. spread and
  // drift are fractions of the heading's font size so the cloud scales with the
  // clamp(); alphas are the visible range mapped from probability mass.
  sample:    { candidates: 4, seed: 5, temperature: 0.55, logitScale: 3.2,
               spread: 0.40, drift: 0.12, minAlpha: 0.07, maxAlpha: 0.34,
               draws: 3, beat: 0.85, collapse: 0.62, rest: 1.4 },
  lens:      { zoom: 1.28, ink: 0.020, ringR: 1.75, sweep: 1.30 }
};

// Which character each ornament anchors to. 'sample' has no anchor -- it
// duplicates the whole word rather than decorating one glyph.
const ANCHOR = { trace: 'i', neurons: 'o', lens: 'o' };

// Copy. `body` is a list of paragraphs, and the one load-bearing phrase in each
// service is wrapped in **markers** -- splitEmphasis turns those into <strong>
// and the panel animates their weight up on open. One phrase per service, not
// per paragraph: emphasis stops meaning anything once it is everywhere.
const SERVICES = [
  { orn: 'trace', word: 'Data & signal',
    head: 'Most projects die before a model is chosen.',
    body: [
      'The data exists. **The signal doesn’t.** Feed that gap to a retriever and it returns noise; feed it to a fine-tune and you’ve baked the noise in permanently.',
      'There’s no standard fix. It depends on what the model needs to learn. Sometimes that means pulling clean structure out of documents built for humans. Sometimes it means generating training pairs from unlabeled text. Sometimes it means throwing most of the corpus away.',
      'That last one is equal parts engineering and judgment, and it’s the part nobody wants to own. I do.',
      'Do that first and the model question gets smaller.'
    ],
    tags: ['Document extraction', 'Data pipelines', 'Training set construction', 'RAG ingestion'] },

  { orn: 'neurons', word: 'Models',
    head: 'Defaults are expensive.',
    body: [
      'Reaching for the largest general model keeps charging you, per call, per second of latency, every time it does something you can’t explain. The requirement rarely needs it. It needs one thing done reliably, at a price that survives scale.',
      'The answer is **composition rather than escalation**. Use a small model fine-tuned on your data, a specialist for the one hard subtask, an API model where breadth pays. Knowing which is which comes from measuring, not preference. The measurement has to exist before the model is chosen, or you’re just picking a favorite.'
    ],
    tags: ['Model selection', 'Fine-tuning', 'Eval harnesses', 'Cost & latency tuning'] },

  { orn: 'sample', word: 'AI systems',
    head: 'The model is the unreliable part.',
    body: [
      'Every system built on a probabilistic component needs a deterministic frame around it. Instructions in a prompt are requests. **Constraints in code are guarantees**. They decide what a step is allowed to reach, when a loop is permitted to stop, whether an answer ships without the evidence it claims to rest on.',
      'The difference only shows up under load. A system that trusts the model to report its own progress works until the day it doesn’t, and then fails in a way nobody can reconstruct afterwards. One that tracks that state independently fails visibly, somewhere you can find it. Same model, same prompts. Different system.'
    ],
    tags: ['Agent architecture', 'Retrieval & RAG', 'Tool & MCP integration', 'Guardrails & evaluation'] },

  { orn: 'lens', word: 'Production',
    head: 'Launch is the start of the problem.',
    body: [
      'Nothing about a deployed AI system holds still. The provider deprecates the model you built on. Your data drifts away from what you tested against. Users find inputs you never imagined. The system that passed evaluation in March is a different system by September, and it didn’t tell you.',
      'Which is why **the eval suite matters more after launch than before it**, and why every run needs a trace someone can read. You want to catch the degradation on a dashboard rather than in a complaint. For the decisions where being wrong is expensive, you want a person in the path, given enough context to judge quickly.'
    ],
    tags: ['MLOps & LLMOps', 'Serving & inference', 'Tracing & observability', 'Human-in-the-loop'] }
];



const NS = 'http://www.w3.org/2000/svg';
const PROBE = 100;           // font size the glyph metrics are measured at once
const ratioCache = {};

function svgEl(name, attrs) {
  const el = document.createElementNS(NS, name);
  for (const k in attrs) el.setAttribute(k, attrs[k]);
  return el;
}

// Glyph metrics as ratios of PROBE, so one measurement serves every clamp() size.
// Returns null where actualBoundingBox* is unsupported; callers fall back to the
// raw client rect, which is coarser but never wrong enough to look broken.
function glyphRatios(font, ch) {
  const key = font + '|' + ch;
  if (key in ratioCache) return ratioCache[key];
  const c = document.createElement('canvas').getContext('2d');
  c.font = font;
  const m = c.measureText(ch);
  ratioCache[key] = (m && typeof m.actualBoundingBoxAscent === 'number') ? {
    fAsc: m.fontBoundingBoxAscent / PROBE, fDesc: m.fontBoundingBoxDescent / PROBE,
    aAsc: m.actualBoundingBoxAscent / PROBE, aDesc: m.actualBoundingBoxDescent / PROBE,
    aLeft: m.actualBoundingBoxLeft / PROBE, aRight: m.actualBoundingBoxRight / PROBE
  } : null;
  return ratioCache[key];
}

// Ink box of one character of `base`, in coordinates relative to `word`.
// Uses a Range so the rect is the real laid-out glyph, not an estimate.
function measureChar(base, word, ch) {
  const geo = window.ServicesGeo;
  // Nothing here is measurable unless the element is in the document. On a
  // DETACHED element getComputedStyle returns an empty declaration, so fontSize
  // is "" and parseFloat("") is NaN -- and that NaN reaches an SVG attribute as
  // `x1="NaN"` before anything notices. This is not a visibility check and must
  // not be relaxed into one: an ATTACHED display:none heading still reports a
  // real font size and measures fine.
  //
  // The window this closes is real. Barba removes the outgoing container before
  // router.js gets to unmount the React root, and that unmount sits behind
  // ensureScripts plus a poll. In between, the section is detached, the
  // rotation's `destroyed` flag is still false, and its progress bar is still
  // counting down toward the callback that lands here.
  if (!base || !word || !base.isConnected || !word.isConnected) return null;

  const text = base.textContent;
  const i = text.toLowerCase().indexOf(ch);
  if (i < 0 || !base.firstChild) return null;

  const range = document.createRange();
  range.setStart(base.firstChild, i);
  range.setEnd(base.firstChild, i + 1);

  const a = range.getBoundingClientRect();
  const b = word.getBoundingClientRect();
  const cs = getComputedStyle(word);
  const F = parseFloat(cs.fontSize);
  const rr = glyphRatios(cs.fontWeight + ' ' + PROBE + 'px ' + cs.fontFamily, text[i]);
  const x0 = a.left - b.left, y0 = a.top - b.top;

  let g;
  if (rr) {
    const ib = geo.inkBox(F, a.height, rr);
    g = { left: x0 + ib.left, right: x0 + ib.right, top: y0 + ib.top, bottom: y0 + ib.bottom,
          cx: x0 + ib.cx, cy: y0 + ib.cy, r: ib.r, ratios: rr };
  } else {
    g = { left: x0, right: x0 + a.width, top: y0, bottom: y0 + a.height,
          cx: x0 + a.width / 2, cy: y0 + a.height * 0.6, r: a.width * 0.48, ratios: null };
  }
  g.w = g.right - g.left;
  g.h = g.bottom - g.top;
  g.u = g.r * 2;
  g.fs = F;
  g.text = text;
  // Returning null rather than a geometry full of NaN is what makes every caller
  // safe, because they already gate on `g` being truthy. One guard here beats a
  // check in each of the four ornament builders.
  return geo.usableGeom(g) ? g : null;
}

// ---------------------------------------------------------------------------
// initServices(root) -> cleanup()
// ---------------------------------------------------------------------------
function initServices(root) {
  const geo = window.ServicesGeo;
  const list = root.querySelector('.sv-list');
  const items = Array.prototype.slice.call(root.querySelectorAll('.sv-item'));
  if (!list || !items.length) return function () {};

  const desktop = window.matchMedia('(min-width: 901px)');
  const hoverable = window.matchMedia('(hover: hover)');
  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');

  let index = 0;
  let bar = null;          // the running WAAPI progress animation

  let live = null;         // the current heading's timeline + injected layers
  let ticker = null;       // the lens's per-frame updater
  let clipSeq = 0;
  let destroyed = false;
  let resizeTimer = null;
  let gsapPoll = null;
  let emTween = null;       // the panel's bold-the-key-phrase tween

  // Measured once at mount: pacing depends only on the copy, which is static.
  // Heading and paragraphs only -- the tag pills are scanned rather than read,
  // and counting them inflated every panel's reading time by about a second.
  const pacing = items.map(function (it) {
    const parts = it.querySelectorAll('.sv-panel h3, .sv-panel p');
    let text = '';
    parts.forEach(function (n) { text += ' ' + n.textContent; });
    return geo.panelPacing(text, CFG.read);
  });

  function gsapReady() {
    return !!(window.gsap && window.SplitText);
  }
  function ornamentsAllowed() {
    return gsapReady() && desktop.matches && !reduced.matches;
  }

  function addLayer(word, cls, layers) {
    const n = svgEl('svg', { class: cls });
    word.appendChild(n);
    layers.push(n);
    return n;
  }

  // -- 01 Data & signal: a jagged trace settling onto the wave beneath it ----
  function ornTrace(word, g, tl, layers) {
    const C = CFG.trace;
    const svg = addLayer(word, 'sv-plot', layers);
    const rand = geo.rng(C.seed);
    const shape = {
      x1: g.cx - (g.fs * C.width) / 2, width: g.fs * C.width,
      cy: g.top - g.fs * C.lift, amp: g.fs * C.amp,
      cycles: C.cycles, steps: C.steps
    };
    // noisy first: it consumes the PRNG, so the clean pass must not
    const noisyD = geo.tracePath(shape, function () { return (rand() - 0.5) * g.fs * C.noise; });
    const cleanD = geo.tracePath(shape);

    const line = svgEl('path', {
      d: noisyD, fill: 'none', stroke: 'var(--ink)',
      'stroke-width': Math.max(1.1, g.fs * 0.012),
      'stroke-linejoin': 'round', 'stroke-linecap': 'round', opacity: 0
    });
    svg.appendChild(line);

    tl.to(line, { opacity: 0.8, duration: 0.3, ease: 'power2.out' }, CFG.hold)
      .to(line, { attr: { d: cleanD }, duration: 1.0, ease: 'power2.inOut' }, CFG.hold + 0.44)
      // and it never quite stays clean
      .to(line, { attr: { d: noisyD }, duration: 1.3, ease: 'sine.inOut',
                  repeat: -1, yoyo: true, repeatDelay: 1.8 }, CFG.hold + 2.1);
  }

  // -- 02 Models: the o forks forward into two more o's ---------------------
  function ornNeurons(word, hub, g, tl, layers) {
    const C = CFG.neurons;
    const wires = addLayer(word, 'sv-wires', layers);
    const sats = document.createElement('div');
    sats.className = 'sv-sats';
    word.appendChild(sats);
    layers.push(sats);

    const fsSat = g.fs * C.satScale;
    const satInk = g.ratios ? geo.inkBox(fsSat, fsSat, g.ratios) : null;
    const rSat = satInk ? satInk.r : g.r * C.satScale;
    const gap = g.u * C.gap;
    const centres = geo.satPositions({ x: g.cx, y: g.cy, r: g.r }, C.sats, g.u, rSat);

    const lines = [], lengths = [], dots = [];
    centres.forEach(function (p) {
      const e = geo.wireEnds({ x: g.cx, y: g.cy, r: g.r }, p, gap);
      const line = svgEl('line', {
        x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2,
        stroke: 'var(--ink)', 'stroke-width': Math.max(1.1, g.fs * 0.010),
        'stroke-linecap': 'round', opacity: 0.85
      });
      wires.appendChild(line);
      lines.push(line);
      // Measured from the endpoints we just computed, not from the DOM. See
      // geo.lineLength: getTotalLength() throws on a non-rendered element, and
      // for a straight segment the browser has nothing to tell us anyway.
      lengths.push(geo.lineLength(e));

      const o = document.createElement('span');
      o.className = 'sv-sat';
      o.textContent = 'o';
      o.style.left = p.x + 'px';
      o.style.top = p.y + 'px';
      o.style.fontSize = fsSat + 'px';
      sats.appendChild(o);

      // GSAP owns the transform, so the centring offset has to live in x/y
      // rather than a CSS translate it would overwrite
      if (satInk) {
        window.gsap.set(o, { x: -satInk.cx, y: -satInk.cy,
                             transformOrigin: satInk.cx + 'px ' + satInk.cy + 'px' });
      } else {
        window.gsap.set(o, { xPercent: -50, yPercent: -50 });
      }
      dots.push(o);
    });

    lines.forEach(function (l, i) {
      const len = lengths[i];
      window.gsap.set(l, { attr: { 'stroke-dasharray': len, 'stroke-dashoffset': len } });
    });

    if (hub) tl.to(hub, { fontWeight: C.boldTo, duration: 0.42, ease: 'power2.out' }, CFG.hold);
    tl.to(lines, { attr: { 'stroke-dashoffset': 0 }, duration: 0.40,
                   ease: 'power2.out', stagger: 0.07 }, CFG.hold + 0.18)
      .to(dots, { opacity: 1, scale: 1, duration: 0.34, ease: 'back.out(2.6)',
                  stagger: 0.07, startAt: { scale: 0.2 } }, CFG.hold + 0.36)
      .to(dots, { opacity: 0.5, duration: 1.1, ease: 'sine.inOut',
                  repeat: -1, yoyo: true, stagger: { each: 0.16 } }, CFG.hold + 0.9);
  }

  // -- 03 AI systems: a distribution being sampled from ---------------------
  // Candidate copies of the word are laid out by probability mass -- the likely
  // one hugs the settled heading, long shots sit further out -- then the weights
  // are redrawn a few times before one candidate wins and the rest fall away.
  //
  // The previous version faded three IDENTICAL ghosts in and out behind a 1.6px
  // blur at alpha 0.16. Measured, that peaked under 0.10 opacity, and because
  // every ghost shared one alpha there was nothing to say which outcome was
  // likely: it read as a smear rather than a distribution. Hence weights driving
  // both position and opacity, and no blur at all -- these are meant to be
  // distinct alternatives, not one soft one.
  function ornSample(word, text, tl, layers) {
    const C = CFG.sample;
    const gsap = window.gsap;
    const fs = parseFloat(getComputedStyle(word).fontSize) || 48;

    const box = document.createElement('div');
    box.className = 'sv-ghosts';
    word.appendChild(box);
    layers.push(box);

    const ghosts = [];
    for (let i = 0; i < C.candidates; i++) {
      const el = document.createElement('span');
      el.className = 'sv-ghost';
      el.textContent = text;
      box.appendChild(el);
      ghosts.push(el);
    }

    const rand = geo.rng(C.seed);
    const spread = fs * C.spread;
    const drift = fs * C.drift;
    const alphaFor = (rel) => C.minAlpha + (C.maxAlpha - C.minAlpha) * rel;

    // Seeded, so every cycle replays the same draws. Deliberate, and the same
    // call the signal trace makes: a distribution that reshuffles differently on
    // every loop reads as a glitch, not as a distribution.
    const loop = gsap.timeline({ repeat: -1, repeatDelay: C.rest });

    for (let d = 0; d < C.draws; d++) {
      const logits = [];
      for (let i = 0; i < C.candidates; i++) logits.push(rand() * C.logitScale);
      const weights = geo.softmax(logits, C.temperature);
      const layout = geo.candidateLayout(weights, spread, drift);
      const at = d * C.beat;

      layout.forEach((p, i) => {
        loop.to(ghosts[i], {
          x: p.x, y: p.y, opacity: alphaFor(p.rel),
          duration: C.beat * (d === 0 ? 0.7 : 0.8),
          ease: d === 0 ? 'power2.out' : 'power2.inOut'
        }, at);
      });

      // last redraw decides it: a real weighted draw, not argmax, so the winner
      // is not always the fattest bar
      if (d === C.draws - 1) {
        const win = geo.sampleIndex(weights, rand());
        const collapseAt = C.draws * C.beat;
        ghosts.forEach((el, i) => {
          const won = i === win;
          loop.to(el, {
            x: 0, y: 0, opacity: 0,
            duration: won ? C.collapse : C.collapse * 0.55,
            ease: won ? 'power3.inOut' : 'power1.in'
          }, collapseAt + (won ? 0.12 : 0));
        });
      }
    }

    tl.add(loop, CFG.hold);
  }

  // -- 04 Production: a glass sweeps the word, magnifying only its window ---
  function ornLens(word, base, g, tl, layers) {
    const C = CFG.lens;
    const gsap = window.gsap;

    const mag = document.createElement('span');
    mag.className = 'sv-mag';
    mag.setAttribute('aria-hidden', 'true');
    mag.textContent = g.text;   // plain text, so its metrics match base by construction
    word.appendChild(mag);

    const id = 'sv-lenscut-' + (++clipSeq);
    const defs = svgEl('svg', { class: 'sv-defs' });
    const cp = svgEl('clipPath', { id: id, clipPathUnits: 'userSpaceOnUse' });
    const cutPath = svgEl('path', { d: geo.lensCut(0, 0, 0), 'clip-rule': 'evenodd' });
    cp.appendChild(cutPath);
    defs.appendChild(cp);
    word.appendChild(defs);
    base.style.clipPath = 'url(#' + id + ')';

    const glass = svgEl('svg', { class: 'sv-glass' });
    word.appendChild(glass);
    layers.push(mag, defs, glass);

    const R = g.r * C.ringR;
    const Rc = R * 0.96;                      // visible window
    const pad = Math.max(1, g.fs * 0.004);    // hole a hair wider, hidden under the ring
    const ang = Math.PI / 4;

    const grp = svgEl('g', {});
    grp.appendChild(svgEl('circle', {
      cx: g.cx, cy: g.cy, r: R, fill: 'none', stroke: 'var(--ink)',
      'stroke-width': Math.max(2.2, g.fs * 0.030)
    }));
    grp.appendChild(svgEl('line', {
      x1: g.cx + Math.cos(ang) * R, y1: g.cy + Math.sin(ang) * R,
      x2: g.cx + Math.cos(ang) * R * 2.05, y2: g.cy + Math.sin(ang) * R * 2.05,
      stroke: 'var(--ink)', 'stroke-width': Math.max(2.8, g.fs * 0.038),
      'stroke-linecap': 'round'
    }));
    glass.appendChild(grp);

    const state = { on: 0, k: 1, sw: 0 };
    const swMax = Math.max(1, g.fs * C.ink);

    const tick = function () {
      const x = gsap.getProperty(grp, 'x') || 0;
      const y = gsap.getProperty(grp, 'y') || 0;
      const cx = g.cx + x, cy = g.cy + y;
      const shape = 'circle(' + geo.lensWindowRadius(Rc, state.k) + 'px at ' + cx + 'px ' + cy + 'px)';
      mag.style.clipPath = shape;
      mag.style.webkitClipPath = shape;
      mag.style.transformOrigin = cx + 'px ' + cy + 'px';
      mag.style.transform = 'scale(' + state.k + ')';
      mag.style.webkitTextStrokeWidth = state.sw + 'px';
      mag.style.opacity = state.on;
      cutPath.setAttribute('d', geo.lensCut(cx, cy, (Rc + pad) * state.on));
    };
    gsap.ticker.add(tick);
    ticker = { fn: tick, base: base };
    tick();

    tl.from(grp, { x: R * 3.4, y: R * 2.8, scale: 0.82, opacity: 0,
                   duration: 0.62, ease: 'power3.out',
                   svgOrigin: g.cx + ' ' + g.cy }, CFG.hold)
      .to(state, { on: 1, k: C.zoom, sw: swMax, duration: 0.40, ease: 'power2.out' }, CFG.hold + 0.34)
      .to(grp, { x: R * C.sweep, y: -R * 0.20, rotation: -6,
                 svgOrigin: g.cx + ' ' + g.cy, duration: 2.6, ease: 'sine.inOut',
                 repeat: -1, yoyo: true }, CFG.hold + 0.95);
  }

  // -- heading lifecycle ----------------------------------------------------
  function clearHeading() {
    if (ticker) {
      window.gsap.ticker.remove(ticker.fn);
      ticker.base.style.clipPath = '';
      ticker = null;
    }
    if (!live) return;
    if (live.tl) live.tl.kill();
    live.layers.forEach(function (n) { if (n.parentNode) n.parentNode.removeChild(n); });
    if (live.split) live.split.revert();
    live = null;
  }

  function animateHeading(item) {
    clearHeading();
    if (!ornamentsAllowed()) return;

    const word = item.querySelector('.sv-word');
    const base = item.querySelector('.sv-base');
    if (!word || !base) return;

    const kind = word.getAttribute('data-orn');
    const text = base.textContent;

    // measure BEFORE anything rewrites the heading: SplitText replaces the text
    // node outright, so measureChar's Range would have nothing left to measure
    const g = ANCHOR[kind] ? measureChar(base, word, ANCHOR[kind]) : null;

    const layers = [];
    const tl = window.gsap.timeline();
    let split = null;

    // The headings themselves do not animate in -- they hold position, and the
    // only movement on activation is the index sliding in and pushing the word
    // right, which is pure CSS (see .sv-idx in pages.css). So there is no
    // per-character entry tween here, and three of the four ornaments never need
    // the word split at all.
    if (kind === 'trace' && g) ornTrace(word, g, tl, layers);
    if (kind === 'neurons' && g) {
      // Only this ornament needs per-character access: it thickens the very "o"
      // the wires fan out of, which means reaching that one glyph as an element.
      split = new window.SplitText(base, { type: 'chars', charsClass: 'sv-ch' });
      let hub = null;
      for (let i = 0; i < split.chars.length; i++) {
        if (split.chars[i].textContent.toLowerCase() === 'o') { hub = split.chars[i]; break; }
      }
      ornNeurons(word, hub, g, tl, layers);
    }
    if (kind === 'sample') ornSample(word, text, tl, layers);
    if (kind === 'lens' && g) ornLens(word, base, g, tl, layers);

    live = { split: split, tl: tl, layers: layers };
  }

  // -- panel emphasis ------------------------------------------------------
  // Animates the marked phrase from normal weight up to semibold once the panel
  // has settled. Only font-weight is tweened: the phrase's colour comes from CSS
  // as --ink against the body's --ink-soft, and GSAP cannot interpolate between
  // two var() references, so animating colour here would mean hardcoding theme
  // literals. Space Grotesk is loaded as a 300..700 variable face, so the weight
  // ramp is continuous rather than snapping between static instances.
  //
  // Deliberately NOT gated on ornamentsAllowed(): the emphasis is content, not
  // decoration, so it also runs on the mobile accordion. When gsap is missing or
  // reduced-motion is set, CSS alone leaves the phrase bold -- the emphasis is
  // never lost, it just stops moving.
  function animateEmphasis(i) {
    const item = items[i];
    // clearProps on kill, not just kill: the tween starts BELOW the CSS weight,
    // so an interrupted one would leave the phrase stranded at 400 with its
    // emphasis silently gone. Dropping the inline value lets the stylesheet's
    // 600 reassert itself instead.
    if (emTween) {
      const prev = emTween.targets();
      emTween.kill();
      emTween = null;
      if (window.gsap && prev.length) window.gsap.set(prev, { clearProps: 'fontWeight' });
    }
    if (!window.gsap || reduced.matches) return;
    const ems = item.querySelectorAll('.sv-em');
    if (!ems.length) return;
    const C = CFG.em;
    // delay is the reader's, not the designer's: it lands once the paragraph has
    // plausibly been read, so the phrase reads as the point rather than a label
    emTween = window.gsap.fromTo(ems,
      { fontWeight: C.from },
      { fontWeight: C.to, duration: C.dur, delay: pacing[i].emDelay,
        stagger: C.stagger, ease: 'power2.out' });
  }

  // -- rotation ------------------------------------------------------------
  // No "manual" latch any more: a click used to stop the rotation permanently,
  // which meant clicking the open item did nothing visible. Now a click just
  // re-enters show(), and runBar restarts the countdown from zero.
  function autoplays() { return desktop.matches && !reduced.matches; }

  function stopBar() {
    if (bar) { bar.cancel(); bar = null; }
  }

  function runBar(i) {
    stopBar();
    if (!autoplays()) return;
    const el = items[i].querySelector('.sv-bar');
    if (!el || !el.animate) return;
    // Do not start the clock for a section that is no longer in the document.
    // This bar's onfinish is what drives the whole rotation, so a countdown that
    // outlives its own DOM is the engine behind every downstream failure here --
    // `destroyed` alone does not catch it, because the React cleanup that sets it
    // runs after barba has already detached the container.
    if (!el.isConnected) return;
    bar = el.animate(
      [{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }],
      { duration: pacing[i].dwell * 1000, easing: 'linear', fill: 'forwards' }
    );
    bar.onfinish = function () { if (!destroyed) show(index + 1); };
  }

  function show(next) {
    index = ((next % items.length) + items.length) % items.length;
    items.forEach(function (item, i) {
      const on = i === index;
      item.classList.toggle('is-on', on);
      item.querySelector('.sv-name').setAttribute('aria-expanded', String(on));
      if (!on) item.querySelector('.sv-bar').style.transform = 'scaleX(0)';
    });
    animateHeading(items[index]);
    animateEmphasis(index);
    runBar(index);
  }

  // -- wiring --------------------------------------------------------------
  const offs = [];
  function on(target, type, fn) {
    target.addEventListener(type, fn);
    offs.push(function () { target.removeEventListener(type, fn); });
  }

  items.forEach(function (item, i) {
    const btn = item.querySelector('.sv-name');
    on(btn, 'click', function () {
      // phones get an accordion, so a second tap on the open item closes it
      if (!desktop.matches && item.classList.contains('is-on')) {
        stopBar();
        item.classList.remove('is-on');
        btn.setAttribute('aria-expanded', 'false');
        return;
      }
      // On desktop a click on the OPEN item is a restart: show() re-runs the
      // ornament, re-arms the emphasis reveal and starts the bar again from zero.
      show(i);
    });
    on(btn, 'focus', function () {
      if (desktop.matches && i !== index) show(i);
    });
    if (hoverable.matches) {
      on(item, 'mouseenter', function () {
        if (desktop.matches && i !== index) show(i);
      });
    }
  });

  // reading the panel pauses the countdown rather than yanking it away
  on(list, 'mouseover', function (e) {
    if (bar && e.target.closest('.sv-panel')) bar.pause();
  });
  on(list, 'mouseout', function (e) {
    if (bar && e.target.closest('.sv-panel') && !list.contains(e.relatedTarget)) bar.play();
  });
  on(document, 'visibilitychange', function () {
    if (!bar) return;
    if (document.hidden) bar.pause(); else bar.play();
  });
  on(window, 'resize', function () {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(function () {
      if (!destroyed) animateHeading(items[index]);
    }, 200);
  });

  show(0);

  // GSAP is injected per-namespace by router.js, so on a first visit it may
  // still be in flight when this runs. The section is fully usable without it --
  // only the ornaments are missing -- so poll briefly and upgrade in place.
  if (!gsapReady()) {
    let waited = 0;
    gsapPoll = setInterval(function () {
      waited += 120;
      if (destroyed || waited > 6000) { clearInterval(gsapPoll); gsapPoll = null; return; }
      if (gsapReady()) {
        clearInterval(gsapPoll);
        gsapPoll = null;
        animateHeading(items[index]);
      }
    }, 120);
  }

  return function cleanup() {
    destroyed = true;
    if (emTween) {
      const prev = emTween.targets();
      emTween.kill();
      if (window.gsap && prev.length) window.gsap.set(prev, { clearProps: 'fontWeight' });
    }
    if (gsapPoll) clearInterval(gsapPoll);
    clearTimeout(resizeTimer);
    stopBar();
    if (gsapReady()) clearHeading();
    offs.forEach(function (off) { off(); });
  };
}

function Services() {
  const geo = window.ServicesGeo;
  const rootRef = useRef(null);
  useEffect(function () {
    // Never let this throw. React 18 unmounts the ENTIRE root when an error
    // escapes an effect, and there is no error boundary above this, so one bad
    // measurement in a decorative ornament took the whole page blank -- verified.
    // The ornaments are decoration; the page is not. A caught failure costs an
    // animation, an uncaught one costs everything.
    try {
      const cleanup = initServices(rootRef.current);
      return function () {
        try { cleanup(); } catch (err) { console.error('[services] cleanup failed:', err); }
      };
    } catch (err) {
      console.error('[services] init failed; section renders without ornaments:', err);
      return undefined;
    }
  }, []);

  return (
    <section className="frame-wrap" ref={rootRef} style={{ paddingBottom: 78 }}>
      <div className="sv-rule"><span className="sv-dot" aria-hidden="true" /></div>
      <div className="section-label" style={{ marginTop: 30 }}>
        <span className="section-label-tick" />Services
      </div>

      {/* Explicit rows, one per service. .sv-panel is placed `grid-row: 1 / -1`
          so it spans the whole name column, but -1 resolves against the EXPLICIT
          grid -- with implicit rows it collapses to row 1 alone, every panel
          piles into that one row, and row 1 inflates to the tallest panel. That
          put a 132px gap after the first heading and none after the others.
          Set here rather than in CSS so the count cannot drift from SERVICES. */}
      <div className="sv-list"
        style={{ gridTemplateRows: `repeat(${SERVICES.length}, auto)` }}>
        {SERVICES.map((s, i) =>
          <article className={`sv-item${i === 0 ? ' is-on' : ''}`} key={s.word}>
            <button className="sv-name" type="button"
              aria-expanded={i === 0} aria-controls={`sv-panel-${i}`}>
              <span className="sv-idx">0{i + 1}</span>
              <span className="sv-word" data-orn={s.orn}>
                <span className="sv-base">{s.word}</span>
              </span>
              <span className="sv-arrow" aria-hidden="true">&#8599;</span>
              <span className="sv-track" aria-hidden="true"><i className="sv-bar" /></span>
            </button>
            <div className="sv-panel" id={`sv-panel-${i}`}>
              <h3>{s.head}</h3>
              {s.body.map((para, p) =>
                <p key={p}>
                  {geo.splitEmphasis(para).map((run, r) =>
                    run.em
                      ? <strong className="sv-em" key={r}>{run.text}</strong>
                      : run.text
                  )}
                </p>
              )}
              <div className="sv-tags">
                {s.tags.map((t) => <span key={t}>{t}</span>)}
              </div>
            </div>
          </article>
        )}
      </div>

      <div className="sv-rule" />
    </section>);

}

window.Services = Services;
})();
