/* =========================================================================
   Chan site v2 — scene components (P0.16: compose + mega-room MERGED into
   one pinned sequence. Verified live on extra.email 2026-06-10: their clip
   window opens over a room whose headline + list are ALREADY laid out at
   final positions underneath; the left column springs up first, the rows
   stamp top to bottom, and the last rail scene renders inside a phone
   outline that hands off to the next section's real device.)
   Vision-confirmed extra.email structure (2026-06-10 scroll-through):
     1 cinematic hero (no product chrome)
     2 compose BRIDGE, scroll-scrubbed: FROM/TO/ATTACHED chrome, body types
       itself (the manifesto lives INSIDE the compose body), giant Send arms
     3 brand mega-room: full-bleed product surface that TRANSFORMS per rail
       scene (raw list → chips stamp on by scroll wipe → rows fly out →
       digest sheet slides up). Their stage bleeds past the viewport edge
       (measured right edge 1735px on a 1470px viewport).
     4 cycling-word carousel: real device + ghost neighbor panels + emoji
       dock (Fae-grade phone treatment)
     5 one full feature room  ·  6 pastel trio cards  ·  7 dark close.
   No pricing section.
   Voice rules: no em-dashes, no "ngl"/"yo"; bubbles are Chan (lowercase,
   first person); prose is brand third-person. Calls wear "in beta".
   Variant toggle: ?word=pill → bordered-chip cycling word.
   ========================================================================= */

const Q = new URLSearchParams(location.search);
const WORD_PILL = Q.get('word') === 'pill';
const REDUCE = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/* ---- tiny scroll-reveal: adds .seen when a [data-reveal] enters.
   Ratio thresholds lie for tall sections (22% of a 300vh pin = most of a
   viewport), so tall targets fire on ~180px of visible height instead. -- */
function useRevealRoot() {
  React.useEffect(() => {
    const io = new IntersectionObserver(
      (es) => es.forEach((e) => {
        if (!e.isIntersecting) return;
        if (e.intersectionRatio >= 0.22 || e.intersectionRect.height >= 180) {
          e.target.classList.add('seen');
        }
      }),
      { threshold: [0, 0.04, 0.08, 0.22] }
    );
    document.querySelectorAll('[data-reveal]').forEach((el) => io.observe(el));
    return () => io.disconnect();
  }, []);
}

/* ---- scroll progress through a tall section (0..1) ---------------------- */
function useScrollProgress(ref, initial) {
  const [p, setP] = React.useState(REDUCE ? initial : 0);
  React.useEffect(() => {
    if (REDUCE) return;
    const onScroll = () => {
      const el = ref.current;
      if (!el) return;
      const total = el.offsetHeight - window.innerHeight;
      setP(Math.min(0.999, Math.max(0, -el.getBoundingClientRect().top / total)));
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener('scroll', onScroll);
  }, []);
  return p;
}

/* ---- the iMessage send affordance (blue circle, white up arrow) -------- */
function SendCircle() {
  return (
    <span className="send-circle" aria-hidden="true">
      <svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" /></svg>
    </span>
  );
}

/* ---- compose-bar CTA: a message field that actually sends -------------- */
function ComposeBar({ label, dark }) {
  return (
    <a className={'compose-bar' + (dark ? ' dark' : '')} href={smsHref()}>
      <span className="cb-text">{label}</span>
      <SendCircle />
    </a>
  );
}

/* ---- iMessage composer row: + button, field, mic (the bottom anatomy a
   real iPhone always shows; every device frame on the page carries it).
   Pass `typed` to put live text + caret + the blue send button in the
   field (the carousel's typing showcase drives this). ------------------- */
function MsgBar({ light, typed }) {
  return (
    <div className={'msgbar' + (light ? ' mb-light' : '') + (typed ? ' mb-live' : '')} aria-hidden="true">
      <span className="mb-plus">
        <svg viewBox="0 0 12 12"><path d="M6 1.5v9M1.5 6h9" /></svg>
      </span>
      <span className="mb-field">
        {typed ? (
          <span className="mb-typed">{typed}<i className="mb-caret" /></span>
        ) : 'iMessage'}
        {typed ? (
          <span className="mb-send">
            <svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" /></svg>
          </span>
        ) : (
          <svg className="mb-mic" viewBox="0 0 24 24">
            <rect x="9.2" y="2.8" width="5.6" height="11" rx="2.8" />
            <path className="mm-arc" d="M5.8 11.2a6.2 6.2 0 0 0 12.4 0M12 17.4V21" />
          </svg>
        )}
      </span>
    </div>
  );
}

/* simplified iOS dark keyboard: fills the screen's lower half the way a
   real mid-conversation phone does (Folk's trick; no more dead black) */
function Keys() {
  const r1 = 'qwertyuiop'.split('');
  const r2 = 'asdfghjkl'.split('');
  const r3 = 'zxcvbnm'.split('');
  return (
    <div className="kb" aria-hidden="true">
      <div className="kb-r">{r1.map((k) => <span key={k} className="kk">{k}</span>)}</div>
      <div className="kb-r kb-r2">{r2.map((k) => <span key={k} className="kk">{k}</span>)}</div>
      <div className="kb-r">
        <span className="kk kk-sp">⇧</span>
        {r3.map((k) => <span key={k} className="kk">{k}</span>)}
        <span className="kk kk-sp">⌫</span>
      </div>
      <div className="kb-r kb-rb">
        <span className="kk kk-sp kk-123">123</span>
        <span className="kk kk-space" />
        <span className="kk kk-sp kk-ret">return</span>
      </div>
    </div>
  );
}

/* composer + home indicator, pinned to the screen's bottom edge */
function DevFoot() {
  return (
    <div className="dev-foot" aria-hidden="true">
      <MsgBar />
      <span className="dev-home" />
    </div>
  );
}

/* ---- device + thread: typing dots, then the bubble lands ---------------- */
function Thread({ msgs, replayKey }) {
  const [shown, setShown] = React.useState(REDUCE ? msgs.length : 0);
  const [typing, setTyping] = React.useState(false);
  React.useEffect(() => {
    if (REDUCE) { setShown(msgs.length); setTyping(false); return; }
    setShown(0); setTyping(false);
    const timers = [];
    let i = 0;
    const next = () => {
      if (i >= msgs.length) return;
      const m = msgs[i];
      const land = () => {
        i += 1; setShown(i);
        timers.push(setTimeout(next, m.me ? 430 : 560));
      };
      if (m.me || m.call) land();
      else {
        setTyping(true);
        timers.push(setTimeout(() => { setTyping(false); land(); }, 640));
      }
    };
    timers.push(setTimeout(next, 380));
    return () => timers.forEach(clearTimeout);
  }, [replayKey, msgs]);
  return (
    <div className="th">
      <div className="th-head">
        <span className="th-back" aria-hidden="true">
          <svg viewBox="0 0 10 16"><path d="M8.5 1.5L2 8l6.5 6.5" /></svg>
          <i>2</i>
        </span>
        <img src="../chan-avatar.png" alt="" />
        <span>Chan ›</span>
        <span className="th-vid" aria-hidden="true">
          <svg viewBox="0 0 24 16"><rect x="1" y="2.4" width="14.6" height="11.2" rx="3.4" /><path d="M16.8 6.6L22.6 3.4v9.2l-5.8-3.2z" /></svg>
        </span>
      </div>
      <div className="th-msgs">
        {msgs.slice(0, shown).map((m, i) => m.call ? (
          <div key={replayKey + '-' + i} className="tb chan tb-call">
            <span className="call-ic">📞</span>
            <span className="call-lbl">{m.call}</span>
            <span className="call-dur">{m.dur}</span>
          </div>
        ) : (
          <div key={replayKey + '-' + i} className={'tb ' + (m.me ? 'me' : 'chan')}>
            {m.t}
            {m.tap && <span className="tap">{m.tapE || '👍'}</span>}
          </div>
        ))}
        {typing && <div className="tb chan tb-typing"><span /><span /><span /></div>}
      </div>
    </div>
  );
}

/* Fae-grade frame: metallic rim, side buttons, status bar + island */
function Device({ msgs, replayKey, glint }) {
  return (
    <div className={'dev' + (glint ? ' glint' : '')} key={glint ? 'g-' + replayKey : undefined}>
      <span className="dev-btn db-l" aria-hidden="true" />
      <span className="dev-btn db-r" aria-hidden="true" />
      <div className="dev-screen">
        <div className="dev-status" aria-hidden="true">
          <span className="ds-time">9:41</span>
          <svg className="ds-icons" viewBox="0 0 52 12">
            <rect x="0" y="7" width="2.6" height="4" rx="1" />
            <rect x="4.2" y="5" width="2.6" height="6" rx="1" />
            <rect x="8.4" y="3" width="2.6" height="8" rx="1" />
            <rect x="12.6" y="1.4" width="2.6" height="9.6" rx="1" />
            <path className="wifi" d="M21.5 6.6a6.8 6.8 0 0 1 8.6 0M23.4 8.8a4 4 0 0 1 4.8 0" />
            <circle cx="25.8" cy="10.9" r="1" />
            <rect className="batt" x="36" y="2.6" width="12.6" height="6.8" rx="2.2" />
            <rect x="37.6" y="4.2" width="7.6" height="3.6" rx="1" />
            <rect x="49.6" y="4.9" width="1.6" height="2.2" rx="0.8" />
          </svg>
        </div>
        <div className="dev-island" />
        <Thread msgs={msgs} replayKey={replayKey} />
        <DevFoot />
      </div>
    </div>
  );
}

/* ---- copy data ---------------------------------------------------------- */
const HERO_MSGS = [
  { me: true, t: 'morning chan' },
  { t: 'ok boss. 2 leads replied overnight, want the drafts?' },
  { me: true, t: 'send them' },
  { t: 'done ✓ 💸', tap: true, tapE: '❤️' },
];

/* compose body = the OFFER (the Loud scene already stated the problem;
   this scene hands it over), exactly where Extra puts their manifesto */
const COMPOSE_P1 = 'Hand over the follow ups 📞, the invoices 🧾, the leads 📈, the meetings 📅.';
const COMPOSE_P2 = 'You were not hired to chase them. Chan was.';

/* mega-room: rail scenes + the raw feed Chan ingests (full-bleed stage) */
const REEL_RAIL = [
  { label: 'Reads everything', cap: 'Email, missed calls, DMs, invoices. Chan reads it all so you never have to.' },
  { label: 'Sorts it out', cap: 'What needs you, what Chan handled, what can wait, what is noise.' },
  { label: 'Texts you what matters', cap: 'One brief in your thread, before your coffee.' },
];
const FEED = [
  { e: '✉️', from: 'jane@acme.co', subj: 'Demo request', prev: 'saw your product, can we talk this week?', k: 'need', chip: 'Needs you' },
  { e: '📞', from: 'Missed call', subj: '(415) 882-0455', prev: 'voicemail: wants a quote for 40 units', k: 'need', chip: 'Needs you' },
  { e: '💬', from: '@sarah.k', subj: 'Instagram DM', prev: 'is the studio open this saturday?', k: 'done', chip: 'Chan handled' },
  { e: '🧾', from: 'billing@vendorco', subj: 'Invoice #214', prev: '12 days overdue, second notice', k: 'wait', chip: 'Waiting' },
  { e: '⭐', from: 'Reviews', subj: 'New 5-star review', prev: 'fast, friendly, would book again', k: 'done', chip: 'Chan handled' },
  { e: '📅', from: 'Calendar', subj: 'Standup moved', prev: 'now 9:30, room 4 freed up', k: 'done', chip: 'Chan handled' },
  { e: '💬', from: 'WhatsApp', subj: 'New message', prev: 'hola! do you ship to miami?', k: 'need', chip: 'Needs you' },
  { e: '✉️', from: 'mike@supplyco', subj: 'Shipment delayed', prev: 'new eta thursday, sorry about this', k: 'need', chip: 'Needs you' },
  { e: '🧾', from: 'Payments', subj: 'Invoice paid', prev: '$1,250 received from acme corp', k: 'done', chip: 'Chan handled' },
  { e: '📅', from: 'Bookings', subj: 'New intro call', prev: 'friday 11:00 confirmed, brief to follow', k: 'done', chip: 'Chan handled' },
  { e: '✉️', from: 'growthletter', subj: '10 growth hacks', prev: 'this week in marketing, plus a webinar', k: 'noise', chip: 'Noise' },
  { e: '🧾', from: 'no-reply@bank', subj: 'Statement ready', prev: 'your may statement is available', k: 'wait', chip: 'Waiting' },
  { e: '✉️', from: 'ads@promoblast', subj: '50% off ads', prev: 'limited time offer ends tonight', k: 'noise', chip: 'Noise' },
];
const CHIP_SET = [
  { k: 'need', chip: 'Needs you' },
  { k: 'done', chip: 'Chan handled' },
  { k: 'wait', chip: 'Waiting' },
  { k: 'noise', chip: 'Noise' },
].map((c) => ({ ...c, n: FEED.filter((f) => f.k === c.k).length }));

/* word → a bespoke packed screen per category (Extra packs each category
   phone with real content; these slide through the fixed frame).
   Each word carries: a 2-line subcaption (what Chan does / what you do),
   a pin preview (the balloon over its pinned chat, reads like the
   thread's latest line), a SHORT history, and a LIVE tail: when the
   screen takes the stage, the composer types the next message, sends
   it, Chan's typing dots fire, and the reply lands (Folk's move; fills
   the screen with the keyboard instead of dead black).
   Scenarios are capability-true: lead gen, win-backs/review asks,
   paste-a-meeting-link-and-Chan-joins, frustrated-email triage,
   invoice chasing. Artifacts are things Chan really sends over
   Messages: links that unfurl (google.com review page, stripe.com
   invoice), never invented UI. Confirmations vary; ✓ is rare. */
const AUTO_WORDS = [
  { w: 'sales', e: '💼',
    sub: ['Chan finds the leads and writes the intros.', 'You just say send.'],
    pin: 'sent 💸 following up friday',
    msgs: [
      { me: true, t: 'find me 10 leads in toronto' },
      { t: 'done. 10 ranked leads, intros drafted for the top 3' },
      { t: 'jane at acme wants a demo. reply is ready, say the word' },
    ],
    live: { me: 'send it', reply: { t: 'sent 💸 ill follow up friday if she goes quiet', tapE: '🎉' } },
  },
  { w: 'marketing', e: '📣',
    sub: ['Win-backs and review asks, written in your voice.', 'You approve in one text.'],
    pin: '2 replies already 👀',
    msgs: [
      { t: 'nike asked about a paid collab 👀 your rate card is drafted' },
      { me: true, t: 'send it with my best 3 posts' },
      { t: 'sent. flagged your top reels too' },
    ],
    live: { me: 'hows the win-back going', reply: { t: '12 drafts out this morning. 2 replies already 👀', tapE: '❤️' } },
  },
  { w: 'meetings', e: '📅',
    sub: ['Paste a meeting link. Chan joins and takes notes.', 'The recap lands in your texts.'],
    pin: 'recap is in your texts',
    msgs: [
      { me: true, t: 'zoom.us/j/82440913', lnk: true },
      { me: true, t: 'join this for me, im double booked' },
      { t: 'on it. ill be in the room' },
    ],
    live: { me: 'what did i miss', reply: { t: 'they want the pilot by march. recap and 3 action items in your email', tapE: '👍' } },
  },
  { w: 'support', e: '🛟',
    sub: ['Frustrated customers get caught and answered fast.', 'Before they walk.'],
    pin: 'five stars, an hour ago 🙏',
    msgs: [
      { t: '2 frustrated emails flagged. replies drafted with a discount, review?' },
      { me: true, t: 'send both' },
      { t: 'sent. both customers answered, one apologized' },
    ],
    live: { me: 'any new reviews this week', reply: { t: 'three new ones. latest an hour ago 🙏', tapE: '‼️', card: { th: 'G', cls: 'lk-g', b: '★★★★★ “fast, friendly, would buy again”', d: 'google.com/maps' } } },
  },
  { w: 'ops', e: '⚙️',
    sub: ['Invoices chased, pipeline cleaned, reports on time.', 'Nothing slips.'],
    pin: 'paid after one nudge 💸',
    msgs: [
      { t: 'your crm has 5 stale deals going nowhere. cleanup list is ready' },
      { me: true, t: 'archive them' },
      { t: 'archived. pipeline is clean now' },
    ],
    /* the FINALE flips the ritual: every other beat ends with Chan
       reporting and YOU reacting; here you thank your manager and CHAN
       reacts 💪 (the tapback the next seam dives into). This screen is
       SCROLL-driven, never timer-driven, so a fast scroller arrives at
       the dive with the conversation already complete. */
    live: {
      me: 'wheres invoice 312',
      reply: { t: 'paid this morning after one nudge 💸', card: { th: '🧾', cls: 'lk-s', b: 'Invoice #312 · $2,400 paid', d: 'stripe.com' } },
      me2: { t: 'thanks chan!', tapE: '💪' },
    },
  },
];

/* the scatter field: everything Chan takes in, bloated on purpose.
   Ring composition around the copy (center kept clear), edges cropped.
   k: me/sms/chan bubbles · e emoji float (s=px) · chip · call · tap · logo.
   l: b back-blur · m sharp mid · f foreground giant. sp parallax, r tilt. */
const SCAT = [
  /* top row */
  { k: 'e',    t: '📞', s: 64,  x: 5,  y: 7,  l: 'm', sp: .45, r: -8, hm: false },
  { k: 'sms',  t: 'done ✓',                  x: 17, y: 11, l: 'm', sp: .25, r: 4,  hm: true },
  { k: 'chan', t: '3 leads replied 👀',      x: 28, y: 4,  l: 'm', sp: .5,  r: -3, hm: false },
  { k: 'e',    t: '✉️', s: 58,  x: 46, y: 9,  l: 'm', sp: .3,  r: 10, hm: true },
  { k: 'me',   t: 'prep my 9am',             x: 56, y: 3,  l: 'm', sp: .4,  r: 2,  hm: true },
  { k: 'e',    t: '🧾', s: 72,  x: 70, y: 8,  l: 'b', sp: .2,  r: -12, hm: true },
  { k: 'chan', t: 'inbox is clear, boss',    x: 81, y: 5,  l: 'm', sp: .55, r: 3,  hm: false },
  /* upper sides */
  { k: 'me',   t: 'find me 10 leads',        x: 3,  y: 21, l: 'm', sp: .35, r: -4, hm: false },
  { k: 'e',    t: '📈', s: 88,  x: 12, y: 32, l: 'f', sp: .55, r: 6,  hm: true },
  { k: 'tap',  t: '👍',                       x: 27, y: 19, l: 'm', sp: .6,  r: 0,  hm: true },
  { k: 'logo', t: 'Gmail', slug: 'gmail',    x: 76, y: 22, l: 'm', sp: .4,  r: -5, hm: true },
  { k: 'tap',  t: '❤️',                       x: 92, y: 18, l: 'm', sp: .45, r: 0,  hm: true },
  { k: 'e',    t: '💸', s: 96,  x: 88, y: 30, l: 'f', sp: .65, r: 8,  hm: false },
  /* mid sides (copy zone stays clear) */
  { k: 'call',                                x: 4,  y: 44, l: 'm', sp: .55, r: -2, hm: true },
  { k: 'chan', t: 'recap is in your texts',  x: 1,  y: 58, l: 'b', sp: .35, r: 3,  hm: true },
  { k: 'e',    t: '📅', s: 76,  x: 13, y: 64, l: 'm', sp: .5,  r: -7, hm: false },
  { k: 'chip', c: 'fc-need', t: 'Needs you', x: 91, y: 42, l: 'm', sp: .3,  r: 4,  hm: true },
  { k: 'me',   t: 'book her for 2pm',        x: 85, y: 52, l: 'm', sp: .4,  r: -3, hm: false },
  { k: 'e',    t: '⏰', s: 60,  x: 94, y: 64, l: 'b', sp: .25, r: 9,  hm: true },
  /* bottom rows */
  { k: 'sms',  t: 'on it boss',              x: 11, y: 78, l: 'm', sp: .5,  r: 3,  hm: false },
  { k: 'e',    t: '💬', s: 110, x: 2,  y: 88, l: 'f', sp: .7,  r: -10, hm: false },
  { k: 'chan', t: 'meeting moved to 2pm',    x: 24, y: 90, l: 'm', sp: .3,  r: -2, hm: true },
  { k: 'e',    t: '📞', s: 54,  x: 36, y: 76, l: 'b', sp: .35, r: 12, hm: true },
  { k: 'me',   t: 'send the drafts',         x: 44, y: 88, l: 'm', sp: .25, r: 2,  hm: true },
  { k: 'sms',  t: 'follow ups queued',       x: 60, y: 80, l: 'm', sp: .45, r: -4, hm: false },
  { k: 'chip', c: 'fc-done', t: 'Chan handled', x: 75, y: 90, l: 'b', sp: .3, r: 5, hm: true },
  { k: 'logo', t: 'Calendar', slug: 'googlecalendar', x: 67, y: 71, l: 'm', sp: .35, r: -6, hm: true },
  { k: 'chan', t: 'booked thursday 2pm 🎉',  x: 80, y: 94, l: 'f', sp: .6,  r: 4,  hm: false },
];

/* the LOUD field: a full-viewport collage of business noise (wafer-style
   bloat: window cards, DM panels, app badges, a cascading unread stack),
   scroll-scrubbed Extra-style so Chan claims every item.
   k: mail/dm/bub/miss/inv/cal/note/badge/stack/ping · in: pop threshold p ·
   x/y %, r tilt, sp parallax, hm hidden-mobile */
const NOISE = [
  /* top band */
  { k: 'mail', from: 'jane@acme.co', subj: 'Demo request', prev: 'saw your product, can we talk this week?', x: 3, y: 5, r: -5, in: .039, sp: .4, hm: false },
  { k: 'badge', slug: 'gmail', n: 23, x: 30, y: 4, r: 3, in: .062, sp: .55, hm: false },
  { k: 'dm', from: '@sarah.k · Instagram', prev: 'is the studio open this saturday?', x: 43, y: 7, r: 2, in: .086, sp: .3, hm: true },
  { k: 'miss', num: '(415) 882-0455', note: 'voicemail: wants a quote for 40 units', x: 66, y: 4, r: -3, in: .109, sp: .45, hm: false },
  { k: 'stack', label: 'Unread', n: 47, x: 86, y: 7, r: 0, in: .133, sp: .35, hm: false },
  /* left flank */
  { k: 'ping', t: 'new lead 👀', x: 10, y: 13, r: -3, in: .094, sp: .6, hm: true },
  { k: 'bub', t: 'hey did you see my email?', x: 2, y: 21, r: -2, in: .156, sp: .5, hm: false },
  { k: 'inv', title: 'Invoice #214', sub: '12 days overdue', x: 4, y: 56, r: 3, in: .179, sp: .35, hm: false },
  { k: 'note', t: 'call the venue back!!', x: 3, y: 66, r: -6, in: .203, sp: .55, hm: true },
  { k: 'badge', slug: 'instagram', n: 7, x: 13, y: 84, r: 5, in: .226, sp: .45, hm: false },
  /* right flank */
  { k: 'cal', title: 'Thursday 2pm', sub: 'two meetings, one you', x: 84, y: 30, r: 4, in: .164, sp: .5, hm: false },
  { k: 'mail', from: 'mike@supplyco', subj: 'Shipment delayed', prev: 'new eta thursday, sorry about this', x: 87, y: 48, r: -4, in: .187, sp: .35, hm: true },
  { k: 'badge', e: '📞', n: 4, x: 94, y: 68, r: -8, in: .211, sp: .6, hm: false },
  { k: 'dm', from: 'shopgram', prev: '3 new comments need replies', x: 82, y: 80, r: 3, in: .234, sp: .4, hm: true },
  /* bottom band */
  { k: 'bub', t: 'any update on the quote?', x: 27, y: 91, r: 2, in: .25, sp: .45, hm: false },
  { k: 'mail', from: 'growthletter', subj: '10 growth hacks', prev: 'this week in marketing, plus a webinar', x: 46, y: 85, r: -2, in: .265, sp: .3, hm: true },
  { k: 'badge', e: '📅', n: 2, x: 63, y: 92, r: 6, in: .281, sp: .55, hm: true },
  { k: 'dm', from: 'Team', prev: 'standup moved to 9:30, room 4 freed up', x: 68, y: 86, r: -3, in: .296, sp: .4, hm: true },
];

/* what cuts through while the noise dims: a ROTATING sequence of
   resolutions, each one answering a noise artifact the visitor just
   watched flood in, and each carrying a REAL iMessage attachment kind
   (call card, paid receipt, location pin, rich tracking link, triage
   summary) so every beat shows something Chan can actually send.
   Scroll-scrubbed like everything else, so it rewinds cleanly. */
const LOUD_ROTATION = [
  { call: 'Call answered', dur: '2:14', t: 'caller wanted a quote. sent it, recap is in your thread', tap: true },
  { att: 'rec', t: 'invoice #214 was 12 days late. nudged them. paid an hour later 💸' },
  { att: 'map', t: 'sarah asked about saturday. sent her the pin and your hours 📍', tap: true, tapE: '❤️' },
  { att: 'link', t: 'shipment slipped to thursday. told the 3 customers waiting on it' },
  { att: 'sweep', t: '47 unread this morning. 2 needed you, both handled ✓', tap: true, tapE: '‼️' },
];

const LOGOS = [
  'gmail', 'googlecalendar', 'hubspot', 'slack', 'notion', 'linear',
  'googlesheets', 'stripe', 'shopify', 'instagram', 'linkedin', 'zoom',
  'canva', 'salesforce', 'airtable', 'calendly',
];
const LOGO_NAMES = {
  gmail: 'Gmail', googlecalendar: 'Calendar', hubspot: 'HubSpot', slack: 'Slack',
  notion: 'Notion', linear: 'Linear', googlesheets: 'Sheets', stripe: 'Stripe',
  shopify: 'Shopify', instagram: 'Instagram', linkedin: 'LinkedIn', zoom: 'Zoom',
  canva: 'Canva', salesforce: 'Salesforce', airtable: 'Airtable', calendly: 'Calendly',
};

function smsHref() {
  return window.chanSmsUrl ? window.chanSmsUrl('firstContact') : '#';
}

/* the morning-brief thread: ONE source of truth, rendered by the digest
   phone AND by the seam flyer (which unsends it row by row in flight) */
const DIGEST_MSGS = [
  { t: 'morning ☀️ 3 things today:' },
  { t: '1. acme demo at 2pm. your brief is ready' },
  { t: '2. jane at acme asked for pricing. reply drafted, say send' },
  { t: '3. invoice #214 is 12 days late. want me to nudge?' },
  /* Chan reacts to YOUR call (reactions flow both ways on this site,
     not just you applauding Chan) */
  { me: true, t: 'send + nudge', tap: true, tapE: '❤️' },
  { t: 'both out 💸 jane has the pricing, vendor got the nudge' },
  { me: true, t: 'whats my afternoon look like' },
  { t: '2 calls, then clear after 4. briefs land an hour before' },
];

/* ---- S1 hero: cinematic room, device thread, headline low (Extra's
        film-still slot; the thread IS Chan's cinema) --------------------- */
function Hero() {
  return (
    <header className="sc-hero">
      <div className="hero-halo" />
      <nav className="v2-nav">
        <a className="v2-wordmark" href="/">Chan</a>
        <div className="v2-nav-right">
          <a className="v2-nav-login" href="/dashboard/">Log in</a>
          <a className="v2-nav-hire" href={smsHref()}>Hire Chan</a>
        </div>
      </nav>
      <div className="hero-dev-wrap">
        <Device msgs={HERO_MSGS} replayKey="hero" />
      </div>
      <div className="hero-copy">
        <h1>Meet your life&rsquo;s new manager.</h1>
        <p className="hero-sub">Chan runs your sales, marketing, and ops. All from your texts.</p>
        <div className="hero-ctas">
          <a className="cta cta-primary" href={smsHref()}>Hire Chan</a>
          <a className="cta cta-frosted" href="#reel">Watch Chan work</a>
        </div>
        <div className="hero-hint">Free to start. On the job in 30 seconds.</div>
      </div>
    </header>
  );
}

/* ---- S1b the LOUD scene: wafer-style noise collage, Extra-style scrub.
        Stage A: the bloat floods in, item by item, as you scroll.
        Stage B: Chan claims each one (grey, shrink, blur) while the calm
        thread + the answered call spring in at center.
        Stage C: noise fully receded; one manager, zero noise. ------------- */
function Loud() {
  const ref = React.useRef(null);
  const p = useScrollProgress(ref, 0.9);
  /* depth drift while pinned: the SECTION top slides 0 → -(H - vh) during
     the scrub (the pin itself never moves), so drive --sy from that */
  React.useEffect(() => {
    if (REDUCE) return;
    let raf = 0;
    const onScroll = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const el = ref.current;
        if (!el) return;
        el.style.setProperty('--sy', String(Math.round(el.getBoundingClientRect().top / 9)));
      });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
    return () => { window.removeEventListener('scroll', onScroll); cancelAnimationFrame(raf); };
  }, []);
  const threadIn = p > 0.5;
  /* which resolution is on stage: one beat per ~10% of the pin */
  const k = Math.min(LOUD_ROTATION.length - 1, Math.max(0, Math.floor((p - 0.52) / 0.096)));
  return (
    <section className="sc-loud" ref={ref} data-reveal="bare">
      <div className={'loud-pin' + (p > 0.82 ? ' calm' : '')}>
        <div className="loud-field" aria-hidden="true">
          {NOISE.map((it, i) => {
            const on = p > it.in;
            const off = p > 0.47 + (i % 9) * 0.034;
            /* n0 = the first wave; it also fires on section ENTER (.seen)
               so the approach is never blank cream */
            const cls = 'nz nz-' + it.k + (on ? ' on' : '') + (off ? ' off' : '') + (it.in <= 0.11 ? ' n0' : '') + (it.hm ? ' hm' : '');
            const style = { left: it.x + '%', top: it.y + '%', ['--sp']: it.sp, ['--nr']: (it.r || 0) + 'deg' };
            if (it.k === 'mail') return (
              <div key={i} className={cls} style={style}>
                <span className="tl" aria-hidden="true"><i /><i /><i /></span>
                <b>{it.from}</b>
                <em>{it.subj}</em>
                <p>{it.prev}</p>
              </div>
            );
            if (it.k === 'dm') return (
              <div key={i} className={cls} style={style}>
                <b>{it.from}</b>
                <p>{it.prev}</p>
              </div>
            );
            if (it.k === 'miss') return (
              <div key={i} className={cls} style={style}>
                <b>📞 Missed call</b>
                <em>{it.num}</em>
                <p>{it.note}</p>
              </div>
            );
            if (it.k === 'inv' || it.k === 'cal') return (
              <div key={i} className={cls} style={style}>
                <b>{it.title}</b>
                <p>{it.sub}</p>
                {it.k === 'inv' && <span className="nz-due">PAST DUE</span>}
              </div>
            );
            if (it.k === 'badge') return (
              <span key={i} className={cls} style={style}>
                {it.slug ? <img src={'../logos/' + it.slug + '.svg'} alt="" loading="lazy" /> : <span className="nzb-e">{it.e}</span>}
                <i>{it.n}</i>
              </span>
            );
            if (it.k === 'stack') return (
              <div key={i} className={cls} style={style}>
                <span /><span /><span /><span />
                <div className="nzs-top"><b>{it.label}</b><em>({it.n})</em></div>
              </div>
            );
            if (it.k === 'note' || it.k === 'ping') return (
              <span key={i} className={cls} style={style}>{it.t}</span>
            );
            return (
              <span key={i} className={cls} style={style}>{it.t}</span>
            );
          })}
        </div>
        <div className="loud-copy">
          <h2 className="rise"><span className={'rise-in' + (p > 0.03 ? ' up' : '')}>Running a business is loud.</span></h2>
          <h2 className="rise loud-l2"><span className={'rise-in' + (p > 0.52 ? ' up' : '')}>Chan hears it all. You hear what matters.</span></h2>
        </div>
        <div className={'loud-thread' + (threadIn ? ' in' : '')}>
          {LOUD_ROTATION.map((m, i) => (
            <div key={i} className={'lt-set' + (i === k ? ' on' : '') + (i < k ? ' past' : '')}>
              {m.call && (
                <div className="lt-row lt-call">
                  <span className="call-ic">📞</span>
                  <span className="call-lbl">{m.call}</span>
                  <span className="call-dur">{m.dur}</span>
                </div>
              )}
              {m.att === 'rec' && (
                <div className="lt-att lt-rec">
                  <span className="ltr-ic">🧾</span>
                  <span className="ltr-meta"><b>Invoice #214</b><em>$1,250 received</em></span>
                  <span className="ltr-paid">PAID</span>
                </div>
              )}
              {m.att === 'map' && (
                <div className="lt-att lt-map">
                  <span className="ltm-tile" aria-hidden="true"><i className="r1" /><i className="r2" /><span className="ltm-pin">📍</span></span>
                  <span className="ltm-meta"><b>Your studio</b><em>open saturday 10 to 4</em></span>
                </div>
              )}
              {m.att === 'link' && (
                <div className="lt-att lt-link">
                  <span className="ltl-thumb" aria-hidden="true">🚚</span>
                  <span className="ltl-meta"><b>Tracking ST-2381</b><em>out for delivery thursday</em></span>
                </div>
              )}
              {m.att === 'sweep' && (
                <div className="lt-att lt-sweep">
                  <span className="fchip fc-need">Needs you 2</span>
                  <span className="ltw-line">45 archived for later</span>
                </div>
              )}
              <div className="lt-row lt-b chan">
                {m.t}
                {m.tap && <span className="tap">{m.tapE || '👍'}</span>}
              </div>
            </div>
          ))}
          <span className="lt-dots" aria-hidden="true">
            {LOUD_ROTATION.map((_, i) => <i key={i} className={i === k ? 'on' : ''} />)}
          </span>
        </div>
      </div>
    </section>
  );
}

/* ---- S2+S3 compose bridge → blue mega-room, ONE pinned sequence:
        a text from Chan writes itself, the giant Send arms, and the pill's
        fill OPENS into an already-furnished room (Extra's construction,
        verified live: the clip window expands over a room laid out at its
        final positions; nothing inside waits for the morph to finish).
        The room then plays the three rail scenes, and the digest phone
        drifts centerward to hand off to the carousel's device below. ----- */
function ComposeStory() {
  const wrapRef = React.useRef(null);
  const pillRef = React.useRef(null);
  const [pillRect, setPillRect] = React.useState(null);
  const p = useScrollProgress(wrapRef, 0.999);
  /* viewport size as STATE: a resize (or rotation) must re-render even
     when the scroll progress is pinned at its cap, otherwise a stale
     desktop hand-off transform survives onto the mobile layout */
  const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
  React.useEffect(() => {
    const onR = () => setVp({ w: window.innerWidth, h: window.innerHeight });
    window.addEventListener('resize', onR);
    return () => window.removeEventListener('resize', onR);
  }, []);
  const w1 = COMPOSE_P1.split(' ');
  const w2 = COMPOSE_P2.split(' ');
  /* timeline: chrome .012 · p1 types .05-.24 · p2 .26-.38 · send arms .42 ·
     window opens .46-.60 · room scenes .60-.96 · phone hand-off .94-.99 */
  const n1 = Math.round(Math.min(1, Math.max(0, (p - 0.05) / 0.19)) * w1.length);
  const n2 = Math.round(Math.min(1, Math.max(0, (p - 0.26) / 0.12)) * w2.length);
  const chromeOn = p > 0.012;
  const armed = p > 0.42;
  const ex = REDUCE ? 1 : Math.min(1, Math.max(0, (p - 0.46) / 0.14));
  /* the send gesture: the circle-arrow FLIES UP off the pill the moment
     the morph starts (Extra's plane-takes-off beat), then the fill opens */
  const fired = !REDUCE && ex > 0.02;
  /* PORTAL construction (Extra's actual build): from the moment the pill
     arms, its fill IS the room surface clipped to the pill rect; the
     button chrome (border, text, arrow) floats above it. There is no
     fill swap at the morph: the same surface simply expands. */
  const portal = !REDUCE && armed;
  /* room scenes (the old standalone reel, now INSIDE the opened window):
     scene 0 stamps chips on the raw feed, 1 floats the chips, 2 digest */
  const rp = Math.min(1, Math.max(0, (p - 0.6) / 0.36));
  const idx = Math.min(2, Math.floor(rp * 3));
  const local = Math.min(1, Math.max(0, rp * 3 - idx));
  const cut = idx > 0 ? FEED.length : Math.floor(local * (FEED.length + 2));
  /* the room furnishes itself IN VIEW, never preloaded (Extra's enter
     order, founder-confirmed): the left column rises from the BOTTOM as
     the window opens, then the rows drop in from the TOP right after,
     quick. Thresholds sit mid-morph so the entrances are witnessed. */
  const lit = REDUCE || ex > 0.5;
  const lit2 = REDUCE || ex > 0.72;
  /* hand-off beat: the digest phone drifts toward center as the pin
     releases, so the carousel device below reads as the SAME object.
     Skipped on mobile (the phone is already centered there). */
  const hx = REDUCE || vp.w <= 720 ? 0 : Math.min(1, Math.max(0, (p - 0.94) / 0.05));
  const railRef = React.useRef(null);
  const itemRefs = React.useRef([]);
  /* tracing-beam dot follows the active rail item */
  React.useLayoutEffect(() => {
    const item = itemRefs.current[idx];
    if (item && railRef.current) {
      railRef.current.style.setProperty('--dot-y', (item.offsetTop + 19) + 'px');
    }
  }, [idx]);
  /* capture the pill's rect when it arms via offset* geometry: layout
     coordinates within the pin (== viewport coords while pinned), IMMUNE
     to the arming scale transition and the hover transform. A bounding
     rect here would catch the pill mid-scale(.95) and leave a cream ring
     between the window and the border. */
  React.useLayoutEffect(() => {
    if (armed && pillRef.current) {
      const el = pillRef.current;
      setPillRect({ t: el.offsetTop, l: el.offsetLeft, w: el.offsetWidth, h: el.offsetHeight });
    }
  }, [armed, vp]);
  let roomStyle = null;
  if (!REDUCE && (ex > 0 || portal) && ex < 1 && pillRect) {
    const vw = vp.w, vh = vp.h;
    /* clamp at 0: a pill cropped by the viewport edge must not produce a
       negative inset (invalid clip-path would snap the surface to full).
       +56: the room runs 56px PAST the viewport bottom (its rounded exit
       corners hide down there while pinned), so the bottom inset measures
       from the element's bottom, not the viewport's. */
    const lerp = (a, b) => Math.max(0, a + (b - a) * ex);
    const t = lerp(pillRect.t, 0), l = lerp(pillRect.l, 0);
    const r = lerp(vw - pillRect.l - pillRect.w, 0), b = lerp(vh - pillRect.t - pillRect.h + 56, 0);
    const rad = lerp(pillRect.h / 2, 0);
    roomStyle = { clipPath: 'inset(' + t + 'px ' + r + 'px ' + b + 'px ' + l + 'px round ' + rad + 'px)' };
  }
  const fade = !REDUCE && ex > 0 ? { opacity: Math.max(0, 1 - ex * 2.2) } : null;
  /* shut until the morph starts (and until the pill rect is known, so a
     deep-link jump never flashes an unclipped room) */
  const ready = REDUCE || ex >= 1 || pillRect;
  const roomCls = 'send-room' + ((ex <= 0 && !portal) || !ready ? ' shut' : '') + (ex >= 1 ? ' open' : '') + (lit ? ' lit' : '') + (lit2 ? ' lit2' : '') + (hx > 0 ? ' hand' : '');
  const exitStyle = hx > 0 ? { opacity: 1 - hx, transform: 'translateX(' + (-60 * hx).toFixed(1) + 'px)' } : null;
  /* focus beat: the phone travels to TRUE viewport center and zooms while
     the vignette dims the room around it, then the unpin hands it to the
     carousel device below */
  let phoneStyle = null;
  if (hx > 0) {
    /* mirrors the .dg-phone CSS dims (width min(384px, 42%)) */
    const pw = Math.min(384, vp.w * 0.42);
    const pr = Math.min(Math.max(60, vp.w * 0.18), 270);
    const tx = (vp.w / 2) - (vp.w - pr - pw / 2);
    phoneStyle = { transform: 'translateX(' + (tx * hx).toFixed(1) + 'px) scale(' + (1 + 0.1 * hx).toFixed(3) + ')' };
  }
  return (
    <section className="sc-compose" id="reel" ref={wrapRef} data-reveal="bare">
      <div className="compose-pin">
        <div className={roomCls} style={roomStyle}>
          <div className="reel-left" style={exitStyle}>
            <h2>Your whole business. Handled over text.</h2>
            <div className="reel-rail" ref={railRef}>
              <span className="rail-dot" />
              {REEL_RAIL.map((s, i) => (
                <div
                  key={s.label}
                  ref={(el) => { itemRefs.current[i] = el; }}
                  className={'reel-item' + (i === idx ? ' on' : '')}
                >
                  <div className="ri-label">{s.label}</div>
                  <div className="ri-cap">{s.cap}</div>
                </div>
              ))}
            </div>
          </div>
          <div className={'reel-stage st-' + idx}>
            <div className="feed">
              {FEED.map((f, i) => (
                <div key={i} className={'feed-row' + (i < cut ? ' sorted' : '')} style={{ ['--ri']: i }}>
                  <span className="feed-e">{f.e}</span>
                  <span className={'fchip fc-' + f.k}>{f.chip}</span>
                  <span className="feed-from">{f.from}</span>
                  <span className="feed-txt"><b>{f.subj}</b>&ensp;{f.prev}</span>
                </div>
              ))}
            </div>
            <div className="chips-float" aria-hidden="true">
              {CHIP_SET.map((c, i) => (
                <span key={c.k} className={'fchip fc-' + c.k} style={{ ['--ci']: i }}>{c.chip}<i>{c.n}</i></span>
              ))}
            </div>
            <div className="dg-phone" style={phoneStyle}>
              <span className="dgp-btn dgp-vol1" aria-hidden="true" />
              <span className="dgp-btn dgp-vol2" aria-hidden="true" />
              <div className="digest">
                <div className="dg-head">
                  <img src="../chan-avatar.png" alt="" />
                  <span className="dg-name">Chan</span>
                </div>
                <div className="dg-stamp">Wed 10, 9:00 AM</div>
                {DIGEST_MSGS.map((m, i) => (
                  <div key={i} className={'dgb' + (m.me ? ' me' : '')} style={{ ['--bi']: i }}>
                    {m.t}
                    {m.tap && <span className="tap">{m.tapE || '👍'}</span>}
                  </div>
                ))}
                <MsgBar light={true} />
              </div>
            </div>
          </div>
        </div>
        <div className="compose-in">
          <div className={'compose-rows' + (chromeOn ? ' on' : '')} style={fade}>
            <div className="compose-row">
              <span className="cr-label">FROM</span>
              <span className="chip chip-blue">Chan 💬</span>
            </div>
            <div className="compose-row">
              <span className="cr-label">TO</span>
              <span className="chip chip-yellow">You</span>
              <span className="chip chip-ghost">+ your business</span>
            </div>
            <div className="compose-row">
              <span className="cr-label">ATTACHED</span>
              {/* the attached artifact IS the loud scene's resolution
                  cluster, scaled down: the SAME object the visitor just
                  watched flies in and lands here (true continuity, no
                  content swap) */}
              <span className="mini-attach" aria-hidden="true">
                <span className="ma-inner">
                  <span className="lt-att lt-sweep">
                    <span className="fchip fc-need">Needs you 2</span>
                    <span className="ltw-line">45 archived for later</span>
                  </span>
                  <span className="lt-row lt-b chan">47 unread this morning. 2 needed you, both handled ✓</span>
                </span>
              </span>
            </div>
          </div>
          <div className="compose-body" style={fade}>
            {/* invisible sizer reserves the FULL height of both paragraphs
                at every width, so the Send pill below never shifts while
                the text types itself (anchored, no layout bob) */}
            <div className="cb-sizer" aria-hidden="true">
              <p className="cb-p1">{COMPOSE_P1}</p>
              <p className="cb-p2">{COMPOSE_P2}</p>
            </div>
            <div className="cb-live">
              <p className="cb-p1">
                {w1.slice(0, n1).join(' ')}
                {n1 > 0 && n1 < w1.length && <span className="caret" />}
              </p>
              {n1 >= w1.length && (
                <p className="cb-p2">
                  {w2.slice(0, n2).join(' ')}
                  {n2 < w2.length && <span className="caret" />}
                </p>
              )}
            </div>
          </div>
          <a
            className={'send-mega' + (armed ? ' armed' : '') + (fired ? ' fired' : '') + (portal ? ' portal' : '')}
            href={smsHref()}
            ref={pillRef}
            style={ex > 0 ? { opacity: Math.max(0, 1 - ex * 3), pointerEvents: ex > 0.1 ? 'none' : undefined } : null}
          >
            <span className="sm-circle" aria-hidden="true">
              <svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" /></svg>
            </span>
            Send
          </a>
        </div>
      </div>
    </section>
  );
}

/* ---- S4 autopilot carousel: PINNED + scroll-scrubbed (Extra's mechanic).
        The five screens slide horizontally through the fixed frame; the
        ACTIVE screen plays its live tail: the composer types the user's
        next message (fast), sends it, Chan's dots fire, the reply lands.
        Inactive screens show the finished conversation, so sliding past
        never reveals a half-played beat. -------------------------------- */
const LIVE_DONE = 7;
function CaroPhone({ wi, armed, lp }) {
  const a = AUTO_WORDS[wi];
  const isLast = wi === AUTO_WORDS.length - 1;
  const [typed, setTyped] = React.useState('');
  /* 0 idle · 1 typing · 2 me sent · 3 chan dots · 4 reply · 5 reply tap ·
     6 your thanks · 7 Chan's reaction */
  const [stage, setStage] = React.useState(REDUCE ? LIVE_DONE : 0);
  React.useEffect(() => {
    if (REDUCE || isLast) { setStage(LIVE_DONE); setTyped(''); return; }
    setStage(0); setTyped('');
    /* the showcase types only once the section has the stage (armed):
       at the hand-off seam the phone arrives holding just its history,
       then the live tail plays in front of the visitor */
    if (!armed) return;
    const timers = [];
    let iv = 0;
    timers.push(setTimeout(() => {
      setStage(1);
      const msg = a.live.me;
      let i = 0;
      iv = setInterval(() => {
        i += 1; setTyped(msg.slice(0, i));
        if (i >= msg.length) {
          clearInterval(iv);
          timers.push(setTimeout(() => { setTyped(''); setStage(2); }, 360));
          timers.push(setTimeout(() => setStage(3), 850));
          timers.push(setTimeout(() => setStage(4), 1750));
          timers.push(setTimeout(() => setStage(5), 2350));
        }
      }, 26);
    }, 550));
    return () => { timers.forEach(clearTimeout); clearInterval(iv); };
  }, [wi, armed, isLast]);
  /* the finale rides the SCROLL, not a clock: blast through and it is
     already complete when the dive needs its tapback anchor; ease
     through and the beats land one by one under your thumb */
  const scrollStage = REDUCE ? LIVE_DONE
    : lp < 0.14 ? 0 : lp < 0.3 ? 2 : lp < 0.44 ? 3 : lp < 0.6 ? 4 : lp < 0.72 ? 6 : 7;
  const activeStage = isLast ? scrollStage : stage;
  const tail = (w, st) => (
    <React.Fragment>
      {st >= 2 && (
        <div className={'sb me' + (st === 2 || st === 3 ? ' pop' : '')}>{w.live.me}</div>
      )}
      {st === 3 && <div className="sb chan sb-typing"><span /><span /><span /></div>}
      {st >= 4 && w.live.reply.card && (
        <div className={'lk' + (st === 4 ? ' pop' : '')}>
          <span className={'lk-th ' + w.live.reply.card.cls}>{w.live.reply.card.th}</span>
          <span className="lk-meta">
            <b>{w.live.reply.card.b}</b>
            <em>{w.live.reply.card.d}</em>
          </span>
        </div>
      )}
      {st >= 4 && (
        <div className={'sb chan' + (st === 4 ? ' pop' : '')}>
          {w.live.reply.t}
          {st >= 5 && w.live.reply.tapE && <span className="tap tp">{w.live.reply.tapE}</span>}
        </div>
      )}
      {st >= 6 && w.live.me2 && (
        <div className={'sb me' + (st === 6 ? ' pop' : '')}>
          {w.live.me2.t}
          {st >= 7 && <span className="tap tp">{w.live.me2.tapE}</span>}
        </div>
      )}
    </React.Fragment>
  );
  return (
    <div className="dev caro-phone">
      <span className="dev-btn db-l" aria-hidden="true" />
      <span className="dev-btn db-r" aria-hidden="true" />
      <div className="dev-screen">
        <div className="dev-status" aria-hidden="true">
          <span className="ds-time">9:41</span>
          <svg className="ds-icons" viewBox="0 0 52 12">
            <rect x="0" y="7" width="2.6" height="4" rx="1" />
            <rect x="4.2" y="5" width="2.6" height="6" rx="1" />
            <rect x="8.4" y="3" width="2.6" height="8" rx="1" />
            <rect x="12.6" y="1.4" width="2.6" height="9.6" rx="1" />
            <path className="wifi" d="M21.5 6.6a6.8 6.8 0 0 1 8.6 0M23.4 8.8a4 4 0 0 1 4.8 0" />
            <circle cx="25.8" cy="10.9" r="1" />
            <rect className="batt" x="36" y="2.6" width="12.6" height="6.8" rx="2.2" />
            <rect x="37.6" y="4.2" width="7.6" height="3.6" rx="1" />
            <rect x="49.6" y="4.9" width="1.6" height="2.2" rx="0.8" />
          </svg>
        </div>
        <div className="dev-island" />
        <div className="th-head">
          <span className="th-back" aria-hidden="true">
            <svg viewBox="0 0 10 16"><path d="M8.5 1.5L2 8l6.5 6.5" /></svg>
            <i>2</i>
          </span>
          <img src="../chan-avatar.png" alt="" />
          <span>Chan ›</span>
          <span className="th-vid" aria-hidden="true">
            <svg viewBox="0 0 24 16"><rect x="1" y="2.4" width="14.6" height="11.2" rx="3.4" /><path d="M16.8 6.6L22.6 3.4v9.2l-5.8-3.2z" /></svg>
          </span>
        </div>
        <div className="scr-vp">
          <div className="scr-track" style={{ transform: 'translateX(' + (wi * -20) + '%)' }}>
            {AUTO_WORDS.map((w, i) => (
              <div key={w.w} className={'scr' + (i === wi ? ' on' : '')}>
                <div className="stamp hb" style={{ ['--hi']: 0 }}>Today 9:41 AM</div>
                {w.msgs.map((m, j) => (
                  <div
                    key={j}
                    className={'sb hb' + (m.me ? ' me' : ' chan') + (m.lnk ? ' lnkb' : '')}
                    style={{ ['--hi']: j + 1 }}
                  >
                    {m.t}
                  </div>
                ))}
                {tail(w, i === wi ? activeStage : LIVE_DONE)}
              </div>
            ))}
          </div>
        </div>
        <div className="dev-foot caro-foot" aria-hidden="true">
          <MsgBar typed={typed} />
          <Keys />
          <span className="dev-home" />
        </div>
      </div>
    </div>
  );
}

function GhostPanel({ a, dim }) {
  return (
    <div className={'ghost' + (dim ? ' ghost-dim' : '')} key={a.w} aria-hidden="true">
      <b>{a.w}</b>
      {a.msgs.filter((m) => m.t).slice(0, 3).map((m, i) => (
        <span key={i} className={'gmb' + (m.me ? ' me' : '')}>{m.t}</span>
      ))}
    </div>
  );
}

function Autopilot() {
  const secRef = React.useRef(null);
  const p = useScrollProgress(secRef, 0.1);
  const [rwi, setRwi] = React.useState(0);
  const wi = REDUCE ? rwi : Math.min(AUTO_WORDS.length - 1, Math.floor(p * AUTO_WORDS.length));
  /* the live tail only plays once the pin has the stage */
  const armed = REDUCE || p > 0.02;
  /* local progress through the CURRENT word's scroll window (drives the
     scroll-scrubbed finale) */
  const lp = Math.min(1, Math.max(0, p * AUTO_WORDS.length - wi));
  const measRef = React.useRef(null);
  const [pw, setPw] = React.useState(null);
  /* flip-words: measure the next word and animate the pill's width to fit */
  React.useLayoutEffect(() => {
    if (measRef.current) setPw(measRef.current.offsetWidth);
  }, [wi]);
  /* re-measure once the display font arrives: a first paint on the
     fallback font bakes a too-narrow pill that clips the word */
  React.useEffect(() => {
    let on = true;
    document.fonts && document.fonts.ready.then(() => {
      if (on && measRef.current) setPw(measRef.current.offsetWidth);
    });
    return () => { on = false; };
  }, []);
  const goto = (i) => {
    if (REDUCE) { setRwi(i); return; }
    const el = secRef.current;
    if (!el) return;
    const start = el.getBoundingClientRect().top + scrollY;
    const span = el.offsetHeight - window.innerHeight;
    window.scrollTo({ top: start + span * ((i + 0.5) / AUTO_WORDS.length), behavior: 'smooth' });
  };
  const cur = AUTO_WORDS[wi];
  const next = AUTO_WORDS[(wi + 1) % AUTO_WORDS.length];
  const next2 = AUTO_WORDS[(wi + 2) % AUTO_WORDS.length];
  const padW = WORD_PILL ? 46 : 32; /* horizontal padding + border of each variant */
  return (
    <section className="sc-auto" ref={secRef}>
      <div className="auto-pin">
        <div className="auto-copy" data-reveal>
          {/* layout-locked lockup: line 1 is always "Put {word}", line 2 is
              always "on autopilot." Both lines are nowrap, so a long word
              (marketing) widens the pill rightward instead of re-wrapping
              the sentence (founder note: meetings dropped a line). */}
          <h2 className="auto-h">
            <span className="ah-l">
              Put{' '}
              <span
                className={WORD_PILL ? 'word-pill' : 'word-hl'}
                style={pw ? { width: pw + padW + 'px' } : null}
              >
                <span className="wp-txt" key={cur.w}>{cur.w}</span>
              </span>
            </span>
            <span className="ah-l">on autopilot.</span>
            <span className="wp-meas" ref={measRef}>{cur.w}</span>
          </h2>
          {/* the subcaption belongs to the WORD, not the section: it swaps
              with each rotation. Two deliberate lines: what Chan does,
              then what YOU do (no orphaned wrap words). */}
          <p className="auto-sub" key={'s-' + cur.w}>
            {cur.sub[0]}
            <span className="as-2">{cur.sub[1]}</span>
          </p>
        </div>
        <div className="caro" data-reveal="bare">
          <div className="caro-dev">
            <CaroPhone wi={wi} armed={armed} lp={lp} />
          </div>
          <GhostPanel a={next} />
          <GhostPanel a={next2} dim={true} />
        </div>
        {/* Chan-native switcher: iMessage PINNED CHATS (round contact
            circles, blue unread dot, preview balloon over the active pin)
            instead of Extra's app dock. Same scrub targets, Chan's world. */}
        <div className="pins" role="tablist" aria-label="what Chan runs" data-reveal="bare">
          {AUTO_WORDS.map((a, i) => (
            <button
              key={a.w}
              className={'pin-it' + (i === wi ? ' on' : '')}
              onClick={() => goto(i)}
              role="tab"
              aria-selected={i === wi}
              style={{ ['--di']: i }}
            >
              <span className="pin-face" aria-hidden="true">
                {a.e}
                <i className="pin-dot" />
                {i === wi && <span className="pin-bub" key={a.w}>{a.pin}</span>}
              </span>
              <span className="pin-l">{a.w}</span>
            </button>
          ))}
        </div>
      </div>
    </section>
  );
}

/* ---- the loud -> compose hand-off: the proof ATTACHES itself -------------
   While the noise pin releases and the compose letter rises, the calm
   resolution cluster shrinks, tilts, and lands INSIDE the letter's
   ATTACHED slot (Extra's hero-into-section-box move). Both real elements
   hide; a fixed flyer lerps between their PINNED-projected rects, face A
   (the cluster, laid out at its real width so the wrap matches) fading
   out as face B (the mini-attach card) fades in. ------------------------- */
function LoudHandoff() {
  const [fly, setFly] = React.useState(null);
  React.useEffect(() => {
    if (REDUCE) return;
    let raf = 0;
    const onScroll = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const comp = document.querySelector('.sc-compose');
        if (!comp) return;
        const vh = window.innerHeight;
        const a = comp.getBoundingClientRect();
        const mp = Math.min(1, Math.max(0, 1 - a.top / vh));
        const src = mp > 0 && mp < 1 ? document.querySelector('.loud-thread') : null;
        const tgt = src ? document.querySelector('.compose-rows .mini-attach') : null;
        if (!src || !tgt) {
          document.body.classList.remove('lh-morph');
          setFly(null);
          return;
        }
        const s = src.getBoundingClientRect();
        const d = tgt.getBoundingClientRect();
        const sTop = s.top - (a.top - vh);
        const t = { top: d.top - a.top, left: d.left, w: d.width, h: d.height };
        const L = (x, y) => x + (y - x) * mp;
        document.body.classList.add('lh-morph');
        setFly({
          top: L(sTop, t.top), left: L(s.left, t.left),
          width: L(s.width, t.w), height: L(s.height, t.h),
          sW: s.width, mp,
        });
      });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    onScroll();
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      cancelAnimationFrame(raf);
      document.body.classList.remove('lh-morph');
    };
  }, []);
  if (!fly) return null;
  return (
    <div
      className="lh-fly"
      aria-hidden="true"
      style={{
        top: fly.top + 'px', left: fly.left + 'px',
        width: fly.width + 'px', height: fly.height + 'px',
        rotate: (fly.mp * -2).toFixed(2) + 'deg',
      }}
    >
      {/* ONE face, no crossfade: the cluster itself shrinks all the way
          into the slot (the landed mini-attach is the same markup at the
          same final scale, so the takeover is invisible) */}
      <div
        className="lhf-a"
        style={{ width: fly.sW + 'px', height: (fly.height / (fly.width / fly.sW)).toFixed(1) + 'px', transform: 'scale(' + (fly.width / fly.sW).toFixed(4) + ')' }}
      >
        <div className="lt-att lt-sweep">
          <span className="fchip fc-need">Needs you 2</span>
          <span className="ltw-line">45 archived for later</span>
        </div>
        <div className="lt-row lt-b chan">47 unread this morning. 2 needed you, both handled ✓</div>
      </div>
    </div>
  );
}

/* ---- the phone hand-off: ONE object across the seam ----------------------
   While the mega-room unpins and the carousel rises, BOTH real phones hide
   and a single fixed flyer interpolates from the digest phone's live rect
   to the carousel device's caught position, crossfading white -> dark
   (the pill->room trick, applied to the phone). Pure measurement, no
   assumptions: source is measured live every frame, target is the device's
   offset within its pin. Reversible by construction. ---------------------- */
function PhoneHandoff() {
  const [fly, setFly] = React.useState(null);
  React.useEffect(() => {
    if (REDUCE) return;
    let raf = 0;
    const onScroll = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const auto = document.querySelector('.sc-auto');
        if (!auto) return;
        const vh = window.innerHeight;
        const a = auto.getBoundingClientRect();
        /* the seam: the carousel section travels its last viewport-height
           into place exactly while the mega-room pin releases */
        const mp = Math.min(1, Math.max(0, 1 - a.top / vh));
        const dg = mp > 0 && mp < 1 ? document.querySelector('.dg-phone') : null;
        const dev = dg ? document.querySelector('.caro-dev .dev') : null;
        if (!dg || !dev) {
          document.body.classList.remove('ph-morph');
          setFly(null);
          return;
        }
        const s = dg.getBoundingClientRect();
        const d = dev.getBoundingClientRect();
        /* project both endpoints to their PINNED positions: the source back
           to where the phone sat when the room pin released (s.top minus
           the pin's slide, which equals a.top - vh), the target to where
           the device sits once the carousel pin catches (a.top at 0).
           Lerping pinned->pinned gives a monotonic glide: the phone
           unsticks from the departing room and lands on the carousel,
           no mid-flight dip. Recomputed from live rects every frame, so
           it reverses perfectly. */
        const sTop = s.top - (a.top - vh);
        const t = { top: d.top - a.top, left: d.left, w: d.width, h: d.height };
        const L = (x, y) => x + (y - x) * mp;
        document.body.classList.add('ph-morph');
        setFly({
          top: L(sTop, t.top), left: L(s.left, t.left),
          width: L(s.width, t.w), height: L(s.height, t.h),
          rTop: L(54, 46), rBot: L(54, 46), mp,
        });
      });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    onScroll();
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      cancelAnimationFrame(raf);
      document.body.classList.remove('ph-morph');
    };
  }, []);
  if (!fly) return null;
  return (
    <div
      className="ph-fly"
      aria-hidden="true"
      style={{
        top: fly.top + 'px', left: fly.left + 'px',
        width: fly.width + 'px', height: fly.height + 'px',
        borderRadius: fly.rTop + 'px ' + fly.rTop + 'px ' + fly.rBot + 'px ' + fly.rBot + 'px',
      }}
    >
      {/* white face stays opaque UNDER the dark face (no translucency
          mid-fade). Its thread is the FULL digest, and the seam UNSENDS
          it: bubbles pop out one by one, newest first (founder note: a
          half-empty fading clone read as broken), the shell goes dark,
          then the next thread's bubbles pop IN oldest-first like they
          are being texted. All thresholds ride mp, so it scrubs both
          ways. */}
      <div className="phf-l">
        <div className="phf-lscr">
          <div className="phf-cl">
            <div className={'dg-head' + (fly.mp > 0.44 ? ' out' : '')}>
              <img src="../chan-avatar.png" alt="" />
              <span className="dg-name">Chan</span>
            </div>
            <div className={'dg-stamp' + (fly.mp > 0.41 ? ' out' : '')}>Wed 10, 9:00 AM</div>
            {DIGEST_MSGS.map((m, i) => (
              <div
                key={i}
                className={'dgb' + (m.me ? ' me' : '') + (fly.mp > 0.05 + (DIGEST_MSGS.length - 1 - i) * 0.042 ? ' out' : '')}
              >
                {m.t}
                {m.tap && <span className="tap">{m.tapE || '👍'}</span>}
              </div>
            ))}
            <div className={'phf-bar' + (fly.mp > 0.46 ? ' out' : '')}><MsgBar light={true} /></div>
          </div>
        </div>
      </div>
      <div className="phf-d" style={{ opacity: fly.mp }}>
        <div className="phf-scr">
          <div className={'phf-cd' + (fly.mp > 0.52 ? ' cd-in' : '')}>
            <div className="th-head">
              <span className="th-back" aria-hidden="true">
                <svg viewBox="0 0 10 16"><path d="M8.5 1.5L2 8l6.5 6.5" /></svg>
                <i>2</i>
              </span>
              <img src="../chan-avatar.png" alt="" />
              <span>Chan ›</span>
              <span className="th-vid" aria-hidden="true">
                <svg viewBox="0 0 24 16"><rect x="1" y="2.4" width="14.6" height="11.2" rx="3.4" /><path d="M16.8 6.6L22.6 3.4v9.2l-5.8-3.2z" /></svg>
              </span>
            </div>
            <div className="phf-msgs">
              <span className={'sb me' + (fly.mp > 0.6 ? ' in' : '')}>find me 10 leads in toronto</span>
              <span className={'sb chan' + (fly.mp > 0.68 ? ' in' : '')}>done. 10 ranked leads, intros drafted for the top 3</span>
              <span className={'sb chan' + (fly.mp > 0.76 ? ' in' : '')}>jane at acme wants a demo. reply is ready, say the word</span>
            </div>
            <div className="dev-foot caro-foot" aria-hidden="true">
              <MsgBar />
              <Keys />
              <span className="dev-home" />
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ---- the carousel -> scatter hand-off: zoom INTO the tapback ------------
   The final beat the visitor saw was the 💪 tapback on the ops reply.
   As the next section approaches, the camera dives into that emoji: a
   yellow disc grows from the tapback's live position until its color
   owns the viewport, then melts into the next section's cream. Pure
   scroll-scrub (mp from the scatter section's approach), reversible. -- */
function FlexZoom() {
  const [z, setZ] = React.useState(null);
  React.useEffect(() => {
    if (REDUCE) return;
    let raf = 0;
    let live = true;
    /* a CONTINUOUS frame loop while the dive window is open: the
       carousel track itself eases over .65s, so a single scroll-event
       measurement can catch the tapback mid-slide (a fast jump once
       parked the disc off-viewport). Re-measuring every frame keeps the
       wash glued to the emoji through every easing in flight. */
    const tick = () => {
      if (!live) return;
      const scat = document.querySelector('.sc-scat');
      if (!scat) return;
      const vh = window.innerHeight;
      const mp = Math.min(1, Math.max(0, 1 - scat.getBoundingClientRect().top / vh));
      /* the dive begins only once the section is clearly arriving (a
         disc appearing at first pixel read as a sticker), and it FADES
         in, so a fast scroll never makes it pop from nowhere */
      if (mp <= 0.2 || mp >= 0.99) { setZ(null); return; }
      const dev = document.querySelector('.caro-dev .dev');
      if (!dev) { setZ(null); return; }
      const dr = dev.getBoundingClientRect();
      /* anchor on the finale tapback, but only while it sits within the
         device (mid-slide it can be projected a track-slot away) */
      const tap = document.querySelector('.scr:last-child .tap');
      let x = dr.left + dr.width * 0.72, y = dr.top + dr.height * 0.5;
      if (tap) {
        const tr = tap.getBoundingClientRect();
        if (tr.left >= dr.left - 24 && tr.right <= dr.right + 24) {
          x = tr.left + tr.width / 2;
          y = tr.top + tr.height / 2;
        }
      }
      setZ({ x, y, mp });
      raf = requestAnimationFrame(tick);
    };
    const onScroll = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(tick);
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    onScroll();
    return () => {
      live = false;
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      cancelAnimationFrame(raf);
    };
  }, []);
  if (!z) return null;
  /* normalized dive time (the active window), accelerating: slow leave,
     fast swallow */
  const t = Math.min(1, Math.max(0, (z.mp - 0.2) / 0.76));
  const g = Math.pow(t, 1.6);
  const d = 70 + g * Math.max(window.innerWidth, window.innerHeight) * 3;
  /* soft everywhere: the wash FADES in (no hard-edged sticker over the
     phone), the emoji blurs and melts INTO the yellow mid-dive, and the
     wash dissolves onto the section's identical #FFB500 at the end */
  const inO = Math.min(1, t / 0.22);
  const outO = z.mp > 0.93 ? 1 - (z.mp - 0.93) / 0.07 : 1;
  const ef = Math.min(1, Math.max(0, (t - 0.5) / 0.32));
  return (
    <div
      className="fz"
      aria-hidden="true"
      style={{ left: z.x + 'px', top: z.y + 'px', width: d + 'px', height: d + 'px', opacity: inO * outO }}
    >
      <span
        className="fz-e"
        style={{
          fontSize: Math.min(280, 30 + g * 330) + 'px',
          opacity: 1 - ef,
          filter: 'blur(' + (ef * 9).toFixed(1) + 'px)',
          rotate: (-10 + g * 10) + 'deg',
        }}
      >💪</span>
    </div>
  );
}

/* ---- S6 trio cards: marketing / meetings / calls (Extra's pastel trio) --- */
function Trio() {
  return (
    <section className="sc-trio">
      <div className="trio" data-reveal="bare">
        <div className="tcard tc-butter">
          <div className="tc-ui">
            <div className="auto-note tc-note">
              <div className="an-head">
                <img src="../chan-avatar.png" alt="" />
                <span className="an-name">Chan</span>
                <span className="an-time">now</span>
              </div>
              <div className="an-body">12 customers went quiet. win-back emails drafted, review?</div>
            </div>
          </div>
          <h3>Marketing that ships</h3>
          <p>Win-backs, review asks, launch drafts. Chan writes them in your voice, you say send.</p>
        </div>
        <div className="tcard tc-sky">
          <div className="tc-ui">
            <div className="tc-bubbles">
              <span className="mb chan" style={{ ['--mi']: 0 }}>sarah at 1pm. brief is 3 lines, want it?</span>
              <span className="mb me" style={{ ['--mi']: 1 }}>push it to 2</span>
              <span className="mb chan" style={{ ['--mi']: 2 }}>done. she confirmed</span>
            </div>
          </div>
          <h3>Meetings, prepped</h3>
          <p>Chan preps every meeting, joins the calls you cannot make, and reschedules the chaos.</p>
        </div>
        <div className="tcard tc-night">
          <div className="tc-ui">
            <div className="tc-call">
              <span className="call-ic">📞</span>
              <span className="call-lbl">Call answered</span>
              <span className="call-dur">2:14</span>
            </div>
            <div className="tc-bubbles">
              <span className="mb chan" style={{ ['--mi']: 1 }}>caller wants a quote for 40 units. recap is in your texts</span>
            </div>
          </div>
          <h3>Chan picks up <em className="beta-chip">in beta</em></h3>
          <p>Chan answers your business line, handles the caller, and texts you the recap.</p>
        </div>
      </div>
    </section>
  );
}

/* ---- S7 the payoff field: Chan doing the work all around the headline
        (Extra's emoji-scatter beat, cut with Chan's own artifacts) -------- */
function Scatter() {
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (REDUCE) return;
    let raf = 0;
    const onScroll = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const el = ref.current;
        if (!el) return;
        const r = el.getBoundingClientRect();
        el.style.setProperty('--sy', String(Math.round((r.top + r.height / 2 - window.innerHeight / 2) / 6)));
      });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
    return () => { window.removeEventListener('scroll', onScroll); cancelAnimationFrame(raf); };
  }, []);
  return (
    <section className="sc-scat" ref={ref} data-reveal="bare">
      <div className="scat-field" aria-hidden="true">
        {SCAT.map((it, i) => {
          const style = { left: it.x + '%', top: it.y + '%', ['--sp']: it.sp, ['--ci']: i, rotate: (it.r || 0) + 'deg' };
          if (it.k === 'e') return (
            <span key={i} className={'scit sc-e l-' + it.l + (it.hm ? ' hm' : '')} style={{ ...style, fontSize: it.s + 'px' }}>{it.t}</span>
          );
          if (it.k === 'logo') return (
            <span key={i} className={'scit logo-chip l-' + it.l + (it.hm ? ' hm' : '')} style={style}>
              <img src={'../logos/' + it.slug + '.svg'} alt="" loading="lazy" />
              {it.t}
            </span>
          );
          if (it.k === 'call') return (
            <div key={i} className={'scit sc-call l-' + it.l + (it.hm ? ' hm' : '')} style={style}>
              <span className="call-ic">📞</span>
              <span className="call-lbl">Call answered</span>
              <span className="call-dur">2:14</span>
            </div>
          );
          if (it.k === 'chip') return (
            <span key={i} className={'scit fchip ' + it.c + ' l-' + it.l + (it.hm ? ' hm' : '')} style={style}>{it.t}</span>
          );
          if (it.k === 'tap') return (
            <span key={i} className={'scit sc-tap l-' + it.l + (it.hm ? ' hm' : '')} style={style}>{it.t}</span>
          );
          return (
            <span key={i} className={'scit sc-b ' + it.k + ' l-' + it.l + (it.hm ? ' hm' : '')} style={style}>{it.t}</span>
          );
        })}
      </div>
      <div className="scat-copy" data-reveal="bare">
        <h2>
          <span className="rise"><span className="rise-in">Not another tool.</span></span>
          <span className="rise rise-d1"><span className="rise-in">A manager.</span></span>
        </h2>
        <p className="scat-sub" data-reveal>Software hands you more to check. Chan hands the work back, done.</p>
        <span className="scat-pill" data-reveal>done ✓ 💸</span>
      </div>
    </section>
  );
}

/* ---- S8 integrations ------------------------------------------------------ */
function Integrations() {
  const half = Math.ceil(LOGOS.length / 2);
  const rows = [LOGOS.slice(0, half), LOGOS.slice(half)];
  return (
    <section className="sc-integr">
      <div data-reveal="bare">
        <h2 className="rise"><span className="rise-in">Plugs into everything you already use.</span></h2>
        <p className="integr-sub" data-reveal>Just text: <code>connect gmail</code></p>
      </div>
      <div className="integr-rows">
        {rows.map((row, r) => (
          <div className={'logo-row' + (r === 1 ? ' rev' : '')} key={r}>
            {[...row, ...row].map((slug, i) => (
              <div className="logo-chip" key={slug + i}>
                <img src={'../logos/' + slug + '.svg'} alt="" loading="lazy" />
                {LOGO_NAMES[slug]}
              </div>
            ))}
          </div>
        ))}
      </div>
    </section>
  );
}

/* ---- S9 close + footer ----------------------------------------------------- */
function Close() {
  return (
    <section className="sc-close grain">
      <svg className="spin-star" viewBox="0 0 100 100" aria-hidden="true">
        <path d="M50 2l7 28 21-19-10 27 28-7-24 17 27 11-29 3 18 23-26-13-3 29-12-27-19 22 4-29-27 12 22-20-29-4 28-9-21-20 28 6z" />
      </svg>
      <h2 className="rise" data-reveal="bare"><span className="rise-in">Your new manager is one text away.</span></h2>
      <ComposeBar label="Say hi to Chan" dark={true} />
      <div className="trust">
        <span>No passwords. Your number is your login.</span>
        <span>Your data is never sold.</span>
        <span>Walk away anytime.</span>
      </div>
      <footer className="v2-footer">
        <span>© 2026 Manager Chan</span>
        <nav>
          <a href={smsHref()}>Contact</a>
          <a href="/terms">Terms</a>
          <a href="/privacy">Privacy</a>
          <a href="/dashboard/">Log in</a>
        </nav>
      </footer>
    </section>
  );
}

/* ---- page ----------------------------------------------------------------- */
function V2() {
  useRevealRoot();
  return (
    <React.Fragment>
      <Hero />
      <Loud />
      <ComposeStory />
      <Autopilot />
      <LoudHandoff />
      <PhoneHandoff />
      <FlexZoom />
      <Scatter />
      <Trio />
      <Integrations />
      <Close />
    </React.Fragment>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<V2 />);
