// Confirmations animation — BunkerAgent
// One-tree composition keyed to authored time T.
// Palette: primary green #00705c, muted #599e94, ice blue #eaffff, black, white.
// Amber accent #d99a2b, red #c14a3a (derived from black-only neutrals + brand green).

const { CompositionStage, useComposition, Shot, Easing, interpolate, animate, clamp } = window;

const COLORS = {
  green: '#00705c',
  greenSoft: '#599e94',
  ice: '#eaffff',
  ink: '#0a0a0a',
  ink70: 'rgba(10,10,10,0.7)',
  ink40: 'rgba(10,10,10,0.4)',
  ink20: 'rgba(10,10,10,0.18)',
  ink10: 'rgba(10,10,10,0.10)',
  ink06: 'rgba(10,10,10,0.06)',
  hair: '#e4e4e4',
  amber: '#d99a2b',
  red: '#c14a3a',
  white: '#ffffff',
};

const FONT = `'Inter', 'Aktiv Grotesk', -apple-system, system-ui, sans-serif`;

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

const MOTION = {
  enter: Easing.easeOutCubic,
  settle: Easing.easeInOutCubic,
  pop: Easing.easeOutBack,
};

// -----------------------------------------------------------------------------
// Field row data — matches the brief exactly.
const ROWS = [
  { key: 'Vessel',       ours: 'MV Atlas',    theirs: 'MV Atlas',    match: true  },
  { key: 'Port',         ours: 'Rotterdam',   theirs: 'Rotterdam',   match: true  },
  { key: 'Grade',        ours: 'VLSFO',       theirs: 'VLSFO',       match: true  },
  { key: 'Quantity',     ours: '1,800 mt',    theirs: '1,800 mt',    match: true  },
  { key: 'Delivery window', ours: '14–16 Aug', theirs: '14–16 Aug',  match: true  },
  { key: 'Sulphur (ISO 8217)', ours: '0.10 %S', theirs: '0.50 %S',    match: false },
];

// Timing constants (authored seconds, relative to section starts)
const ROW_STAGGER = 0.25;
const ROW_TRAVEL = 0.55;

// -----------------------------------------------------------------------------
// Small building blocks

function PdfDoc({ label, caption, filename, x, y, w, h, opacity, lift }) {
  // Formal document silhouette — PDF badge + filename + text lines
  const lines = [0.85, 0.6, 0.75, 0.5, 0.7, 0.4];
  return (
    <g transform={`translate(${x}, ${y + lift})`} opacity={opacity}>
      <text
        x={0} y={-18}
        style={{ font: `500 12px ${FONT}`, letterSpacing: '0.14em', textTransform: 'uppercase', fill: COLORS.greenSoft }}
      >{label}</text>
      <text
        x={0} y={-2}
        style={{ font: `300 11px ${FONT}`, fill: COLORS.ink70, fontStyle: 'italic' }}
      >{caption}</text>
      <rect x={0} y={10} width={w} height={h} fill={COLORS.white} stroke={COLORS.hair} strokeWidth={1} />
      {/* PDF badge */}
      <g transform={`translate(12, 22)`}>
        <rect width={30} height={14} fill={COLORS.green} />
        <text x={15} y={11} textAnchor="middle" style={{ font: `700 8px ${FONT}`, fill: COLORS.white, letterSpacing: '0.06em' }}>PDF</text>
      </g>
      {/* Filename */}
      <text x={50} y={33} style={{ font: `400 10px ${FONT}`, fill: COLORS.ink70 }}>{filename}</text>
      {/* Divider */}
      <line x1={12} y1={44} x2={w - 12} y2={44} stroke={COLORS.hair} />
      {lines.map((frac, i) => (
        <rect key={i} x={12} y={54 + i * 14} width={(w - 24) * frac} height={4} fill={COLORS.ink10} />
      ))}
    </g>
  );
}

function EmailDoc({ label, caption, fromLine, subjectLine, x, y, w, h, opacity, lift }) {  // Email silhouette — envelope glyph + From/Subject header rows + body lines
  const bodyLines = [0.9, 0.65];
  return (
    <g transform={`translate(${x}, ${y + lift})`} opacity={opacity}>
      <text
        x={0} y={-18}
        style={{ font: `500 12px ${FONT}`, letterSpacing: '0.14em', textTransform: 'uppercase', fill: COLORS.greenSoft }}
      >{label}</text>
      <text
        x={0} y={-2}
        style={{ font: `300 11px ${FONT}`, fill: COLORS.ink70, fontStyle: 'italic' }}
      >{caption}</text>
      <rect x={0} y={10} width={w} height={h} fill={COLORS.white} stroke={COLORS.hair} strokeWidth={1} />
      {/* Envelope icon */}
      <g transform={`translate(12, 20)`} stroke={COLORS.green} strokeWidth={1.4} fill="none" strokeLinejoin="round" strokeLinecap="round">
        <rect x={0} y={0} width={22} height={16} />
        <path d="M 0 0 L 11 9 L 22 0" />
      </g>
      {/* Header rows: From / Subject */}
      <g transform={`translate(12, 46)`}>
        <text style={{ font: `500 9px ${FONT}`, fill: COLORS.ink, letterSpacing: '0.02em' }}>
          <tspan x={0} dy={0}>From</tspan>
          <tspan x={52} style={{ fontWeight: 400, fill: COLORS.ink70 }}>{fromLine}</tspan>
        </text>
        <text style={{ font: `500 9px ${FONT}`, fill: COLORS.ink, letterSpacing: '0.02em' }}>
          <tspan x={0} dy={14}>Subject</tspan>
          <tspan x={52} dy={0} style={{ fontWeight: 400, fill: COLORS.ink70 }}>{subjectLine}</tspan>
        </text>
      </g>
      {/* Divider */}
      <line x1={12} y1={78} x2={w - 12} y2={78} stroke={COLORS.hair} />
      {/* Body lines */}
      {bodyLines.map((frac, i) => (
        <rect key={i} x={12} y={90 + i * 14} width={(w - 24) * frac} height={4} fill={COLORS.ink10} />
      ))}
    </g>
  );
}

function StatusMarker({ x, y, kind, scale = 1, opacity = 1 }) {  // kind: 'tick' | 'amber' | 'flag' | 'tickFinal'
  const r = 10;
  if (kind === 'tick' || kind === 'tickFinal') {
    return (
      <g transform={`translate(${x}, ${y}) scale(${scale})`} opacity={opacity}>
        <circle r={r} fill={COLORS.green} />
        <path d="M -4 0 L -1 3 L 4.5 -3" stroke={COLORS.white} strokeWidth={2} fill="none" strokeLinecap="round" strokeLinejoin="round" />
      </g>
    );
  }
  if (kind === 'amber') {
    return (
      <g transform={`translate(${x}, ${y}) scale(${scale})`} opacity={opacity}>
        <circle r={r} fill={COLORS.amber} />
        <rect x={-0.8} y={-4.5} width={1.6} height={5.5} fill={COLORS.white} />
        <rect x={-0.8} y={2.5} width={1.6} height={1.6} fill={COLORS.white} />
      </g>
    );
  }
  // flag (red)
  return (
    <g transform={`translate(${x}, ${y}) scale(${scale})`} opacity={opacity}>
      <circle r={r} fill={COLORS.red} />
      <g stroke={COLORS.white} strokeWidth={1.4} fill={COLORS.white} strokeLinecap="round" strokeLinejoin="round">
        <line x1={-3.5} y1={-5} x2={-3.5} y2={5.5} />
        <path d="M -3.5 -5 L 4 -4 L 1.5 -1.5 L 4 1 L -3.5 1 Z" />
      </g>
    </g>
  );
}

// -----------------------------------------------------------------------------
// Row lifecycle
// A row's authored start = CUES.Extract + i * ROW_STAGGER
// Travel from doc edges to center over ROW_TRAVEL. Marker resolves shortly after.

function FieldRow({ row, i, T, extractStart, resetStart, midX, midW, y, docLeftX, docRightX, rightPad = 32, labelFS = 9 }) {
  const start = extractStart + i * ROW_STAGGER;
  const landed = start + ROW_TRAVEL;

  // Travel progress
  const p = clamp((T - start) / ROW_TRAVEL, 0, 1);
  const ease = MOTION.enter(p);

  // Left value slides from left doc, right value slides from right doc
  const leftFromX = docLeftX;
  const rightFromX = docRightX;
  const leftToX = midX + 18;
  const rightToX = midX + midW - 18;

  const leftX = lerp(leftFromX, leftToX, ease);
  const rightX = lerp(rightFromX, rightToX, ease);
  const opacity = clamp(p * 1.4, 0, 1);

  // Marker timing
  const markerT = clamp((T - landed) / 0.28, 0, 1);
  let markerScale = MOTION.pop(markerT);
  let markerOpacity = markerT;

  // Red row special beat: amber pulse then settle to red
  let markerKind = row.match ? 'tick' : 'amber';
  let extraScale = 1;
  if (!row.match) {
    // amber for ~0.5s after landing, then crossfade to red
    const amberEnd = landed + 0.45;
    const redStart = landed + 0.55;
    if (T > redStart) markerKind = 'flag';
    // subtle scale-up on land (slightly larger than tick)
    extraScale = lerp(1, 1.15, MOTION.settle(clamp((T - landed) / 0.45, 0, 1)));
  }

  // Frame 4: red flag on price row crossfades to green tick.
  // Also: whole comparison rows fade to Frame 1 state near loop end for seamless loop.
  // Do NOT crossfade the red flag to green — the mismatch stays flagged until
  // the counterparty actually confirms; our click only sends the draft.
  let finalTickOverlay = 0;

  // Loop reset fade: fade rows out just before authoredTotal
  const fadeOutStart = resetStart - 0.5;
  const rowVisibility = 1 - clamp((T - fadeOutStart) / 0.5, 0, 1);

  // Highlight background for the mismatch row
  const isRed = !row.match && T > landed;
  const bgFill = isRed
    ? `rgba(193, 74, 58, ${0.05 * (1 - finalTickOverlay) + 0.02})`
    : 'transparent';

  return (
    <g opacity={opacity * rowVisibility}>
      {/* row background band */}
      <rect x={midX} y={y - 13} width={midW} height={26} fill={bgFill} />
      {/* label (field name) — aligned with the values on the same row */}
      <text
        x={midX + midW / 2}
        y={y + 4}
        textAnchor="middle"
        style={{ font: `500 ${labelFS}px ${FONT}`, fill: COLORS.ink40, letterSpacing: '0.1em', textTransform: 'uppercase' }}
        opacity={markerOpacity * 0.9}
      >{row.key}</text>
      {/* left (buyer) value — travels from left doc */}
      <text
        x={leftX} y={y + 4}
        style={{ font: `400 13px ${FONT}`, fill: COLORS.ink }}
        textAnchor="start"
      >{row.ours}</text>
      {/* right (buyer) value */}
      <text
        x={rightX - rightPad} y={y + 4}
        style={{ font: `400 13px ${FONT}`, fill: COLORS.ink }}
        textAnchor="end"
      >{row.theirs}</text>
      {/* marker */}
      {markerT > 0 && (
        <>
          <StatusMarker
            x={midX + midW + 22}
            y={y}
            kind={markerKind}
            scale={markerScale * extraScale}
            opacity={markerOpacity * (1 - finalTickOverlay)}
          />
          {/* Final resolution tick overlay for the red row */}
          {!row.match && finalTickOverlay > 0 && (
            <StatusMarker x={midX + midW + 22} y={y} kind="tickFinal" scale={1} opacity={finalTickOverlay} />
          )}
        </>
      )}
    </g>
  );
}

// -----------------------------------------------------------------------------
// Draft card

function DraftCard({ T, CUES, x, y, w, h }) {
  const draftIn = CUES.Draft;
  const draftOut = CUES.Resolve + 1.1;
  const clickAt = draftOut - 0.2;
  const cursorStart = clickAt - 0.55;

  // Slide in from right
  const inP = MOTION.enter(clamp((T - draftIn) / 0.5, 0, 1));
  const outP = MOTION.settle(clamp((T - draftOut) / 0.45, 0, 1));
  const offX = lerp(80, 0, inP) + lerp(0, 140, outP);
  const opacity = inP * (1 - outP);

  // subtle breathing (halts once cursor arrives)
  const breathDamp = 1 - clamp((T - cursorStart) / 0.4, 0, 1);
  const breath = Math.sin((T - draftIn) * 2.4) * 1.2 * breathDamp;

  // Approve-pill click: cursor arrives, presses, brief ring pulse.
  const cursorP = MOTION.enter(clamp((T - cursorStart) / 0.55, 0, 1));
  const pressed = T >= clickAt && T < clickAt + 0.18;
  const ringP = clamp((T - clickAt) / 0.5, 0, 1);

  if (opacity <= 0.01) return null;

  return (
    <g transform={`translate(${x + offX}, ${y + breath})`} opacity={opacity}>
      {/* card */}
      <rect width={w} height={h} fill={COLORS.white} stroke={COLORS.hair} strokeWidth={1} />
      {/* header stripe */}
      <rect width={w} height={22} fill={COLORS.green} />
      <text x={12} y={15} style={{ font: `500 9px ${FONT}`, fill: COLORS.white, letterSpacing: '0.14em', textTransform: 'uppercase' }}>Draft reply</text>
      {/* body */}
      <g transform="translate(12, 36)">
        <text style={{ font: `400 10px ${FONT}`, fill: COLORS.ink70 }}>
          <tspan x={0} dy={0} style={{ fontWeight: 500, fill: COLORS.ink }}>To: </tspan>
          <tspan>counterparty@buyer.com</tspan>
        </text>
        <text style={{ font: `400 10px ${FONT}`, fill: COLORS.ink70 }}>
          <tspan x={0} dy={16} style={{ fontWeight: 500, fill: COLORS.ink }}>Subject: </tspan>
          <tspan>MV Atlas — sulphur spec discrepancy</tspan>
        </text>
        <line x1={0} y1={30} x2={w - 24} y2={30} stroke={COLORS.hair} />
        <text style={{ font: `300 11px ${FONT}`, fill: COLORS.ink }}>
          <tspan x={0} dy={46}>Confirming 0.50 %S per your</tspan>
          <tspan x={0} dy={14}>confirmation of 12:04, vs 0.10 %S</tspan>
          <tspan x={0} dy={14}>on our record. Please advise on spec.</tspan>
        </text>
      </g>
      {/* Approve pill (presses when clicked) */}
      <g transform={`translate(12, ${h - 30})`}>
        {/* click ring */}
        {ringP > 0 && ringP < 1 && (
          <circle
            cx={55} cy={11}
            r={lerp(18, 46, ringP)}
            fill="none"
            stroke={COLORS.green}
            strokeWidth={1.5}
            opacity={(1 - ringP) * 0.7}
          />
        )}
        <g transform={pressed ? 'translate(55, 11) scale(0.96) translate(-55, -11)' : ''}>
          <rect width={110} height={22} rx={11} ry={11}
            fill={pressed ? COLORS.greenSoft : COLORS.green} />
          <text x={55} y={15} textAnchor="middle" style={{ font: `500 10px ${FONT}`, fill: COLORS.white, letterSpacing: '0.06em' }}>Approve draft</text>
        </g>
      </g>
      {/* Cursor moves in and taps the pill */}
      {cursorP > 0 && (
        <g
          transform={`translate(${lerp(w + 40, 88, cursorP)}, ${lerp(h + 24, h - 15, cursorP)})`}
          opacity={cursorP < 1 ? cursorP : (T < clickAt + 0.35 ? 1 : Math.max(0, 1 - (T - clickAt - 0.35) / 0.2))}
        >
          <path d="M 0 0 L 0 14 L 3.5 10.5 L 6 15.5 L 8 14.5 L 5.5 9.5 L 10 9 Z"
            fill={COLORS.ink} stroke={COLORS.white} strokeWidth={1} strokeLinejoin="round" />
        </g>
      )}
      {/* pointer to red row */}
      <path
        d={`M -14 ${h / 2} L 0 ${h / 2}`}
        stroke={COLORS.hair} strokeWidth={1}
      />
    </g>
  );
}

// -----------------------------------------------------------------------------
// Stem chip (bottom)

function StemChip({ T, CUES, cx, cy, authoredTotal }) {
  // Three states across the timeline:
  //  awaiting: from start until near end of Extract
  //  mismatch: from ~end of Extract through Draft (amber)
  //  locked:   during Resolve
  const mismatchStart = CUES.Extract + ROW_STAGGER * (ROWS.length - 1) + ROW_TRAVEL + 0.15;
  // Sent = the moment we approve the draft; stem stays flagged (not locked)
  // until counterparty replies — which is out of scope for this loop.
  const sentStart = CUES.Resolve + 1.1;

  let state = 'awaiting';
  if (T >= sentStart) state = 'sent';
  else if (T >= mismatchStart) state = 'mismatch';

  // Chip fades in during intro and out at end of loop → seamless seam
  const chipIn = MOTION.enter(clamp(T / 0.8, 0, 1));
  const chipOut = MOTION.settle(clamp((T - (authoredTotal - 0.5)) / 0.5, 0, 1));
  const chipOpacity = chipIn * (1 - chipOut);

  // Crossfade values between state changes
  const cf1 = clamp((T - mismatchStart) / 0.35, 0, 1); // awaiting -> mismatch
  const cf2 = clamp((T - sentStart) / 0.35, 0, 1);     // mismatch -> sent (still amber)

  const config = {
    awaiting: { bg: COLORS.ink06, fg: COLORS.ink70, label: 'Awaiting counterparty', dot: COLORS.ink40 },
    mismatch: { bg: 'rgba(217,154,43,0.14)', fg: '#8a5f10', label: 'Mismatch', dot: COLORS.amber },
    sent:     { bg: 'rgba(217,154,43,0.14)', fg: '#8a5f10', label: 'Query sent · awaiting reply', dot: COLORS.amber },
  };

  // Blend colors between states
  const active = cf2 > 0 ? config.sent : cf1 > 0 ? config.mismatch : config.awaiting;
  const { bg, fg, label, dot } = active;

  // No radial glow — nothing has resolved yet.
  const glowOpacity = 0;
  const glowR = 0;

  const w = 260, h = 34;

  return (
    <g transform={`translate(${cx - w/2}, ${cy - h/2})`} opacity={chipOpacity}>
      {/* glow */}
      {glowOpacity > 0.01 && (
        <>
          <defs>
            <radialGradient id="stemGlow" cx="50%" cy="50%" r="50%">
              <stop offset="0%" stopColor={COLORS.green} stopOpacity={0.6} />
              <stop offset="100%" stopColor={COLORS.green} stopOpacity={0} />
            </radialGradient>
          </defs>
          <circle cx={w/2} cy={h/2} r={glowR} fill="url(#stemGlow)" opacity={glowOpacity} />
        </>
      )}
      <rect width={w} height={h} rx={h/2} ry={h/2} fill={bg} />
      <circle cx={22} cy={h/2} r={4.5} fill={dot} />
      <text x={38} y={h/2 + 4} style={{ font: `500 12px ${FONT}`, fill: fg, letterSpacing: '0.02em' }}>Stem · {label}</text>
    </g>
  );
}

// -----------------------------------------------------------------------------
// Root scene

function ChatCard({ x, y, w, opacity }) {
  // WhatsApp-style chat card — sits alongside (below) the email
  const h = 72;
  const whatsappGreen = '#25D366';
  return (
    <g transform={`translate(${x}, ${y})`} opacity={opacity}>
      {/* subtle drop for lift over the email — hairline outline only per DS */}
      <rect x={0} y={0} width={w} height={h} fill={COLORS.white} stroke={COLORS.hair} strokeWidth={1} />
      {/* header stripe */}
      <rect x={0} y={0} width={w} height={20} fill={whatsappGreen} />
      {/* WhatsApp glyph (speech bubble) */}
      <g transform={`translate(8, 4)`} fill={COLORS.white}>
        <path d="M 6 0 A 6 6 0 1 1 0.6 8.5 L 0 12 L 3.5 11.2 A 6 6 0 0 0 6 12 A 6 6 0 1 1 6 0 Z" />
      </g>
      <text x={22} y={13} style={{ font: `600 9px ${FONT}`, fill: COLORS.white, letterSpacing: '0.04em' }}>WhatsApp · Trader chat</text>
      {/* message rows */}
      <g transform={`translate(10, 32)`}>
        <text style={{ font: `500 9px ${FONT}`, fill: COLORS.ink }}>Trader (MV Atlas)</text>
        <rect x={0} y={6} width={w - 40} height={4} fill={COLORS.ink10} />
        <rect x={0} y={16} width={(w - 40) * 0.7} height={4} fill={COLORS.ink10} />
        <text x={0} y={34} style={{ font: `400 8px ${FONT}`, fill: COLORS.ink40 }}>12:18</text>
      </g>
    </g>
  );
}

// Two layouts: wide (desktop) and portrait (phones). Same scene, same timing -
// only the geometry changes, so the piece stays legible on a 390px screen.
const PORTRAIT_Q = '(max-width:760px)';

function useIsPortrait() {
  const [m, setM] = React.useState(function () {
    return typeof window !== 'undefined' && window.matchMedia(PORTRAIT_Q).matches;
  });
  React.useEffect(function () {
    const mq = window.matchMedia(PORTRAIT_Q);
    const h = function (e) { setM(e.matches); };
    mq.addEventListener ? mq.addEventListener('change', h) : mq.addListener(h);
    return function () { mq.removeEventListener ? mq.removeEventListener('change', h) : mq.removeListener(h); };
  }, []);
  return m;
}

const LAYOUT_WIDE = {
  W: 1200, H: 528, topY: 90,
  docW: 220, docH: 260, emailH: 120, chatGap: 10,
  docLeftX: 60, docRightX: 920, emailX: 920, chatX: 920,
  emailY: 90, chatStackAfterEmail: true,
  midX: 380, midW: 440,
  rowsTop: 150, rowH: 32,
  divTop: 82, divBottom: 358,
  draftX: 874, draftY: 330, draftW: 260, draftH: 160,
  stemCX: 600, stemY: 392,
};

const LAYOUT_PORTRAIT = {
  W: 380, H: 1030, topY: 76,
  docW: 340, docH: 176, emailH: 104, chatGap: 10,
  docLeftX: 20, docRightX: 20, emailX: 20, chatX: 20,
  emailY: 300, chatStackAfterEmail: true,
  midX: 14, midW: 304, rightPad: 6, labelFS: 8,
  rowsTop: 566, rowH: 38,
  divTop: 520, divBottom: 780,
  draftX: 20, draftY: 790, draftW: 310, draftH: 170,
  stemCX: 190, stemY: 990,
};

function Piece() {
  const { T, CUES, authoredTotal } = useComposition();
  const portrait = useIsPortrait();
  const L = portrait ? LAYOUT_PORTRAIT : LAYOUT_WIDE;

  const W = L.W, H = L.H;
  const topY = L.topY;
  const docW = L.docW, docH = L.docH;
  const emailH = L.emailH;
  const chatGap = L.chatGap;
  const midW = L.midW;
  const midX = L.midX;
  const docLeftX = L.docLeftX;
  const docRightX = L.docRightX;

  // Frame 1: doc fade-in
  const introEnd = CUES.Extract;
  const docPIn = MOTION.enter(clamp(T / introEnd, 0, 1));
  const docPOut = MOTION.settle(clamp((T - (authoredTotal - 0.5)) / 0.5, 0, 1));
  const docP = docPIn * (1 - docPOut);
  const docLift = lerp(8, 0, docPIn);

  // Loop reset fade (Frame 1 return)
  const resetStart = authoredTotal - 0.6;
  const resetP = clamp((T - resetStart) / 0.6, 0, 1);

  // Middle column visibility (fade in early, fade out at loop reset)
  const midIn = MOTION.enter(clamp((T - 0.4) / 0.6, 0, 1));
  const midOut = MOTION.settle(clamp((T - (authoredTotal - 0.5)) / 0.5, 0, 1));
  const midOpacity = midIn * (1 - midOut);

  // Row Y positions inside middle column
  const rowsTop = L.rowsTop;
  const rowH = L.rowH;

  // Draft card position — points at the red (last) row from the right; sits
  // below the WhatsApp card so it doesn't obstruct it.
  const draftX = L.draftX;
  const draftW = L.draftW, draftH = L.draftH;
  const draftY = L.draftY;

  // Stem chip
  const stemY = L.stemY;

  // WhatsApp arrives ~0.6s after the docs are legible — reader sees email first
  const chatInP = MOTION.enter(clamp((T - 0.7) / 0.55, 0, 1));

  return (
    <svg
      width="100%"
      height="100%"
      viewBox={`0 0 ${W} ${H}`}
      preserveAspectRatio="xMidYMid meet"
      style={{ display: 'block', background: COLORS.ice, fontFamily: FONT }}
    >
      {/* Documents */}
      <PdfDoc label="Supplier confirmation" caption="our record · issued 12:31"
        filename="Supplier confirmation.pdf"
        x={docLeftX} y={topY} w={docW} h={docH} opacity={docP} lift={docLift} />
      <EmailDoc label="Buyer confirmation" caption="MV Atlas · received 12:04"
        fromLine="trader@shipowner.com"
        subjectLine="MV Atlas — stem confirmation"
        x={L.emailX} y={L.emailY} w={docW} h={emailH} opacity={docP} lift={docLift} />
      {/* WhatsApp card — arrives after the email is legible, sits below it */}
      <ChatCard
        x={L.chatX}
        y={L.emailY + 10 + emailH + chatGap + lerp(14, 0, chatInP)}
        w={docW}
        opacity={docP * chatInP}
      />

      {/* Middle column */}
      <g opacity={midOpacity}>
        {/* vertical dividers */}
        <line x1={midX} y1={L.divTop} x2={midX} y2={L.divBottom} stroke={COLORS.hair} />
        <line x1={midX + midW} y1={L.divTop} x2={midX + midW} y2={L.divBottom} stroke={COLORS.hair} />
        {/* tag */}
        <text
          x={midX + midW / 2}
          y={L.divTop - 6}
          textAnchor="middle"
          style={{ font: `500 10px ${FONT}`, fill: COLORS.greenSoft, letterSpacing: '0.16em', textTransform: 'uppercase' }}
        >Matched fields</text>
      </g>

      {/* Field rows */}
      <g>
        {ROWS.map((row, i) => (
          <FieldRow
            key={row.key}
            row={row}
            i={i}
            T={T}
            extractStart={CUES.Extract}
            resetStart={authoredTotal}
            midX={midX}
            midW={midW}
            y={rowsTop + i * rowH}
            docLeftX={docLeftX + 20}
            docRightX={docRightX + docW - 20}
            rightPad={L.rightPad}
            labelFS={L.labelFS}
          />
        ))}
      </g>

      {/* Draft card */}
      <DraftCard T={T} CUES={CUES} x={draftX} y={draftY} w={draftW} h={draftH} />

      {/* Stem chip */}
      <StemChip T={T} CUES={CUES} cx={L.stemCX} cy={stemY} authoredTotal={authoredTotal} />
    </svg>
  );
}

function App() {
  const scenes = window.OM_SCENES;
  const playback = window.OM_PLAYBACK;
  const portrait = useIsPortrait();
  const L = portrait ? LAYOUT_PORTRAIT : LAYOUT_WIDE;
  return (
    <CompositionStage width={L.W} height={L.H} scenes={scenes} playback={playback} bg={COLORS.ice} once>
      <Piece />
    </CompositionStage>
  );
}

window.App = App;
