// pentagon-anim-v22.jsx
// QWings — v22: v20 base with stacked header labels for all pieces EXCEPT the
// middle one (AI DATA COPILOT stays single-line). Smooth crossfade between
// single-line and stacked variants across phase A so the wrap doesn't snap.
// Loop sequence:
//   0–9    Intro: spikes + labels + tagline animate in (typewriter)
//   9–11   PENTAGON_ANCHOR — full pentagon hold (read time)
//  11–13   Unfold (forward)
//  13–14.5 Lift + morph + spread
//  14.7–15.7 Table body reveals
//  17.5    TABLE_ANCHOR — full table hold (read time)
//  20.5–22.5 REVERSE: morph back, fold the chain into the pentagon
//  22.5–26   Pentagon shown again (no intro animation); loop
//
// Bottom piece (AI Data Copilot) is the ANCHOR — never moves, stays horizontal.
// Other pieces unfold around shared inner-edge vertices like a chain:
//   Copilot ←—(V3)— Data ←—(V4)— Valuation
//   Copilot —(V2)→ Visual —(V1)→ Mapping
// At u=0 the chain is closed (pentagon). At u=1 the chain is straight (row).
// Then the whole row lifts up & morphs trapezoid → rectangle for the table headers.

// ── Geometry constants ─────────────────────────────────────────────────────
const CX = 960;
const CY = 540;
const R_OUTER = 400;
const R_INNER = 240;
const APOTHEM_OUTER = R_OUTER * Math.cos(Math.PI / 5);
const APOTHEM_INNER = R_INNER * Math.cos(Math.PI / 5);
const HALF_OUTER_SIDE = R_OUTER * Math.sin(Math.PI / 5);
const HALF_INNER_SIDE = R_INNER * Math.sin(Math.PI / 5);
const PIECE_HEIGHT = APOTHEM_OUTER - APOTHEM_INNER;
const PIECE_CENTER_DIST = (APOTHEM_OUTER + APOTHEM_INNER) / 2;

// Header (final) geometry
const HEADER_W = 332;
const HEADER_H = 84;
const HEADER_GAP = 14;
const HEADER_TOTAL_W = 5 * HEADER_W + 4 * HEADER_GAP;
const HEADER_X_START = (1920 - HEADER_TOTAL_W) / 2;
const HEADER_Y = 200;

// Interior pentagon angle: 108°. Each hinge must rotate by 72° to flatten.
const UNFOLD_ANGLE = 72;

// ── Anchor times (auto-pause points) ───────────────────────────────────────
// Two natural "rest states" the animation lands on so a viewer can read:
//   PENTAGON_ANCHOR — pentagon assembled, all labels + tagline visible
//   TABLE_ANCHOR    — table fully revealed (header + descriptor + signature)
// InteractiveControls snaps playback to these on crossing.
// Pentagon is fully present at t=0. Anchors shifted earlier accordingly.
const PENTAGON_ANCHOR = 9.0;
const TABLE_ANCHOR = 17.5;
const REVERSE_START = 20.5;
const REVERSE_END = 22.5;
const TOTAL_DURATION = 26;

// ── Piece definitions ──────────────────────────────────────────────────────
// Column order matches the unfolded chain (left → right):
//   Valuation — Data — Copilot(anchor) — Visual — Mapping
const PIECES = [
  {
    label: "VALUATION SOLUTIONS",
    headerLabel: "VALUATION\nSOLUTIONS",
    wrapAfter: 0,
    color: "#2C4358",
    textColor: "#ffffff",
    sideIdx: 4,
    descriptor: "AI-driven Automated Valuation Models — independently ranked #1 for accuracy and national coverage.",
    metric: "#1-ranked AVM",
  },
  {
    label: "DATA AND ANALYTICS",
    headerLabel: "DATA &\nANALYTICS",
    wrapAfter: 1,
    color: "#A9B0B4",
    textColor: "#1d262c",
    sideIdx: 3,
    descriptor: "Deep property intelligence across 158M+ U.S. properties — attributes, transactions, mortgages, records.",
    metric: "158M+ properties",
  },
  {
    label: "AI DATA COPILOT",
    color: "#00C8E6",
    textColor: "#0a2530",
    sideIdx: 2, // ANCHOR
    descriptor: "Large language models layered on Quantarium's AI-augmented data lake for conversational answers.",
    metric: "Q Data Copilot",
  },
  {
    label: "VISUAL TECHNOLOGIES",
    headerLabel: "VISUAL\nTECHNOLOGIES",
    wrapAfter: 0,
    color: "#C9CDD0",
    textColor: "#1d262c",
    sideIdx: 1,
    descriptor: "Computer vision that reads property condition, features, and desirability the way an expert appraiser would.",
    metric: "Computer vision",
  },
  {
    label: "INTELLIGENT MAPPING",
    headerLabel: "INTELLIGENT\nMAPPING",
    wrapAfter: 0,
    color: "#6F8492",
    textColor: "#ffffff",
    sideIdx: 0,
    descriptor: "TerraPlot™ — live market exploration through a rich, responsive, visually-driven mapping UI.",
    metric: "TerraPlot™",
  },
];

// ── Helpers ────────────────────────────────────────────────────────────────
const lerp = (a, b, t) => a + (b - a) * t;

function innerVertex(k) {
  const a = -Math.PI / 2 + k * (2 * Math.PI / 5);
  return { x: CX + R_INNER * Math.cos(a), y: CY + R_INNER * Math.sin(a) };
}

const V0 = innerVertex(0); // top inner vertex
const V1 = innerVertex(1); // upper-right inner vertex
const V2 = innerVertex(2); // lower-right inner vertex (joint Copilot↔Visual)
const V3 = innerVertex(3); // lower-left inner vertex  (joint Copilot↔Data)
const V4 = innerVertex(4); // upper-left inner vertex

// Side outward angle (radial direction "away from pentagon center" for side i)
const sideAngle = (s) => -Math.PI / 2 + (s + 0.5) * (2 * Math.PI / 5);

// Piece center in pentagon
function pentagonPos(sideIdx) {
  const t = sideAngle(sideIdx);
  return { x: CX + PIECE_CENTER_DIST * Math.cos(t), y: CY + PIECE_CENTER_DIST * Math.sin(t) };
}

// IMPORTANT: convention for THIS version differs from v1.
// Local piece frame: WIDE (outer) edge at +y (bottom), NARROW (inner) edge at -y (top).
// So Copilot's pentagon rotation is 0° (wide edge already at bottom matches its outward direction).
// Pentagon rotation for any piece = (outward angle θ) − 90°  (degrees)
function pentagonRot(sideIdx) {
  const tDeg = (sideAngle(sideIdx) * 180) / Math.PI;
  return tDeg - 90;
}

// Rotate point p around pivot by angleDeg (CSS convention: positive = clockwise on screen)
function rotatePoint(p, pivot, angleDeg) {
  const t = (angleDeg * Math.PI) / 180;
  const cos = Math.cos(t), sin = Math.sin(t);
  const dx = p.x - pivot.x, dy = p.y - pivot.y;
  return {
    x: pivot.x + dx * cos - dy * sin,
    y: pivot.y + dx * sin + dy * cos,
  };
}

// ── Unfolded state per piece, for u ∈ [0, 1] ───────────────────────────────
// u=0: pentagon assembled.  u=1: chain fully unrolled into a horizontal row
// (all pieces have rotation 0°, matching Copilot's orientation).
function unfoldedState(sideIdx, u) {
  const pent = pentagonPos(sideIdx);
  const pentRot = pentagonRot(sideIdx);
  let pos = { x: pent.x, y: pent.y };
  let rot = pentRot;

  if (sideIdx === 2) {
    // Copilot — anchor, doesn't move
    return { x: pos.x, y: pos.y, rot };
  }
  if (sideIdx === 1) {
    // Visual — rotate around V2 (CW = positive)
    const d = u * UNFOLD_ANGLE;
    pos = rotatePoint(pos, V2, d);
    rot += d;
    return { x: pos.x, y: pos.y, rot };
  }
  if (sideIdx === 0) {
    // Mapping — carried by Visual (around V2), then its own rotation around V1-current
    const d1 = u * UNFOLD_ANGLE;
    pos = rotatePoint(pos, V2, d1);
    const v1Now = rotatePoint(V1, V2, d1);
    const d2 = u * UNFOLD_ANGLE;
    pos = rotatePoint(pos, v1Now, d2);
    rot += d1 + d2;
    return { x: pos.x, y: pos.y, rot };
  }
  if (sideIdx === 3) {
    // Data — rotate around V3 (CCW = negative)
    const d = -u * UNFOLD_ANGLE;
    pos = rotatePoint(pos, V3, d);
    rot += d;
    return { x: pos.x, y: pos.y, rot };
  }
  if (sideIdx === 4) {
    // Valuation — carried by Data (around V3), then own rotation around V4-current
    const d1 = -u * UNFOLD_ANGLE;
    pos = rotatePoint(pos, V3, d1);
    const v4Now = rotatePoint(V4, V3, d1);
    const d2 = -u * UNFOLD_ANGLE;
    pos = rotatePoint(pos, v4Now, d2);
    rot += d1 + d2;
    return { x: pos.x, y: pos.y, rot };
  }
  return { x: pos.x, y: pos.y, rot };
}

// Offscreen entry position (used during initial assembly)
function offscreenPos(sideIdx) {
  const t = sideAngle(sideIdx);
  const r = PIECE_CENTER_DIST + 700;
  return { x: CX + r * Math.cos(t), y: CY + r * Math.sin(t) };
}

// Header (final table) position per column index
function headerPos(pieceIdx) {
  return {
    x: HEADER_X_START + HEADER_W / 2 + pieceIdx * (HEADER_W + HEADER_GAP),
    y: HEADER_Y + HEADER_H / 2,
  };
}

// Pentagon-state label (outside the pentagon, dotted leader line)
function labelPentagonPos(sideIdx) {
  const t = sideAngle(sideIdx);
  // Label distances tuned so the widest labels ("VISUAL TECHNOLOGIES",
  // "VALUATION SOLUTIONS", etc.) fit inside the 1920px canvas with a buffer
  // between the spike tip and the first character.
  const distances = { 0: 510, 1: 510, 2: 470, 3: 510, 4: 510 };
  const r = distances[sideIdx] || 510;
  return { x: CX + r * Math.cos(t), y: CY + r * Math.sin(t) };
}
function labelAnchorPos(sideIdx) {
  const t = sideAngle(sideIdx);
  const r = APOTHEM_OUTER + 14;
  return { x: CX + r * Math.cos(t), y: CY + r * Math.sin(t) };
}

// Build a trapezoid SVG path with a puzzle TAB on the right slant (bumping
// outward) and a matching SLOT on the left slant (cut inward). Every piece
// uses the same signature — tab on right, slot on left — so each piece's tab
// fits the next piece's slot all the way around the ring. tabR is the puzzle
// nub radius; when it shrinks to ~0 the path collapses to a plain trapezoid.
function puzzlePath(outerHalfW, innerHalfW, halfH, tabR) {
  if (tabR < 0.5) {
    return `M ${-outerHalfW} ${halfH} L ${outerHalfW} ${halfH} L ${innerHalfW} ${-halfH} L ${-innerHalfW} ${-halfH} Z`;
  }
  // Right slant unit direction (outer-bottom-right → inner-top-right)
  const rdx = innerHalfW - outerHalfW, rdy = -2 * halfH;
  const rLen = Math.hypot(rdx, rdy);
  const rux = rdx / rLen, ruy = rdy / rLen;
  const rMx = (outerHalfW + innerHalfW) / 2, rMy = 0;
  const rSx = rMx - rux * tabR, rSy = rMy - ruy * tabR;
  const rEx = rMx + rux * tabR, rEy = rMy + ruy * tabR;
  // Left slant unit direction (inner-top-left → outer-bottom-left)
  const ldx = innerHalfW - outerHalfW, ldy = 2 * halfH;
  const lLen = Math.hypot(ldx, ldy);
  const lux = ldx / lLen, luy = ldy / lLen;
  const lMx = -(outerHalfW + innerHalfW) / 2, lMy = 0;
  const lSx = lMx - lux * tabR, lSy = lMy - luy * tabR;
  const lEx = lMx + lux * tabR, lEy = lMy + luy * tabR;
  return [
    `M ${-outerHalfW} ${halfH}`,
    `L ${outerHalfW} ${halfH}`,
    `L ${rSx} ${rSy}`,
    // Right slant: TAB arc — sweep=0 → bulges OUTward (away from piece center)
    `A ${tabR} ${tabR} 0 0 0 ${rEx} ${rEy}`,
    `L ${innerHalfW} ${-halfH}`,
    `L ${-innerHalfW} ${-halfH}`,
    `L ${lSx} ${lSy}`,
    // Left slant: SLOT arc — sweep=1 → cuts INward (toward piece center)
    `A ${tabR} ${tabR} 0 0 1 ${lEx} ${lEy}`,
    `L ${-outerHalfW} ${halfH}`,
    `Z`,
  ].join(' ');
}

// ── Piece component ────────────────────────────────────────────────────────
function Piece({ pieceIdx, info, t }) {
  const sideIdx = info.sideIdx;
  const pent = pentagonPos(sideIdx);
  const pentRot = pentagonRot(sideIdx);
  const head = headerPos(pieceIdx);
  const off = offscreenPos(sideIdx);

  // ── v11 phase timings: pentagon visible at t=0; serial typewriter labels ──
  // 0.0 – 7.6    spikes + labels animate in clockwise, serial (each finishes typing before next)
  // 7.8 – 11.0   tagline visible, full pentagon hold (read time)
  // 11.0 – 13.0  unfold
  // 13.0 – 14.5  lift + morph + spread
  // 14.7 – 16.0  table body reveals
  // 17.5         TABLE_ANCHOR
  // 16.0 – 23.0  hold table (read time — 7s before loop)

  // All pieces fully visible from t=0 — no stagger.
  const a = 1;

  // Unfold parameter — forward 0→1 over 11–13s, hold at 1 (table), then
  // reverse 1→0 over 20.5–22.5s.
  const uFn = interpolate(
    [0, 12.5, 14.5, REVERSE_START, REVERSE_END, TOTAL_DURATION],
    [0, 0,    1,    1,             0,           0],
    Easing.easeInOutCubic
  );
  const u = uFn(t);

  // Table-spread parameter — same forward+reverse profile.
  const vFn = interpolate(
    [0, 14.5, 16.0, REVERSE_START - 1.0, REVERSE_END, TOTAL_DURATION],
    [0, 0,    1,    1,                   0,           0],
    Easing.easeInOutCubic
  );
  const v = vFn(t);

  // Compute unfolded (pentagon) position
  const unfolded = unfoldedState(sideIdx, u);

  // v8: pieces appear IN their pentagon position (no offscreen blend) — fade only.
  const baseX = unfolded.x;
  const baseY = unfolded.y;
  const baseRot = unfolded.rot;

  // Then blend pentagon → table headers during lift/spread
  const x = lerp(baseX, head.x, v);
  const y = lerp(baseY, head.y, v);
  const rot = lerp(baseRot, 0, v);

  // Shape morph: trapezoid (0) → rectangle (1) on forward, back to trapezoid on reverse.
  const morphFn = interpolate(
    [0, 14.5, 16.0, REVERSE_START - 1.0, REVERSE_END, TOTAL_DURATION],
    [0, 0,    1,    1,                   0,           0],
    Easing.easeInOutCubic
  );
  const morph = morphFn(t);

  // Geometry (local frame: wide outer edge at +y, narrow inner edge at -y)
  const outerHalfW = lerp(HALF_OUTER_SIDE, HEADER_W / 2, morph);
  const innerHalfW = lerp(HALF_INNER_SIDE, HEADER_W / 2, morph);
  const halfH = lerp(PIECE_HEIGHT / 2, HEADER_H / 2, morph);

  // Puzzle tab radius — full size during pentagon + unfold, fades to 0 as the
  // pieces morph into rectangular table headers.
  const tabR = 12 * Math.max(0, 1 - morph);

  // Trapezoid path with puzzle tab on right slant, slot on left slant.
  const path = puzzlePath(outerHalfW, innerHalfW, halfH, tabR);

  const opacity = a;
  // Remove inside-piece header label — the PentagonLabel single element now
  // covers the header static state as well as the transition.
  const insideOpacity = 0;

  return (
    <div style={{
      position: 'absolute', left: 0, top: 0,
      transform: `translate(${x}px, ${y}px) rotate(${rot}deg)`,
      transformOrigin: '0 0',
      opacity,
      willChange: 'transform, opacity',
    }}>
      <div style={{
        position: 'absolute', left: -250, top: -150,
        width: 500, height: 300,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}>
        <svg width="500" height="300" viewBox="-250 -150 500 300" style={{ overflow: 'visible' }}>
          <defs>
            <linearGradient id={`gradV4-${pieceIdx}`} x1="0%" y1="0%" x2="0%" y2="100%">
              <stop offset="0%" stopColor={info.color} stopOpacity="0.96" />
              <stop offset="100%" stopColor={info.color} stopOpacity="1" />
            </linearGradient>
          </defs>
          {/* Fine white separator border between adjacent pieces. Stroke is
              drawn outside the fill so the joint lines read crisply. */}
          <path
            d={path}
            fill={`url(#gradV4-${pieceIdx})`}
            stroke="#ffffff"
            strokeWidth="2.5"
            strokeLinejoin="round"
            strokeLinecap="round"
          />
        </svg>
      </div>

      {/* Header-state label (inside the rectangle once morphed). Counter-rotation
          would only matter if piece ends rotated — here it ends at 0° so plain text is fine. */}
      <div style={{
        position: 'absolute',
        left: -HEADER_W / 2, top: -HEADER_H / 2,
        width: HEADER_W, height: HEADER_H,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        opacity: insideOpacity,
        pointerEvents: 'none',
      }}>
        <span style={{
          fontFamily: 'Inter, system-ui, sans-serif',
          fontWeight: 700, fontSize: 26,
          letterSpacing: '0.06em',
          color: info.textColor,
          textAlign: 'center',
          lineHeight: 1.15,
          padding: '0 12px',
        }}>
          {info.label}
        </span>
      </div>
    </div>
  );
}

// ── Floating pentagon-state label with dotted leader line ──────────────────
function PentagonLabel({ info, pieceIdx, t }) {
  const labelPos = labelPentagonPos(info.sideIdx);
  const anchor = labelAnchorPos(info.sideIdx);

  // Staggered draw-in: clockwise from upper-left.
  const LABEL_ORDER = { 4: 0, 0: 1, 1: 2, 2: 3, 3: 4 };
  const order = LABEL_ORDER[info.sideIdx] || 0;
  // Type each label out and finish BEFORE the wrap (phaseA) begins at t=11.0
  // so the last few chars aren't still appearing while the words start moving.
  const drawStart = 0.0 + order * 2.2;
  const drawEnd = drawStart + 0.9;
  // Typewriter window — starts as the line lands, stretches across ~1.1s
  // (a bit slower than v10 so characters are easier to read).
  const textStart = drawStart + 0.40;
  const textEnd = textStart + 1.30;

  // Line draw progress: 0 = just the anchor, 1 = full line reaches label dot
  const dpFn = interpolate([drawStart, drawEnd, 11], [0, 1, 1], Easing.easeOutCubic);
  const dp = dpFn(t);
  // Spike thickness grows as it extends — narrow at the pentagon, thicker at the label.
  const strokeWidth = lerp(1.0, 2.4, dp);

  // Text fade-in
  const textInFn = interpolate([textStart, textEnd, 11], [0, 1, 1], Easing.easeOutCubic);
  // Label-end dot pops in right as the line completes.
  const dotPopFn = interpolate(
    [drawEnd - 0.18, drawEnd + 0.15, TOTAL_DURATION],
    [0, 1, 1],
    Easing.easeOutBack
  );
  const dotPop = dotPopFn(t);

  // Typewriter progress for THIS piece's label.
  const typeFn = interpolate([textStart, textEnd, TOTAL_DURATION], [0, 1, 1], Easing.linear);
  const typedFraction = typeFn(t);
  const fullLabel = info.label;
  const charsToShow = Math.floor(typedFraction * fullLabel.length + 0.0001);
  const typedLabel = fullLabel.slice(0, charsToShow);
  const showCaret = typedFraction > 0 && typedFraction < 1;

  // Row visibility — starts the moment typing begins so the caret is visible immediately.
  const rowFadeFn = interpolate([textStart - 0.05, textStart + 0.1, TOTAL_DURATION], [0, 1, 1], Easing.easeOutCubic);
  const rowFade = rowFadeFn(t);

  // === V20 single-element transition ===
  // phaseA (style morph): forward 11.0→13.0 / reverse 19.5→21.5
  // phaseB (position travel): forward 13.0→14.5 / reverse 19.0→21.0
  // These overlap at the boundaries with the unfold/lift/morph piece motion so
  // the label rides along smoothly.
  const phaseAFn = interpolate(
    [0, 12.5, 14.5, 20.5, 22.5, TOTAL_DURATION],
    [0, 0,    1,    1,    0,    0],
    Easing.easeInOutCubic
  );
  const phaseA = phaseAFn(t);
  const phaseBFn = interpolate(
    [0, 14.5, 16.0, 19.5, 20.5, TOTAL_DURATION],
    [0, 0,    1,    1,    0,    0],
    Easing.easeInOutCubic
  );
  const phaseB = phaseBFn(t);

  // Leader line + dots — fade out during phase A start, fade in at end of reverse phase A
  const exitFn = interpolate(
    [0, 10.8, 11.3, 21.7, 22.2, TOTAL_DURATION],
    [1, 1,    0,    0,    1,    1],
    Easing.easeInOutCubic
  );
  const exit = exitFn(t);

  const lineOpacity = clamp(dp, 0, 1) * exit;
  const labelDotOpacity = clamp(dotPop, 0, 1) * exit;
  const labelDotR = clamp(dotPop, 0, 1) * 7;
  const anchorDotOpacity = clamp(dp * 4, 0, 1) * exit;
  const textOpacity = rowFade;

  if (lineOpacity + textOpacity + labelDotOpacity + anchorDotOpacity < 0.01) return null;

  const onRight = labelPos.x > CX + 10;
  const onLeft = labelPos.x < CX - 10;
  const align = onRight ? 'left' : onLeft ? 'right' : 'center';

  let labelDotX = labelPos.x;
  if (onRight) labelDotX = labelPos.x - 12;
  else if (onLeft) labelDotX = labelPos.x + 12;
  const labelDotY = align === 'center' ? labelPos.y - 22 : labelPos.y;
  const textTop = align === 'center' ? labelPos.y - 4 : labelPos.y - 18;

  // headerCenter for traveling Copy 2
  const headerCenter = headerPos(pieceIdx);

  // Line endpoint interpolates from anchor toward labelDot as dp grows.
  const endX = anchor.x + (labelDotX - anchor.x) * dp;
  const endY = anchor.y + (labelDotY - anchor.y) * dp;

  return (
    <>
      <svg style={{
        position: 'absolute', left: 0, top: 0,
        width: 1920, height: 1080,
        pointerEvents: 'none',
      }}>
        <line x1={anchor.x} y1={anchor.y} x2={endX} y2={endY}
              stroke="#3a4a55" strokeWidth={strokeWidth} strokeDasharray="5 5" strokeLinecap="round"
              opacity={lineOpacity} />
        <circle cx={anchor.x} cy={anchor.y} r={5} fill="#3a4a55" opacity={anchorDotOpacity} />
        <circle cx={labelDotX} cy={labelDotY} r={labelDotR} fill="#3a4a55" opacity={labelDotOpacity} />
      </svg>

      {/* Single label element: continuously visible from pentagon to header.
          Position lerps via per-label text-width estimate so v16 pentagon
          visible position is preserved exactly; font/letter-spacing/wrap all
          morph through phase A; position translates through phase B; color
          crossfades via two stacked spans (dark navy → piece textColor). */}
      {textOpacity > 0.01 && (() => {
        // Per-label text-width estimates at the pentagon-state styling
        // (Inter 700 34px / 0.08em) and at the header-state styling
        // (Inter 700 26px / 0.06em). These let us position the centered
        // container so its VISIBLE text edges land at v16's exact positions.
        const TEXT_WIDTH_PENT = {
          "VALUATION SOLUTIONS": 412,
          "DATA AND ANALYTICS":  364,
          "AI DATA COPILOT":     330,
          "VISUAL TECHNOLOGIES": 410,
          "INTELLIGENT MAPPING": 402,
        }[info.label] || 380;
        // Use the STATIC pentagon-state text width so pentCx stays fixed
        // during the wrap (phaseA) — otherwise the container drifted toward
        // center as the font shrunk, making edge labels look like they
        // disappeared mid-flight.
        const textWidthCurrent = TEXT_WIDTH_PENT;
        const currentFontSize = lerp(34, 26, phaseA);
        const currentLetterSpacing = lerp(0.08, 0.06, phaseA);

        // Pentagon-state visible center: matches v16's text-edge-to-dot positions
        let pentCx;
        if (onLeft)       { pentCx = labelPos.x - 44 - textWidthCurrent / 2; }
        else if (onRight) { pentCx = labelPos.x + 44 + textWidthCurrent / 2; }
        else              { pentCx = labelPos.x; }
        const pentCy = align === 'center' ? labelPos.y + 26 : labelPos.y;

        // Header-state visible center
        const headCx = headerCenter.x;
        const headCy = headerCenter.y;

        // Travel via phaseB
        const cx = lerp(pentCx, headCx, phaseB);
        const cy = lerp(pentCy, headCy, phaseB);

        // === Word-level smooth wrap ===
        // Each word is rendered as an absolutely-positioned span. Inline (row)
        // positions are computed for the pentagon state; stacked positions for
        // the header state. wrapBlend lerps each word from its inline to its
        // stacked position, so words visibly travel into place.
        // Wrap window: full phaseA, eased for soft endpoints. Slower overall
        // because phaseA itself was widened.
        const wrapBlendRaw = clamp(phaseA, 0, 1);
        const wrapBlend = Easing.easeInOutCubic(wrapBlendRaw);
        // Color crossfade: stay dark navy throughout the travel, only flip to
        // the piece's textColor right as the label arrives inside the header.
        const colorBlend = clamp((phaseB - 0.85) / 0.15, 0, 1);
        const inlineWords = info.label.split(' ');
        const stackedWords = inlineWords; // keep text identical — only positions change
        const words = inlineWords;
        const wrapAfter = (typeof info.wrapAfter === 'number') ? info.wrapAfter : -1;

        // Estimate text metrics for Inter 700 at currentFontSize. Caps are
        // wider than average — use 0.66 char width and 0.50 space so unstacked
        // words have a clear, comfortable gap.
        const avgCharW = currentFontSize * 0.66;
        const spaceW = currentFontSize * 0.50;
        const lineH = currentFontSize * 1.18;
        const lsPx = currentFontSize * currentLetterSpacing;
        const wordW = (w) => w.length * avgCharW + Math.max(0, w.length - 1) * lsPx;
        const wWidthsInline = inlineWords.map(wordW);

        // Inline layout: single row centered at (0, 0)
        const inlineRowW = wWidthsInline.reduce((a,b)=>a+b,0) + spaceW * Math.max(0, inlineWords.length - 1);
        const inlineX = [];
        {
          let x = -inlineRowW / 2;
          for (let i = 0; i < inlineWords.length; i++) {
            inlineX.push(x + wWidthsInline[i] / 2);
            x += wWidthsInline[i] + spaceW;
          }
        }
        const inlineY = inlineWords.map(() => 0);

        // Stacked layout: row 0 = words 0..wrapAfter, row 1 = words wrapAfter+1..end
        let stackedX = inlineX.slice();
        let stackedY = inlineY.slice();
        if (wrapAfter >= 0 && wrapAfter < inlineWords.length - 1) {
          const wWidthsStacked = stackedWords.map(wordW);
          const topW = wWidthsStacked.slice(0, wrapAfter + 1).reduce((a,b)=>a+b,0) + spaceW * Math.max(0, wrapAfter);
          const botW = wWidthsStacked.slice(wrapAfter + 1).reduce((a,b)=>a+b,0) + spaceW * Math.max(0, stackedWords.length - wrapAfter - 2);
          let tx = -topW / 2;
          for (let i = 0; i <= wrapAfter; i++) {
            stackedX[i] = tx + wWidthsStacked[i] / 2;
            stackedY[i] = -lineH / 2;
            tx += wWidthsStacked[i] + spaceW;
          }
          let bx = -botW / 2;
          for (let i = wrapAfter + 1; i < inlineWords.length; i++) {
            stackedX[i] = bx + wWidthsStacked[i] / 2;
            stackedY[i] = lineH / 2;
            bx += wWidthsStacked[i] + spaceW;
          }
        }

        // Strict Y-then-X separation: words finish moving to their target
        // ROW before any horizontal travel begins. This guarantees no
        // cross-row overlap during the transition.
        const yProgress = clamp(wrapBlend * 3, 0, 1);
        const xProgress = clamp((wrapBlend - 0.5) / 0.5, 0, 1);
        const finalX = inlineWords.map((_, i) => lerp(inlineX[i], stackedX[i], xProgress));
        const finalY = inlineWords.map((_, i) => lerp(inlineY[i], stackedY[i], yProgress));

        const wordStyle = {
          position: 'absolute',
          left: 0, top: 0,
          fontFamily: 'Inter, system-ui, sans-serif',
          fontWeight: 700,
          fontSize: currentFontSize,
          letterSpacing: currentLetterSpacing + 'em',
          lineHeight: 1,
          whiteSpace: 'nowrap',
          willChange: 'transform, opacity',
        };

        return (
          <div style={{
            position: 'absolute',
            left: cx, top: cy,
            width: 0, height: 0,
            opacity: textOpacity,
            pointerEvents: 'none',
          }}>
            {(() => {
              // Per-word typewriter reveal during intro — restores the
              // character-by-character intro that was lost in the word-level
              // refactor.
              let cum = 0;
              const wordStartChars = inlineWords.map((w, i) => {
                const start = cum + i;
                cum += w.length;
                return start;
              });
              return inlineWords.map((word, i) => {
                const wStart = wordStartChars[i];
                const visibleChars = clamp(charsToShow - wStart, 0, word.length);
                const visibleText = word.slice(0, visibleChars);
                const isTyping = visibleChars > 0 && visibleChars < word.length;
                // Left-anchor the span at (finalX - wordFullWidth/2) so chars
                // appear left-to-right as they would on a typewriter, instead
                // of growing outward from the word's center.
                const wordFullWidth = wordW(word);
                const leftAnchor = finalX[i] - wordFullWidth / 2;
                const tx = `translate(${leftAnchor}px, ${finalY[i]}px) translateY(-50%)`;
                const caretSpan = (isTyping && showCaret) ? (
                  <span style={{
                    display: 'inline-block',
                    width: 3,
                    height: '0.95em',
                    verticalAlign: '-0.05em',
                    background: '#2c4358',
                    marginLeft: 4,
                    opacity: Math.floor(t * 2.5) % 2 === 0 ? 1 : 0.25,
                  }} />
                ) : null;
                return (
                  <React.Fragment key={i}>
                    <span style={{ ...wordStyle, color: '#0f1e2c', opacity: 1 - colorBlend, transform: tx }}>
                      {visibleText}{caretSpan}
                    </span>
                    <span style={{ ...wordStyle, color: info.textColor, opacity: colorBlend, transform: tx }}>
                      {visibleText}
                    </span>
                  </React.Fragment>
                );
              });
            })()}
          </div>
        );
      })()}
    </>
  );
}

// ── Table body (descriptor + metric rows) ──────────────────────────────────
function TableBody({ t }) {
  const bodyStart = 16.2;
  const stagger = 0.11;

  return (
    <>
      {PIECES.map((info, i) => {
        const cellStart = bodyStart + i * stagger;
        // Cells fade in as the table reveals, hold through TABLE_ANCHOR,
        // then fade back out before the reverse fold begins.
        const opacityFn = interpolate(
          [cellStart, cellStart + 0.55, REVERSE_START - 1.4, REVERSE_START - 0.4, TOTAL_DURATION],
          [0,         1,                 1,                   0,                   0],
          Easing.easeOutCubic
        );
        const yFn = interpolate(
          [cellStart, cellStart + 0.55, REVERSE_START - 0.4, TOTAL_DURATION],
          [22,        0,                 0,                   -18],
          Easing.easeOutCubic
        );
        const opacity = opacityFn(t);
        const yOff = yFn(t);

        const colX = HEADER_X_START + i * (HEADER_W + HEADER_GAP);
        const descY = HEADER_Y + HEADER_H + 16;
        const metricY = descY + 232;

        return (
          <React.Fragment key={i}>
            <div style={{
              position: 'absolute', left: colX, top: descY,
              width: HEADER_W, height: 220,
              padding: '28px 28px', boxSizing: 'border-box',
              background: '#ffffff', border: '1px solid #e2e0d8', borderRadius: 4,
              opacity, transform: `translateY(${yOff}px)`,
              display: 'flex', alignItems: 'center',
            }}>
              <div style={{
                fontFamily: 'Inter, system-ui, sans-serif',
                fontSize: 26, fontWeight: 400, lineHeight: 1.38, color: '#1d262c',
              }}>{info.descriptor}</div>
            </div>

            <div style={{
              position: 'absolute', left: colX, top: metricY,
              width: HEADER_W, height: 100,
              padding: '22px 28px', boxSizing: 'border-box',
              background: '#ffffff', border: '1px solid #e2e0d8', borderRadius: 4,
              display: 'flex', flexDirection: 'column', justifyContent: 'center',
              opacity, transform: `translateY(${yOff}px)`,
            }}>
              <div style={{
                fontFamily: 'Inter, system-ui, sans-serif',
                fontSize: 30, fontWeight: 600,
                color: info.color === '#00C8E6' ? '#007a8e' : '#1d262c',
                letterSpacing: '-0.005em',
              }}>{info.metric}</div>
            </div>
          </React.Fragment>
        );
      })}
    </>
  );
}

// ── Tagline (pentagon hold) ────────────────────────────────────────────────
// Module-scope flag: typewriter intro plays once per page load. After the
// first cycle completes the text always renders fully — opacity handles
// fade-in/out so the headline doesn't re-type on every loop.
let __taglineTyped = false;
function Tagline({ t }) {
  // Tagline appears once labels finish typing, holds readable, fades as the
  // wrap begins. Phase timings below were pushed back by 1.5s to give room.
  const opacityFn = interpolate(
    [10.5, 10.7, 12.5, 12.9, REVERSE_END - 0.5, REVERSE_END + 0.5, TOTAL_DURATION],
    [0,    1,    1,    0,    0,                  1,                  1],
    Easing.easeInOutCubic
  );
  const opacity = opacityFn(t);
  if (opacity < 0.01) return null;

  // Typewriter intro: first cycle only. A module-scope flag flips true once
  // the typewriter completes, so subsequent loops (and the fade-out at the
  // end of the pentagon hold) render the full text — no character redisplay.
  const headFullA = 'One AI-driven';
  const headFullB = 'Ecosystem';
  const total = headFullA.length + headFullB.length;
  if (t >= 12.3) __taglineTyped = true;
  const typeFn = interpolate([10.8, 12.3, TOTAL_DURATION], [0, 1, 1], Easing.linear);
  const p = __taglineTyped ? 1 : typeFn(t);
  const shown = Math.floor(p * total + 0.0001);
  const lineA = p >= 1 ? headFullA : headFullA.slice(0, Math.min(shown, headFullA.length));
  const lineB = p >= 1 ? headFullB : headFullB.slice(0, Math.max(0, shown - headFullA.length));
  const typingActive = p > 0 && p < 1;
  const caretOn = typingActive && (Math.floor(t * 2.5) % 2 === 0);
  const caret = caretOn ? (
    <span style={{
      display: 'inline-block', width: 4, height: '0.9em',
      verticalAlign: '-0.05em', background: '#0f1e2c', marginLeft: 4,
    }} />
  ) : null;

  return (
    <div style={{
      position: 'absolute', left: CX - 300, top: CY - 30,
      width: 600, textAlign: 'center', opacity,
      pointerEvents: 'none',
    }}>
      <div style={{
        fontFamily: 'Inter, system-ui, sans-serif',
        fontSize: 17, fontWeight: 600, letterSpacing: '0.32em',
        color: '#6a7480', textTransform: 'uppercase', marginBottom: 14,
      }}>Rich capabilities</div>
      <div style={{
        fontFamily: 'Inter, system-ui, sans-serif',
        fontSize: 36, fontWeight: 500, color: '#0f1e2c',
        letterSpacing: '-0.01em', lineHeight: 1.18,
      }}>
        <div>
          {lineA}
          {shown <= headFullA.length ? caret : null}
        </div>
        <div>
          {lineB}
          {shown > headFullA.length ? caret : null}
        </div>
      </div>
    </div>
  );
}

// ── Final-state title ──────────────────────────────────────────────────────
function FinalTitle({ t }) {
  const opacityFn = interpolate([7.6, 8.2, TOTAL_DURATION], [0, 1, 1], Easing.easeOutCubic);
  const yFn = interpolate([7.6, 8.2, TOTAL_DURATION], [12, 0, 0], Easing.easeOutCubic);
  const opacity = opacityFn(t);
  const yOff = yFn(t);
  if (opacity < 0.01) return null;
  return (
    <div style={{
      position: 'absolute', left: HEADER_X_START, top: 96,
      width: HEADER_TOTAL_W, opacity,
      transform: `translateY(${yOff}px)`,
      display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
    }}>
      <div>
        <div style={{
          fontFamily: 'Inter, system-ui, sans-serif',
          fontSize: 12, fontWeight: 600, letterSpacing: '0.32em',
          color: '#6a7480', textTransform: 'uppercase', marginBottom: 6,
        }}>The QWings platform</div>
        <div style={{
          fontFamily: 'Inter, system-ui, sans-serif',
          fontSize: 34, fontWeight: 600, color: '#1d262c',
          letterSpacing: '-0.018em', lineHeight: 1.1,
        }}>Five capabilities, side by side</div>
      </div>
      <div style={{
        fontFamily: 'Inter, system-ui, sans-serif',
        fontSize: 13, fontWeight: 500, letterSpacing: '0.04em', color: '#6a7480',
      }}>Capability matrix</div>
    </div>
  );
}

// ── Pentagon ghost (faint preview at t=0 so the page never reads blank) ────
function PentagonGhost({ t }) {
  // Visible at low opacity from frame 0; fades as the flying-in pieces arrive.
  const ghostFn = interpolate(
    [0, 1.4, 2.0, TOTAL_DURATION], [0.16, 0.16, 0, 0], Easing.easeInOutCubic
  );
  const ghostOpacity = ghostFn(t);
  if (ghostOpacity < 0.01) return null;

  const path = puzzlePath(HALF_OUTER_SIDE, HALF_INNER_SIDE, PIECE_HEIGHT / 2, 12);

  return (
    <>
      {PIECES.map((info, i) => {
        const pent = pentagonPos(info.sideIdx);
        const pentRotDeg = pentagonRot(info.sideIdx);
        return (
          <div key={`ghost-${i}`} style={{
            position: 'absolute', left: 0, top: 0,
            transform: `translate(${pent.x}px, ${pent.y}px) rotate(${pentRotDeg}deg)`,
            transformOrigin: '0 0',
            opacity: ghostOpacity,
            pointerEvents: 'none',
          }}>
            <div style={{
              position: 'absolute', left: -250, top: -150,
              width: 500, height: 300,
            }}>
              <svg width="500" height="300" viewBox="-250 -150 500 300" style={{ overflow: 'visible' }}>
                <path d={path}
                      fill={info.color}
                      stroke="#ffffff"
                      strokeWidth="2.5"
                      strokeLinejoin="round"
                      strokeLinecap="round" />
              </svg>
            </div>
          </div>
        );
      })}
    </>
  );
}

// ── Interactive controls (click-to-advance + auto-pause at anchors) ────────
// Mounts INSIDE Stage so it can read playback state via useTimeline().
// Auto-pauses on first crossing of PENTAGON_ANCHOR (so labels can be read) and
// TABLE_ANCHOR (so the table can be read). Click anywhere on the canvas → if
// playing, pauses; if paused at an anchor, advances to the next anchor.
function InteractiveControls({ showPill = true, continuous = false } = {}) {
  const { time, playing, setTime, setPlaying } = useTimeline();
  const prevTimeRef = React.useRef(0);
  const [pauseAtNext, setPauseAtNext] = React.useState(false);
  // Click-feedback flash: shows a centered ▶ / ⏸ icon for ~700ms after each click
  // so the user sees their click landed before motion catches up.
  const [flashIcon, setFlashIcon] = React.useState(null);
  const flashTimerRef = React.useRef(null);
  const showFlash = React.useCallback((icon) => {
    setFlashIcon({ icon, key: Date.now() });
    clearTimeout(flashTimerRef.current);
    flashTimerRef.current = setTimeout(() => setFlashIcon(null), 700);
  }, []);

  // Auto-pause on anchor crossing. In default mode this happens on every cross;
  // in continuous mode it only happens when the viewer has armed it with a click.
  React.useEffect(() => {
    const prev = prevTimeRef.current;
    const shouldPause = continuous ? pauseAtNext : true;
    if (playing && shouldPause) {
      let snap = null;
      if (prev < PENTAGON_ANCHOR && time >= PENTAGON_ANCHOR && time < TABLE_ANCHOR - 1) {
        snap = PENTAGON_ANCHOR;
      } else if (prev < TABLE_ANCHOR && time >= TABLE_ANCHOR) {
        snap = TABLE_ANCHOR;
      } else if (time < prev && time >= PENTAGON_ANCHOR && time < TABLE_ANCHOR) {
        // Just looped from end back near start, but past pentagon — catch it.
        snap = PENTAGON_ANCHOR;
      }
      if (snap !== null) {
        setTime(snap);
        setPlaying(false);
        setPauseAtNext(false);
      }
    }
    prevTimeRef.current = time;
  }, [time, playing, pauseAtNext, continuous, setTime, setPlaying]);

  // Click anywhere on the document → simply toggle play/pause at current time.
  React.useEffect(() => {
    const handler = (e) => {
      if (e.target && e.target.closest && e.target.closest('button, [data-no-pause-click]')) return;
      if (playing) {
        setPlaying(false);
        setPauseAtNext(false);
        showFlash('pause');
      } else {
        // Resume — if user paused inside a "read hold" zone (pentagon tagline
        // visible or table fully revealed), skip past the hold to the next
        // motion so resume feels immediate.
        let resumeAt = time;
        // Pentagon hold: REWIND to 11.5 (1s before phaseA at 12.5) so resume
        // always gives a full 1s "still pentagon" beat — even if user paused
        // late in the hold zone.
        if (time >= 10.1 && time < 12.5) resumeAt = 11.5;
        // Table hold: rewind to 18.5 (1s before reverse phaseB at 19.5).
        else if (time >= 17.0 && time < 19.5) resumeAt = 18.5;
        // Post-reverse pentagon hold: jump back to 11.5 (next-cycle pentagon
        // hold with 1s beat before wrap).
        else if (time >= 22.5) resumeAt = 11.5;
        if (resumeAt !== time) setTime(resumeAt);
        setPlaying(true);
        setPauseAtNext(false);
        showFlash('play');
      }
    };
    document.addEventListener('click', handler);
    return () => document.removeEventListener('click', handler);
  }, [time, playing, continuous, setTime, setPlaying, showFlash]);

  const goPentagon = () => { setTime(PENTAGON_ANCHOR); setPlaying(false); };
  const goTable = () => { setTime(TABLE_ANCHOR); setPlaying(false); };
  const togglePlay = () => setPlaying((p) => !p);

  const atPentagon = !playing && Math.abs(time - PENTAGON_ANCHOR) < 0.2;
  const atTable = !playing && Math.abs(time - TABLE_ANCHOR) < 0.2;

  // Centered click-feedback flash icon (always rendered, even when pill is hidden).
  const FlashOverlay = flashIcon && (
    <div key={flashIcon.key} style={{
      position: 'absolute',
      left: '50%', top: '50%',
      width: 110, height: 110,
      marginLeft: -55, marginTop: -55,
      borderRadius: '50%',
      background: 'rgba(20, 36, 47, 0.78)',
      color: '#ffffff',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      fontSize: 44,
      pointerEvents: 'none',
      zIndex: 90,
      animation: 'qwingsFlash 700ms ease-out forwards',
    }}>
      {flashIcon.icon === 'play' ? '▶' : '❚❚'}
      <style>{`
        @keyframes qwingsFlash {
          0%   { opacity: 0; transform: scale(0.6); }
          15%  { opacity: 1; transform: scale(1.05); }
          60%  { opacity: 1; transform: scale(1.0); }
          100% { opacity: 0; transform: scale(1.15); }
        }
      `}</style>
    </div>
  );

  if (!showPill) return FlashOverlay;

  const btn = {
    fontFamily: 'Inter, system-ui, sans-serif',
    fontSize: 14, fontWeight: 600,
    padding: '12px 22px',
    border: 'none',
    background: 'transparent',
    color: '#ffffff',
    cursor: 'pointer',
    borderRadius: 999,
    letterSpacing: '0.02em',
    transition: 'background 120ms',
  };
  const btnActive = { ...btn, background: 'rgba(255,255,255,0.20)' };
  const btnPlay = { ...btn, width: 44, padding: 0, fontSize: 16 };

  return ReactDOM.createPortal(
    <>
      {FlashOverlay}
      <div data-no-pause-click style={{
      position: 'fixed',
      bottom: 18, left: '50%',
      transform: 'translateX(-50%)',
      display: 'flex', gap: 4,
      background: 'rgba(20, 36, 47, 0.92)',
      border: '1px solid rgba(255,255,255,0.12)',
      borderRadius: 999,
      padding: 4,
      backdropFilter: 'blur(10px)',
      WebkitBackdropFilter: 'blur(10px)',
      zIndex: 100,
      boxShadow: '0 10px 30px -10px rgba(20,36,47,0.4)',
    }}>
      <button onClick={goPentagon} style={atPentagon ? btnActive : btn}>Pentagon view</button>
      <button onClick={togglePlay} style={btnPlay} title={playing ? 'Pause' : 'Play'}>
        {playing ? '❚❚' : '▶'}
      </button>
      <button onClick={goTable} style={atTable ? btnActive : btn}>Table view</button>
    </div>
    </>,
    document.body
  );
}

// ── Background ─────────────────────────────────────────────────────────────
function Backdrop({ t }) {
  const driftFn = interpolate([0, 11], [-6, 6], Easing.linear);
  const drift = driftFn(t);
  return (
    <div style={{
      position: 'absolute', inset: 0,
      background: 'radial-gradient(ellipse at 50% 40%, #ffffff 0%, #f4f1ea 65%, #ece8df 100%)',
      transform: `translateX(${drift}px)`,
    }} />
  );
}

// ── Main composition ───────────────────────────────────────────────────────
function PentagonSceneV22({ bare = false } = {}) {
  const t = useTime();

  React.useEffect(() => {
    const sec = Math.floor(t);
    const root = document.querySelector('[data-screen-root="qwings-v22"]');
    if (root) root.setAttribute('data-screen-label', `v22 ${String(sec).padStart(2, '0')}s`);
  }, [Math.floor(t)]);

  return (
    <div data-screen-root="qwings-v22" data-screen-label="v22 00s" style={{ position: 'absolute', inset: 0 }}>
      {!bare && <Backdrop t={t} />}

      {PIECES.map((info, i) => (
        <Piece key={i} pieceIdx={i} info={info} t={t} />
      ))}

      {PIECES.map((info, i) => (
        <PentagonLabel key={`lbl-${i}`} info={info} pieceIdx={i} t={t} />
      ))}

      <Tagline t={t} />
      <TableBody t={t} />
    </div>
  );
}

// ── Loop controller — plays intro once, then bounces pentagon ↔ table ────────
// Drop this as a child of <Stage> with loop={false}. It watches the playhead
// and, when it reaches the end of the post-reverse pentagon hold, jumps back
// to just before the forward unfold — skipping the entire 0–11s intro.
function LoopAfterIntro() {
  const ctx = useTimeline();
  const triggeredRef = React.useRef(false);
  React.useEffect(() => {
    if (ctx.time >= TOTAL_DURATION - 0.06) {
      ctx.setTime(10.85);
      triggeredRef.current = true;
    }
  }, [ctx.time]);
  return null;
}

Object.assign(window, {
  PentagonSceneV22, InteractiveControls, LoopAfterIntro,
  PIECES_V22: PIECES,
  PENTAGON_ANCHOR, TABLE_ANCHOR, TOTAL_DURATION,
});
