/* eslint-disable */

/* ============================================================
   Data
   ============================================================ */
const FOUR_WAYS = [
  { n: '01', title: 'Advance the research', route: 'research', cta: 'Our research',
    body: 'We support and generate empirical work on what AI does to human judgment, attention, trust, and decision-making. Everything we learn is published.' },
  { n: '02', title: 'Build the bridges', route: 'about', cta: 'Meet the network',
    body: 'We connect researchers, practitioners, and institutions working on the behavioral impact of AI. Free charter membership brings shared findings, tools, and a peer network.' },
  { n: '03', title: 'Apply the science', route: 'services', cta: 'Our services',
    body: 'We work with a small number of organisations to measure what deployed AI is doing to their people. Anonymised findings feed the public research.' },
];

/* Two cards, both of which point at something that exists and plays.

   A third card — 'The Human-AI Paradox', a Reflections piece — used to sit
   here with a 'Read more' button routing to #insights. There is no such
   piece on #insights. It was removed rather than relabelled: the card was
   the only evidence the piece existed.                                     */
/* The third entry anticipates rather than announces. It carries no `img` and
   no `href` on purpose: there is nothing truthful to picture and nowhere real
   to send anyone yet, and the section previously held a fabricated third card.
   `statement` swaps the photo well for type; `label` renders the non-clickable
   "Details soon" affordance established by the events rebuild, so this cannot
   become another dead CTA. It names no date and no subject. */
const EVENTS = [
  { kind: 'Podcast',     title: 'From Slime Mold to Superintelligence', meta: 'Robert Sapolsky · 57:56',   cta: 'Listen now',      href: 'https://www.youtube.com/watch?v=2FI8b0xAFxw',                img: 'assets/library/social-podcast-sapolsky.jpeg' },
  { kind: 'Webinar',     title: 'Behavioral AI Horizon',                meta: 'What we learned in 2025',   cta: 'Watch recording', href: 'https://us06web.zoom.us/clips/share/aERC1F0kSLSDkCwIpLJ-yg', img: 'assets/library/social-horizon-webinar.jpeg' },
  { kind: 'Next',        title: 'The next one is coming',               meta: 'Announced here first',      label: 'Details soon',  anticipate: 'assets/library/illustration-hourglass-alpha.webp' },
];

const TEAM_MEMBERS = [
  { name: 'Antoine Ferrère', email: 'antoine@behavioralai-institute.org', role: 'Co-founder & President', photo: 'assets/people/cropped/antoine-ferrere.jpg', linkedin: 'https://www.linkedin.com/in/aferrere/', city: 'Paris', bio: 'Behavioral science, data science, and AI. Former Global Head of Behavioral & Data Science at Novartis; published in MIT Sloan Management Review and Harvard Business Review.', fullBio: ["Antoine brings a unique blend of expertise in behavioral science, data science, and AI. With experience leading teams at Novartis as Global Head of Behavioral & Data Science, he has pioneered the integration of AI with behavioral insights to drive ethical decision-making and organizational transformation. His work has been published in MIT Sloan Management Review and Harvard Business Review, focusing on psychological safety, ethical conduct, and building behavioral science capabilities in organizations."] },
  { name: 'Samuel Salzer', email: 'samuel@behavioralai-institute.org', role: 'Co-founder & Operations Director', photo: 'assets/people/cropped/samuel-salzer.jpg', linkedin: 'https://www.linkedin.com/in/samuelsalzer/', city: 'Stockholm', bio: 'Applied behavioral scientist designing large-scale, AI-driven interventions that have reached 350M+ users. Founder of Habit Weekly and co-founder of Nuance Behavior.', fullBio: ["Samuel is an applied behavioral scientist at the forefront of designing large-scale, AI-driven, personalized interventions — impacting over 350 million users worldwide over the past decade. A leading voice in AI and behavioral science, he has built and advised multiple behavioral science teams and founded Habit Weekly — the world\'s largest community for applied behavioral practitioners. He is also the co-founder of Nuance Behavior, where he leads expert advisory services and the Nuance AI Lab, helping teams embed behavioral science into product design and AI systems."] },
  { name: 'Dr Paul Sacher', email: 'paul@behavioralai-institute.org', role: 'Co-founder & Research Director', photo: 'assets/people/cropped/paul-sacher.jpg', linkedin: 'https://www.linkedin.com/in/drpaulsacher/', city: 'London', bio: 'Digital health clinician and behavioral AI scientist. Developed the FAST framework and PromptSafe; Honorary Senior Lecturer at Imperial College London and CEO of Sacher AI.', fullBio: ["Paul is a digital health clinician and behavioral AI scientist specializing in the design and evaluation of human-facing AI systems. He developed the peer-reviewed FAST framework and PromptSafe™, used together to evaluate the safety and behavior of AI agents. Paul previously led behavioral science at Allurion Technologies, a global public medtech company. He is CEO of Sacher AI and holds an Honorary Senior Lectureship in the Faculty of Medicine at Imperial College London. He has published extensively and served as co-investigator on studies across the US, UK, and EU."] },
  { name: 'Luana de Mattos Gabriel', email: 'luana@behavioralai-institute.org', role: 'Honorary Fellow, Research & Communications', photo: 'assets/people/cropped/luana-de-mattos-gabriel.jpg', linkedin: 'https://www.linkedin.com/in/luana-mattos', bio: 'Cognitive scientist and strategist for technology-for-good, from concept to launch. Award-winning behavioral work for Schneider Electric and the Department of Energy of Abu Dhabi.', fullBio: ["Luana is a cognitive scientist and strategist specializing in technology for good, from concept to launch. She has applied behavioral science across sectors and countries – developing an award-winning energy renovation platform for Schneider Electric and leading behavioral interventions for the Department of Energy of Abu Dhabi. As an innovation consultant, she guides major corporations through tech transformations and delivers trainings on AI and change management."] },
  { name: 'Sasha Watkins', email: 'sasha@behavioralai-institute.org', role: 'Honorary Fellow, Operations & Strategic Projects', photo: 'assets/people/cropped/sasha-watkins.jpg', linkedin: 'https://www.linkedin.com/in/sasha-watkins-profile', bio: 'Clinical product leader in evidence-based nutrition and behavior change. Led nutrition science translation and product strategy at ZOE and Field Doctor.', fullBio: ["Sasha is a clinical product leader specializing in evidence-based nutrition and human behavior change. With two decades in nutrition and five years in health innovation, she transforms complex gut, metabolic, and chronic disease science into engaging digital products that support healthier eating behaviours. At ZOE and Field Doctor, she led nutrition science translation and behavior-led product strategy. She is driven by making healthy eating simple, actionable, and truly effective."] },
  { name: 'Dr Marcos Economides', email: 'marcos@behavioralai-institute.org', role: 'Honorary Fellow, Research', photo: 'assets/people/cropped/marcos-economides.jpg', linkedin: 'https://www.linkedin.com/in/marcos-economides-phd', bio: 'Scientific leader with a decade in digital health and therapeutics across neuroscience, clinical research, and product. Worked with Headspace and Unmind.', fullBio: ["Marcos Economides PhD is a scientific leader with over a decade of experience in digital health and therapeutics. His work spans the intersection of neuroscience, clinical research, behavioural science, and product development, helping to bridge scientific rigour with real-world impact. He has worked with leading companies including Headspace and Unmind, and continues to work with organisations at the forefront of digital mental health and generative AI."] },
  { name: 'Dr Keith O\'Brien', email: 'keith@behavioralai-institute.org', role: 'Honorary Fellow, Innovation & Impact', photo: 'assets/people/cropped/keith-obrien.jpg', linkedin: 'https://www.linkedin.com/in/keithmobrien/', bio: 'Behavioral scientist with a PhD in Experimental Psychology from UCL and co-creator of Precision Coaching. Former behavioral insights lead at MindGym and Simply Business.', fullBio: ["Dr. Keith O\'Brien is a distinguished behavioral scientist and innovator with a PhD in Experimental Psychology from UCL. As the co-creator of the award-winning Precision Coaching methodology, he specializes in applying behavioral science to drive organizational change, adaptability, and high performance. He has led behavioral insights teams at major organizations like MindGym and Simply Business, delivering impactful interventions across healthcare, finance, and more."] },
  { name: 'Dr Baiba Renerte', email: 'baiba@behavioralai-institute.org', role: 'Honorary Fellow, Research', photo: 'assets/people/cropped/baiba-renerte.jpg', linkedin: 'https://www.linkedin.com/in/baibarenerte/', bio: 'Behavioral and experimental economist at the University of Zurich studying human-AI collaboration, honesty, and organizational decision-making.', fullBio: ["Dr. Baiba Renerte is a behavioral and experimental economist at the University of Zurich whose work spans the intersection of behavioral economics, human-AI collaboration and organizational decision-making. Combining laboratory and field experiments with large-scale organizational data, she studies how AI-enabled tools and organizational designs shape decision-making, honesty and accountability. Her research has been published in peer-reviewed behavioral and economics journals and featured in leading management and practitioner outlets. She previously worked as a behavioral science research lead in the ethics, risk and compliance function at a Fortune Global 500 company."] },
  { name: 'Dr Fendi Tsim', email: 'fendi@behavioralai-institute.org', role: 'Honorary Fellow, Research', photo: 'assets/people/cropped/fendi-tsim.jpg', linkedin: 'https://www.linkedin.com/in/fenditsim/', bio: 'Behavioural scientist with a PhD from Warwick Business School, bridging behavioural economics, game theory, and cognitive psychology. Co-founder of BehSci Meets AI.', fullBio: ["Dr. Fendi Tsim is a Behavioural Scientist with a PhD in Behavioural Science at Warwick Business School. His work spans the intersection of Behavioural Economics, Game Theory, and Cognitive Psychology. After completing his PhD, he aims to bridge the gap between human psychology and artificial intelligence to drive informed strategic decisions. He is a Co-Founder of BehSci Meets AI — a professional community at the intersection of behavioural science, AI, and human-centred innovation in London, United Kingdom."] },
  { name: 'Alexis Abellar', email: 'alexis@behavioralai-institute.org', role: 'Honorary Associate Fellow, Behavioral AI Systems', photo: 'assets/people/cropped/alexis-abellar.jpg', linkedin: 'https://www.linkedin.com/in/alexis-michelle-abellar/', bio: 'Mathematics and psychology graduate from De La Salle University–Manila, working toward mathematical psychology and behavioral data science.', fullBio: ["Alexis Michelle Abellar is a recent Cum Laude graduate from De La Salle University–Manila with a BS in Mathematics, Business Specialization, and a Minor in Psychology. Her undergraduate research focused on mathematical modeling of the traditional jeepney system for base fare optimization. She aims to pursue a career in mathematical psychology, operations research, or behavioral data science, integrating analytical rigor with a human-centered approach to decision-making."] },
  { name: 'Anya Bashkirova', email: 'anya@behavioralai-institute.org', role: 'Honorary Fellow, Research', photo: 'assets/people/cropped/anya-bashkirova.jpg', linkedin: 'https://www.linkedin.com/in/anna-bashkirova/', bio: 'Product manager and behavioural scientist working at the intersection of human-computer interaction and AI, across healthcare, enterprise, and mental health.', fullBio: ["Anya is a product manager and behavioural scientist whose work draws on human-computer interaction to shape how people engage with AI systems. She has carried out research and product work across healthcare, enterprise technology, organisational development, and mental health, with a particular interest in high-stakes settings where human-AI interaction shapes long-term outcomes. Her research on how clinicians interact with AI diagnostic recommendations was published in a peer-reviewed journal focused on computers in human behaviour.", "What interests her now is how the spread of AI across professional, social, and health contexts is reshaping cognition at scale, including intelligent delegation, automation bias, and how professional skills are shaped by sustained AI assistance. Alongside her research, she develops products from concept to launch and builds community at the intersection of behavioural science and AI."] },
  { name: 'Ollie Miles', email: 'ollie@behavioralai-institute.org', role: 'Honorary Fellow, Research', photo: 'assets/people/cropped/ollie-miles.jpg', linkedin: 'https://www.linkedin.com/in/ollie-miles-112356138/', bio: 'Behavioural scientist and user researcher across healthcare, public services, and startups, focused on how people adopt AI-enabled products. Research cited by the WHO.', fullBio: ["Ollie is a behavioural scientist and user researcher specialising in the intersection of human behaviour, digital health, and artificial intelligence. He has led research across healthcare, public services, and innovative early-stage startups, helping teams understand how people engage with AI-enabled products, make decisions, and adopt new behaviours. His work combines behavioural science, mixed-methods research, and product strategy to design AI systems and interventions that are effective, trustworthy, and aligned with human needs. His research on the acceptability of AI in healthcare has been published internationally and cited by organisations including the World Health Organization."] },
  { name: 'Helen Mankin', email: 'helen@behavioralai-institute.org', role: 'Honorary Fellow, Research', photo: 'assets/people/cropped/helen-mankin.jpg', linkedin: 'https://www.linkedin.com/in/helenmankin/', bio: 'Applied behavioral scientist at Google DeepMind researching how frontier AI reshapes organizations, team dynamics, and human potential. Previously led R&D for organisational diagnostics at MindGym.', fullBio: ["Helen is an applied behavioral scientist exploring how frontier AI reshapes organizations, team dynamics, and human potential. As a Research Strategist at Google DeepMind, she drives applied research at the intersection of organizational behavior and human-AI systems\u2014designing evidence-based frameworks and scalable interventions to ensure individuals and teams thrive as we advance toward AGI. Her background spans research, consulting and product design, having previously led R&D for organisational diagnostics at MindGym and end-to-end behavioral experiments at Robert Cialdini's Influence at Work."] },
];



/* ---------- Shared-element portrait morph ----------

   The card's photograph physically travels into the spread's portrait column,
   so the card reads as becoming the bio rather than being swapped for it.

   Coordinates are DOCUMENT space and the clone is position:absolute on <body>,
   so it scrolls with everything else. Fixed/viewport was tried and is wrong:
   opening a bio can smooth-scroll the section into place by over a thousand
   pixels, and a fixed clone holds its viewport position while the page moves
   under it — it flew to where the destination USED to be and left the portrait
   slot empty beige for the whole flight, which is the exact fault this rework
   set out to remove.

   Document space is scroll-invariant, so the flight is only ever real layout
   movement. The 2000px sanity clamp the old FLIP needed is not required here:
   nothing re-measures across renders, both rects are read in the same frame.

   The clone also inherits the card's own transform at both ends. The card is
   tilted a degree or two at rest and, because you are hovering it at the moment
   you click, is usually mid-way through straightening and lifting 4px. Reading
   getBoundingClientRect() on the tilted <img> returned its axis-aligned
   BOUNDING BOX — wider than the element and offset from it — so the flight
   began a few pixels away from the photograph it was supposed to be
   continuing, and snapped. `from` is now the card's untransformed layout box
   with the live transform carried alongside it, which is exact in every hover
   state.

   Width/height are animated rather than transform:scale. Both endpoints are
   object-fit:cover crops of the same file at different aspect ratios (a square
   or 3:4 card, a tall 4:5 column); scaling would stretch the face, whereas
   animating the box lets `cover` re-crop continuously, which is what makes it
   look like one photograph settling into a new frame. */
const MORPH_MS = 560;
const MORPH_EASE = 'cubic-bezier(0.22, 1, 0.36, 1)';
/* The section's height change and the spread's arrival share this, so the box
   stops growing on the same beat the contents finish landing. */
const SPREAD_MS = 620;

function prefersReducedMotion() {
  return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

function docRect(el) {
  const r = el.getBoundingClientRect();
  return { top: r.top + window.scrollY, left: r.left + window.scrollX, width: r.width, height: r.height };
}

/* The card's RESTING document box — where it sits when nothing is animating.

   getBoundingClientRect() includes every transform on the element and its
   ancestors, so it answers "where is this pixel right now", which is the wrong
   question mid-animation. Closing a profile measures the card it is flying back
   to while that card is part-way through its return — translated 16px down and
   scaled to 0.965 — so the portrait landed short and the card snapped out from
   under it.

   Walking the offsetParent chain was tried instead and is worse: it came out
   29px off in this tree, because offsetTop is relative to each offsetParent and
   the route wrapper's own offsets do not reconcile with viewport coordinates.
   Suppressing the one animation that is lying, reading the rect, and putting it
   back is exact — one synchronous reflow, on a click, once. */
function restingRect(cardEl) {
  const cell = cardEl.closest('.people-cell');
  if (!cell) return docRect(cardEl);
  const prev = cell.style.animation;
  cell.style.animation = 'none';
  const r = docRect(cardEl);
  cell.style.animation = prev;
  return r;
}

function flyPortrait({ from, to, src, radius = 10, fromTransform = 'none', toTransform = 'none', onDone }) {
  if (!from || !to || !from.width || !to.width || prefersReducedMotion() || !document.body.animate) {
    onDone && onDone(); return null;
  }
  const fly = document.createElement('img');
  fly.src = src;
  fly.setAttribute('aria-hidden', 'true');
  Object.assign(fly.style, {
    position: 'absolute', margin: 0, zIndex: 60, pointerEvents: 'none',
    objectFit: 'cover', objectPosition: '50% 50%',
    filter: 'grayscale(1) contrast(1.05)',
    top: `${from.top}px`, left: `${from.left}px`,
    width: `${from.width}px`, height: `${from.height}px`,
    borderRadius: `${radius}px`,
    transform: fromTransform,
  });
  document.body.appendChild(fly);
  const anim = fly.animate(
    [
      { top: `${from.top}px`, left: `${from.left}px`, width: `${from.width}px`, height: `${from.height}px`, borderRadius: `${radius}px`, transform: fromTransform, boxShadow: '0 4px 16px rgba(0,0,0,0.08)' },
      { boxShadow: '0 26px 60px rgba(0,0,0,0.20)', offset: 0.42 },
      { top: `${to.top}px`, left: `${to.left}px`, width: `${to.width}px`, height: `${to.height}px`, borderRadius: `${radius}px`, transform: toTransform, boxShadow: '0 10px 34px rgba(0,0,0,0.10)' },
    ],
    { duration: MORPH_MS, easing: MORPH_EASE, fill: 'forwards' }
  );
  anim.onfinish = () => { fly.remove(); onDone && onDone(); };
  anim.oncancel = () => { fly.remove(); onDone && onDone(); };
  return anim;
}

/* Splits a hero line on ** and wraps the marked words. Returns an array of
   strings and elements, which React renders inline — so the span's own box,
   and therefore everything the parallax measures, is unchanged. */
function heroEmphasis(text) {
  return String(text).split('**').map((part, i) =>
    i % 2 === 1 ? <em key={i} className="hero-mark">{part}</em> : part
  );
}

/* Stable URL slug for a team member. Derived from the name rather than stored
   so a new TEAM_MEMBERS entry cannot forget one; accents are folded so
   "Antoine Ferrère" resolves as antoine-ferrere from a plain-ASCII link. */
function personSlug(name) {
  return String(name)
    .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
    .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}


/* ============================================================
   Shared components
   ============================================================ */
function Arrow() {
  return (
    <svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ flexShrink: 0 }}>
      <path d="M3 11L11 3M11 3H4.5M11 3V9.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
    </svg>
  );
}
function ArrowDown() {
  return (
    <svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ flexShrink: 0 }}>
      <path d="M3 3L11 11M11 11H4.5M11 11V4.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
    </svg>
  );
}











/* ============================================================
   EventGlyph — per-event abstract visual
   ============================================================ */
function EventGlyph({ i }) {
  if (i === 0) {
    return (
      <svg viewBox="0 0 200 80" style={{ width: '100%', height: 80 }}>
        {Array.from({ length: 40 }, (_, k) => {
          const h = 6 + Math.abs(Math.sin(k * 0.55) * Math.cos(k * 0.21)) * 60;
          return <rect key={k} x={k * 5} y={40 - h / 2} width="2" height={h} fill="var(--baii-blue)" opacity={0.6 + (k % 5) * 0.08}/>;
        })}
      </svg>
    );
  }
  if (i === 1) {
    return (
      <svg viewBox="0 0 200 80" style={{ width: '100%', height: 80 }}>
        {[0, 1, 2, 3].map(k => (
          <g key={k}>
            <rect x={k * 48 + 4} y="20" width="40" height="40" fill="none" stroke="var(--baii-graphite)" strokeWidth="1"/>
            <rect x={k * 48 + 4} y="20" width="40" height={[14, 28, 22, 36][k]} fill={k === 1 ? 'var(--baii-blue)' : 'rgba(0,0,0,0.1)'}/>
            <text x={k * 48 + 24} y="74" fontFamily="IBM Plex Mono, monospace" fontSize="8" textAnchor="middle" fill="var(--fg3)">
              {['MON', 'TUE', 'WED', 'THU'][k]}
            </text>
          </g>
        ))}
      </svg>
    );
  }
  return (
    <svg viewBox="0 0 200 80" style={{ width: '100%', height: 80 }}>
      <rect x="20" y="14" width="160" height="52" fill="none" stroke="var(--baii-graphite)" strokeWidth="1" strokeDasharray="3 4"/>
      <text x="100" y="44" textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="11" fill="var(--baii-blue)" letterSpacing="0.18em">TBA</text>
      <text x="100" y="58" textAnchor="middle" fontFamily="IBM Plex Mono, monospace" fontSize="8" fill="var(--fg3)" letterSpacing="0.14em">Q3 2026</text>
    </svg>
  );
}


/* ============================================================
   Scroll Reveal System
   ============================================================ */
/* A single-frame scroll jump — trackpad flick, scrollbar drag, browser scroll
   restoration, anchor jump — can carry an element from below the viewport to
   above it without the IntersectionObserver ever seeing a threshold crossing.
   No callback fires and the element stays at opacity 0 forever. This shared
   rAF-throttled pass catches anything whose top edge has ended up above the
   viewport. On an ordinary downward scroll it is strictly LATER than the 0.15
   threshold, so normal reveal timing and stagger are untouched. */
const revealSweeper = (() => {
  const waiting = new Set();
  let scheduled = false;
  const flush = () => {
    scheduled = false;
    waiting.forEach(item => {
      if (item.el.getBoundingClientRect().top < 0) item.reveal();
    });
  };
  const schedule = () => {
    if (scheduled) return;
    scheduled = true;
    window.requestAnimationFrame(flush);
  };
  return {
    add(item) {
      if (waiting.size === 0) {
        window.addEventListener('scroll', schedule, { passive: true });
        window.addEventListener('resize', schedule, { passive: true });
      }
      waiting.add(item);
    },
    remove(item) {
      if (!waiting.delete(item)) return;
      if (waiting.size === 0) {
        window.removeEventListener('scroll', schedule);
        window.removeEventListener('resize', schedule);
      }
    },
  };
})();

/* `still` renders the final state immediately: same <div>, same classes, same
   style, no observer, no transition. It exists because Antoine's note on
   2026-08-04 was specifically about TEXT — "I would only animate subtly some
   of the pictures" — so the cut has to be per-instance, and the alternative
   (deleting the wrapper at each text site) is not equivalent. This component
   renders a real block-level <div>, 14 call sites pass a React `key` through
   it, and styles.css hangs a descendant rule off `.reveal--visible`
   (.section-index__rule). Unwrapping would move layout, break lists and
   silently kill that rule. Adding one word does none of that, and deleting
   the word restores the animation exactly. */
function ScrollReveal({ children, delay = 0, direction = 'up', duration, className = '', style = {}, still = false }) {
  const ref = React.useRef(null);
  const [visible, setVisible] = React.useState(still);

  React.useEffect(() => {
    if (still) return;
    const el = ref.current;
    if (!el) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setVisible(true);
      return;
    }
    let observer = null;
    let done = false;
    const item = {
      el,
      reveal() {
        if (done) return;
        done = true;
        if (observer) observer.disconnect();
        revealSweeper.remove(item);
        setVisible(true);
      },
    };
    observer = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting || entry.boundingClientRect.top < 0) item.reveal(); },
      { threshold: 0.15 }
    );
    observer.observe(el);
    revealSweeper.add(item);
    return () => { observer.disconnect(); revealSweeper.remove(item); };
  }, []);

  const dirClass = direction === 'left' ? 'reveal--left' : direction === 'right' ? 'reveal--right' : '';
  const transitionDuration = duration ? `${duration}ms` : undefined;

  return (
    <div
      ref={ref}
      className={`reveal ${dirClass} ${visible ? 'reveal--visible' : ''} ${className}`}
      style={{
        transitionDelay: `${delay}ms`,
        ...(transitionDuration ? { transitionDuration } : {}),
        ...style,
      }}
    >
      {children}
    </div>
  );
}

function StaggerChildren({ children, baseDelay = 0, stagger = 100, direction = 'up' }) {
  return React.Children.map(children, (child, i) => (
    <ScrollReveal still delay={baseDelay + i * stagger} direction={direction}>
      {child}
    </ScrollReveal>
  ));
}

/* The section-index eyebrows — "Mission", "Research", "People" — type
   themselves out on scroll.

   Removed wholesale in 4929017 as part of answering Antoine's note about too
   much motion, then reinstated here on Samuel's call: with the 107 body-copy
   reveals now still, these 40 small mono labels are the only text that moves,
   which is what makes them read as a signature rather than as part of a
   general drift. The thing Antoine objected to was the wash, not this.

   Restored verbatim from fd40c79 — same observer, same revealSweeper
   catch-up, same 45ms cadence. Call sites never changed; they kept passing
   `delay` through the static stand-in, so nothing at the 40 sites needs
   touching either way. */
function TypewriterLabel({ text, delay = 0 }) {
  const ref = React.useRef(null);
  const [visible, setVisible] = React.useState(false);
  const [charCount, setCharCount] = React.useState(0);

  /* Same single-frame-jump failure as ScrollReveal: a flick that carries the
     eyebrow from below the viewport to above it crosses no threshold, the
     observer never fires, and the label is stranded blank forever. Reuses the
     shared revealSweeper and the same `boundingClientRect.top < 0` clause.
     `typeOut` is false on both catch-up paths — the element is already above
     the fold, so animating it is pointless; it just has to be there. */
  const [instant, setInstant] = React.useState(false);

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setVisible(true);
      setCharCount(text.length);
      return;
    }
    let observer = null;
    let done = false;
    const item = {
      el,
      /* revealSweeper calls reveal() with no argument, so the sweeper path is
         the `typeOut` falsy branch by construction. */
      reveal(typeOut) {
        if (done) return;
        done = true;
        if (observer) observer.disconnect();
        revealSweeper.remove(item);
        if (!typeOut) { setInstant(true); setCharCount(text.length); }
        setVisible(true);
      },
    };
    observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) item.reveal(true);
        else if (entry.boundingClientRect.top < 0) item.reveal(false);
      },
      { threshold: 0.15 }
    );
    observer.observe(el);
    revealSweeper.add(item);
    return () => { observer.disconnect(); revealSweeper.remove(item); };
  }, []);

  React.useEffect(() => {
    if (!visible || instant) return;
    const timeout = setTimeout(() => {
      let i = 0;
      const interval = setInterval(() => {
        i++;
        setCharCount(i);
        if (i >= text.length) clearInterval(interval);
      }, 45);
      return () => clearInterval(interval);
    }, delay);
    return () => clearTimeout(timeout);
  }, [visible, instant, text, delay]);

  return (
    <span ref={ref} style={{ minWidth: `${text.length}ch` }}>
      {visible ? text.slice(0, charCount) : ' '}
      {visible && charCount < text.length && (
        <span style={{ opacity: 0.4, animation: 'blink 600ms steps(1) infinite' }}>|</span>
      )}
    </span>
  );
}

function useParallax(rate = 0.4) {
  const ref = React.useRef(null);
  const [offset, setOffset] = React.useState(0);

  React.useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    let ticking = false;
    const onScroll = () => {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(() => {
        const el = ref.current;
        if (el) {
          const rect = el.getBoundingClientRect();
          const center = rect.top + rect.height / 2;
          const viewCenter = window.innerHeight / 2;
          setOffset((center - viewCenter) * rate);
        }
        ticking = false;
      });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener('scroll', onScroll);
  }, [rate]);

  return { ref, offset };
}

/* Live media query. Used to keep decorative art OUT OF THE DOM at widths where
   the stylesheet hides it — display:none does not stop a browser downloading an
   <img>, so hiding alone still costs the visitor the bytes. */
function useMediaQuery(query) {
  const [matches, setMatches] = React.useState(
    () => window.matchMedia ? window.matchMedia(query).matches : true
  );
  React.useEffect(() => {
    if (!window.matchMedia) return;
    const mq = window.matchMedia(query);
    const on = e => setMatches(e.matches);
    setMatches(mq.matches);
    if (mq.addEventListener) { mq.addEventListener('change', on); return () => mq.removeEventListener('change', on); }
    mq.addListener(on); return () => mq.removeListener(on);
  }, [query]);
  return matches;
}

/* ============================================================
   Navigation
   ============================================================ */

/* Insights is HELD until there is real writing to publish.

   BLOG_POSTS held exactly ONE entry: "What We Mean by 'Behavioral AI'",
   bylined to Antoine with his photo and dated Dec 4 2025. It was a placeholder
   that shipped looking like a published article, and it WAS the whole section —
   remove it and the page is empty. Antoine caught it on 2026-08-04: "Obviously
   we'd not go live with my fake blog post would we?" Samuel's call the same day
   was to pull it and hide the section rather than launch with it or wait.

   The page, its components, its route title and its render branch all still
   exist. This flag is the only thing between them and the site. To bring
   Insights back: add a real post to BLOG_POSTS and set this to true. The
   placeholder's text is recoverable from git history at 86bc0b6~1. */
const INSIGHTS_LIVE = false;

const EXPLORE_ITEMS = [
  { label: 'Blog', route: 'insights' },
  { label: 'Events', route: 'events' },
  { label: 'Glossary', route: 'glossary' },
  { label: 'Open Letter', route: 'open-letter' },
];

const WORK_ITEMS = [
  { label: 'Consulting', route: 'services' },
  { label: 'Training', route: 'training' },
  { label: 'Contact', route: 'contact' },
];

/* Explore and Work are menus, not routes. They exist because #events and
   #training are finished pages that nothing linked to — these two menus are
   the only path to them on desktop.

   They REPLACE the flat Services and Insights pills rather than sitting
   alongside them (Work -> Consulting is #services, Explore -> Blog is
   #insights, so nothing became less reachable). Measured: at 1024px the pill
   has 119.5px of slack and two extra top-level pills need ~189px, and below
   900px the pill is already over-full — "Get in touch" is crushed from 129.6px
   to 75px at 769px. Swapping keeps the bar the same width at every breakpoint. */
/* The v1 launch nav, set by b5a1609 (2026-07-09) and reordered per Luana's
   review (prep/CHANGES.md §2, 2026-07-22): About sits last, before the CTA.

   FLAT ON PURPOSE. Do not reintroduce the Explore / Work dropdowns — they were
   removed in b5a1609 and their EXPLORE_ITEMS / WORK_ITEMS labels ('Blog',
   'Consulting') predate two naming rulings: Blog→Insights (Samuel, 2026-06-01)
   and Consulting→Services (Paul 2026-05-21, confirmed by Samuel 2026-07-10).

   Page-visibility tiers from that same commit:
     Tier 1 (in this nav) home · research · services · insights · about · contact
     Tier 2 (footer + direct URL only) glossary · open-letter · newsletter · join
     Tier 3 (HELD, hash-only, NO LINKS ANYWHERE) training · events
   Training is not a service line (Antoine, 2026-07-02). Re-confirmed 2026-07-31. */
const NAV_ITEMS = [
  { label: 'Home', route: 'home' },
  { label: 'Research', route: 'research' },
  { label: 'Services', route: 'services' },
  ...(INSIGHTS_LIVE ? [{ label: 'Insights', route: 'insights' }] : []),
  { label: 'About', route: 'about' },
];

/* WAI-ARIA "disclosure navigation" — a real <button> that owns aria-expanded,
   real <button>s inside, Escape closes and hands focus back, arrow keys walk
   the list, and focus leaving the control closes it.

   As written before this it was a bare <a> with no href, no tabIndex, no role
   and no aria-expanded, holding href-less <a> items: nothing in it took focus,
   so the whole menu was invisible to the keyboard and to a screen reader.

   The panel is only in the DOM while open. Keeping it mounted at opacity:0
   would leave four permanently-tabbable phantom stops in the header. */
function NavDropdown({ label, items, route, setRoute, isActive }) {
  const [open, setOpen] = React.useState(false);
  const closeTimer = React.useRef(null);
  const wrapRef = React.useRef(null);
  const triggerRef = React.useRef(null);
  const panelRef = React.useRef(null);
  const menuId = 'nav-menu-' + label.toLowerCase().replace(/[^a-z0-9]+/g, '-');

  const cancelClose = () => { clearTimeout(closeTimer.current); closeTimer.current = null; };
  React.useEffect(() => () => cancelClose(), []);

  const closeAndReturn = () => {
    cancelClose();
    setOpen(false);
    if (triggerRef.current) triggerRef.current.focus();
  };

  const itemNodes = () => (panelRef.current
    ? Array.prototype.slice.call(panelRef.current.querySelectorAll('button'))
    : []);

  /* the panel mounts on the next commit, so focus has to wait a frame */
  const openAt = (which) => {
    setOpen(true);
    requestAnimationFrame(() => {
      const nodes = itemNodes();
      if (!nodes.length) return;
      (which === 'last' ? nodes[nodes.length - 1] : nodes[0]).focus();
    });
  };

  const onTriggerKeyDown = (e) => {
    if (e.key === 'ArrowDown') { e.preventDefault(); openAt('first'); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); openAt('last'); }
    else if (e.key === 'Escape' && open) { e.preventDefault(); closeAndReturn(); }
  };

  const onPanelKeyDown = (e) => {
    const nodes = itemNodes();
    if (!nodes.length) return;
    const i = nodes.indexOf(document.activeElement);
    if (e.key === 'Escape') { e.preventDefault(); closeAndReturn(); }
    else if (e.key === 'ArrowDown') { e.preventDefault(); nodes[(i + 1) % nodes.length].focus(); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); nodes[(i - 1 + nodes.length) % nodes.length].focus(); }
    else if (e.key === 'Home') { e.preventDefault(); nodes[0].focus(); }
    else if (e.key === 'End') { e.preventDefault(); nodes[nodes.length - 1].focus(); }
  };

  /* React's onBlur is focusout, so it bubbles. Close when focus leaves the
     control entirely — never pull focus back, or Tab becomes a trap. */
  const onBlur = (e) => {
    if (wrapRef.current && e.relatedTarget && wrapRef.current.contains(e.relatedTarget)) return;
    cancelClose();
    setOpen(false);
  };

  return (
    <div
      ref={wrapRef}
      className="nav-dropdown"
      onMouseEnter={() => { cancelClose(); setOpen(true); }}
      onMouseLeave={() => { cancelClose(); closeTimer.current = setTimeout(() => setOpen(false), 120); }}
      onBlur={onBlur}
    >
      <button
        ref={triggerRef}
        type="button"
        className={`nav-pill__item nav-dropdown__trigger ${isActive || open ? 'is-active' : ''}`}
        aria-expanded={open}
        aria-haspopup="true"
        aria-controls={open ? menuId : undefined}
        aria-current={isActive ? 'true' : undefined}
        onClick={() => (open ? (cancelClose(), setOpen(false)) : openAt('first'))}
        onKeyDown={onTriggerKeyDown}
      >
        {label}
        <svg width="8" height="5" viewBox="0 0 8 5" aria-hidden="true" style={{ opacity: open ? 0.6 : 0.3, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 200ms var(--ease-out), opacity 200ms var(--ease-standard)' }}>
          <path d="M0.5 0.5l3.5 4 3.5-4" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
        </svg>
      </button>
      {open && (
        <div className="nav-dropdown__panel" ref={panelRef} onKeyDown={onPanelKeyDown}>
          {/* covers the nav pill's own border so the panel reads as one piece */}
          <div className="nav-dropdown__bridge" aria-hidden="true" />
          <div className="nav-dropdown__menu" id={menuId} aria-label={label}>
            {items.map((item) => (
              <button
                key={item.route}
                type="button"
                className={`nav-dropdown__item ${route === item.route ? 'is-active' : ''}`}
                aria-current={route === item.route ? 'page' : undefined}
                onClick={() => { setRoute(item.route); cancelClose(); setOpen(false); }}
              >{item.label}</button>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

/* Portalled to <body> because .route-content's routeIn animation has
   `fill: both`, so it settles on an identity transform — and a transformed
   ancestor is the containing block for position:fixed.
   (The About person panel hit this too, before it became a spread that needs
   no fixed positioning at all.)
   Rendered in the tree the overlay sized to the *route* — measured 8923px tall
   with top:-2492 on #about at scrollY 2500 — and five of the six items landed
   above the viewport. The visitor saw a blank white sheet. */
function MobileNav({ route, setRoute, isOpen, onClose, returnFocusRef }) {
  const overlayRef = React.useRef(null);
  const closeRef = React.useRef(null);
  const onCloseRef = React.useRef(onClose);
  onCloseRef.current = onClose;

  React.useEffect(() => {
    if (!isOpen) return;
    const overlay = overlayRef.current;
    if (!overlay) return;
    const returnTo = returnFocusRef && returnFocusRef.current;

    /* Put the X exactly where the hamburger is standing. Its position differs
       per route (top 25-57, right 25-81 at 390px), so a hard-coded corner would
       make the button jump on open. Scroll is locked while the menu is up, so
       one measurement holds. Clamped in case the menu is opened from a scroll
       depth where the hamburger itself is off-screen. */
    if (closeRef.current && returnTo) {
      const r = returnTo.getBoundingClientRect();
      if (r.width) {
        const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
        closeRef.current.style.top = clamp(r.top, 16, window.innerHeight - r.height - 16) + 'px';
        closeRef.current.style.left = clamp(r.left, 16, window.innerWidth - r.width - 16) + 'px';
        closeRef.current.style.right = 'auto';
      }
    }

    const focusables = () =>
      Array.prototype.slice.call(overlay.querySelectorAll('button:not([disabled])'));

    const firstNode = focusables()[0];
    if (firstNode) firstNode.focus({ preventScroll: true });

    const onKey = (e) => {
      if (e.key === 'Escape') { e.preventDefault(); onCloseRef.current(); return; }
      if (e.key !== 'Tab') return;
      const nodes = focusables();
      if (!nodes.length) return;
      const first = nodes[0];
      const last = nodes[nodes.length - 1];
      const active = document.activeElement;
      /* focus escaped the overlay entirely (or is on the container) — pull it back */
      if (!overlay.contains(active) || active === overlay) {
        e.preventDefault();
        (e.shiftKey ? last : first).focus({ preventScroll: true });
        return;
      }
      if (e.shiftKey && active === first) { e.preventDefault(); last.focus({ preventScroll: true }); }
      else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus({ preventScroll: true }); }
    };

    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('keydown', onKey);
      if (returnTo && document.body.contains(returnTo)) returnTo.focus({ preventScroll: true });
    };
  }, [isOpen]);

  if (!isOpen) return null;

  const go = (r) => { setRoute(r); onClose(); };
  /* real <button>s, not href-less <a>s: the anchors took no focus at all, so
     none of the six items was reachable by keyboard or screen reader */
  const item = (r, label) => (
    <button
      type="button"
      className={`mobile-nav-item ${route === r ? 'is-active' : ''}`}
      aria-current={route === r ? 'page' : undefined}
      onClick={() => go(r)}
    >{label}</button>
  );

  return ReactDOM.createPortal(
    <div
      className="mobile-nav-overlay is-open"
      id="mobile-nav"
      ref={overlayRef}
      role="dialog"
      aria-modal="true"
      aria-label="Site menu"
    >
      {/* the in-nav hamburger scrolls with the page and sits inside
          .route-content's stacking context, so it paints *under* this overlay.
          This is the X the visitor actually sees and can reach with Tab. */}
      <button
        type="button"
        className="hamburger-btn is-open mobile-nav-close"
        ref={closeRef}
        onClick={onClose}
        aria-label="Close menu"
      >
        <span></span>
        <span></span>
        <span></span>
      </button>
      {/* Mirrors NAV_ITEMS exactly. Training and Events are Tier 3 — held,
          hash-only, no links anywhere — per b5a1609 and re-confirmed 2026-07-31.
          Do not add them here. The Tier 2 routes stay in the footer. */}
      {item('home', 'Home')}
      {item('research', 'Research')}
      {item('services', 'Services')}
      {INSIGHTS_LIVE && item('insights', 'Insights')}
      {item('about', 'About')}
      <button type="button" className="mobile-nav-cta" onClick={() => go('contact')}>Get in touch</button>
    </div>,
    document.body
  );
}

function HamburgerButton({ isOpen, onClick, buttonRef }) {
  return (
    <button
      ref={buttonRef}
      type="button"
      className={`hamburger-btn ${isOpen ? 'is-open' : ''}`}
      onClick={onClick}
      aria-label={isOpen ? 'Close menu' : 'Open menu'}
      aria-expanded={isOpen}
      aria-controls="mobile-nav"
    >
      <span></span>
      <span></span>
      <span></span>
    </button>
  );
}

/* Keeps the portalled header sitting on top of the hole it left behind.

   Why a portal + position:fixed rather than position:sticky, measured on this
   page rather than reasoned about: the header's ancestor chain is
   header > .hero-top-bar > .hero-inner > .surface > main.route-content, and
   .route-content computes to transform: matrix(1,0,0,1,0,0) — identity, but
   not `none`, because routeIn has `fill: both`. A transformed ancestor is the
   containing block for position:fixed, so a fixed header left in the tree
   would size and scroll with the ~5000-9000px route, not the viewport. That
   is the bug this site has already hit twice (see MobileNav, and the About
   person panel that preceded PersonSpread).
   position:sticky does not work either: .surface is overflow:hidden on #home
   and on #about the chain is .about-hero-stick (overflow:hidden, itself
   sticky) inside .about-hero-scroll (overflow:clip) — sticky pins to the
   nearest scrollport, so it would release at the bottom of the hero card.

   So the header is portalled to <body>, out of every transform and every
   overflow, and follows a zero-cost placeholder left at its old position in
   the flow. top = max(slotTop, stickTop) is exactly sticky's own rule, which
   is why it looks identical to the old header until you scroll past it. */
/* Where the portalled header actually lives in the document.

   Tab order follows DOM order, and document.body appends to the END: parked
   straight on <body> the entire primary navigation came after the whole route
   (5,000-9,000px) and the footer — measured, Tab reached nothing in the header
   inside 26 presses on #home. So the host is moved back to where the header
   used to sit, immediately after the skip link and before .page. Both
   .page-bg and .page are untransformed and overflow:visible (verified on
   #home, #about and #research), so position:fixed inside them still resolves
   against the viewport — which is the whole point of the portal.

   The skip link does not exist yet during the first render, so the host is
   parked on <body> and moved in a layout effect, before paint. */
function useHeaderHost() {
  const [host] = React.useState(() => {
    const el = document.createElement('div');
    el.className = 'site-header-host';
    document.body.appendChild(el);
    return el;
  });
  React.useLayoutEffect(() => {
    const skip = document.querySelector('.skip-link');
    if (skip && skip.parentNode && skip.nextSibling !== host) {
      skip.parentNode.insertBefore(host, skip.nextSibling);
    }
    return () => { if (host.parentNode) host.parentNode.removeChild(host); };
  }, [host]);
  return host;
}

function useStickyHeader(slotRef, headerRef) {
  React.useLayoutEffect(() => {
    const slot = slotRef.current;
    const header = headerRef.current;
    if (!slot || !header) return;

    let scheduled = 0;
    let burst = 0;

    const sync = () => {
      /* In the flow the header was a shrinkable flex item, and anything it
         could not give back was clipped by .surface { overflow: hidden }.
         Fixed and portalled it is neither: it lays out at max-content and is
         no longer clipped, which pushed 29px of horizontal document overflow
         at 769px and 16px at 820px — the band where this pill was already
         over-full before any of this (at 769px "Get in touch" is squeezed from
         129.6px to 75px). Hand it the same budget its row had. */
      const parent = slot.parentElement;
      if (parent) {
        const ps = getComputedStyle(parent);
        let avail = parent.clientWidth - parseFloat(ps.paddingLeft) - parseFloat(ps.paddingRight);
        Array.prototype.forEach.call(parent.children, (child) => {
          if (child !== slot) avail -= child.getBoundingClientRect().width;
        });
        header.style.maxWidth = Math.max(0, avail) + 'px';
      }
      const hw = header.offsetWidth;
      const hh = header.offsetHeight;
      /* the placeholder holds the header's exact box, so .hero-top-bar and
         .subpage-nav-wrap (both justify-content: space-between) lay out as if
         nothing moved */
      if (Math.abs(hw - slot.offsetWidth) > 0.5 || Math.abs(hh - slot.offsetHeight) > 0.5) {
        slot.style.width = hw + 'px';
        slot.style.height = hh + 'px';
      }
      const r = slot.getBoundingClientRect();
      /* matches .page's own padding: 24px desktop, 12px at the mobile breakpoint */
      const stickTop = window.innerWidth <= 768 ? 12 : 24;
      const top = Math.max(r.top, stickTop);
      header.style.top = top + 'px';
      header.style.left = r.left + 'px';
      const stuck = top > r.top + 0.5 ? 'true' : 'false';
      if (header.dataset.stuck !== stuck) header.dataset.stuck = stuck;
      /* scroll-margin-top for in-page jump targets reads this. It is the
         header's stuck bottom edge plus 24px, not just the header height:
         with only the height the #publications / #areas / #services-section /
         #contact / #courses / #sign-section CTAs landed 6px UNDER the header,
         because the page settles by ~7px after a jump. */
      const root = document.documentElement;
      root.style.setProperty('--site-header-h', hh + 'px');
      root.style.setProperty('--site-header-clear', (stickTop + hh + 24) + 'px');
    };

    const schedule = () => {
      if (scheduled) return;
      scheduled = requestAnimationFrame(() => { scheduled = 0; sync(); });
    };

    /* .route-content's routeIn animates translateY 10px -> 0 over 360ms and
       reveals settle after that; neither fires a scroll or resize event, so
       track the first ~800ms of a route by frame instead of guessing. */
    const startedAt = performance.now();
    const followEntrance = () => {
      sync();
      if (performance.now() - startedAt < 800) burst = requestAnimationFrame(followEntrance);
      else burst = 0;
    };
    followEntrance();

    window.addEventListener('scroll', schedule, { passive: true });
    window.addEventListener('resize', schedule);
    window.addEventListener('load', schedule);
    const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null;
    if (ro) { ro.observe(header); ro.observe(document.body); }

    return () => {
      if (scheduled) cancelAnimationFrame(scheduled);
      if (burst) cancelAnimationFrame(burst);
      window.removeEventListener('scroll', schedule);
      window.removeEventListener('resize', schedule);
      window.removeEventListener('load', schedule);
      if (ro) ro.disconnect();
    };
  }, []);
}

function MainNav({ route, setRoute }) {
  const [mobileOpen, setMobileOpen] = React.useState(false);
  const hamburgerRef = React.useRef(null);
  const slotRef = React.useRef(null);
  const headerRef = React.useRef(null);

  React.useEffect(() => { setMobileOpen(false); }, [route]);

  const headerHost = useHeaderHost();
  useStickyHeader(slotRef, headerRef);

  /* `body { overflow: hidden }` on its own does not hold the reading position:
     the document stops being scrollable, the browser clamps scrollTop, and the
     offset is gone by the time the menu closes (#home 2500 -> 0 -> 0).
     Pin the body instead and put the offset back by hand. Note this keeps every
     element at the same viewport coordinate, so the About hero's rAF loop —
     which reads getBoundingClientRect() — is unaffected. */
  React.useEffect(() => {
    if (!mobileOpen) return;
    const body = document.body;
    const y = window.scrollY || window.pageYOffset || 0;
    const prev = {
      position: body.style.position,
      top: body.style.top,
      left: body.style.left,
      right: body.style.right,
      width: body.style.width,
      overflow: body.style.overflow,
    };
    body.style.position = 'fixed';
    body.style.top = `-${y}px`;
    body.style.left = '0';
    body.style.right = '0';
    body.style.width = '100%';
    body.style.overflow = 'hidden';
    return () => {
      body.style.position = prev.position;
      body.style.top = prev.top;
      body.style.left = prev.left;
      body.style.right = prev.right;
      body.style.width = prev.width;
      body.style.overflow = prev.overflow;
      const root = document.documentElement;
      const prevBehavior = root.style.scrollBehavior;
      root.style.scrollBehavior = 'auto';   // never animate the restore
      window.scrollTo(0, y);
      root.style.scrollBehavior = prevBehavior;
    };
  }, [mobileOpen]);

  return (
    <>
      {/* The hole the header used to fill. Sized from the header in
          useStickyHeader; nothing is drawn in it. */}
      <div className="site-header-slot" ref={slotRef} aria-hidden="true" />
      {ReactDOM.createPortal(
        /* Every item is a real <button>. As bare <a> elements with no href they
           were not in the tab order at all, so the primary navigation could not
           be reached from the keyboard on any viewport (WCAG 2.1.1).
           The hamburger moved inside the <header> so one fixed box carries the
           navigation on every viewport — .nav-pill and .hamburger-btn already
           swap on display at 768px. */
        <header className="site-header site-header--fixed" ref={headerRef} data-stuck="false">
          <nav className="nav-pill" aria-label="Primary">
            {/* Flat only — no dropdown branch. NAV_ITEMS carries no `items`,
                and rendering one would resurrect the pre-June labels. */}
            {NAV_ITEMS.map(item => (
              <button
                key={item.route}
                type="button"
                className={`nav-pill__item ${route === item.route ? 'is-active' : ''}`}
                aria-current={route === item.route ? 'page' : undefined}
                onClick={() => setRoute(item.route)}
              >{item.label}</button>
            ))}
            <button type="button" className="nav-pill__item nav-pill__cta" onClick={() => setRoute('contact')}>Get in touch</button>
          </nav>
          <HamburgerButton buttonRef={hamburgerRef} isOpen={mobileOpen} onClick={() => setMobileOpen(!mobileOpen)} />
        </header>,
        headerHost
      )}
      <MobileNav
        route={route}
        setRoute={setRoute}
        isOpen={mobileOpen}
        onClose={() => setMobileOpen(false)}
        returnFocusRef={hamburgerRef}
      />
    </>
  );
}

/* ============================================================
   Page sections
   ============================================================ */


function HeroCard({ route, setRoute }) {
  const { ref: parallaxRef, offset: parallaxOffset } = useParallax(0.15);
  const spread = Math.max(0, -parallaxOffset * 2.5);
  const growth = 1 + Math.max(0, -parallaxOffset * 0.0008);

  /* The brains are hidden below 769px (styles.css), so don't mount them there
     at all — a hidden <img> is still a download, and on a phone it buys nothing. */
  const showBrains = useMediaQuery('(min-width: 769px)');

  /* The entrance used to be a plain CSS animation on a timer: fade in over 1.6s
     starting at 0.3s. That clock starts when the stylesheet applies, which is
     long before a hero illustration has actually arrived — so the fade would run
     to completion on an empty box and the image then SNAPPED in whenever it
     landed. That pop is what the reveal was supposed to prevent.
     Now each side reveals on its own decode, so the fade always has something to
     fade. useParallax re-renders this component on every scroll frame, so the ref
     callbacks must be stable or they would detach and re-decode continuously. */
  const [leftIn, setLeftIn] = React.useState(false);
  const [rightIn, setRightIn] = React.useState(false);
  const revealOn = (setIn) => (node) => {
    if (!node) return;
    const go = () => setIn(true);
    if (node.decode) node.decode().then(go, go);
    else if (node.complete) go();
    else { node.addEventListener('load', go, { once: true }); node.addEventListener('error', go, { once: true }); }
  };
  const leftRef = React.useCallback(revealOn(setLeftIn), []);
  const rightRef = React.useCallback(revealOn(setRightIn), []);

  /* Both transitions have to be declared HERE, not in the stylesheet. The
     parallax transform is an inline style, and `transition` is a shorthand — an
     inline `transition: transform ...` resets transition-property to transform
     alone and outranks any stylesheet rule, so a fade declared in CSS would
     silently never run and the image would snap to full opacity. */
  const brainTransition = (delay) =>
    `transform 0.1s linear, opacity 1.1s cubic-bezier(0.25, 0.1, 0.25, 1) ${delay}ms`;

  return (
    <section ref={parallaxRef} className="surface hero-loaded" style={{ overflow: 'hidden', position: 'relative' }}>
      {showBrains && <>
      <div className={`hero-brain-left${leftIn ? ' is-in' : ''}`} style={{
        position: 'absolute', width: 620, top: '50%', left: -205,
        transform: `translateY(-40%) translateX(${-spread}px) scale(${growth})`,
        pointerEvents: 'none', zIndex: 0,
        transition: brainTransition(0),
      }}>
        <img ref={leftRef} src="assets/illustrations/hero/brain.webp" alt="" className="hero-float" style={{
          width: '100%',
          filter: 'grayscale(1) contrast(1.1)',
          opacity: 0.85,
        }}/>
      </div>
      <div className={`hero-brain-right${rightIn ? ' is-in' : ''}`} style={{
        position: 'absolute', width: 620, top: '50%', right: -205,
        transform: `translateY(-40%) translateX(${spread}px) scale(${growth})`,
        pointerEvents: 'none', zIndex: 0,
        transition: brainTransition(120),
      }}>
        <img ref={rightRef} src="assets/illustrations/hero/brain-cortex.webp" alt="" className="hero-float hero-float--delay" style={{
          width: '100%',
          filter: 'grayscale(1) contrast(1.1)',
          opacity: 0.85,
        }}/>
      </div>
      </>}

      <div className="hero-inner" style={{ position: 'relative', zIndex: 1, padding: '32px 56px 64px' }}>
        <div className="hero-top-bar" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 48 }}>
          <img src="assets/logos/svg/wordmark-horizontal-blue.svg" alt="BAII" style={{ height: 32 }}/>
          <MainNav route={route} setRoute={setRoute} />
        </div>

        <div className="hero-center" style={{
          display: 'flex', flexDirection: 'column', alignItems: 'center',
          textAlign: 'center', paddingTop: 120,
        }}>
          <h1 className="display-xl display-hero hero-title" style={{ marginBottom: 24, animationDelay: '50ms' }}>
            AI changes people.<br/><span style={{ color: 'var(--baii-blue)' }}>We discover how.</span>
          </h1>
          <p className="hero-subtitle" style={{
            fontFamily: 'var(--font-mono)', fontSize: 17, lineHeight: 1.6,
            color: 'var(--fg2)', maxWidth: '48ch', marginBottom: 40,
            animationDelay: '350ms',
          }}>
            Independent, non-profit research institute measuring AI's real impact on how we think, decide, and work.
          </p>
          <div className="hero-ctas btn-group-mobile" style={{ display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center', animationDelay: '500ms' }}>
            <button type="button" className="btn btn--primary" onClick={() => setRoute('services')}>
              Our services
              <Arrow />
            </button>
            <button type="button" className="btn btn--hollow" onClick={() => setRoute('research')}>
              Explore our research
              <Arrow />
            </button>
          </div>
        </div>
        <div className="scroll-indicator scroll-indicator-wrap" style={{
          textAlign: 'center', paddingTop: 80, paddingBottom: 32,
          fontFamily: 'var(--font-mono)', fontSize: 11,
          letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--fg3)',
        }}>
          Scroll to discover ↓
        </div>
      </div>
    </section>
  );
}


function MissionIllustrationCard({ src, alt, label, width, height, rotate, style }) {
  const [hovered, setHovered] = React.useState(false);
  return (
    <div
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      style={{
        background: '#fff',
        border: '1px solid rgba(2, 99, 255, 0.15)',
        borderRadius: 12,
        overflow: 'hidden',
        position: 'absolute',
        width, height,
        boxShadow: hovered
          ? '0 8px 32px rgba(2, 99, 255, 0.12), 0 2px 8px rgba(0,0,0,0.06)'
          : '0 2px 16px rgba(0,0,0,0.06)',
        transform: `rotate(${hovered ? 0 : (rotate || 0)}deg) translateY(${hovered ? -6 : 0}px) scale(${hovered ? 1.02 : 1})`,
        transition: 'transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.35s ease',
        cursor: 'default',
        ...style,
      }}
    >
      <img src={src} alt={alt} style={{
        width: '100%', height: 'calc(100% - 44px)', objectFit: 'cover',
        filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)',
      }}/>
      <div style={{
        padding: '8px 14px 12px',
        display: 'flex', alignItems: 'center', gap: 8,
      }}>
        <div style={{
          width: 5, height: 5, borderRadius: '50%',
          background: 'var(--baii-blue)', flexShrink: 0,
        }}/>
        <div style={{
          height: 1, width: hovered ? 24 : 16,
          background: 'var(--baii-blue)',
          opacity: hovered ? 0.6 : 0.25,
          transition: 'width 0.3s ease, opacity 0.3s ease',
        }}/>
        <span style={{
          fontFamily: 'var(--font-mono)',
          fontSize: 10,
          fontWeight: 500,
          letterSpacing: '0.12em',
          textTransform: 'uppercase',
          color: hovered ? 'var(--baii-blue)' : 'var(--fg3)',
          transition: 'color 0.3s ease',
        }}>{label}</span>
      </div>
    </div>
  );
}

function MissionCard() {
  return (
    <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <div className="grid-2col" style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 64 }}>
            <div>
              <ScrollReveal still>
                <div className="section-index" style={{ marginBottom: 24 }}>
                  <span className="section-index__num">01</span>
                  <span className="section-index__rule"></span>
                  <TypewriterLabel text="Mission" delay={300} />
                </div>
                <h2 className="display-lg" style={{ marginBottom: 32, maxWidth: '22ch' }}>
                  Bridging research and <span style={{ color: 'var(--baii-blue)' }}>real-world impact</span>.
                </h2>
              </ScrollReveal>
              <ScrollReveal delay={150}>
                <div className="mission-mobile-img" style={{ display: 'none', marginBottom: 32, borderRadius: 8, overflow: 'hidden', border: '1px solid var(--baii-bone)' }}>
                  <img src="assets/library/illustration-hand-robotic.webp" alt="Robotic hand" style={{
                    width: '100%', display: 'block',
                    filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)',
                  }} />
                </div>
              </ScrollReveal>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: '48ch' }}>
                <StaggerChildren baseDelay={100} stagger={80}>
                  <p className="body-mono">
                    AI is reshaping how we communicate, make decisions, and relate to one another.
                  </p>
                  <p className="body-mono">
                    At worst it erodes the human foundations of good work: attention, judgment, the capacity to think. At best it sharpens them instead. Neither outcome is guaranteed, and we work to make the second more likely.
                  </p>
                </StaggerChildren>
              </div>
              <ScrollReveal still delay={150} direction="left">
                <div style={{
                  marginTop: 40,
                  padding: '28px 28px 28px 24px',
                  background: '#fff',
                  border: '1px solid var(--baii-bone)',
                  borderLeft: '3px solid var(--baii-blue)',
                  borderRadius: '0 8px 8px 0',
                  maxWidth: '48ch',
                  boxShadow: '0 2px 8px rgba(0,0,0,0.03)',
                }}>
                  <p style={{
                    fontFamily: 'var(--font-mono)', fontSize: 14, lineHeight: 1.7,
                    color: 'var(--fg1)', margin: 0, fontStyle: 'normal',
                  }}>
                    <span style={{
                      fontWeight: 600,
                      background: 'rgba(2, 99, 255, 0.10)',
                      borderRadius: 2,
                      padding: '1px 4px',
                      margin: '0 -2px',
                      WebkitBoxDecorationBreak: 'clone',
                      boxDecorationBreak: 'clone',
                    }}>The Behavioral AI Institute</span> exists to understand what AI does to us, to measure it, and to make everything we learn public.
                  </p>
                </div>
              </ScrollReveal>
            </div>
            <div className="mission-illustrations" style={{ position: 'relative', minHeight: 500, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
              <ScrollReveal delay={200}>
              <div style={{ position: 'relative', width: 480, height: 510 }}>
                <MissionIllustrationCard
                  src="assets/library/illustration-neuron.webp"
                  alt="Neuron" label="Discover"
                  width={240} height={300}
                  rotate={-2.5}
                  style={{ top: 0, left: 0, zIndex: 1 }}
                />
                <MissionIllustrationCard
                  src="assets/library/illustration-fingerprint.webp"
                  alt="Fingerprint" label="Measure"
                  width={180} height={230}
                  rotate={3}
                  style={{ top: 25, right: 0, zIndex: 2 }}
                />
                <MissionIllustrationCard
                  src="assets/library/illustration-hand-human.webp"
                  alt="Human hand" label="Apply"
                  width={250} height={260}
                  rotate={1.5}
                  style={{ bottom: -60, left: 120, zIndex: 3 }}
                />
              </div>
              </ScrollReveal>
            </div>
          </div>
        </div>
      </section>
    </ScrollReveal>
  );
}



const RESEARCH_INSTITUTIONS = [
  { name: 'Imperial College London', logo: 'assets/logos/imperial.png' },
  { name: 'University College London', logo: 'assets/logos/ucl.png' },
  { name: 'University of Exeter', logo: 'assets/logos/exeter.png' },
  { name: 'University of Edinburgh', logo: 'assets/logos/edinburgh.png' },
  { name: 'Duke University', logo: 'assets/logos/duke.png' },
  { name: "Queen's University Belfast", logo: 'assets/logos/queens-belfast.png' },
  { name: 'Alan Turing Institute', logo: 'assets/logos/turing.png' },
  { name: 'University of Sydney', logo: 'assets/logos/sydney.png' },
  { name: 'Harvard University', logo: 'assets/logos/harvard.png' },
];

function ResearchCard({ setRoute }) {
  const [hovered, setHovered] = React.useState(false);
  const [paperVisible, setPaperVisible] = React.useState(false);
  const paperRef = React.useRef(null);
  const sig = useSignatories();

  React.useEffect(() => {
    const el = paperRef.current;
    if (!el) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { setPaperVisible(true); return; }
    const observer = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting) { setPaperVisible(true); observer.disconnect(); } },
      { threshold: 0.2 }
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const paperTilt = !paperVisible
    ? 'rotateY(-12deg) rotateX(4deg) translateY(20px)'
    : hovered
      ? 'rotateY(0deg) rotateX(0deg) translateY(-8px)'
      : 'rotateY(-5deg) rotateX(2deg)';

  return (
    <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <div className="grid-2col research-grid-mobile" style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 64 }}>
            <ScrollReveal delay={200}>
              <div ref={paperRef} className="research-paper-visual" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', perspective: 800 }}>
                <div
                  onMouseEnter={() => setHovered(true)}
                  onMouseLeave={() => setHovered(false)}
                  style={{
                    position: 'relative', maxWidth: 380,
                    opacity: paperVisible ? 1 : 0,
                    transition: 'opacity 0.6s cubic-bezier(0.25, 0.1, 0.25, 1)',
                  }}
                >
                  <div style={{
                    position: 'absolute', inset: 0,
                    background: '#e8eaf0',
                    borderRadius: 4,
                    transform: 'rotate(2.5deg) translate(8px, 8px)',
                    zIndex: 0,
                  }}/>
                  <div style={{
                    position: 'absolute', inset: 0,
                    background: '#eff0f5',
                    borderRadius: 4,
                    transform: 'rotate(1.2deg) translate(4px, 4px)',
                    zIndex: 1,
                  }}/>
                  <div style={{
                    position: 'relative', zIndex: 2,
                    borderRadius: 4,
                    overflow: 'hidden',
                    border: '1px solid var(--baii-bone)',
                    boxShadow: hovered
                      ? '0 24px 48px rgba(0,0,0,0.15), 0 8px 16px rgba(0,0,0,0.08)'
                      : '0 8px 32px rgba(0,0,0,0.1), 0 2px 8px rgba(0,0,0,0.06)',
                    transform: paperTilt,
                    transition: 'transform 0.7s cubic-bezier(0.34, 1.2, 0.64, 1), box-shadow 0.4s ease',
                  }}>
                    <img
                      src="assets/library/paper-wellcome-open-research.webp"
                      alt="The missing discipline in AI: a call for behavioural science"
                      style={{ width: '104%', marginLeft: '-3%', display: 'block' }}
                    />
                  </div>
                </div>
              </div>
            </ScrollReveal>
            <div>
              <ScrollReveal still>
                <div className="section-index" style={{ marginBottom: 24 }}>
                  <span className="section-index__num">02</span>
                  <span className="section-index__rule"></span>
                  <TypewriterLabel text="Research" delay={300} />
                </div>
                <h2 className="display-lg" style={{ marginBottom: 24, maxWidth: '20ch' }}>
                  The <span style={{ color: 'var(--baii-blue)' }}>missing</span> discipline in AI.
                </h2>
              </ScrollReveal>
              <ScrollReveal delay={100}>
                <div className="research-mobile-img" style={{ display: 'none', marginBottom: 24, maxWidth: 280, borderRadius: 4, overflow: 'hidden', border: '1px solid var(--baii-bone)', boxShadow: '0 4px 16px rgba(0,0,0,0.08)' }}>
                  <img src="assets/library/paper-wellcome-open-research.webp" alt="The missing discipline in AI" style={{ width: '104%', marginLeft: '-3%', display: 'block' }} />
                </div>
              </ScrollReveal>
              <ScrollReveal still delay={100}>
                <p className="caption" style={{ marginBottom: 28, color: 'var(--fg3)' }}>
                  <span style={{ color: 'var(--baii-blue)', fontWeight: 600 }}>Wellcome Open Research</span> · February 2026 · Peer reviewed
                </p>
              </ScrollReveal>
              <StaggerChildren baseDelay={150} stagger={80}>
                <p className="body-mono" style={{ maxWidth: '54ch' }}>
                  Our founding open letter, published in Wellcome Open Research, outlines why behavioral science should be treated as a core component of responsible AI practice.
                </p>
                <p className="body-mono" style={{ maxWidth: '54ch' }}>
                  AI systems increasingly shape how people think, feel, and act, yet their behavioral impacts remain under-evaluated and under-governed.
                </p>
                <p className="body-mono" style={{ maxWidth: '54ch' }}>
                  The paper proposes practical steps for funders, researchers, and developers to embed behavioral expertise across the AI lifecycle.
                </p>
              </StaggerChildren>
              <ScrollReveal still delay={400}>
                <div className="btn-group-mobile" style={{ marginTop: 32, display: 'flex', gap: 12 }}>
                  <a href="https://wellcomeopenresearch.org/articles/11-152" target="_blank" rel="noopener noreferrer" className="btn btn--primary">
                    Read the paper <Arrow />
                  </a>
                  <button type="button" className="btn btn--hollow" onClick={() => setRoute('open-letter')}>
                    See signatories <Arrow />
                  </button>
                </div>
                <p className="caption" style={{ marginTop: 16, color: 'var(--fg3)', maxWidth: '60ch', textTransform: 'none' }}>
                  <strong style={{ color: 'var(--baii-blue)', fontWeight: 600 }}>{signatoryCount(sig)} signatories</strong>: our open letter has been co-signed by cognitive and behavioral scientists, practitioners, and AI researchers from institutions around the world.
                </p>
              </ScrollReveal>
            </div>
          </div>
          <ScrollReveal still delay={300}>
            <div style={{
              marginTop: 48,
              paddingTop: 32,
              borderTop: '1px solid var(--baii-bone)',
            }}>
              <div style={{
                display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20,
              }}>
                <span style={{
                  fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.14em',
                  textTransform: 'uppercase', color: 'var(--fg3)',
                }}>Our network spans leading researchers from institutions around the world</span>
                <div className="institutions-row" style={{
                  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 28, flexWrap: 'wrap',
                }}>
                  {RESEARCH_INSTITUTIONS.map((inst) => (
                    <img key={inst.name} src={inst.logo} alt={inst.name} title={inst.name} style={{
                      height: 24, width: 'auto', objectFit: 'contain',
                      filter: 'grayscale(1)', opacity: 0.35, flexShrink: 0,
                    }}/>
                  ))}
                </div>
              </div>
            </div>
          </ScrollReveal>
        </div>
      </section>
    </ScrollReveal>
  );
}

function NetworkCard({ setRoute }) {
  const photoOrder = [4, 7, 1, 9, 0, 11, 3, 6, 2, 8, 5, 10];
  const clusterPhotos = photoOrder.map((idx) => TEAM_MEMBERS[idx]);
  const photoSizes = [64, 80, 56, 72, 60, 84, 56, 68, 72, 62, 70, 66, 74, 58, 70];
  const photoRotations = [-2, 1.5, -1, 2.5, -1.5, 0.5, 1.8, -2.2, 1, -1.2, 2, -1.6, 2.2, -0.8, 1.3];
  const { ref: globeParallaxRef, offset: globeOffset } = useParallax(0.08);
  return (
    <ScrollReveal still>
      <section ref={globeParallaxRef} className="surface">
        <div className="surface__inner">
          <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
            <ScrollReveal still>
              <div className="section-index" style={{ marginBottom: 24 }}>
                <span className="section-index__num">03</span>
                <span className="section-index__rule"></span>
                <TypewriterLabel text="People" delay={300} />
              </div>
              <h2 className="display-lg" style={{ marginBottom: 40, maxWidth: '20ch' }}>
                A global network making AI <span style={{ color: 'var(--baii-blue)' }}>work for us.</span>
              </h2>
            </ScrollReveal>

            <ScrollReveal still delay={150} duration={800}>
              <div style={{
                position: 'relative', width: '100%', maxWidth: 360, margin: '0 auto',
                transform: `translateY(${globeOffset * 0.4}px) scale(${1 + Math.abs(globeOffset) * 0.0003})`,
                transition: 'transform 0.1s linear',
              }}>
                <img
                  src="assets/library/illustration-globe.webp"
                  alt="Globe"
                  style={{ width: '100%', display: 'block', filter: 'grayscale(1) contrast(1.1)' }}
                />
                <svg viewBox="0 0 420 420" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>
                  <defs>
                    <clipPath id="globe-clip">
                      <path d="M210,18 C310,18 380,60 395,140 C410,220 390,320 340,370 C290,420 180,415 120,380 C60,345 20,280 18,210 C16,140 110,18 210,18 Z" />
                    </clipPath>
                  </defs>
                  <g clipPath="url(#globe-clip)">
                    {Array.from({ length: 11 }, (_, i) => {
                      const pos = 20 + i * 38;
                      return (
                        <React.Fragment key={`grid-${i}`}>
                          <line x1={pos} y1="0" x2={pos} y2="420" stroke="#0263FF" strokeWidth="1" opacity="0.35">
                            <animate attributeName="opacity" values="0.35;0.15;0.35" dur={`${4 + i * 0.3}s`} repeatCount="indefinite" />
                          </line>
                          <line x1="0" y1={pos} x2="420" y2={pos} stroke="#0263FF" strokeWidth="1" opacity="0.35">
                            <animate attributeName="opacity" values="0.35;0.15;0.35" dur={`${4.5 + i * 0.25}s`} repeatCount="indefinite" />
                          </line>
                        </React.Fragment>
                      );
                    })}
                    {Array.from({ length: 11 }, (_, row) =>
                      Array.from({ length: 11 }, (_, col) => {
                        const x = 20 + col * 38;
                        const y = 20 + row * 38;
                        const dx = x - 210, dy = y - 210;
                        if (Math.sqrt(dx * dx + dy * dy) > 200) return null;
                        const delay = ((row + col) * 0.2) % 3;
                        return (
                          <circle key={`d-${row}-${col}`} cx={x} cy={y} r="2.5" fill="#0263FF">
                            <animate attributeName="opacity" values="0.7;0.2;0.7" dur="3s" begin={`${delay}s`} repeatCount="indefinite" />
                            <animate attributeName="r" values="2.5;1.8;2.5" dur="3s" begin={`${delay}s`} repeatCount="indefinite" />
                          </circle>
                        );
                      })
                    )}
                  </g>
                </svg>
              </div>
            </ScrollReveal>

            <div style={{
              marginTop: 20, fontFamily: 'var(--font-mono)', fontSize: 11,
              letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--fg3)',
            }}>
              A team of researchers, practitioners, builders, and behavioral scientists
            </div>

            <ScrollReveal still delay={150}>
              <div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', justifyContent: 'center', maxWidth: 560, marginTop: 36 }}>
                {clusterPhotos.map((p, i) => (
                  p.placeholder ? (
                    <div key={`ph-${i}`} aria-hidden="true" style={{
                      width: photoSizes[i], height: photoSizes[i],
                      borderRadius: '22%', overflow: 'hidden',
                      border: '2px dashed var(--baii-bone)', opacity: 0.45,
                      transform: `rotate(${photoRotations[i]}deg)`,
                    }}>
                      <img src={p.photo} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'grayscale(1)' }} />
                    </div>
                  ) : (
                  /* Deep-links into About -> Meet the team with this person's
                     profile already open. It used to point at LinkedIn, which
                     broke twice over: three fellows have no `linkedin` at all,
                     so their portrait rendered an anchor with no href — an
                     affordance that announced a "LinkedIn profile" and then
                     did nothing. Routing internally fixes the dead ones and
                     the live ones together, and LinkedIn still appears inside
                     the profile panel, where it renders conditionally. */
                  <a key={i} href={`#about?person=${personSlug(p.name)}`}
                    title={`${p.name} — ${p.role}`}
                    /* The link's only content is a photo, so without this the
                       name is whatever the alt text happens to be and the
                       destination is never announced. */
                    aria-label={`${p.name}, ${p.role} — open profile`}
                    style={{
                      display: 'block',
                      width: photoSizes[i], height: photoSizes[i],
                      borderRadius: '22%', overflow: 'hidden',
                      border: '2px solid var(--baii-bone)',
                      position: 'relative',
                      transform: `rotate(${photoRotations[i]}deg)`,
                      transition: 'transform 200ms',
                    }}
                    onMouseEnter={e => { e.currentTarget.style.transform = 'rotate(0deg) scale(1.1)'; e.currentTarget.querySelector('[data-overlay]').style.opacity = '1'; }}
                    onMouseLeave={e => { e.currentTarget.style.transform = `rotate(${photoRotations[i]}deg) scale(1)`; e.currentTarget.querySelector('[data-overlay]').style.opacity = '0'; }}
                  >
                    <img src={p.photo} alt={p.name} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 30%', filter: 'grayscale(1)' }} />
                    <div data-overlay="" style={{ position: 'absolute', inset: 0, background: 'linear-gradient(135deg, rgba(2, 99, 255, 0.35) 0%, rgba(2, 50, 160, 0.5) 100%)', opacity: 0, transition: 'opacity 0.3s ease', zIndex: 2 }} />
                  </a>
                  )
                ))}
              </div>
            </ScrollReveal>

            <button type="button" className="btn btn--hollow" onClick={() => setRoute('about')} style={{ marginTop: 36 }}>
              Meet our team
              <Arrow />
            </button>
          </div>
        </div>
      </section>
    </ScrollReveal>
  );
}

function FourWaysCard({ setRoute }) {
  return (
    <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <ScrollReveal still>
            <div style={{ marginBottom: 56 }}>
              <div className="section-index" style={{ marginBottom: 20 }}>
                <span className="section-index__num">04</span>
                <span className="section-index__rule"></span>
                <TypewriterLabel text="About" delay={300} />
              </div>
              <h2 className="display-lg" style={{ maxWidth: '20ch' }}>
                Three ways we <span style={{ color: 'var(--baii-blue)' }}>work</span>
              </h2>
            </div>
          </ScrollReveal>

          <div className="grid-4col" style={{
            display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 0,
            borderTop: '1px solid var(--baii-bone)', borderBottom: '1px solid var(--baii-bone)',
          }}>
            {FOUR_WAYS.map((w, i) => (
              <ScrollReveal still key={w.n} delay={i * 100}>
                <article className="fourways-card" role="button" tabIndex={0}
                  aria-label={`${w.title} — ${w.body}`}
                  onClick={() => setRoute(w.route)}
                  /* A role="button" has to answer Space as well as Enter, and
                     Space has to be swallowed or the page scrolls underneath. */
                  onKeyDown={e => {
                    if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
                      e.preventDefault();
                      setRoute(w.route);
                    }
                  }}
                  style={{
                  padding: '36px 28px 28px',
                  borderLeft: i === 0 ? '1px solid var(--baii-bone)' : 'none',
                  borderRight: '1px solid var(--baii-bone)',
                  display: 'flex', flexDirection: 'column', gap: 18, minHeight: 360,
                  background: '#fff', position: 'relative',
                  transition: 'background 200ms var(--ease-standard), transform 200ms var(--ease-standard), box-shadow 200ms var(--ease-standard)',
                  cursor: 'pointer',
                }}
                onMouseEnter={e => { e.currentTarget.style.background = 'var(--baii-blue-95)'; e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.boxShadow = '0 10px 28px rgba(2,99,255,0.10)'; }}
                onMouseLeave={e => { e.currentTarget.style.background = '#fff'; e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = 'none'; }}
                >
                  <div style={{
                    fontFamily: 'var(--font-mono)', fontSize: 11,
                    letterSpacing: '0.18em', color: 'var(--baii-blue)',
                  }}>{w.n}</div>
                  <h3 style={{
                    fontFamily: 'var(--font-display)', fontSize: 26, lineHeight: 1.1,
                    letterSpacing: '-0.01em', margin: 0, fontWeight: 400, textWrap: 'balance',
                  }}>{w.title}</h3>
                  <p style={{
                    fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.55,
                    color: 'var(--fg2)', margin: 0, flexGrow: 1,
                  }}>{w.body}</p>
                  {/* Decoration, not a control: the whole card is the button.
                      As a second role="button" nested inside the first it was a
                      duplicate tab stop going to the same route. */}
                  <div className="tap-link" aria-hidden="true" style={{
                    display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer',
                  }}>
                    <span style={{
                      display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                      width: 36, height: 36, borderRadius: 999,
                      border: '1px solid var(--baii-bone)', color: 'var(--fg2)',
                      textDecoration: 'none', transition: 'all 200ms var(--ease-standard)',
                    }}
                    onMouseEnter={e => { e.currentTarget.style.background = 'var(--baii-blue)'; e.currentTarget.style.borderColor = 'var(--baii-blue)'; e.currentTarget.style.color = '#fff'; }}
                    onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = 'var(--baii-bone)'; e.currentTarget.style.color = 'var(--fg2)'; }}>
                      <Arrow />
                    </span>
                    <span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg3)', letterSpacing: '0.02em' }}>{w.cta}</span>
                  </div>
                </article>
              </ScrollReveal>
            ))}
          </div>
        </div>
      </section>
    </ScrollReveal>
  );
}


function EventsCard({ setRoute }) {
  return (
    <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <ScrollReveal still>
            <div className="section-index" style={{ marginBottom: 20 }}>
              <span className="section-index__num">05</span>
              <span className="section-index__rule"></span>
              <TypewriterLabel text="Insights & Media" delay={300} />
            </div>
            <div style={{ marginBottom: 48, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 24 }}>
              <h2 className="display-lg" style={{ maxWidth: '22ch' }}>
                Where to follow the <span style={{ color: 'var(--baii-blue)' }}>conversation</span>.
              </h2>
              <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg3)', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
                Podcast · Insights · Newsletter
              </div>
            </div>
          </ScrollReveal>

          <div className="grid-3col" style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.min(EVENTS.length, 3)}, 1fr)`, gap: 20 }}>
            {EVENTS.map((ev, i) => {
              const tilts = [
                'rotate(-1.5deg) translateY(-5px) translateX(-3px)',
                'rotate(0.8deg) translateY(-6px)',
                'rotate(1.5deg) translateY(-5px) translateX(3px)',
              ];
              /* The card tilts under the cursor and shows a pointer, so the
                 whole thing has to go somewhere. Where the card links out it
                 is the anchor itself and the button inside becomes a label —
                 an anchor cannot contain an anchor. A card that routes
                 internally keeps its button and gives up the pointer. */
              const Tag = ev.href ? 'a' : 'article';
              const linkProps = ev.href
                ? { href: ev.href, target: '_blank', rel: 'noopener noreferrer' }
                : {};
              return (
              <ScrollReveal key={ev.title} delay={i * 120}>
                <Tag {...linkProps}
                  onMouseEnter={e => { e.currentTarget.style.transform = tilts[i]; e.currentTarget.style.boxShadow = '0 8px 30px rgba(0,0,0,0.10)'; }}
                  onMouseLeave={e => { e.currentTarget.style.transform = 'none'; e.currentTarget.style.boxShadow = 'none'; }}
                  style={{
                  border: '1px solid var(--baii-graphite)', borderRadius: 4,
                  background: '#fff', position: 'relative',
                  display: 'flex', flexDirection: 'column',
                  /* Fill the grid cell. The .reveal wrapper is the grid item and
                     stretches; the card inside did not, so the third card came
                     up 9px short — its "Details soon" label is shorter than the
                     other two cards' buttons. Height here rather than padding on
                     the label, so any future difference in body length is
                     absorbed too. */
                  height: '100%',
                  overflow: 'hidden',
                  color: 'inherit', textDecoration: 'none',
                  transition: 'transform 350ms var(--ease-out), box-shadow 350ms var(--ease-out)',
                  cursor: ev.href ? 'pointer' : 'default',
                }}>
                  {ev.anticipate ? (
                    /* The "next one" well. Same brand gradient as the two real
                       social cards so the three read as one set, with a
                       stippled hourglass inverted to sit light on it — the same
                       illustration language as the About hero's Thinker.

                       Deliberately an object, not a specific asset: the only
                       social card left in the library announces a named piece
                       by a named person, and dropping that in as a placeholder
                       would re-create exactly the fabricated-event bug this
                       section was cleaned of. An hourglass claims nothing. */
                    <div style={{
                      aspectRatio: '1 / 1', overflow: 'hidden', position: 'relative',
                      display: 'flex', alignItems: 'center', justifyContent: 'center',
                      background: 'linear-gradient(135deg, #0263FF 0%, #013C9A 100%)',
                    }}>
                      <img src={ev.anticipate} alt="" aria-hidden="true" style={{
                        width: '58%', height: 'auto', display: 'block',
                        filter: 'invert(1)', opacity: 0.92,
                        mixBlendMode: 'screen',
                      }}/>
                    </div>
                  ) : (
                    <div style={{ aspectRatio: '1 / 1', overflow: 'hidden' }}>
                      <img src={ev.img} alt={ev.title} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
                    </div>
                  )}
                  <div style={{ padding: '16px 20px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
                    <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                      <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--fg3)' }}>
                        {ev.kind}
                      </div>
                      <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--fg3)' }}>
                        [{String(i + 1).padStart(2, '0')}]
                      </div>
                    </div>
                    <div>
                      <div style={{
                        fontFamily: 'var(--font-display)', fontSize: 22, lineHeight: 1.15,
                        letterSpacing: '-0.01em', textWrap: 'balance',
                      }}>{ev.title}</div>
                      <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg3)', marginTop: 6 }}>
                        {ev.meta}
                      </div>
                    </div>
                    {ev.href
                      ? <span className="btn btn--hollow btn--sm" style={{ alignSelf: 'flex-start' }}>{ev.cta} <Arrow /></span>
                      : ev.label
                        /* Not a button and not a link — there is nowhere to go
                           yet, and a control that does nothing is the exact bug
                           the events rebuild removed. */
                        ? <span style={{
                            alignSelf: 'flex-start', fontFamily: 'var(--font-mono)',
                            fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase',
                            color: 'var(--fg3)', border: '1px dashed var(--baii-bone)',
                            borderRadius: 999, padding: '5px 13px',
                          }}>{ev.label}</span>
                        : <button type="button" className="btn btn--hollow btn--sm" onClick={() => setRoute(ev.route)} style={{ alignSelf: 'flex-start' }}>{ev.cta} <Arrow /></button>
                    }
                  </div>
                </Tag>
              </ScrollReveal>
              );
            })}
          </div>
        </div>
      </section>
    </ScrollReveal>
  );
}

function BriefCard({ setRoute }) {
  const [email, setEmail] = React.useState('');
  const F = window.BAIIForms;
  const fx = F.useFormSubmit(F.TABLES.NEWSLETTER);
  const sent = fx.sent;
  return (
    <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner" style={{ textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
          <h2 className="display-lg" style={{ marginBottom: 12 }}>
            The BAII Brief
          </h2>
          <p className="body-mono" style={{ color: 'var(--fg2)', marginBottom: 32 }}>
            For people who want to stay on top of what AI is doing to human behavior: research, findings, and tools from the institute.
          </p>
          <form
            className="brief-subscribe-form"
            onSubmit={(e) => { e.preventDefault(); fx.submit({ email: email, source: 'brief_card' }); }}
            style={{
              display: 'flex', gap: 8, padding: 8,
              border: '1px solid var(--baii-graphite)',
              borderRadius: 999, background: '#fff',
              width: '100%', maxWidth: 440,
              marginBottom: 16,
            }}
          >
            <F.Honeypot hp={fx.hp} />
            <label className="vh-label" htmlFor="brief-email">Email address</label>
            <input
              id="brief-email"
              name="email"
              type="email"
              required
              placeholder="you@institution.org"
              value={email}
              onChange={e => setEmail(e.target.value)}
              style={{
                flex: 1, border: 0, outline: 'none', background: 'transparent',
                padding: '10px 16px', fontFamily: 'var(--font-mono)',
                fontSize: 14, color: 'var(--fg1)',
              }}
            />
            <button type="submit" className="btn btn--primary btn--sm" disabled={fx.sending} aria-busy={fx.sending}>
              {sent ? 'Subscribed ✓' : 'Subscribe'}
              {!sent && <Arrow />}
            </button>
          </form>
          {/* The pill is a flex row; an error inside it would land beside the
              input. It sits under the form instead. */}
          {fx.error && <div style={{ width: '100%', maxWidth: 440, marginBottom: 16, marginTop: -8, textAlign: 'left' }}><F.ErrorNote error={fx.error} /></div>}
          <button type="button" className="tap-link btn-reset" onClick={() => setRoute('newsletter')} style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg3)', textDecoration: 'none', cursor: 'pointer' }}>View past issues →</button>
        </div>
      </section>
    </ScrollReveal>
  );
}

/* The footer's bottom half is two rows, not one list. The engagement links are
   the Tier 2 pages a reader might actually want; the legal links are the ones
   they only look for when they already know what they want. Rendering all eight
   at one weight made the Open Letter — the institute's flagship public artifact
   — sit at the same size and colour as "Terms".

   Glossary is deliberately absent. It stays in PUBLIC_ROUTES so existing links
   resolve, but it is unlinked until the 50-term verification run happens
   (decision D5, 2026-07-10) — the same held-but-reachable treatment as Tier 3. */
/* An entry carries EITHER a `route` (internal, renders a button that calls
   setRoute) or an `href` (external, renders an anchor). Keeping LinkedIn in
   this list rather than positioning it by hand means the order is readable
   here instead of buried in the markup — it sits beside Newsletter because
   they are the two ways to keep hearing from us, as opposed to pages you go
   and read. */
const FOOTER_NAV_LINKS = [
  { label: 'Open Letter', route: 'open-letter' },
  { label: 'Join Us', route: 'join' },
  { label: 'Newsletter', route: 'newsletter' },
  { label: 'LinkedIn', href: 'https://www.linkedin.com/company/behavioralai-institute/' },
  { label: 'YouTube', href: 'https://www.youtube.com/channel/UCRbtuwRHTw_DfVfCGkITbgw' },
  { label: 'Contact', route: 'contact' },
];

const FOOTER_LEGAL_LINKS = [
  { label: 'Privacy', route: 'privacy' },
  { label: 'Terms', route: 'terms' },
  { label: 'Your Data', route: 'data-rights' },
];

const FOOTER_PHRASES = [
  { left: 'behavior', right: 'AI' },
  { left: 'humans', right: 'science' },
  { left: 'psychology', right: 'technology' },
  { left: 'insight', right: 'impact' },
  { left: 'understanding', right: 'intelligence' },
];

function RotatingTagline({ visible }) {
  const [phraseIndex, setPhraseIndex] = React.useState(0);
  const [displayLeft, setDisplayLeft] = React.useState('');
  const [displayRight, setDisplayRight] = React.useState('');
  const [phase, setPhase] = React.useState('idle');
  const [wordOpacity, setWordOpacity] = React.useState(1);
  const reduced = React.useRef(window.matchMedia('(prefers-reduced-motion: reduce)').matches);
  const cleanupRef = React.useRef([]);

  const clearAllTimers = () => {
    cleanupRef.current.forEach(id => clearInterval(id));
    cleanupRef.current = [];
  };

  React.useEffect(() => {
    if (!visible) return;
    if (reduced.current) {
      setDisplayLeft(FOOTER_PHRASES[0].left);
      setDisplayRight(FOOTER_PHRASES[0].right);
      return;
    }

    clearAllTimers();
    setPhase('typing');
    setWordOpacity(1);
    const phrase = FOOTER_PHRASES[phraseIndex];
    let i = 0;

    const typeLeft = setInterval(() => {
      i++;
      setDisplayLeft(phrase.left.slice(0, i));
      if (i >= phrase.left.length) {
        clearInterval(typeLeft);
        let j = 0;
        const typeRight = setInterval(() => {
          j++;
          setDisplayRight(phrase.right.slice(0, j));
          if (j >= phrase.right.length) {
            clearInterval(typeRight);
            setPhase('holding');
          }
        }, 75);
        cleanupRef.current.push(typeRight);
      }
    }, 75);
    cleanupRef.current.push(typeLeft);

    return clearAllTimers;
  }, [visible, phraseIndex]);

  React.useEffect(() => {
    if (phase !== 'holding') return;
    const holdTimer = setTimeout(() => {
      setWordOpacity(0);
      setTimeout(() => {
        setDisplayLeft('');
        setDisplayRight('');
        setPhraseIndex((phraseIndex + 1) % FOOTER_PHRASES.length);
      }, 400);
    }, 4000);

    return () => clearTimeout(holdTimer);
  }, [phase, phraseIndex]);

  const wordStyle = {
    borderBottom: '1px solid rgba(255,255,255,0.25)',
    paddingBottom: 2,
    opacity: wordOpacity,
    transition: 'opacity 400ms ease',
  };

  return (
    <span>
      Where <span style={wordStyle}>{displayLeft || ' '}</span>
      {' meets '}
      <span style={wordStyle}>{displayRight || ' '}</span>.
    </span>
  );
}

function FooterStrip({ setRoute }) {
  const ref = React.useRef(null);
  const [visible, setVisible] = React.useState(false);

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setVisible(true);
      return;
    }
    const observer = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting) { setVisible(true); observer.disconnect(); } },
      { threshold: 0.2 }
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return (
    <footer ref={ref} style={{
      marginTop: 8, padding: 0,
      background: 'var(--baii-blue)',
      borderRadius: 8,
      overflow: 'hidden',
      position: 'relative',
    }}>
      <svg viewBox="0 0 600 200" preserveAspectRatio="none" style={{
        position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.08,
      }}>
        {Array.from({ length: 20 }, (_, i) => (
          <line key={`h${i}`} x1="0" y1={i * 10} x2="600" y2={i * 10} stroke="#fff" strokeWidth="0.5"/>
        ))}
        {Array.from({ length: 30 }, (_, i) => (
          <line key={`v${i}`} x1={i * 20} y1="0" x2={i * 20} y2="200" stroke="#fff" strokeWidth="0.5"/>
        ))}
      </svg>

      <div className="footer-pad" style={{ position: 'relative', padding: '56px 56px 40px' }}>
        <div className={`footer-main footer-logo ${visible ? 'footer-logo--visible' : ''}`} style={{
          display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 32, marginBottom: 48,
        }}>
          {/* The logo and the CTA are equal-width columns (flex: 1 1 0) so the
              tagline between them is centred on the FOOTER, not on whatever
              space the two of them happen to leave. Before this, the row was
              space-between with a 56px logo on one side and a 171px button on
              the other, so the middle column's centre sat (171-56)/2 = 57px
              left of true centre — measured at exactly -57px on every width.
              Do not give either side a width that depends on its contents. */}
          <div className="footer-brand" style={{ flex: '1 1 0', display: 'flex', justifyContent: 'flex-start', minWidth: 0 }}>
            <img src="assets/logos/svg/logomark-white.svg" alt="BAII" style={{ height: 56, flexShrink: 0 }} />
          </div>
          <p className="footer-tagline-text" style={{
            fontFamily: 'var(--font-display)', fontStyle: 'italic',
            fontSize: 22, lineHeight: 1.3, color: 'rgba(255,255,255,0.85)',
            margin: 0, textAlign: 'center', letterSpacing: '-0.01em',
            flex: '0 1 auto',
          }}>
            <RotatingTagline visible={visible} />
          </p>
          {/* LinkedIn used to sit here beside the button. It read as a second
              CTA competing with the primary one, and it crowded the right edge
              of a row whose job is logo / tagline / one action. It now lives in
              the engagement row below, next to Newsletter. */}
          <div className="footer-cta" style={{
            display: 'flex', alignItems: 'center', gap: 24,
            flex: '1 1 0', justifyContent: 'flex-end', minWidth: 0,
          }}>
            <a href="mailto:hello@behavioralai-institute.org" className="btn btn--on-blue">
              Get in touch
              <Arrow />
            </a>
          </div>
        </div>

        <div className={`footer-tagline ${visible ? 'footer-tagline--visible' : ''}`}>
          <div style={{
            height: 1, background: 'rgba(255,255,255,0.15)', marginBottom: 24,
          }} />
          {/* Centred rather than left-aligned: with the logo hard left and the
              button hard right in the row above, a left-aligned strip here sat
              under the logo and left the footer weighted to one side. */}
          <div className="footer-nav" style={{
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            fontFamily: 'var(--font-mono)', fontSize: 12,
            letterSpacing: '0.1em', textTransform: 'uppercase',
            flexWrap: 'wrap', gap: 16, marginBottom: 28,
          }}>
            <div className="footer-links" style={{ display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: 28 }}>
              {FOOTER_NAV_LINKS.map(link => (
                link.href ? (
                  <a
                    key={link.label}
                    href={link.href}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="footer-link"
                    style={{ color: 'rgba(255,255,255,0.85)', textDecoration: 'none', whiteSpace: 'nowrap' }}
                  >{link.label}</a>
                ) : (
                  <button
                    key={link.route}
                    type="button"
                    className="footer-link btn-reset"
                    onClick={() => setRoute(link.route)}
                    style={{ color: 'rgba(255,255,255,0.85)' }}
                  >{link.label}</button>
                )
              ))}
            </div>
          </div>

          <div className="footer-bottom" style={{
            display: 'flex', justifyContent: 'space-between', alignItems: 'center',
            fontFamily: 'var(--font-mono)', fontSize: 11, color: 'rgba(255,255,255,0.45)',
            letterSpacing: '0.1em', textTransform: 'uppercase',
            flexWrap: 'wrap', gap: 16,
          }}>
            <span>© 2026 · Behavioral AI Institute</span>
            <div className="footer-links" style={{ display: 'flex', gap: 20 }}>
              {FOOTER_LEGAL_LINKS.map(link => (
                <button
                  key={link.route}
                  type="button"
                  className="footer-link btn-reset"
                  onClick={() => setRoute(link.route)}
                  style={{ color: 'rgba(255,255,255,0.55)' }}
                >{link.label}</button>
              ))}
            </div>
          </div>
        </div>
      </div>
    </footer>
  );
}


/* ============================================================
   Sub-page data
   ============================================================ */
const WORK_AREAS = [
  { n: '01', title: 'Measuring Behavioral Impact',
    body: 'Most measurement stops at adoption and usage. We build evaluation frameworks for what AI does to people: decision quality, cognitive offloading, and trust.' },
  { n: '02', title: 'Emotions, Cognition & AI',
    body: 'AI is not cognitively neutral. We examine how it changes how people think, feel, and react, including the slow erosion of skills we stop practicing.' },
  { n: '03', title: 'Human-AI Collaboration',
    body: 'Under what conditions does human-AI partnership break down? We study trust, accountability, and shared decision-making in practice.',
    subTopic: { label: 'Delegation & Verification', note: 'How people decide what to delegate to AI, and how they verify its output. The subject of our current white paper.' } },
  { n: '04', title: 'AI Product Design',
    body: 'Every design choice in an AI product is a behavioral intervention. We study how systems, prompts, deployment choices, and ways of working change what AI does to people.' },
];

const COMMUNITY_AUDIENCES = [
  { title: 'Researchers & Academics',
    body: 'Collaborate on studies, contribute to working groups, or bring your existing research into our network.' },
  { title: 'Practitioners & Industry',
    body: "If you're navigating real-world AI deployment, we will bring the right frameworks to support your challenges." },
  { title: 'Institutional Partners',
    body: 'Commission research, co-design studies, or partner on work that shapes how AI is evaluated and deployed.' },
];

const GUIDING_PRINCIPLES = [
  { n: '01', title: 'Human understanding first',
    body: 'Every AI system that touches human life should be judged by what it does to how people actually think, feel, and decide.',
    backBody: 'AI systems are designed by engineers and optimized by algorithms, but used by humans. Without grounding in cognitive science, behavioral economics, and psychology, even well-intentioned systems can erode trust, amplify bias, or quietly reshape how people think and choose.',
    image: 'assets/illustrations/hero/brain-section.webp' },
  { n: '02', title: 'Evidence over intuition',
    body: 'We ground every recommendation in empirical research, not assumptions about what people want or need.',
    backBody: 'Intuitions about human behavior are often wrong, even expert intuitions. We run studies, analyze data, and publish findings before making recommendations. When the evidence is unclear, we say so. This discipline is what separates science from opinion.',
    image: 'assets/library/illustration-book.webp' },
  { n: '03', title: 'Open by default',
    body: 'Our work belongs to the field. We publish openly, share methods freely, and welcome scrutiny.',
    backBody: 'This work is too important to be proprietary. We publish our research openly, share our methods, and actively invite challenge and replication. The field grows faster when knowledge circulates.',
    image: 'assets/library/illustration-molecular-network.webp' },
];

/* Segmented by SITUATION, not sector. The previous cut was by organisation type
   — AI labs, governments, "non-tech companies" — which put two audiences on the
   page that this offer does not actually serve (frontier labs run their own
   behavioral research; public bodies do not buy through a design-partner model),
   and left the one that fits defined by a negative. A visitor recognises the
   moment they are in faster than they place themselves in a sector. */
const WORK_AUDIENCES = [
  { n: '01', title: 'Before you deploy AI',
    body: "You're about to put AI in front of thousands of people. Measure first and you will know what actually changed — not what everyone assumes changed." },
  { n: '02', title: "After you've deployed AI",
    body: 'Usage is climbing and the dashboards look healthy. What they do not show is what is happening to judgment, trust, and the quality of the work underneath.' },
  /* Cards 01 and 02 are both "[time] you deploy AI". This one is a different
     AUDIENCE rather than a different moment — they do not deploy, they build what
     the other two deploy — so it opens with "if" instead of a time word. */
  { n: '03', title: 'If you build the AI',
    body: 'Your product sits between people and the decisions they make. We measure its effects in real deployments, so you can show what it does — the risks and the benefits.' },
];

// Offering name provisional until the Jul 23 All Hands — keep the label swappable.
const WORK_SERVICES = [
  { n: '01', title: 'A Behavioral Impact Audit',
    body: 'For organisations: we measure what your AI is doing to your people and hand back evidence and recommendations. Measured through questionnaires and AI-led interviews, building on validated scales.',
    backBody: 'What you get: evidence of what your AI is doing to your people, the risks and the benefits, and recommendations you can act on. The exact shape and cadence of an engagement is agreed with each design partner.' },
  { n: '02', title: 'How an engagement runs',
    body: 'We start with a small number of design partners: a focused engagement, then ongoing re-evaluation. Effects build over time, so re-measuring is the model.',
    backBody: 'Baseline: measure what AI is doing to people today. Intervene: change the system, the use, or the guardrails. Re-measure: show what changed, with evidence. Case study: anonymised findings that inform the field.' },
  { n: '03', title: 'For the wider community',
    body: 'We publish what we learn. Free charter membership brings early access to our research, shared findings and tools, a peer network, and invitations to our webinars and events.',
    backBody: 'What you get: research before it goes public, shared findings and tools, a peer network, and webinar and event invitations. Free to start.' },
];

const WORK_CREDENTIALS = [
  { title: 'We sit at the intersection.',
    body: "Our founders and fellows hold active positions across behavioral science, AI development, digital health, and clinical research. This isn't applied consultancy built on top of a research brand — the research is the work." },
  { title: 'We are independent.',
    body: 'We take no money from frontier AI labs and have no commercial stake in the systems we evaluate. That independence is what makes our evidence meaningful to the organisations we work with, to regulators, and to the public.' },
  // {count} and {phrase} are filled from the live signatory count in
  // CredentialsSection. Leave them as tokens — a literal number here would
  // go stale the next time somebody signs.
  { title: '{count} field leaders already agree.',
    body: 'Our open letter has been co-signed by {phrase} cognitive and behavioral scientists, practitioners, and AI researchers worldwide, and published in Wellcome Open Research.',
    badge: true },
];

const ADOPTION_PILLARS = [
  { n: '01', title: 'Diagnose adoption readiness',
    body: 'Most AI rollouts fail not because of technology but because of behavior. We assess where your people actually are — their mental models, their friction points, their readiness to change.' },
  { n: '02', title: 'Design for real behavior change',
    body: 'Effective adoption is not a training problem. It is a design problem. We show you how to structure environments, workflows, and incentives that make AI use the path of least resistance.' },
  { n: '03', title: 'Scale without losing people',
    body: 'Scaling AI adoption means scaling behavior change. We help you build internal capability to sustain adoption across teams, geographies, and evolving tools — without burning trust.' },
];

const TRAINING_COURSES = [
  { n: '01', title: 'Behavioral AI Foundations',
    format: 'Online course · 6 weeks',
    body: 'A structured introduction to how AI systems affect human cognition, decision-making, and behavior. Designed for product managers, designers, and engineers who build AI-powered products.',
    audience: 'Product & Engineering teams',
    status: 'Enrolling now' },
  { n: '02', title: 'AI Adoption Masterclass',
    format: 'Workshop · 3 days',
    body: 'Our flagship program on driving AI adoption through behavioral science. Covers resistance mapping, friction audits, habit loop design, and measuring real adoption vs. surface-level usage.',
    audience: 'Change & Transformation leads',
    status: 'Next cohort: Sept 2026' },
  { n: '03', title: 'AI Evaluation for Behavioral Impact',
    format: 'Workshop · 2 days',
    body: 'Learn to design and run behavioral evaluations of AI systems. Covers study design, metrics selection, cognitive load assessment, and translating findings into product decisions.',
    audience: 'Researchers & UX teams',
    status: 'Next cohort: Oct 2026' },
  { n: '04', title: 'Executive Briefing: AI & Human Factors',
    format: 'Briefing · Half day',
    body: 'What leadership teams need to know about AI adoption barriers, behavioral risks, and the evidence gaps that turn promising deployments into expensive shelf-ware.',
    audience: 'C-suite & Board members',
    status: 'Available on request' },
];

const TRAINING_CREDENTIALS = [
  { n: '01', title: 'Proprietary research',
    body: 'Our curriculum is built on original behavioral science research into AI adoption, not recycled best-practice lists. We study what actually changes behavior — and teach from those findings.' },
  { n: '02', title: 'Battle-tested frameworks',
    body: 'Every framework we teach has been applied with real organizations navigating real AI deployments. From friction audits to adoption readiness diagnostics — these tools work in practice.' },
  { n: '03', title: 'Behavioral scientists who build',
    body: 'Our instructors are active researchers and consultants. They publish papers, advise AI labs, and work with product teams daily. No career trainers — only practitioners.' },
  { n: '04', title: 'Measurable outcomes',
    body: 'We don\'t just train and leave. Every program includes adoption metrics, baseline assessments, and follow-up measurement so you can prove the investment worked.' },
];

/* ---------- Events Page Data ----------

   Rule for this block: nothing goes in that cannot be pointed at. A date, a
   venue, an attendee count, a speaker or a registration link is either
   verified or absent — never estimated, never rounded up, never inferred
   from a plan. Three summits, a masterclass, a roundtable and two past
   sessions were removed from here because none of them existed.

   `placeholder: true` is the honest state for a session that is being
   planned but has no date, no format and no way to sign up yet. It is not
   a styling flag — it changes what the card is allowed to claim. A
   placeholder row carries a month, a subject, and the word 'Planned'. It
   does not carry a day, a venue, a duration, or a button.                  */
const UPCOMING_EVENTS = [
  { placeholder: true, title: 'A session with people from the Institute',
    month: 'SEP', year: '2026', date: 'September 2026',
    description: 'A session featuring people from the Institute. Subject, date and format still to be confirmed.' },
  { placeholder: true, title: 'Designing with AI',
    month: 'OCT', year: '2026', date: 'October 2026',
    description: 'A session on what it takes to design with AI. Subject, date and format still to be confirmed.' },
  { placeholder: true, title: 'To be announced',
    month: 'NOV', year: '2026', date: 'November 2026',
    description: 'A third session is being planned. We will announce it here once it is set.' },
];

/* One entry. It is the January 2026 inaugural webinar, and every field in it
   is corroborated: the Luma listing (luma.com/35j8i2f4, 15 January, hosted by
   Luana de Mattos Gabriel and Samuel Salzer) and a Zoom clip that plays
   without a login. The 670 is the registered attendance, not an estimate.

   Removed: 'Behavioral AI Horizon 2025' (Oct 2025, London, 200 attendees) —
   no such event on the BAII Luma calendar, and its existence would have made
   the January webinar something other than inaugural. Also removed, for the
   same reason: an August 2025 workshop and a July 2025 open-letter launch.
   Both carried a 'Watch recording' line with no recording behind it.        */
const PAST_EVENTS = [
  { title: 'Behavioral AI Horizon — Inaugural Webinar', date: 'Jan 2026', location: 'Online', duration: '1.5h recording', attendees: 670, href: 'https://us06web.zoom.us/clips/share/aERC1F0kSLSDkCwIpLJ-yg', thumbnail: 'assets/illustrations/globe-compass.webp' },
];
const PODCAST_EPISODES = [
  { title: 'Robert Sapolsky: From Slime Mold to Superintelligence', host: 'Antoine Ferrère', duration: '57:56', url: 'https://www.youtube.com/watch?v=2FI8b0xAFxw' },
];
const COMMUNITY_LINKS = [
  { platform: 'Luma', body: 'RSVP for events, get calendar reminders, and never miss a session.', cta: 'Follow on Luma →', href: 'https://luma.com/behavioralai' },
  { platform: 'YouTube', body: 'Recordings, keynotes, panel discussions, and podcast episodes.', cta: 'Subscribe →', href: 'https://www.youtube.com/@behavioralaiinstitute' },
  { platform: 'LinkedIn', body: 'Updates, discussions, and community posts.', cta: 'Follow us →', href: 'https://www.linkedin.com/company/behavioralai-institute/' },
];

/* ---------- Open Letter signatories — live, from the database ----------

   This list used to be a hardcoded array of 30 names and the count was the
   string '500+'. Both were wrong the moment anyone signed: the database held
   533 approved signatories and a newly approved signature never appeared.

   Now there is exactly one fetch per page load, shared by every component
   that shows a name or a count, so all of them move together and none of
   them can drift again.

   Source: the `open_letter_signatures_public` view — approved rows only,
   and no email column. Never read the base table from the browser.

   Ordering: `display_order` restarts at 1 within each tier (1..59 for
   featured, 1..474 for the rest), so ordering by it alone would interleave
   the two. `featured.desc` has to come first.                              */

const SIGNATORY_QUERY =
  'select=id,name,title,affiliation,featured,display_order' +
  '&order=featured.desc,display_order.asc,name.asc';

// Shown until the real number arrives, and if it never does. It is a true
// statement about 533 rather than an invented number, so a failed fetch
// understates the count instead of fabricating one.
const SIGNATORY_COUNT_FALLBACK = '500+';
const SIGNATORY_PHRASE_FALLBACK = 'more than 500';

const signatoryStore = (() => {
  let state = { status: 'loading', rows: [], total: null, error: null };
  const subscribers = new Set();
  let started = false;

  const set = (next) => {
    state = next;
    subscribers.forEach(fn => fn(state));
  };

  function load() {
    const F = window.BAIIForms;
    if (!F || typeof F.fetchRows !== 'function') {
      set({ status: 'error', rows: [], total: null, error: 'forms.js did not load' });
      return;
    }
    if (state.status !== 'loading') set({ status: 'loading', rows: [], total: null, error: null });
    F.fetchRows(F.VIEWS.SIGNATURES_PUBLIC, SIGNATORY_QUERY)
      .then(({ rows, total, truncated }) => {
        set({ status: 'ready', rows, total, truncated, error: null });
      })
      .catch((err) => {
        console.error('[signatories] could not load the signatory list', err);
        set({ status: 'error', rows: [], total: null, error: String((err && err.message) || err) });
      });
  }

  return {
    get: () => state,
    // Retry after a failure. Only reachable from the button in the error state.
    reload: () => { started = true; load(); },
    subscribe(fn) {
      subscribers.add(fn);
      if (!started) { started = true; load(); }
      return () => { subscribers.delete(fn); };
    },
  };
})();

function useSignatories() {
  const [state, setState] = React.useState(signatoryStore.get());
  React.useEffect(() => {
    setState(signatoryStore.get());   // may have resolved before this mounted
    return signatoryStore.subscribe(setState);
  }, []);
  return state;
}

/* ------------------------------------------------------------------
 * Affiliation handling.
 *
 * The imported 2025 rows carry whatever people typed. 39 of them are not
 * organisations: 'freelance', 'self employed', 'na', '-', blanks. Those are
 * open as 'Independent' or not at all, and never counted as organisations.
 *
 * orgKey() must stay in step with the maths that produced the published
 * figures — same lowercasing, same legal-suffix stripping, same alias map.
 * If it drifts, the page starts claiming a different number of organisations
 * than we have ever verified.
 * ------------------------------------------------------------------ */
const AFF_BLANK = /^(na|n\/a|-|—|none)$/i;
const AFF_INDEPENDENT = /^(freelance|freelancer|independent|independant|independent consultant|self|self employed|self-employed|private|retired|me|myself)$/i;

// Conservative: acronym <-> full name, and trivial legal/punctuation variants.
// Deliberately NOT merged: 'university of nottingham malaysia' (a different
// campus) and career-history strings like 'former novo nordisk, novartis, …'.
const ORG_ALIAS = {
  'ucl': 'university college london',
  'university college, london': 'university college london',
  'lse': 'london school of economics and political science',
  'london school of economics and political science (lse)': 'london school of economics and political science',
  'imperial': 'imperial college london',
  'imperial college and nhs trust': 'imperial college london',
  'abn amro bank': 'abn amro',
  'behamics ag': 'behamics',
  'influence at work / columbia business school': 'influence at work',
  'bad / bestatdigital': 'bestatdigital',
};

const tidy = (v) => String(v || '').replace(/\s+/g, ' ').replace(/^[\s.,]+|[\s.,]+$/g, '');

// What the reader sees next to a name. null = print the name bare.
function affiliationLabel(aff) {
  const raw = tidy(aff);
  if (!raw || AFF_BLANK.test(raw)) return null;
  if (AFF_INDEPENDENT.test(raw)) return 'Independent';
  return raw;
}

// What we count. null = not an organisation.
function orgKey(aff) {
  const raw = tidy(aff);
  if (!raw || AFF_BLANK.test(raw) || AFF_INDEPENDENT.test(raw)) return null;
  const k = raw.toLowerCase().replace(/&/g, 'and').replace(/\s+/g, ' ')
    .replace(/\b(the|inc|ltd|llc|gmbh|plc|co|corp|limited)\b/g, '')
    .replace(/^[\s.,]+|[\s.,]+$/g, '');
  return ORG_ALIAS[k] || k;
}

// { orgs, solo } — distinct organisations, and how many sent exactly one person.
function orgStats(rows) {
  const counts = new Map();
  (rows || []).forEach((r) => {
    const k = orgKey(r.affiliation);
    if (k) counts.set(k, (counts.get(k) || 0) + 1);
  });
  let solo = 0;
  counts.forEach((v) => { if (v === 1) solo += 1; });
  return { orgs: counts.size, solo };
}

/* Fires once, when the element first scrolls into view. */
function useInView(ref, threshold = 0.4) {
  const [inView, setInView] = React.useState(false);
  React.useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver !== 'function') { setInView(true); return; }
    const io = new IntersectionObserver((entries, obs) => {
      entries.forEach((en) => { if (en.isIntersecting) { setInView(true); obs.disconnect(); } });
    }, { threshold });
    io.observe(el);
    return () => io.disconnect();
  }, [ref, threshold]);
  return inView;
}

/* Counts 0 -> value on an ease-out once `active`. Returns null while the
   number is unknown, so the caller can hold a fallback rather than show 0.
   Skipped entirely under prefers-reduced-motion. */
function useCountUp(value, active, duration = 2200) {
  const [n, setN] = React.useState(0);
  React.useEffect(() => {
    if (!active || value == null) return;
    const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (reduced) { setN(value); return; }
    let raf = null;
    const t0 = (window.performance || Date).now();
    const tick = (now) => {
      const t = Math.min(1, (now - t0) / duration);
      setN(Math.round((1 - Math.pow(1 - t, 3)) * value));
      if (t < 1) raf = requestAnimationFrame(tick); else setN(value);
    };
    raf = requestAnimationFrame(tick);
    return () => { if (raf) cancelAnimationFrame(raf); };
  }, [value, active, duration]);
  return value == null ? null : n;
}

// '533' once loaded, '500+' before that and if the fetch fails.
const signatoryCount = (sig) => (sig.status === 'ready' ? String(sig.total) : SIGNATORY_COUNT_FALLBACK);
// For sentences reading "co-signed by ___ scientists".
const signatoryPhrase = (sig) => (sig.status === 'ready' ? String(sig.total) : SIGNATORY_PHRASE_FALLBACK);

/* Empty until someone actually writes something. See INSIGHTS_LIVE above for
   why, and for how to turn the section back on. A restored post needs
   `featured: true` — BlogPage leads with the featured entry and renders
   nothing without one. */
const BLOG_POSTS = [];


/* ============================================================
   Sub-page shared components
   ============================================================ */
function SubPageNav({ route, setRoute, light }) {
  return (
    <div className="subpage-nav-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 48 }}>
      {/* The logo was a bare <img onClick>: clickable, never focusable, and
          with no role it was not announced as a control at all. */}
      <button type="button" className="btn-reset" aria-label="Behavioral AI Institute — home" onClick={() => setRoute('home')} style={{ display: 'flex' }}>
        <img src={light ? "assets/logos/svg/wordmark-horizontal-white.svg" : "assets/logos/svg/wordmark-horizontal-black.svg"} alt="" style={{ height: 32 }}/>
      </button>
      <MainNav route={route} setRoute={setRoute} />
    </div>
  );
}

function PillarIcon({ type }) {
  const shared = { width: 32, height: 32, viewBox: '0 0 32 32', fill: 'none', stroke: 'var(--baii-blue)', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round' };
  if (type === 'research') return (<svg {...shared}><circle cx="14" cy="14" r="8"/><line x1="19.5" y1="19.5" x2="27" y2="27"/><circle cx="14" cy="14" r="3" strokeDasharray="2 2"/></svg>);
  if (type === 'community') return (<svg {...shared}><circle cx="16" cy="10" r="4"/><circle cx="7" cy="20" r="3"/><circle cx="25" cy="20" r="3"/><path d="M10 20 L16 14 L22 20"/></svg>);
  return (<svg {...shared}><rect x="4" y="8" width="24" height="18" rx="2"/><line x1="4" y1="14" x2="28" y2="14"/><circle cx="16" cy="22" r="2"/></svg>);
}

function SignatoriesBadge({ count }) {
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', gap: 12, background: '#fff', color: 'var(--fg1)', padding: '10px 16px 10px 12px', borderRadius: 999, border: '1px solid var(--baii-blue)' }}>
      <span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--baii-blue)', display: 'inline-block' }}/>
      <span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 500, color: 'var(--baii-blue)' }}>{count}</span>
      <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--fg2)' }}>Signatories →</span>
    </div>
  );
}



/* ============================================================
   RESEARCH PAGE
   ============================================================ */
function ResearchConstellation() {
  const [hoveredNode, setHoveredNode] = React.useState(-1);
  const [time, setTime] = React.useState(0);
  React.useEffect(() => {
    let frame;
    const tick = () => { setTime(t => t + 0.008); frame = requestAnimationFrame(tick); };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, []);

  const baseNodes = [
    { x: 260, y: 180, r: 48, src: 'assets/illustrations/hero/brain.webp', label: 'Cognition' },
    { x: 460, y: 280, r: 42, src: 'assets/illustrations/neural-network.webp', label: 'AI Systems' },
    { x: 120, y: 370, r: 44, src: 'assets/library/illustration-eye.webp', label: 'Perception' },
    { x: 400, y: 450, r: 38, src: 'assets/library/illustration-dna-helix.webp', label: 'Evidence' },
    { x: 80, y: 210, r: 34, src: 'assets/library/illustration-hand-robotic.webp', label: 'Human-AI Collaboration' },
    { x: 500, y: 130, r: 30, src: 'assets/library/illustration-telescope.webp', label: 'Foresight' },
  ];

  const nodes = baseNodes.map((n, i) => ({
    ...n,
    x: n.x + Math.sin(time + i * 1.8) * 15,
    y: n.y + Math.cos(time * 0.7 + i * 2.1) * 10,
    r: n.r + Math.sin(time * 1.2 + i * 1.4) * 4,
  }));

  const dots = [
    { x: 60, y: 420 }, { x: 200, y: 120 }, { x: 520, y: 360 },
    { x: 320, y: 320 }, { x: 150, y: 490 }, { x: 480, y: 490 },
    { x: 300, y: 480 }, { x: 40, y: 300 }, { x: 540, y: 220 },
    { x: 380, y: 150 }, { x: 180, y: 250 }, { x: 440, y: 400 },
  ];
  const edges = [
    [0, 1], [0, 2], [0, 4], [1, 3], [1, 5], [2, 3], [3, 4], [2, 5],
  ];
  return (
    <div className="research-constellation-wrap" style={{ position: 'relative', width: '100%', height: '100%', minHeight: 520 }}>
      <svg viewBox="0 0 580 520" style={{ width: '100%', height: '100%', position: 'absolute', inset: 0 }}>
        {edges.map(([a, b], i) => (
          <line key={i} x1={nodes[a].x} y1={nodes[a].y} x2={nodes[b].x} y2={nodes[b].y}
            stroke="var(--baii-blue)" strokeWidth={hoveredNode === a || hoveredNode === b ? 1.4 : 0.7}
            opacity={hoveredNode === a || hoveredNode === b ? 0.5 : 0.2}/>
        ))}
        {dots.map((d, i) => {
          const dx = d.x + Math.sin(time * 0.5 + i * 2.3) * 6;
          const dy = d.y + Math.cos(time * 0.4 + i * 1.7) * 5;
          return <circle key={i} cx={dx} cy={dy} r={2.5 + Math.sin(time + i) * 1} fill="var(--baii-blue)" opacity={0.12 + Math.sin(time * 0.8 + i * 1.1) * 0.1}/>;
        })}
        {edges.map(([a, b], i) => {
          const midX = (nodes[a].x + nodes[b].x) / 2;
          const midY = (nodes[a].y + nodes[b].y) / 2;
          return <circle key={`mid-${i}`} cx={midX} cy={midY} r={2} fill="var(--baii-blue)" opacity={0.2}/>;
        })}
      </svg>
      {nodes.map((n, i) => (
        <div key={i}
          onMouseEnter={() => setHoveredNode(i)}
          onMouseLeave={() => setHoveredNode(-1)}
          style={{
            position: 'absolute',
            left: `${(n.x / 580) * 100}%`,
            top: `${(n.y / 520) * 100}%`,
            transform: `translate(-50%, -50%) scale(${hoveredNode === i ? 1.15 : 1})`,
            transition: 'transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease',
            width: n.r * 2, height: n.r * 2, borderRadius: '50%',
            overflow: 'hidden',
            border: hoveredNode === i ? '2px solid var(--baii-blue)' : '1.5px solid rgba(2, 99, 255, 0.25)',
            boxShadow: hoveredNode === i ? '0 4px 20px rgba(2, 99, 255, 0.2)' : '0 2px 8px rgba(0,0,0,0.06)',
            cursor: 'default',
          }}>
          <img src={n.src} alt="" style={{
            width: '100%', height: '100%', objectFit: 'cover',
            filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)',
          }}/>
        </div>
      ))}
      {nodes.map((n, i) => hoveredNode === i && (
        <div key={`label-${i}`} style={{
          position: 'absolute',
          left: `${(n.x / 500) * 100}%`,
          top: `${((n.y + n.r + 16) / 520) * 100}%`,
          transform: 'translateX(-50%)',
          background: 'var(--baii-blue)',
          color: '#fff',
          padding: '5px 10px',
          borderRadius: 4,
          fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 500,
          letterSpacing: '0.1em', textTransform: 'uppercase',
          whiteSpace: 'nowrap',
          pointerEvents: 'none',
          boxShadow: '0 2px 8px rgba(2, 99, 255, 0.25)',
        }}>{n.label}</div>
      ))}
    </div>
  );
}

function ResearchHeroCard({ route, setRoute }) {
  return (
    <section className="surface" style={{ overflow: 'hidden' }}>
      <div className="subpage-hero-pad" style={{ padding: '32px 56px 80px' }}>
        <SubPageNav route={route} setRoute={setRoute} />
        <div className="grid-2col research-hero-grid" style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.2fr)', gap: 48, alignItems: 'center', minHeight: 540 }}>
          <ScrollReveal still>
          <div style={{ paddingTop: 40 }}>
            <div className="section-index" style={{ marginBottom: 24 }}>
              <span className="section-index__num">00</span>
              <span className="section-index__rule"></span>
              <TypewriterLabel text="Research" delay={200} />
            </div>
            <h1 className="display-lg" style={{ marginBottom: 24, maxWidth: '20ch' }}>What happens when AI meets human behavior?</h1>
            <div className="research-hero-mobile-img" style={{ display: 'none', marginBottom: 24, position: 'relative', width: 260, height: 180 }}>
              <div style={{ position: 'absolute', left: 0, top: 16, width: 120, height: 140, borderRadius: 8, overflow: 'hidden', border: '1px solid var(--baii-bone)', boxShadow: '0 4px 16px rgba(0,0,0,0.08)', zIndex: 1, transform: 'rotate(-3deg)' }}>
                <img src="assets/library/illustration-retro-computer.webp" alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)' }} />
              </div>
              <div style={{ position: 'absolute', left: 70, top: 0, width: 120, height: 140, borderRadius: 8, overflow: 'hidden', border: '1px solid var(--baii-bone)', boxShadow: '0 4px 16px rgba(0,0,0,0.08)', zIndex: 2, transform: 'rotate(1deg)' }}>
                <img src="assets/illustrations/neural-network.webp" alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)' }} />
              </div>
              <div style={{ position: 'absolute', left: 140, top: 20, width: 120, height: 140, borderRadius: 8, overflow: 'hidden', border: '1px solid var(--baii-bone)', boxShadow: '0 4px 16px rgba(0,0,0,0.08)', zIndex: 3, transform: 'rotate(3deg)' }}>
                <img src="assets/library/illustration-brain-exterior.webp" alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)' }} />
              </div>
            </div>
            <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '48ch', marginBottom: 32 }}>
              We measure the behavioral impact of AI on people: how it shapes decisions, shifts cognition, and erodes or builds trust.
            </p>
            <div className="btn-group-mobile" style={{ display: 'flex', gap: 12 }}>
              <button type="button" className="btn btn--primary" onClick={() => document.getElementById('publications')?.scrollIntoView({ behavior: 'smooth' })}>Our publications <ArrowDown /></button>
              <button type="button" className="btn btn--hollow" onClick={() => document.getElementById('areas')?.scrollIntoView({ behavior: 'smooth' })}>Work areas <ArrowDown /></button>
            </div>
          </div>
          </ScrollReveal>
          <ScrollReveal still delay={200}>
          <ResearchConstellation />
          </ScrollReveal>
        </div>
      </div>
    </section>
  );
}

function ResearchPublicationsCard() {
  const [hovered, setHovered] = React.useState(false);
  const sig = useSignatories();
  const ALL_PUBS = [
    { kind: 'Open Letter', title: 'The Missing Discipline in AI', meta: `${signatoryCount(sig)} signatories · 2026`, active: true, url: 'https://wellcomeopenresearch.org/articles/11-152' },
    { kind: 'Paper', title: 'Psychological Competence as a Missing Dimension in AI Evaluation', meta: 'arXiv · cs.AI · 2026', active: true, url: 'https://arxiv.org/abs/2607.08285' },
  ];
  return (
    <ScrollReveal still>
    <section className="surface" id="publications">
      <div className="surface__inner">
        <div className="grid-2col publications-hero-grid" style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.4fr)', gap: 48, alignItems: 'center' }}>
          <ScrollReveal>
          <div className="publications-paper-col" style={{ perspective: 600 }}>
            <div
              onMouseEnter={() => setHovered(true)}
              onMouseLeave={() => setHovered(false)}
              style={{ position: 'relative', maxWidth: 320, margin: '0 auto' }}>
              <div style={{ position: 'absolute', inset: 0, background: '#e8eaf0', borderRadius: 4, transform: 'rotate(2.5deg) translate(8px, 8px)', zIndex: 0 }}/>
              <div style={{ position: 'absolute', inset: 0, background: '#eff0f5', borderRadius: 4, transform: 'rotate(1.2deg) translate(4px, 4px)', zIndex: 1 }}/>
              <div style={{
                position: 'relative', zIndex: 2, borderRadius: 4, overflow: 'hidden',
                border: '1px solid var(--baii-bone)',
                boxShadow: hovered ? '0 24px 48px rgba(0,0,0,0.15), 0 8px 16px rgba(0,0,0,0.08)' : '0 8px 32px rgba(0,0,0,0.1), 0 2px 8px rgba(0,0,0,0.06)',
                transform: hovered ? 'rotateY(0deg) rotateX(0deg) translateY(-8px)' : 'rotateY(-5deg) rotateX(2deg)',
                transition: 'transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.4s ease',
              }}>
                <img src="assets/library/paper-wellcome-open-research.webp" alt="Open letter paper" style={{ width: '104%', marginLeft: '-3%', display: 'block' }}/>
              </div>
              {/* Signatories sticker */}
              <div style={{
                position: 'absolute', bottom: -14, left: -28, zIndex: 3,
                display: 'flex', alignItems: 'center', gap: 12,
                background: hovered ? 'rgba(255,255,255,0.97)' : 'rgba(255,255,255,0.92)',
                backdropFilter: 'blur(12px)',
                border: hovered ? '1px solid rgba(2, 99, 255, 0.3)' : '1px solid rgba(2, 99, 255, 0.12)',
                borderRadius: 999, padding: '10px 20px 10px 10px',
                boxShadow: hovered ? '0 8px 28px rgba(2, 99, 255, 0.15), 0 2px 8px rgba(0,0,0,0.06)' : '0 4px 20px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.04)',
                transform: hovered ? 'translate(4px, -4px) scale(1.05)' : 'translate(0, 0) scale(1)',
                transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
                cursor: 'default',
              }}>
                <div style={{
                  width: 40, height: 40, borderRadius: '50%',
                  background: hovered ? 'var(--baii-blue)' : 'var(--baii-blue-95)',
                  display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
                  transition: 'background 0.3s ease',
                }}>
                  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={hovered ? '#fff' : 'var(--baii-blue)'} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ transition: 'stroke 0.3s ease' }}>
                    <circle cx="9" cy="7" r="4"/><path d="M2 21v-2a4 4 0 0 1 4-4h6a4 4 0 0 1 4 4v2"/><circle cx="19" cy="7" r="3"/><path d="M19 15a4 4 0 0 1 3 4v2"/>
                  </svg>
                </div>
                <div>
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 15, fontWeight: 600, color: 'var(--baii-blue)', lineHeight: 1 }}>{signatoryCount(sig)}</div>
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg2)', lineHeight: 1.3, marginTop: 2 }}>Open Letter<br/>signatories</div>
                </div>
              </div>
            </div>
          </div>
          </ScrollReveal>
          <ScrollReveal still delay={150}>
          <div>
            <div className="section-index" style={{ marginBottom: 24 }}>
              <span className="section-index__num">01</span>
              <span className="section-index__rule"></span>
              <TypewriterLabel text="Publications" delay={300} />
            </div>
            <h2 className="display-lg" style={{ marginBottom: 24, maxWidth: '20ch' }}>The evidence we produce.</h2>
            <div className="publications-mobile-img" style={{ display: 'none', marginBottom: 32, maxWidth: 260, paddingBottom: 20 }}>
              <div style={{ position: 'relative' }}>
                <div style={{ position: 'absolute', inset: 0, background: '#e8eaf0', borderRadius: 4, transform: 'rotate(2.5deg) translate(6px, 6px)', zIndex: 0 }}/>
                <div style={{ position: 'absolute', inset: 0, background: '#eff0f5', borderRadius: 4, transform: 'rotate(1.2deg) translate(3px, 3px)', zIndex: 1 }}/>
                <div style={{
                  position: 'relative', zIndex: 2, borderRadius: 4, overflow: 'hidden',
                  border: '1px solid var(--baii-bone)',
                  boxShadow: '0 8px 32px rgba(0,0,0,0.1), 0 2px 8px rgba(0,0,0,0.06)',
                }}>
                  <img src="assets/library/paper-wellcome-open-research.webp" alt="Open letter paper" style={{ width: '104%', marginLeft: '-3%', display: 'block' }}/>
                </div>
                <div style={{
                  position: 'absolute', bottom: -14, left: -12, zIndex: 3,
                  display: 'flex', alignItems: 'center', gap: 10,
                  background: 'rgba(255,255,255,0.92)', backdropFilter: 'blur(12px)',
                  border: '1px solid rgba(2, 99, 255, 0.12)',
                  borderRadius: 999, padding: '8px 16px 8px 8px',
                  boxShadow: '0 4px 20px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.04)',
                }}>
                  <div style={{
                    width: 32, height: 32, borderRadius: '50%',
                    background: 'var(--baii-blue-95)',
                    display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
                  }}>
                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--baii-blue)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
                      <circle cx="9" cy="7" r="4"/><path d="M2 21v-2a4 4 0 0 1 4-4h6a4 4 0 0 1 4 4v2"/><circle cx="19" cy="7" r="3"/><path d="M19 15a4 4 0 0 1 3 4v2"/>
                    </svg>
                  </div>
                  <div>
                    <div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, color: 'var(--baii-blue)', lineHeight: 1 }}>{signatoryCount(sig)}</div>
                    <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg2)', lineHeight: 1.3, marginTop: 2 }}>signatories</div>
                  </div>
                </div>
              </div>
            </div>
            <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '48ch', marginBottom: 24 }}>
              Our founding open letter, published in Wellcome Open Research, argues that behavioral science must be a core discipline in responsible AI, not an afterthought.
            </p>
            <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '48ch', marginBottom: 24 }}>
              More than 500 cognitive and behavioral scientists, practitioners, and AI researchers worldwide have co-signed.
            </p>
            <div className="btn-group-mobile" style={{ display: 'flex', gap: 12 }}>
              <a className="btn btn--primary btn--sm" href="https://wellcomeopenresearch.org/articles/11-152" target="_blank" rel="noopener noreferrer">Read the paper <Arrow /></a>
              <a className="btn btn--hollow btn--sm" href="#open-letter">See signatories <Arrow /></a>
            </div>
          </div>
          </ScrollReveal>
        </div>

        <ScrollReveal still>
        <div style={{ borderTop: '1px solid var(--baii-bone)', margin: '56px 0 0', paddingTop: 40 }}>
          <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 24, lineHeight: 1.2, letterSpacing: '-0.01em', marginBottom: 28, fontWeight: 400 }}>Find all of our publications</h3>
          <div className="grid-3col publication-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 20 }}>
            {ALL_PUBS.map((p, i) => (
              <ScrollReveal still key={p.title + '-reveal'} delay={i * 120}>
              <a key={p.title} href={p.active ? p.url : undefined} target={p.active ? '_blank' : undefined} rel={p.active ? 'noopener noreferrer' : undefined} style={{ textDecoration: 'none', color: 'inherit', cursor: p.active ? 'pointer' : 'default' }}>
                <article style={{
                  border: p.active ? '1px solid var(--baii-graphite)' : '1px dashed var(--baii-ash)', borderRadius: 4,
                  background: 'var(--baii-paper)', aspectRatio: '1 / 1.25',
                  display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
                  padding: '20px 22px', overflow: 'hidden', transition: 'all 200ms var(--ease-standard)', opacity: p.active ? 1 : 0.6,
                }}
                onMouseEnter={e => { if (p.active) { e.currentTarget.style.background = 'var(--baii-blue)'; e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = 'var(--baii-blue)'; } }}
                onMouseLeave={e => { if (p.active) { e.currentTarget.style.background = 'var(--baii-paper)'; e.currentTarget.style.color = 'inherit'; e.currentTarget.style.borderColor = 'var(--baii-graphite)'; } }}
                >
                  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
                    <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', opacity: 0.7 }}>[{String(i + 1).padStart(2, '0')}]</div>
                    {p.active && <Arrow />}
                  </div>
                  <div style={{ textAlign: 'center', padding: '0 8px' }}>
                    <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.14em', textTransform: 'uppercase', opacity: 0.7, marginBottom: 10 }}>{p.kind}</div>
                    <div style={{ fontFamily: 'var(--font-display)', fontSize: 28, lineHeight: 1.1, letterSpacing: '-0.01em', textWrap: 'balance' }}>{p.title}</div>
                  </div>
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', opacity: 0.7 }}>{p.meta}</div>
                </article>
              </a>
              </ScrollReveal>
            ))}
          </div>
        </div>
        </ScrollReveal>
      </div>
    </section>
    </ScrollReveal>
  );
}

function WorkAreasCard() {
  const [hov, setHov] = React.useState(-1);
  function AreaCell({ w, i, idx }) {
    const isHovered = hov === idx;
    return (
      <article className="work-area-cell" style={{
        padding: '32px 28px 28px', borderLeft: i % 2 === 0 ? '1px solid var(--baii-bone)' : 'none',
        borderRight: '1px solid var(--baii-bone)', borderTop: i >= 2 ? '1px solid var(--baii-bone)' : 'none',
        display: 'flex', flexDirection: 'column', gap: 16,
        minHeight: 280, background: isHovered ? 'var(--baii-blue)' : '#fff',
        color: isHovered ? '#fff' : 'inherit',
        transition: 'all 300ms cubic-bezier(0.4, 0, 0.2, 1)',
        position: 'relative', overflow: 'hidden',
      }}
      onMouseEnter={() => setHov(idx)}
      onMouseLeave={() => setHov(-1)}>
        <div style={{
          position: 'absolute', top: -20, right: -20, width: 100, height: 100, borderRadius: '50%',
          background: isHovered ? 'rgba(255,255,255,0.08)' : 'transparent',
          transition: 'all 400ms ease', transform: isHovered ? 'scale(1.5)' : 'scale(0.5)',
        }}/>
        <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, letterSpacing: '0.18em', color: isHovered ? 'rgba(255,255,255,0.7)' : 'var(--baii-blue)', transition: 'color 300ms ease' }}>{w.n}</div>
        <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 26, lineHeight: 1.1, letterSpacing: '-0.01em', margin: 0, fontWeight: 400, textWrap: 'balance', position: 'relative' }}>{w.title}</h3>
        <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.55, color: isHovered ? 'rgba(255,255,255,0.8)' : 'var(--fg2)', margin: 0, flexGrow: 1, transition: 'color 300ms ease', position: 'relative' }}>{w.body}</p>
        {w.subTopic && (
          <div style={{
            marginTop: 4, paddingTop: 14,
            borderTop: isHovered ? '1px solid rgba(255,255,255,0.25)' : '1px solid var(--baii-bone)',
            transition: 'border-color 300ms ease', position: 'relative',
          }}>
            <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: isHovered ? 'rgba(255,255,255,0.75)' : 'var(--baii-blue)', marginBottom: 5 }}>Includes · {w.subTopic.label}</div>
            <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, color: isHovered ? 'rgba(255,255,255,0.8)' : 'var(--fg2)' }}>{w.subTopic.note}</div>
          </div>
        )}
      </article>
    );
  }
  return (
    <ScrollReveal still>
    <section className="surface" id="areas">
      <div className="surface__inner">
        <ScrollReveal still>
        <div style={{ marginBottom: 56 }}>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">02</span><span className="section-index__rule"></span><TypewriterLabel text="Work areas" delay={300} />
          </div>
          <h2 className="display-lg" style={{ maxWidth: '22ch' }}>Four areas where the impact goes unmeasured.</h2>
          <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '56ch', marginTop: 16 }}>Each area represents a frontier where evidence should be shaping AI, and isn't yet.</p>
        </div>
        </ScrollReveal>
        <ScrollReveal still delay={150}>
        {/* 2×2 grid of all four areas (reuses grid-work-areas-top/grid-3col for the mobile flatten rules) */}
        <div className="grid-work-areas-top" style={{ borderTop: '1px solid var(--baii-bone)', borderBottom: '1px solid var(--baii-bone)' }}>
          <div className="grid-3col" style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)' }}>{WORK_AREAS.map((w, i) => <AreaCell key={w.n} w={w} i={i} idx={parseInt(w.n) - 1} />)}</div>
        </div>
        </ScrollReveal>
      </div>
    </section>
    </ScrollReveal>
  );
}

function ResearchCommunityCard({ setRoute }) {
  return (
    <ScrollReveal still>
    <section className="surface">
      <div className="surface__inner">
        <div className="grid-2col" style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.2fr)', gap: 64 }}>
          <ScrollReveal still>
          <div>
            <div className="section-index" style={{ marginBottom: 24 }}>
              <span className="section-index__num">03</span><span className="section-index__rule"></span><TypewriterLabel text="Community" delay={300} />
            </div>
            <h2 className="display-lg" style={{ marginBottom: 16, maxWidth: '22ch' }}>Research is better in community.</h2>
            <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '48ch', marginBottom: 32 }}>We're building a network of researchers, practitioners, and institutions united by a conviction: the behavioral impact of AI on people must be measured, not assumed. We publish what we learn.</p>
            <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
              <button className="btn btn--primary" onClick={() => setRoute('contact')} style={{ cursor: 'pointer' }}>Get in touch <Arrow /></button>
              <button className="btn btn--hollow" onClick={() => setRoute('about')} style={{ cursor: 'pointer' }}>Meet the research team <Arrow /></button>
            </div>
          </div>
          </ScrollReveal>
          <div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
              {COMMUNITY_AUDIENCES.map((a, i) => (
                <ScrollReveal still key={a.title} delay={i * 120}>
                <div style={{ padding: '24px 0', borderTop: i > 0 ? '1px solid var(--baii-bone)' : 'none' }}>
                  <div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
                    <div style={{ width: 48, height: 48, borderRadius: '50%', border: '1px solid var(--baii-bone)', background: 'var(--baii-paper)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
                      <svg width="22" height="22" viewBox="0 0 32 32" fill="none" stroke="var(--fg2)" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
                        {i === 0 && <><circle cx="14" cy="14" r="8"/><line x1="19.5" y1="19.5" x2="27" y2="27"/><circle cx="14" cy="14" r="3" strokeDasharray="2 2"/></>}
                        {i === 1 && <><circle cx="16" cy="10" r="4"/><circle cx="7" cy="22" r="3"/><circle cx="25" cy="22" r="3"/><path d="M10 22 L16 14 L22 22"/></>}
                        {i === 2 && <><rect x="4" y="6" width="24" height="20" rx="2"/><path d="M4 12h24"/><circle cx="16" cy="20" r="3"/></>}
                      </svg>
                    </div>
                    <div>
                      <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 20, lineHeight: 1.2, letterSpacing: '-0.01em', margin: '0 0 6px', fontWeight: 400 }}>{a.title}</h3>
                      <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.55, color: 'var(--fg2)', margin: 0 }}>{a.body}</p>
                    </div>
                  </div>
                </div>
                </ScrollReveal>
              ))}
            </div>
          </div>
        </div>
        <ScrollReveal still delay={200}>
        <div style={{
          borderTop: '1px solid var(--baii-bone)', marginTop: 48, paddingTop: 32,
          background: 'var(--baii-paper)', borderRadius: 8, padding: '28px 32px',
          border: '1px solid var(--baii-bone)',
        }}>
          <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 20 }}>
            <div style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--baii-blue)', opacity: 0.6, flexShrink: 0, marginTop: 5 }} />
            <p style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--fg3)', margin: 0 }}>Our network spans leading researchers from institutions around the world</p>
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
            {RESEARCH_INSTITUTIONS.map((inst) => (
              <div key={inst.name} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 10, padding: '18px 12px', background: '#fff', border: '1px solid var(--baii-bone)', borderRadius: 6 }}>
                <img src={inst.logo} alt="" style={{ width: 34, height: 34, objectFit: 'contain', filter: 'grayscale(1)', opacity: 0.6, borderRadius: 4 }}/>
                <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5, fontWeight: 500, color: 'var(--fg2)', letterSpacing: '0.02em', lineHeight: 1.35 }}>{inst.name}</span>
              </div>
            ))}
          </div>
        </div>
        </ScrollReveal>
      </div>
    </section>
    </ScrollReveal>
  );
}


/* ============================================================
   ABOUT PAGE
   ============================================================ */
function AboutHeroCard({ route, setRoute }) {
  const scrollRef = React.useRef(null);
  const trackRef = React.useRef(null);
  const textRef = React.useRef(null);
  const lineRefs = React.useRef([]);

  React.useEffect(() => {
    const root = scrollRef.current;
    if (!root) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

    // Windows overlap, so two or three lines are always mid-fade and the group
    // reads as one wave rather than four separate events. All have landed by
    // ~0.79, leaving the last fifth of the scroll to the figure alone.
    const START = 0.06, STAGGER = 0.135, DURATION = 0.32, RISE = 30;
    const ease = (t) => 1 - Math.pow(1 - t, 3);
    const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);

    // How far the figure may travel before it scrolls past its own bottom edge.
    // object-fit: contain letterboxes inside the track, so the rendered figure
    // is shorter than the track; moving further than this exposes the image's
    // edge and a band of empty paper. Measured, not hardcoded — the rendered
    // height depends on the card width, which depends on the viewport.
    let maxTravel = 0;
    const measure = () => {
      const track = trackRef.current;
      const img = track && track.querySelector('img');
      if (!img || !img.naturalWidth) return;
      const windowH = track.parentElement.clientHeight;
      const rendered = track.clientWidth * (img.naturalHeight / img.naturalWidth);
      const letterbox = Math.max((track.clientHeight - rendered) / 2, 0);
      maxTravel = Math.max(letterbox + rendered - windowH, 0);
    };
    measure();
    const img = trackRef.current && trackRef.current.querySelector('img');
    if (img && !img.complete) img.addEventListener('load', measure);
    window.addEventListener('resize', measure);

    let raf = 0;
    const frame = () => {
      const rect = root.getBoundingClientRect();
      const total = root.offsetHeight - window.innerHeight;
      // derived from absolute position, so it is correct at any scroll offset
      const p = total > 0 ? clamp01(-rect.top / total) : 0;

      if (trackRef.current) {
        trackRef.current.style.transform =
          'translate3d(0,' + (-p * maxTravel).toFixed(2) + 'px,0)';
      }
      if (textRef.current) {
        // two-speed: the text travels ~50px against the figure's longer journey
        textRef.current.style.transform =
          'translate3d(0,' + (26 - p * 52).toFixed(1) + 'px,0)';
      }
      lineRefs.current.forEach((el, i) => {
        if (!el) return;
        const t = ease(clamp01((p - (START + i * STAGGER)) / DURATION));
        el.style.opacity = t.toFixed(3);
        el.style.transform =
          t === 1 ? 'none' : 'translate3d(0,' + ((1 - t) * RISE).toFixed(2) + 'px,0)';
      });
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('resize', measure);
      if (img) img.removeEventListener('load', measure);
    };
  }, []);

  // the first line is an anchor — present the moment you land, so the hero is
  // never an empty half-page. Only these four are driven by scroll.
  /* T4. Five authored words carry a hairline rule. The emphasis is marked
     inline with ** so these stay plain strings: `key={text}` depends on that,
     and lineRefs indexes the lines positionally while the parallax drives the
     spans themselves — neither may change shape. heroEmphasis only changes
     what renders *inside* a span, never the span. */
  const revealed = [
    'what **AI** does to **people**,',
    'to **measure** it,',
    'and to make everything',
  ];

  return (
    <section className="surface about-hero-scroll" ref={scrollRef}>
      <div className="about-hero-stick">
        <div className="about-hero-nav">
          <SubPageNav route={route} setRoute={setRoute} />
        </div>

        <div className="about-hero-figure">
          <div className="about-hero-track" ref={trackRef}>
            <img src="assets/illustrations/thinker-statue.webp" alt="" />
          </div>
        </div>

        <div className="about-hero-body">
          <div className="about-hero-text" ref={textRef}>
            <div className="section-index">
              <span className="section-index__num">00</span>
              <span className="section-index__rule"></span>
              <TypewriterLabel text="About BAII" delay={200} />
            </div>
            <h1 className="about-hero-mission">
              <span className="about-hero-line">{heroEmphasis('We exist to **understand**')}</span>
              {revealed.map((text, i) => (
                <span
                  key={text}
                  className="about-hero-line"
                  ref={(el) => { lineRefs.current[i] = el; }}
                >{heroEmphasis(text)}</span>
              ))}
              <span
                className="about-hero-line"
                ref={(el) => { lineRefs.current[revealed.length] = el; }}
              >{heroEmphasis('we learn **public**.')}</span>
            </h1>
          </div>
        </div>
      </div>
    </section>
  );
}

function PrinciplesCard() {
  const [flippedIdx, setFlippedIdx] = React.useState(-1);
  const [hovIdx, setHovIdx] = React.useState(-1);
  return (
    <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <ScrollReveal still>
            <div className="section-index" style={{ marginBottom: 24 }}>
              <span className="section-index__num">01</span><span className="section-index__rule"></span><TypewriterLabel text="Our guiding principles" delay={300} />
            </div>
            <h2 className="display-lg" style={{ marginBottom: 0, maxWidth: '22ch' }}>What guides everything we do.</h2>
          </ScrollReveal>

          <ScrollReveal still delay={100}>
          <div className="mission-quote-card" style={{
            margin: '40px 0 48px',
            background: 'var(--baii-paper)', borderRadius: 6,
            border: '1px solid var(--baii-bone)',
            padding: '40px 48px',
          }}>
            <div style={{
              fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em',
              textTransform: 'uppercase', color: 'var(--fg2)', marginBottom: 16,
              display: 'flex', alignItems: 'center', gap: 10,
            }}>
              <div style={{ width: 16, height: 1, background: 'var(--fg3)', opacity: 0.5 }} />
              Our mission
            </div>
            <p style={{
              fontFamily: 'var(--font-display)',
              fontSize: 24, lineHeight: 1.45, letterSpacing: '-0.01em',
              color: 'var(--fg1)', margin: 0,
            }}>
              Bridging research and real-world impact. The world doesn't yet know how to measure what AI is doing to people, the risks and the benefits, and we exist to change that. We lead the research, build the methods, and publish everything we learn. We are independent: we take no money from frontier AI labs and have no commercial stake in the systems we evaluate.
            </p>
          </div>
          </ScrollReveal>

          <div className="grid-3col" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 24 }}>
            {GUIDING_PRINCIPLES.map((p, i) => {
              const isFlipped = flippedIdx === i;
              const isHovered = hovIdx === i;
              return (
              <ScrollReveal still key={p.n} delay={150 + i * 120}>
                <div
                  onClick={() => setFlippedIdx(isFlipped ? -1 : i)}
                  onMouseEnter={() => setHovIdx(i)}
                  onMouseLeave={() => setHovIdx(-1)}
                  className="principle-card"
                  style={{ cursor: 'pointer', perspective: 800, aspectRatio: '1 / 1.3' }}>
                  <div style={{
                    position: 'relative', width: '100%', height: '100%',
                    transformStyle: 'preserve-3d',
                    transform: isFlipped ? 'rotateY(180deg)' : isHovered ? 'translateY(-4px)' : 'translateY(0)',
                    transition: 'transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1)',
                    borderRadius: 8,
                  }}>
                    {/* Front */}
                    <div style={{
                      position: 'absolute', inset: 0, backfaceVisibility: 'hidden', WebkitBackfaceVisibility: 'hidden',
                      borderRadius: 8, overflow: 'hidden',
                      border: '1px solid var(--baii-bone)', background: '#fff',
                      boxShadow: isHovered ? 'var(--shadow-2)' : 'var(--shadow-1)',
                      display: 'flex', flexDirection: 'column',
                    }}>
                      <div style={{ aspectRatio: '4 / 3', overflow: 'hidden', flexShrink: 0 }}>
                        <img src={p.image} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)', transform: isHovered ? 'scale(1.05)' : 'scale(1)', transition: 'transform 0.4s var(--ease-out)' }}/>
                      </div>
                      <div style={{ padding: '20px 24px 24px', display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
                        <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.18em', color: 'var(--baii-blue)', marginBottom: 8 }}>{p.n}</div>
                        <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 22, lineHeight: 1.15, letterSpacing: '-0.01em', margin: '0 0 8px', fontWeight: 400 }}>{p.title}</h3>
                        <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.55, color: 'var(--fg2)', margin: 0 }}>{p.body}</p>
                      </div>
                    </div>
                    {/* Back */}
                    <div style={{
                      position: 'absolute', inset: 0, backfaceVisibility: 'hidden', WebkitBackfaceVisibility: 'hidden',
                      transform: 'rotateY(180deg)',
                      borderRadius: 8, overflow: 'hidden',
                      border: '1px solid var(--baii-bone)', background: '#fff',
                      boxShadow: 'var(--shadow-2)',
                      display: 'flex', flexDirection: 'column', justifyContent: 'center',
                      padding: '32px 28px',
                    }}>
                      <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 500, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--baii-blue)', marginBottom: 8 }}>Principle {p.n}</div>
                      <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 22, lineHeight: 1.15, letterSpacing: '-0.01em', margin: '0 0 16px', fontWeight: 400, fontStyle: 'italic' }}>{p.title}</h3>
                      <div style={{ width: 24, height: 1, background: 'var(--baii-bone)', marginBottom: 16 }} />
                      <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.65, color: 'var(--fg2)', margin: 0 }}>{p.backBody}</p>
                    </div>
                  </div>
                </div>
              </ScrollReveal>
              );
            })}
          </div>
        </div>
      </section>
    </ScrollReveal>
  );
}

function GrainFilterDef() {
  return (
    <svg style={{ position: 'absolute', width: 0, height: 0 }}>
      <defs>
        <filter id="grain">
          <feTurbulence type="fractalNoise" baseFrequency="0.65" numOctaves="3" stitchTiles="stitch"/>
          <feColorMatrix type="saturate" values="0"/>
        </filter>
      </defs>
    </svg>
  );
}

function BlueGrainOverlay({ visible }) {
  return (
    <>
      <div style={{
        position: 'absolute', inset: 0, zIndex: 2, pointerEvents: 'none',
        background: 'linear-gradient(135deg, rgba(2, 99, 255, 0.35) 0%, rgba(2, 50, 160, 0.5) 100%)',
        opacity: visible ? 1 : 0,
        transition: 'opacity 0.4s ease',
      }}/>
      <div style={{
        position: 'absolute', inset: 0, zIndex: 3, pointerEvents: 'none',
        filter: 'url(#grain)',
        opacity: visible ? 0.08 : 0,
        mixBlendMode: 'overlay',
        transition: 'opacity 0.4s ease',
      }}/>
    </>
  );
}

function PersonCard({ person, size = 'medium', rotate = 0, delay = 0, aspect = '3 / 4', onOpen }) {
  const [hovered, setHovered] = React.useState(false);
  const isFounder = person.role.startsWith('Co-founder');
  const displayRole = isFounder ? person.role.replace('Co-founder & ', '') : person.role;

  const sizeStyles = {
    large:  { fontSize: 28, roleFontSize: 12, padding: '60px 24px 24px' },
    medium: { fontSize: 22, roleFontSize: 11, padding: '48px 20px 20px' },
    small:  { fontSize: 18, roleFontSize: 10, padding: '36px 16px 16px' },
  }[size];

  return (
    <ScrollReveal still delay={delay}>
      <div
        role="button"
        tabIndex={0}
        /* Not aria-expanded: this control does not reveal something alongside
           itself, the whole section is replaced BY the thing it opens. The card
           leaves the visible layout on activation, so PersonSpread takes focus
           on mount and hands it back to this card by data-person on close —
           otherwise focus would fall to <body> the moment someone clicks. */
        data-person={personSlug(person.name)}
        aria-label={`Read ${person.name}'s full biography`}
        onClick={() => onOpen(person)}
        onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(person); } }}
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        style={{ cursor: 'pointer', aspectRatio: aspect }}
      >
        <div className="card__inner" style={{
          position: 'relative', width: '100%', height: '100%',
          transform: hovered ? 'rotate(0deg) translateY(-4px)' : `rotate(${rotate}deg)`,
          transition: 'transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.4s ease',
          borderRadius: 10, overflow: 'hidden',
          boxShadow: hovered ? '0 12px 40px rgba(2, 99, 255, 0.2)' : '0 2px 12px rgba(0,0,0,0.06)',
        }}>
          <img src={person.photo} alt={person.name} style={{
            position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover',
            filter: 'grayscale(1) contrast(1.05)', zIndex: 1,
          }}/>
          <BlueGrainOverlay visible={hovered} />
          <div style={{
            position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 4,
            background: hovered ? 'transparent' : 'linear-gradient(transparent, rgba(0,0,0,0.7))',
            padding: sizeStyles.padding, transition: 'background 0.4s ease',
          }}>
            {isFounder && person.city && (
              <div style={{
                display: 'inline-flex', alignItems: 'center', gap: 6, marginBottom: 10,
                background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)',
                borderRadius: 999, padding: '4px 10px 4px 7px',
                border: '1px solid rgba(255,255,255,0.2)',
              }}>
                <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
                <span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: '#fff', letterSpacing: '0.1em', textTransform: 'uppercase' }}>{person.city}</span>
              </div>
            )}
            <h3 style={{ fontFamily: 'var(--font-display)', fontSize: sizeStyles.fontSize, lineHeight: 1.1, letterSpacing: '-0.01em', margin: '0 0 4px', fontWeight: 400, color: '#fff' }}>{person.name}</h3>
            <div style={{ fontFamily: 'var(--font-mono)', fontSize: sizeStyles.roleFontSize, color: 'rgba(255,255,255,0.7)', letterSpacing: '0.02em' }}>{displayRole}</div>
          </div>
        </div>
      </div>
    </ScrollReveal>
  );
}

/* The card used to flip, with the bio and links on its back face, and then to
   open an inline panel that took over its grid row. Both are gone.

   The panel model had the grid rebuild itself on every click: the clicked card
   was destroyed, a full-width panel took its row, and every other card was
   re-ordered and FLIP-animated to a new cell. Five faults measured against it,
   all downstream of that one decision — a detached portrait adrift for 520ms,
   a close button under the nav pill for the first tenth of a second, a third of
   the grid pushed off-screen, the page growing 446px while a smooth-scroll
   fought the animation, and no way to read a second person without closing,
   scrolling back and hunting for the next face.

   This is a MODE CHANGE instead. The grid dissolves and the section becomes a
   reading spread sized to one screen: a tall portrait, the bio set to be read,
   and the other eleven as an index you move through with a click or the arrow
   keys. Nothing is re-ordered, nothing is destroyed, and the spread never
   coexists with the grid — so there is no half-state to get wrong. */

/* Straight dissolve for the portrait when stepping between people.

   This used to be DIRECTIONAL: the outgoing face travelled -26px and scaled to
   1.03 over 420ms while the incoming one travelled up from +26px at scale 1.04
   over 460ms. Two faces, both part-transparent, moving opposite ways at once —
   which read as the old portrait dropping away and a new one popping up, and it
   was the thing people noticed instead of the person they had just clicked.

   Now only the OUTGOING face is animated, and only its opacity. The incoming
   one is already sitting underneath at full opacity, so the old face simply
   dissolves to reveal it: no counter-motion, and no mid-transition dip where
   both layers are part-transparent and the paper shows through between them.
   Nothing arrives from anywhere. The face just changes.

   PREVIOUS src is passed in, not read off the element. React has already
   committed the new src by the time a layout effect runs, so cloning the live
   <img> here produced a "ghost" that was the INCOMING face — the transition
   crossfaded the new photograph with itself, on top of a live element that was
   still decoding. That is what read as the image loading twice.

   The incoming face is still decoded before anything moves. It is always in
   cache (the grid loaded all twelve at full size), so this costs a frame at
   most, but it guarantees the dissolve never uncovers a half-painted image. */
function crossPortrait(slot, prevSrc, nextSrc) {
  const live = slot.querySelector('.pspread__photo-img');
  if (!live) return;
  if (prefersReducedMotion() || !live.animate) return;

  /* A step can be interrupted by the next one before its decode resolves.
     Clear any ghost still on the slot rather than stacking faces. */
  slot.querySelectorAll('.pspread__photo-ghost').forEach(g => g.remove());

  const ghost = document.createElement('img');
  ghost.src = prevSrc;
  ghost.className = 'pspread__photo-img pspread__photo-ghost';
  ghost.setAttribute('aria-hidden', 'true');
  slot.appendChild(ghost);

  /* The live element is deliberately left alone. It is opaque and directly
     under the ghost, so fading the ghost out IS the dissolve — animating both
     would put two part-transparent layers over the paper and flash it. */
  const run = () => {
    if (!ghost.isConnected) return;
    ghost.animate(
      [{ opacity: 1 }, { opacity: 0 }],
      { duration: 200, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', fill: 'forwards' }
    ).onfinish = () => ghost.remove();
  };

  const pre = new Image();
  pre.src = nextSrc;
  if (pre.decode) pre.decode().then(run, run);
  else if (pre.complete) run();
  else { pre.onload = run; pre.onerror = run; }
}

/* The reading spread. Absolutely positioned over the grid's box, so the two
   views occupy the same place and the section can ease between their heights
   without either ever being in the other's flow. */
function PersonSpread({ person, people, leaving, flew, onClose, onPick, onStep }) {
  const photoRef = React.useRef(null);
  const stripRef = React.useRef(null);
  const markerRef = React.useRef(null);
  const rootRef = React.useRef(null);
  const lastName = React.useRef(null);
  const lastPhoto = React.useRef(null);

  const active = people.findIndex(p => p.name === person.name);
  const isFounder = person.role.startsWith('Co-founder');
  const displayRole = isFounder ? person.role.replace('Co-founder & ', '') : person.role;
  const paragraphs = person.fullBio || [person.bio];

  /* Cross-dissolve the portrait only when STEPPING between people — on first
     mount the portrait has just flown in from the card as a shared element and
     must not be animated a second time on top of that. */
  React.useLayoutEffect(() => {
    const slot = photoRef.current;
    if (!slot) return;
    if (lastPhoto.current && lastPhoto.current !== person.photo) {
      crossPortrait(slot, lastPhoto.current, person.photo);
    }
    lastName.current = person.name;
    lastPhoto.current = person.photo;
  }, [person.name, person.photo]);

  /* The active ring is one element that travels along the strip rather than a
     class that blinks from one thumbnail to the next — the eye follows a
     moving object and loses a blinking one. It also keeps the active face in
     view when the strip has to scroll. */
  React.useLayoutEffect(() => {
    const strip = stripRef.current, marker = markerRef.current;
    if (!strip || !marker) return;
    const thumb = strip.querySelector(`[data-thumb="${personSlug(person.name)}"]`);
    if (!thumb) return;
    marker.style.transform = `translateX(${thumb.offsetLeft}px)`;
    marker.style.width = thumb.offsetWidth + 'px';
    marker.style.height = thumb.offsetHeight + 'px';
    if (strip.scrollWidth > strip.clientWidth) {
      const want = thumb.offsetLeft - (strip.clientWidth - thumb.offsetWidth) / 2;
      strip.scrollTo({ left: Math.max(0, want), behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
    }
  }, [person.name]);

  React.useEffect(() => {
    if (rootRef.current) rootRef.current.focus({ preventScroll: true });
  }, []);

  const linkStyle = {
    display: 'inline-flex', alignItems: 'center', gap: 9,
    fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 500,
    color: 'var(--baii-blue)', textDecoration: 'none', wordBreak: 'break-word',
  };

  return (
    <div
      className={`pspread${leaving ? ' pspread--leaving' : ''}${flew ? '' : ' pspread--arrive'}`}
      ref={rootRef}
      tabIndex={-1}
      role="region"
      aria-label={`${person.name} — full biography`}
    >
      <div className="pspread__main">
        <div className="pspread__photo" ref={photoRef}>
          <img className="pspread__photo-img" src={person.photo} alt={person.name} />
          {isFounder && person.city && <span className="pspread__city">{person.city}</span>}
        </div>

        {/* Re-keyed on the person so the staggered arrival replays on every
            step instead of only on first mount.

            Stepping gets a much tighter stagger than opening. The opening
            delays sit behind a 560ms portrait flight; reused on a keypress they
            left the text column blank for a quarter second after the new
            portrait had already landed, which read as a stall. lastName still
            holds the previous render's value here — the layout effect that
            updates it has not run yet — so this is a reliable "is this a step"
            at render time. */}
        <div
          className={`pspread__text${lastName.current && lastName.current !== person.name ? ' pspread__text--step' : ''}`}
          key={person.name}
        >
          <h3 className="pspread__name">{person.name}</h3>
          <div className="pspread__role">{displayRole}</div>
          <div className="pspread__rule" />
          {paragraphs.map((text, i) => (
            <p key={i} className="pspread__bio">{text}</p>
          ))}
          <div className="pspread__links">
            {person.email && (
              <a href={`mailto:${person.email}`} style={linkStyle}>
                <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="var(--baii-blue)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/></svg>
                {person.email}
              </a>
            )}
            {person.linkedin && (
              <a href={person.linkedin} target="_blank" rel="noopener noreferrer" style={linkStyle}>
                <svg width="17" height="17" viewBox="0 0 24 24" fill="var(--baii-blue)" style={{ flexShrink: 0 }}><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
                LinkedIn
              </a>
            )}
          </div>
        </div>
      </div>

      <div className="pspread__index">
        <button className="pspread__back" onClick={onClose}>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m15 18-6-6 6-6"/></svg>
          All people
        </button>

        {/* The index is a listbox of faces, not decoration — it is the only way
            back to the other eleven while the grid is dissolved. */}
        <div className="pspread__strip" ref={stripRef} role="listbox" aria-label="Institute members">
          <span className="pspread__marker" ref={markerRef} aria-hidden="true" />
          {people.map((p) => (
            <button
              key={p.name}
              data-thumb={personSlug(p.name)}
              className={`pspread__thumb${p.name === person.name ? ' is-on' : ''}`}
              role="option"
              aria-selected={p.name === person.name}
              aria-label={p.name}
              title={p.name}
              onClick={() => onPick(p)}
            >
              <img src={p.photo} alt="" />
            </button>
          ))}
        </div>

        <div className="pspread__nav">
          <span className="pspread__count">
            {String(active + 1).padStart(2, '0')} <i>/</i> {String(people.length).padStart(2, '0')}
          </span>
          <button className="pspread__step" onClick={() => onStep(-1)} aria-label="Previous person">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m15 18-6-6 6-6"/></svg>
          </button>
          <button className="pspread__step" onClick={() => onStep(1)} aria-label="Next person">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m9 18 6-6-6-6"/></svg>
          </button>
        </div>
      </div>
    </div>
  );
}

/* A plain grid of people. It no longer re-orders, measures its own column
   count, or runs FLIP: the spread never shares the layout with it, so there is
   nothing for the cards to move out of the way of. Everything that made the
   previous version fragile was in service of a hole that no longer opens. */
function PeopleGrid({ people, onOpen, cardProps, gridClass, gridStyle }) {
  return (
    <div className={gridClass} style={gridStyle}>
      {people.map((p, i) => (
        <div className="people-cell" key={p.name} data-cell={personSlug(p.name)}>
          <PersonCard
            person={p}
            {...(typeof cardProps === 'function' ? cardProps(p, i) : cardProps)}
            onOpen={() => onOpen(p)}
          />
        </div>
      ))}
    </div>
  );
}

function PeopleCard() {
  const [active, setActive] = React.useState(null);
  const [leaving, setLeaving] = React.useState(false);
  /* Whether the portrait flew in as a shared element. When it did not, the
     spread gives it its own entrance instead of appearing fully formed. */
  const [flew, setFlew] = React.useState(false);
  const founders = TEAM_MEMBERS.filter(m => m.role.startsWith('Co-founder'));
  const fellows = TEAM_MEMBERS.filter(m => !m.role.startsWith('Co-founder'));
  const sectionRef = React.useRef(null);
  const stageRef = React.useRef(null);
  const flowRef = React.useRef(null);
  const swapTimer = React.useRef(0);
  const activeRef = React.useRef(null);
  activeRef.current = active;

  const headerClear = () => parseFloat(
    getComputedStyle(document.documentElement).getPropertyValue('--site-header-clear')
  ) || 96;

  /* The grid does not fade as a block. Each card leaves on a delay set by how
     far it sits from the one that was clicked, so the dissolve radiates out
     from the face under the reader's cursor and the section comes apart in the
     direction their attention already is. Reversed on the way back. */
  const rippleFrom = React.useCallback((slug, back) => {
    const flow = flowRef.current;
    if (!flow) return;
    const cells = [...flow.querySelectorAll('.people-cell')];
    const origin = flow.querySelector(`[data-cell="${slug}"]`);
    if (!origin || !cells.length) return;
    const o = origin.getBoundingClientRect();
    const ox = o.left + o.width / 2, oy = o.top + o.height / 2;
    const dists = cells.map(el => {
      const r = el.getBoundingClientRect();
      return Math.hypot(r.left + r.width / 2 - ox, r.top + r.height / 2 - oy);
    });
    const far = Math.max(1, ...dists);
    cells.forEach((el, i) => {
      const t = dists[i] / far;
      el.style.setProperty('--cell-delay', Math.round((back ? 1 - t : t) * 190) + 'ms');
    });
  }, []);

  /* Opening. Four things start together and finish at different times, which
     is what gives the change a shape instead of a duration: the portrait flies,
     the grid ripples away, the section eases to the spread's height, and the
     spread's own contents write themselves in behind all of it. */
  const openPerson = React.useCallback((person) => {
    const stage = stageRef.current;
    const slug = personSlug(person.name);
    const card = document.querySelector(`[data-person="${slug}"]`);
    const inner = card && card.querySelector('.card__inner');
    const from = card ? restingRect(card) : null;
    const fromTransform = inner ? getComputedStyle(inner).transform : 'none';

    /* Decide the scroll BEFORE anything moves. The section's top does not shift
       when the spread opens — only its height does — so this rect stays true. */
    const secTop = sectionRef.current ? sectionRef.current.getBoundingClientRect().top : 0;
    const clear = headerClear();
    const settled = secTop >= 0 && secTop < clear + 40;
    const travel = settled ? 0 : Math.abs(secTop - clear);

    /* The shared-element flight is worth it only when the reader can see it.
       The spread renders at the TOP of the section, so clicking a face in the
       bottom row sends the view over a thousand pixels — and the portrait would
       spend its whole flight in a part of the document that is off-screen,
       buying nothing and leaving the slot empty on arrival. Past that distance
       the portrait simply arrives, which reads better than a flight nobody
       sees. */
    const fly = !!from && travel < 400;

    rippleFrom(slug, false);
    if (stage) stage.style.height = stage.getBoundingClientRect().height + 'px';

    setFlew(fly);
    setActive(person);

    /* Start travelling on the same frame as the dissolve. Held back by 90ms
       only when the portrait is flying, so the flight is not racing a moving
       page for its first third. */
    if (!settled) {
      const to = Math.max(0, secTop + window.scrollY - clear);
      const go = () => window.scrollTo({ top: to, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
      if (fly) window.setTimeout(go, 90); else go();
    }

    requestAnimationFrame(() => requestAnimationFrame(() => {
      const spread = document.querySelector('.pspread');
      if (!spread || !stage) return;

      const panelImg = spread.querySelector('.pspread__photo-img');
      if (fly && panelImg) {
        /* Hide the destination for the flight and reveal it exactly as the
           clone lands, so the same face is never on screen twice and there is
           never a gap where a photograph should be. */
        panelImg.style.opacity = '0';
        flyPortrait({
          from, to: docRect(spread.querySelector('.pspread__photo')), src: person.photo,
          fromTransform, toTransform: 'none',
          onDone: () => { panelImg.style.opacity = ''; },
        });
      }

      stage.style.transition = `height ${SPREAD_MS}ms ${MORPH_EASE}`;
      stage.style.height = Math.ceil(spread.getBoundingClientRect().height) + 'px';
    }));
  }, [rippleFrom]);

  /* Stepping. The text leaves before the replacement arrives — a straight swap
     reads as a glitch at this size. The index marker and the counter update
     immediately rather than waiting, so the controls answer the keypress while
     the content is still turning over. */
  const goTo = React.useCallback((person) => {
    if (!person || person.name === activeRef.current?.name) return;
    window.clearTimeout(swapTimer.current);
    setLeaving(true);
    const commit = () => {
      setActive(person);
      setLeaving(false);
      const stage = stageRef.current;
      const spread = document.querySelector('.pspread');
      /* Bios differ by a factor of two in length, so the section follows the
         one being read rather than staying pinned to the first one opened. */
      if (stage && spread) {
        requestAnimationFrame(() => {
          stage.style.height = Math.ceil(spread.getBoundingClientRect().height) + 'px';
        });
      }
    };
    if (prefersReducedMotion()) commit();
    else swapTimer.current = window.setTimeout(commit, 120);
  }, []);

  const stepBy = React.useCallback((d) => {
    const cur = activeRef.current;
    if (!cur) return;
    const i = TEAM_MEMBERS.findIndex(p => p.name === cur.name);
    /* Wraps, and runs straight through founders into fellows — the index is
       one list of twelve, not two lists the reader has to notice. */
    goTo(TEAM_MEMBERS[(i + d + TEAM_MEMBERS.length) % TEAM_MEMBERS.length]);
  }, [goTo]);

  const closePerson = React.useCallback(() => {
    const cur = activeRef.current;
    if (!cur) return;
    window.clearTimeout(swapTimer.current);
    const stage = stageRef.current;
    const slug = personSlug(cur.name);
    const panelSlot = document.querySelector('.pspread__photo');
    const from = panelSlot ? docRect(panelSlot) : null;

    rippleFrom(slug, true);
    setLeaving(false);
    setActive(null);

    requestAnimationFrame(() => requestAnimationFrame(() => {
      const card = document.querySelector(`[data-person="${slug}"]`);
      card?.focus({ preventScroll: true });
      const inner = card?.querySelector('.card__inner');
      const cardImg = card?.querySelector('img');
      if (from && card && cardImg) {
        /* Land on the tilt the card is actually wearing. Focus does not trigger
           :hover, so the card is back at its resting rotation — arriving at
           `none` would drop the photograph flat and let the card snap under it. */
        cardImg.style.opacity = '0';
        flyPortrait({
          from, to: restingRect(card), src: cur.photo,
          fromTransform: 'none',
          toTransform: inner ? getComputedStyle(inner).transform : 'none',
          onDone: () => { cardImg.style.opacity = ''; },
        });
      }
      if (stage) {
        stage.style.height = stage.scrollHeight + 'px';
        window.setTimeout(() => {
          stage.style.height = '';
          stage.style.transition = '';
        }, SPREAD_MS + 40);
      }
    }));

    if (hashParam('person')) {
      history.replaceState(
        history.state, '',
        window.location.pathname + window.location.search + '#about'
      );
    }
  }, [rippleFrom]);

  /* Escape closes; the arrows walk the institute. Bound at the document so the
     keys work wherever focus has ended up inside the spread, and ignored while
     the reader is typing or holding a modifier for a browser shortcut. */
  React.useEffect(() => {
    const onKey = (e) => {
      if (!activeRef.current) return;
      const t = e.target;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      if (e.key === 'Escape') { e.preventDefault(); closePerson(); }
      else if (e.key === 'ArrowRight') { e.preventDefault(); stepBy(1); }
      else if (e.key === 'ArrowLeft') { e.preventDefault(); stepBy(-1); }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [closePerson, stepBy]);

  React.useEffect(() => () => window.clearTimeout(swapTimer.current), []);

  /* Open a profile named by `#about?person=<slug>` — the home people cluster
     links here. An unknown slug is ignored rather than 404'd: the route itself
     is valid, and a stale link should still land on the team.

     Listens to hashchange as well as mount. Arriving from home is a route
     change and remounts, but changing only the parameter while already on
     About does not, and a mount-only effect would leave the panel showing
     whoever was open before. An absent parameter deliberately does NOT force
     the spread shut: card clicks open a profile without touching the hash. */
  React.useEffect(() => {
    let raf = 0;
    const sync = () => {
      const slug = hashParam('person');
      if (!slug) return;
      const match = TEAM_MEMBERS.find(m => personSlug(m.name) === slug.toLowerCase());
      if (!match) return;
      rippleFrom(personSlug(match.name), false);
      const stage = stageRef.current;
      if (stage) stage.style.height = stage.getBoundingClientRect().height + 'px';
      setActive(match);
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => requestAnimationFrame(() => {
        const spread = document.querySelector('.pspread');
        if (stage && spread) {
          stage.style.transition = `height ${SPREAD_MS}ms ${MORPH_EASE}`;
          stage.style.height = Math.ceil(spread.getBoundingClientRect().height) + 'px';
        }
        sectionRef.current?.scrollIntoView({ block: 'start' });
      }));
    };
    sync();
    window.addEventListener('hashchange', sync);
    return () => { cancelAnimationFrame(raf); window.removeEventListener('hashchange', sync); };
  }, [rippleFrom]);

  const founderRotations = [-1.2, 0.8, -0.5];
  const fellowRotations = [1.5, -1.0, 0.7, -1.8, 1.2, -0.4, 1.0, -1.3, 0.6];
  const open = !!active;

  return (
    <ScrollReveal still>
      <section className="surface" ref={sectionRef} style={{ scrollMarginTop: 'var(--site-header-clear, 96px)' }}>
        <div className="surface__inner">
          <ScrollReveal still>
            <div className="section-index" style={{ marginBottom: 24 }}>
              <span className="section-index__num">02</span><span className="section-index__rule"></span><TypewriterLabel text="Our people" delay={300} />
            </div>
          </ScrollReveal>

          {/* The two views share one box. The grid is in flow and sets the
              resting height; the spread is absolute on top of it, so neither is
              ever in the other's layout and the box simply eases between the
              two heights. */}
          <div className={`people-stage${open ? ' is-open' : ''}`} ref={stageRef}>
            <div className="people-flow" ref={flowRef} aria-hidden={open ? 'true' : undefined}>
              <ScrollReveal still>
                <h2 className="display-lg" style={{ marginBottom: 56, maxWidth: '28ch' }}>
                  Bringing together experts across behavioral science, cognitive science, and artificial intelligence.
                </h2>
              </ScrollReveal>

              <ScrollReveal still>
                <div className="people-grouplabel">Co-founders</div>
              </ScrollReveal>
              <PeopleGrid
                people={founders}
                onOpen={openPerson}
                gridClass="grid-3col"
                gridStyle={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 20, marginBottom: 48 }}
                cardProps={(f, i) => ({ size: 'large', rotate: founderRotations[i], delay: 100 + i * 120, aspect: '3 / 4' })}
              />

              <div style={{ height: 1, background: 'var(--baii-bone)', marginBottom: 48 }} />

              <ScrollReveal still>
                <div className="people-grouplabel">Fellows</div>
              </ScrollReveal>
              <PeopleGrid
                people={fellows}
                onOpen={openPerson}
                gridClass="grid-3col"
                gridStyle={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}
                cardProps={(p, i) => ({ size: 'medium', rotate: fellowRotations[i], delay: 80 + i * 80, aspect: '1 / 1' })}
              />
            </div>

            {active && (
              <PersonSpread
                person={active}
                people={TEAM_MEMBERS}
                leaving={leaving}
                flew={flew}
                onClose={closePerson}
                onPick={goTo}
                onStep={stepBy}
              />
            )}
          </div>
        </div>
      </section>
    </ScrollReveal>
  );
}


/* ============================================================
   WORK WITH US PAGE
   ============================================================ */
function WorkHeroCard({ route, setRoute }) {
  return (
    <section className="surface" style={{ overflow: 'hidden', position: 'relative' }}>
      <img src="assets/illustrations/globe-compass.webp" alt="" style={{
        position: 'absolute', width: 820, top: '50%', left: '65%',
        transform: 'translate(-50%, -50%)',
        filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)',
        opacity: 0.35, pointerEvents: 'none', zIndex: 0,
        maskImage: 'radial-gradient(ellipse 60% 60% at center, black 20%, transparent 70%)',
        WebkitMaskImage: 'radial-gradient(ellipse 60% 60% at center, black 20%, transparent 70%)',
      }}/>
      <div className="subpage-hero-pad" style={{ position: 'relative', zIndex: 1, padding: '32px 56px 80px' }}>
        <SubPageNav route={route} setRoute={setRoute} />
        <ScrollReveal still>
        <div style={{ maxWidth: 600, paddingTop: 80, paddingBottom: 40 }}>
          <div className="section-index" style={{ marginBottom: 24 }}>
            <span className="section-index__num">00</span><span className="section-index__rule"></span><TypewriterLabel text="Services" delay={200} />
          </div>
          <h1 className="display-lg" style={{ marginBottom: 20, maxWidth: '18ch' }}>We measure the behavioral impact of AI on people.</h1>
          <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '44ch', marginBottom: 36 }}>
            The risks and the benefits. You're deploying AI at scale; we measure what it's doing to your people and hand back evidence and recommendations you can act on.
          </p>
          <div className="btn-group-mobile" style={{ display: 'flex', gap: 12 }}>
            <button type="button" className="btn btn--primary" onClick={() => document.getElementById('contact')?.scrollIntoView({ behavior: 'smooth' })}>Get in touch <ArrowDown /></button>
            <button type="button" className="btn btn--hollow" onClick={() => document.getElementById('services-section')?.scrollIntoView({ behavior: 'smooth' })}>Our services <ArrowDown /></button>
          </div>
        </div>
        </ScrollReveal>
      </div>
    </section>
  );
}

function AudienceCardsSection() {
  return (
    <ScrollReveal still>
    <section className="surface">
      <div className="surface__inner">
        <ScrollReveal still>
        <div style={{ marginBottom: 56 }}>
          <div className="section-index" style={{ marginBottom: 20 }}>
            {/* Eyebrow deliberately NOT "Who we work with" — that is the h2 directly
                below it, and the previous pair ("Who we support" / "Who this is for.")
                already spent two lines saying one thing. */}
            <span className="section-index__num">01</span><span className="section-index__rule"></span><TypewriterLabel text="Organisations" delay={300} />
          </div>
          <h2 className="display-lg" style={{ maxWidth: '22ch' }}>Who we work with.</h2>
        </div>
        </ScrollReveal>
        <div className="audience-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', borderTop: '1px solid var(--baii-bone)', borderBottom: '1px solid var(--baii-bone)' }}>
          {WORK_AUDIENCES.map((w, i) => (
            <ScrollReveal still key={w.n} delay={i * 120}>
            <article
              onClick={() => document.getElementById('contact')?.scrollIntoView({ behavior: 'smooth' })}
              style={{
              padding: '36px 28px 28px', borderLeft: i === 0 ? '1px solid var(--baii-bone)' : 'none',
              borderRight: '1px solid var(--baii-bone)', display: 'flex', flexDirection: 'column', gap: 18,
              minHeight: 360, background: '#fff', transition: 'background 200ms var(--ease-standard)',
              cursor: 'pointer',
            }}
            onMouseEnter={e => e.currentTarget.style.background = 'var(--baii-blue-95)'}
            onMouseLeave={e => e.currentTarget.style.background = '#fff'}>
              <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.18em', color: 'var(--baii-blue)' }}>{w.n}</div>
              <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 26, lineHeight: 1.1, letterSpacing: '-0.01em', margin: 0, fontWeight: 400, textWrap: 'balance' }}>{w.title}</h3>
              <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.55, color: 'var(--fg2)', margin: 0, flexGrow: 1 }}>{w.body}</p>
              <span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, borderRadius: 999, border: '1px solid var(--baii-bone)', color: 'var(--fg2)', textDecoration: 'none', transition: 'all 200ms var(--ease-standard)' }}
              onMouseEnter={e => { e.currentTarget.style.background = 'var(--baii-blue)'; e.currentTarget.style.borderColor = 'var(--baii-blue)'; e.currentTarget.style.color = '#fff'; }}
              onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.borderColor = 'var(--baii-bone)'; e.currentTarget.style.color = 'var(--fg2)'; }}><ArrowDown /></span>
            </article>
            </ScrollReveal>
          ))}
        </div>

        {/* The cards ask "is this my situation?"; this asks "is this my job?".
            It was a labelled box with the three roles as chips, which turned it into
            a second, competing set of cards. One sentence and the button instead —
            the roles are named inside the sentence rather than beside it. */}
        <ScrollReveal still delay={360}>
        <div className="audience-close">
          <p className="audience-close__line">
            Are you working in leadership, people and workforce, transformation, risk
            or governance? Wherever you sit in the organisation, we're here to help.
          </p>
          <button
            type="button"
            className="btn btn--primary audience-close__cta"
            onClick={() => document.getElementById('contact')?.scrollIntoView({ behavior: 'smooth' })}
          >
            Get in touch <ArrowDown />
          </button>
        </div>
        </ScrollReveal>
      </div>
    </section>
    </ScrollReveal>
  );
}

function ServiceCardsSection() {
  const [hovIdx, setHovIdx] = React.useState(-1);
  const [audit, engagement, community] = WORK_SERVICES;
  const AUDIT_OUTCOMES = ['De-risk AI adoption', 'Evidence for better rollout decisions', 'Future-proof how your organisation works with AI'];
  const ENGAGEMENT_STEPS = [
    { k: '01', label: 'Design partners', note: 'We start with a small number of organisations.' },
    { k: '02', label: 'Focused engagement', note: 'Baseline what your AI is doing to people today.' },
    { k: '03', label: 'Re-evaluation', note: 'Change the system or guardrails, then re-measure to show what changed.' },
    { k: '04', label: 'Effects build over time', note: 'Effects compound, so re-measuring is the model.' },
  ];
  return (
    <ScrollReveal still>
    {/* The id must not be the bare route name. #services is a route hash, so
        the old id made the browser fire a native fragment jump the moment App wrote
        window.location.hash = 'services', landing every visit to the
        Services page at scrollY 1469 instead of the top. The in-page CTA
        reaches it by getElementById, so the id is free to differ from the
        route name. Keep every section id out of PUBLIC_ROUTES. */}
    <section className="surface" id="services-section">
      <div className="surface__inner">
        <ScrollReveal still>
        <div style={{ marginBottom: 48 }}>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">02</span><span className="section-index__rule"></span><TypewriterLabel text="How we help" delay={300} />
          </div>
          <h2 className="display-lg" style={{ maxWidth: '22ch' }}>One engagement: evidence you can act on.</h2>
        </div>
        </ScrollReveal>
        {/* Top row: Behavioral Impact Audit + engagement timeline side by side (§5.4) */}
        <div className="grid-2col service-top-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 20, marginBottom: 20 }}>
          {/* 01 — Behavioral Impact Audit, with concrete outcomes (§5.5) */}
          <ScrollReveal still>
          <div onMouseEnter={() => setHovIdx(0)} onMouseLeave={() => setHovIdx(-1)}
            style={{
              border: `1px solid ${hovIdx === 0 ? 'var(--baii-blue)' : 'var(--baii-graphite)'}`,
              background: '#FBFAF7', borderRadius: 4, padding: 28, height: '100%',
              display: 'flex', flexDirection: 'column',
              transform: hovIdx === 0 ? 'translateY(-4px) rotate(-0.4deg)' : 'rotate(-0.4deg)',
              transition: 'border-color 200ms ease, transform 200ms ease',
            }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
              <PillarIcon type="research"/>
              <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--fg3)' }}>[{audit.n}]</span>
            </div>
            <div style={{ fontFamily: 'var(--font-display)', fontSize: 26, lineHeight: 1.1, letterSpacing: '-0.01em', textWrap: 'balance', marginBottom: 12 }}>{audit.title}</div>
            <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.6, color: 'var(--fg2)', margin: '0 0 20px' }}>{audit.body}</p>
            <div style={{ marginTop: 'auto', paddingTop: 18, borderTop: '1px solid var(--baii-bone)' }}>
              <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--baii-blue)', marginBottom: 12 }}>What you get</div>
              <ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 9 }}>
                {AUDIT_OUTCOMES.map((o, k) => (
                  <li key={k} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.5, color: 'var(--fg1)' }}>
                    <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--baii-blue)', marginTop: 7, flexShrink: 0 }} />
                    {o}
                  </li>
                ))}
              </ul>
            </div>
          </div>
          </ScrollReveal>

          {/* 02 — How an engagement runs, as a timeline (§5.3) */}
          <ScrollReveal still delay={120}>
          <div onMouseEnter={() => setHovIdx(1)} onMouseLeave={() => setHovIdx(-1)}
            style={{
              border: `1px solid ${hovIdx === 1 ? 'var(--baii-blue)' : 'var(--baii-graphite)'}`,
              background: '#FBFAF7', borderRadius: 4, padding: 28, height: '100%',
              display: 'flex', flexDirection: 'column',
              transform: hovIdx === 1 ? 'translateY(-4px) rotate(0.4deg)' : 'rotate(0.4deg)',
              transition: 'border-color 200ms ease, transform 200ms ease',
            }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
              <PillarIcon type="advisory"/>
              <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--fg3)' }}>[{engagement.n}]</span>
            </div>
            <div style={{ fontFamily: 'var(--font-display)', fontSize: 26, lineHeight: 1.1, letterSpacing: '-0.01em', textWrap: 'balance', marginBottom: 22 }}>{engagement.title}</div>
            <ol style={{ listStyle: 'none', margin: 0, padding: 0 }}>
              {ENGAGEMENT_STEPS.map((st, k) => (
                <li key={st.k} style={{ display: 'flex', gap: 16, paddingBottom: k < ENGAGEMENT_STEPS.length - 1 ? 22 : 0, position: 'relative' }}>
                  {k < ENGAGEMENT_STEPS.length - 1 && (
                    <span style={{ position: 'absolute', left: 13, top: 28, bottom: 0, width: 1, background: 'var(--baii-bone)' }} />
                  )}
                  <span style={{ flexShrink: 0, width: 28, height: 28, borderRadius: '50%', border: '1px solid var(--baii-blue)', color: 'var(--baii-blue)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600, background: '#FBFAF7', position: 'relative', zIndex: 1 }}>{st.k}</span>
                  <div style={{ paddingTop: 3 }}>
                    <div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, color: 'var(--fg1)' }}>{st.label}</div>
                    <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, color: 'var(--fg2)', marginTop: 3 }}>{st.note}</div>
                  </div>
                </li>
              ))}
            </ol>
          </div>
          </ScrollReveal>
        </div>

        {/* Bottom: For the wider community, full width (§5.4) */}
        <ScrollReveal still delay={200}>
        <div onMouseEnter={() => setHovIdx(2)} onMouseLeave={() => setHovIdx(-1)}
          style={{
            border: `1px solid ${hovIdx === 2 ? 'var(--baii-blue)' : 'var(--baii-graphite)'}`,
            background: '#FBFAF7', borderRadius: 4, padding: 28,
            display: 'flex', flexDirection: 'row', gap: 40, alignItems: 'flex-start', flexWrap: 'wrap',
            transform: hovIdx === 2 ? 'translateY(-4px)' : 'translateY(0)',
            transition: 'border-color 200ms ease, transform 200ms ease',
          }}>
          <div style={{ flex: '1 1 320px' }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
              <PillarIcon type="community"/>
              <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--fg3)' }}>[{community.n}]</span>
            </div>
            <div style={{ fontFamily: 'var(--font-display)', fontSize: 26, lineHeight: 1.1, letterSpacing: '-0.01em', marginBottom: 12 }}>{community.title}</div>
            <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.6, color: 'var(--fg2)', margin: 0, maxWidth: '52ch' }}>{community.body}</p>
          </div>
          <div style={{ flex: '1 1 260px', paddingTop: 4 }}>
            <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--baii-blue)', marginBottom: 12 }}>Free to start</div>
            <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.6, color: 'var(--fg2)', margin: 0 }}>{community.backBody}</p>
          </div>
        </div>
        </ScrollReveal>
      </div>
    </section>
    </ScrollReveal>
  );
}

function CredentialsSection() {
  const sig = useSignatories();
  const fill = (s) => s.replace('{count}', signatoryCount(sig)).replace('{phrase}', signatoryPhrase(sig));
  return (
    <ScrollReveal still>
    <section className="surface surface--emphasis">
      <div className="surface__inner">
        <ScrollReveal still>
        <div className="section-index" style={{ marginBottom: 24 }}>
          <span className="section-index__num">03</span><span className="section-index__rule"></span><TypewriterLabel text="Why us" delay={300} />
        </div>
        <h2 className="display-lg" style={{ marginBottom: 48, maxWidth: '20ch' }}>Our credentials.</h2>
        </ScrollReveal>
        <div className="credentials-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr' }}>
          {WORK_CREDENTIALS.map((c, i) => (
            <div key={c.title} style={{ padding: i === 0 ? '0 32px 0 0' : i === 2 ? '0 0 0 32px' : '0 32px', borderLeft: i > 0 ? '1px solid var(--baii-bone)' : 'none' }}>
              <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 'clamp(22px, 2vw, 32px)', lineHeight: 1.15, letterSpacing: '-0.01em', margin: '0 0 16px', fontWeight: 400, fontStyle: 'italic' }}>{fill(c.title)}</h3>
              <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.55, color: 'var(--fg2)', margin: 0 }}>{fill(c.body)}</p>
              {c.badge && <div style={{ marginTop: 20 }}><SignatoriesBadge count={signatoryCount(sig)} /></div>}
            </div>
          ))}
        </div>
      </div>
    </section>
    </ScrollReveal>
  );
}

function ContactSection() {
  const [form, setForm] = React.useState({ name: '', org: '', role: '', topic: '', email: '' });
  const F = window.BAIIForms;
  const fx = F.useFormSubmit(F.TABLES.CONTACT);
  const sent = fx.sent;
  const sectionRef = React.useRef(null);
  const inputStyle = { width: '100%', border: '1px solid rgba(255,255,255,0.3)', borderRadius: 8, background: 'rgba(255,255,255,0.08)', padding: '14px 18px', fontFamily: 'var(--font-mono)', fontSize: 14, color: '#fff', outline: 'none', transition: 'border-color 0.2s ease' };

  /* `#services?to=contact` lands ON this form. Route changes scrollTo(0,0),
     and this is the LAST section of the page, so every CTA that merely said
     setRoute('services') dropped the visitor four sections above the thing
     they had just asked for. Same hash-parameter mechanism the About page
     uses for `?person=`; the App only rewrites the hash when the BASE differs,
     so the parameter survives the route change. */
  React.useEffect(() => {
    let raf = 0;
    const sync = () => {
      if (hashParam('to') !== 'contact') return;
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => requestAnimationFrame(() => {
        sectionRef.current?.scrollIntoView({
          block: 'start',
          behavior: prefersReducedMotion() ? 'auto' : 'smooth',
        });
      }));
    };
    sync();
    window.addEventListener('hashchange', sync);
    return () => { cancelAnimationFrame(raf); window.removeEventListener('hashchange', sync); };
  }, []);

  return (
    <ScrollReveal still>
    <section className="surface surface--blue" id="contact" ref={sectionRef}>
      <div className="surface__inner">
        <ScrollReveal still>
        <div className="section-index" style={{ marginBottom: 24, color: 'rgba(255,255,255,0.6)' }}>
          <span style={{ color: 'rgba(255,255,255,0.8)' }}>04</span>
          <span className="section-index__rule" style={{ background: 'rgba(255,255,255,0.3)' }}></span>
          <TypewriterLabel text="Get in touch" delay={300} />
        </div>
        </ScrollReveal>
        <div className="grid-2col contact-grid" style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 64, alignItems: 'start' }}>
          <ScrollReveal still delay={100}>
          <div>
            <h2 className="display-lg" style={{ marginBottom: 24, maxWidth: '16ch', color: '#fff' }}>Let's talk.</h2>
            <p style={{ fontFamily: 'var(--font-mono)', fontSize: 15, lineHeight: 1.6, color: 'rgba(255,255,255,0.8)', maxWidth: '48ch' }}>
              If you're deploying AI and want to understand its behavioral impact, the risks and the benefits, we'd like to hear from you. The same goes if you already have a question and need the right expertise to answer it.
            </p>
            <div style={{ marginTop: 28, display: 'flex', flexDirection: 'column', gap: 12 }}>
              <a className="tap-link" href="mailto:hello@behavioralai-institute.org" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontFamily: 'var(--font-mono)', fontSize: 14, color: '#fff', textDecoration: 'none' }}>
                <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/></svg>
                hello@behavioralai-institute.org
              </a>
              <a className="tap-link" href="#about" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontFamily: 'var(--font-mono)', fontSize: 13, color: 'rgba(255,255,255,0.75)', textDecoration: 'none' }}>
                Prefer someone specific? Meet the team →
              </a>
            </div>
          </div>
          </ScrollReveal>
          <ScrollReveal still delay={200}>
          <div>
            {sent ? (
              <div style={{ padding: '48px 0', textAlign: 'center' }}>
                <div style={{ fontFamily: 'var(--font-display)', fontSize: 28, lineHeight: 1.15, color: '#fff', marginBottom: 12 }}>Thank you.</div>
                <p style={{ fontFamily: 'var(--font-mono)', fontSize: 14, color: 'rgba(255,255,255,0.7)' }}>We'll be in touch by email.</p>
              </div>
            ) : (
              <form
                onSubmit={e => {
                  e.preventDefault();
                  fx.submit({
                    name: form.name,
                    organization: form.org,
                    role: form.role,
                    email: form.email,
                    message: form.topic,
                    source: 'services_contact',
                  });
                }}
                style={{ display: 'flex', flexDirection: 'column', gap: 14 }}
              >
                <F.Honeypot hp={fx.hp} />
                {/* All four columns stay nullable in contact_messages — the imports
                    depend on that — so `required` here is a FORM-level rule, not a
                    constraint. Name and organisation became required on 2026-08-11:
                    someone asking us to measure AI's impact "in your organisation"
                    while naming neither themselves nor it is not an enquiry we can
                    answer. `role` is the new field and stays optional. The textarea
                    is held in state as `topic` but maps to the `message` column. */}
                <div className="field-row">
                  <label className="field-label" htmlFor="services-contact-name">Name</label>
                  <input id="services-contact-name" name="name" type="text" required placeholder="Name" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} style={inputStyle} onFocus={e => e.target.style.borderColor = 'rgba(255,255,255,0.6)'} onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,0.3)'} />
                </div>
                <div className="field-row">
                  <label className="field-label" htmlFor="services-contact-org">Organisation</label>
                  <input id="services-contact-org" name="organization" type="text" required placeholder="Organisation" value={form.org} onChange={e => setForm({ ...form, org: e.target.value })} style={inputStyle} onFocus={e => e.target.style.borderColor = 'rgba(255,255,255,0.6)'} onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,0.3)'} />
                </div>
                <div className="field-row">
                  <label className="field-label" htmlFor="services-contact-role">Your role</label>
                  <input id="services-contact-role" name="role" type="text" required placeholder="Head of People, CTO, researcher…" value={form.role} onChange={e => setForm({ ...form, role: e.target.value })} style={inputStyle} onFocus={e => e.target.style.borderColor = 'rgba(255,255,255,0.6)'} onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,0.3)'} />
                </div>
                <div className="field-row">
                  <label className="field-label" htmlFor="services-contact-email">Email address</label>
                  <input id="services-contact-email" name="email" type="email" required placeholder="Email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} style={inputStyle} onFocus={e => e.target.style.borderColor = 'rgba(255,255,255,0.6)'} onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,0.3)'} />
                </div>
                <div className="field-row">
                  <label className="field-label" htmlFor="services-contact-message">What you're working on</label>
                  <textarea id="services-contact-message" name="message" required placeholder="What you're working on" value={form.topic} onChange={e => setForm({ ...form, topic: e.target.value })} rows={4} style={{ ...inputStyle, resize: 'vertical', minHeight: 100 }} onFocus={e => e.target.style.borderColor = 'rgba(255,255,255,0.6)'} onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,0.3)'} />
                </div>
                <button type="submit" className="btn btn--on-blue" style={{ alignSelf: 'flex-start', marginTop: 8 }} disabled={fx.sending} aria-busy={fx.sending}>Send inquiry <Arrow /></button>
                <F.ErrorNote error={fx.error} onBlue />
              </form>
            )}
          </div>
          </ScrollReveal>
        </div>
      </div>
    </section>
    </ScrollReveal>
  );
}


/* ============================================================
   TRAINING PAGE
   ============================================================ */
function TrainingHeroCard({ route, setRoute }) {
  return (
    <section className="surface" style={{ overflow: 'hidden', position: 'relative' }}>
      <img src="assets/illustrations/light-bulb.webp" alt="" style={{
        position: 'absolute', width: 820, top: '50%', left: '55%',
        transform: 'translate(-50%, -50%)',
        filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)',
        opacity: 0.35, pointerEvents: 'none', zIndex: 0,
        maskImage: 'radial-gradient(ellipse 60% 60% at center, black 20%, transparent 70%)',
        WebkitMaskImage: 'radial-gradient(ellipse 60% 60% at center, black 20%, transparent 70%)',
      }}/>
      <div className="subpage-hero-pad" style={{ position: 'relative', zIndex: 1, padding: '32px 56px 80px' }}>
        <SubPageNav route={route} setRoute={setRoute} />
        <ScrollReveal still>
        <div style={{ maxWidth: 600, paddingTop: 80, paddingBottom: 40 }}>
          <div className="section-index" style={{ marginBottom: 24 }}>
            <span className="section-index__num">00</span><span className="section-index__rule"></span><TypewriterLabel text="Training & Adoption" delay={200} />
          </div>
          <h1 className="display-xl" style={{ marginBottom: 20, maxWidth: '14ch' }}>AI adoption is a behavior change problem.</h1>
          <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '44ch', marginBottom: 36 }}>
            Most adoption programs teach tools. Ours change habits. Courses, workshops, and embedded programs grounded in original research.
          </p>
          <div className="btn-group-mobile" style={{ display: 'flex', gap: 12 }}>
            <button type="button" className="btn btn--primary" onClick={() => document.getElementById('courses')?.scrollIntoView({ behavior: 'smooth' })}>Our programs <ArrowDown /></button>
            <button type="button" className="btn btn--hollow" onClick={() => document.getElementById('adoption')?.scrollIntoView({ behavior: 'smooth' })}>Our approach <ArrowDown /></button>
          </div>
        </div>
        </ScrollReveal>
      </div>
    </section>
  );
}

function AdoptionSection() {
  const [hovIdx, setHovIdx] = React.useState(-1);
  return (
    <ScrollReveal still>
    <section className="surface" id="adoption">
      <div className="surface__inner">
        <ScrollReveal still>
        <div className="section-index" style={{ marginBottom: 24 }}>
          <span className="section-index__num">01</span><span className="section-index__rule"></span><TypewriterLabel text="AI Adoption" delay={300} />
        </div>
        <h2 className="display-lg" style={{ marginBottom: 16, maxWidth: '24ch' }}>Adoption that sticks.</h2>
        <p style={{ fontFamily: 'var(--font-mono)', fontSize: 14, lineHeight: 1.6, color: 'var(--fg2)', maxWidth: '56ch', marginBottom: 48 }}>
          We help organizations move from surface-level AI usage to genuine integration — by treating adoption as the behavior change problem it actually is.
        </p>
        </ScrollReveal>
        <div className="adoption-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 0, borderTop: '1px solid var(--baii-bone)', borderBottom: '1px solid var(--baii-bone)' }}>
          {ADOPTION_PILLARS.map((p, i) => (
            <ScrollReveal still key={p.n} delay={i * 120}>
            <article
              onMouseEnter={() => setHovIdx(i)} onMouseLeave={() => setHovIdx(-1)}
              style={{
                padding: '36px 28px', display: 'flex', flexDirection: 'column', gap: 16,
                borderLeft: i === 0 ? '1px solid var(--baii-bone)' : 'none',
                borderRight: '1px solid var(--baii-bone)',
                background: hovIdx === i ? 'var(--baii-blue)' : '#fff',
                color: hovIdx === i ? '#fff' : 'inherit',
                transition: 'all 0.35s ease',
                cursor: 'default',
              }}>
              <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.18em', opacity: 0.6 }}>{p.n}</div>
              <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 24, lineHeight: 1.15, letterSpacing: '-0.01em', margin: 0, fontWeight: 400 }}>{p.title}</h3>
              <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.6, margin: 0, opacity: 0.85, flexGrow: 1 }}>{p.body}</p>
            </article>
            </ScrollReveal>
          ))}
        </div>
      </div>
    </section>
    </ScrollReveal>
  );
}

function TrainingCoursesCard({ setRoute }) {
  const [hovIdx, setHovIdx] = React.useState(-1);
  return (
    <ScrollReveal still>
    <section className="surface" id="courses">
      <div className="surface__inner">
        <ScrollReveal still>
        <div className="section-index" style={{ marginBottom: 24 }}>
          <span className="section-index__num">02</span><span className="section-index__rule"></span><TypewriterLabel text="Courses & workshops" delay={300} />
        </div>
        <h2 className="display-lg" style={{ marginBottom: 48, maxWidth: '22ch' }}>Our programs.</h2>
        </ScrollReveal>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
          {TRAINING_COURSES.map((c, i) => (
            <ScrollReveal still key={c.n} delay={i * 150}>
            <article className="training-course-grid"
              onClick={() => setRoute('services')}
              onMouseEnter={() => setHovIdx(i)} onMouseLeave={() => setHovIdx(-1)}
              style={{
                display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 0,
                border: '1px solid var(--baii-bone)', borderRadius: 8, overflow: 'hidden',
                background: hovIdx === i ? 'var(--baii-blue-95)' : '#fff',
                transform: hovIdx === i ? 'translateY(-2px)' : 'translateY(0)',
                boxShadow: hovIdx === i ? '0 8px 28px rgba(2, 99, 255, 0.08)' : '0 1px 4px rgba(0,0,0,0.03)',
                transition: 'all 0.3s ease',
                cursor: 'pointer',
              }}>
              <div style={{ padding: '28px 24px', borderRight: '1px solid var(--baii-bone)', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
                <div>
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.18em', color: 'var(--baii-blue)', marginBottom: 12 }}>{c.n}</div>
                  <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 24, lineHeight: 1.15, letterSpacing: '-0.01em', margin: '0 0 8px', fontWeight: 400 }}>{c.title}</h3>
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)', letterSpacing: '0.04em' }}>{c.format}</div>
                </div>
                <div style={{
                  display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 16,
                  background: c.status === 'Enrolling now' ? 'var(--baii-blue)' : 'var(--baii-graphite)',
                  color: '#fff', borderRadius: 999, padding: '5px 12px',
                  fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.06em', width: 'fit-content',
                }}>
                  {c.status === 'Enrolling now' && <span style={{ width: 5, height: 5, borderRadius: '50%', background: '#4ade80', flexShrink: 0 }}/>}
                  {c.status}
                </div>
              </div>
              <div style={{ padding: '28px 24px', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
                <div>
                  <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.6, color: 'var(--fg2)', margin: '0 0 14px' }}>{c.body}</p>
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)', letterSpacing: '0.06em' }}>
                    <span style={{ color: 'var(--baii-blue)', marginRight: 6 }}>→</span>{c.audience}
                  </div>
                </div>
                <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
                  <span style={{
                    display: 'inline-flex', alignItems: 'center', gap: 8,
                    fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--baii-blue)',
                    opacity: hovIdx === i ? 1 : 0, transition: 'opacity 0.3s ease',
                  }}>Inquire <Arrow /></span>
                </div>
              </div>
            </article>
            </ScrollReveal>
          ))}
        </div>
      </div>
    </section>
    </ScrollReveal>
  );
}

function TrainingCredentialsCard() {
  const [hovIdx, setHovIdx] = React.useState(-1);
  return (
    <ScrollReveal still>
    <section className="surface surface--emphasis">
      <div className="surface__inner">
        <ScrollReveal still>
        <div className="section-index" style={{ marginBottom: 24 }}>
          <span className="section-index__num">03</span><span className="section-index__rule"></span><TypewriterLabel text="Why train with us" delay={300} />
        </div>
        <h2 className="display-lg" style={{ marginBottom: 48, maxWidth: '24ch' }}>This isn't off-the-shelf training.</h2>
        </ScrollReveal>
        <div className="training-creds-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 20 }}>
          {TRAINING_CREDENTIALS.map((c, i) => (
            <ScrollReveal still key={c.n} delay={i * 120}>
            <article
              onMouseEnter={() => setHovIdx(i)} onMouseLeave={() => setHovIdx(-1)}
              style={{
                border: '1px solid var(--baii-bone)', borderRadius: 8, padding: '32px 28px',
                background: hovIdx === i ? '#fff' : 'transparent',
                boxShadow: hovIdx === i ? '0 8px 24px rgba(0,0,0,0.04)' : 'none',
                transform: hovIdx === i ? 'translateY(-2px)' : 'translateY(0)',
                transition: 'all 0.3s ease',
              }}>
              <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.18em', color: 'var(--baii-blue)', marginBottom: 14 }}>{c.n}</div>
              <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 22, lineHeight: 1.2, letterSpacing: '-0.01em', margin: '0 0 12px', fontWeight: 400 }}>{c.title}</h3>
              <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.6, color: 'var(--fg2)', margin: 0 }}>{c.body}</p>
            </article>
            </ScrollReveal>
          ))}
        </div>
      </div>
    </section>
    </ScrollReveal>
  );
}

function TrainingCTACard() {
  return (
    <ScrollReveal still>
    <section className="surface surface--blue">
      <div className="surface__inner" style={{ textAlign: 'center' }}>
        <h2 className="display-lg" style={{ marginBottom: 16, color: '#fff', maxWidth: '18ch', margin: '0 auto 16px' }}>Ready to build real adoption capability?</h2>
        <p style={{ fontFamily: 'var(--font-mono)', fontSize: 15, lineHeight: 1.6, color: 'rgba(255,255,255,0.8)', maxWidth: '52ch', margin: '0 auto 32px' }}>
          Whether you need a half-day executive briefing or a multi-week embedded program, we design training around your organization's specific adoption challenges.
        </p>
        {/* Was setRoute('services'), which landed at the top of a five-section
            page with the form at the bottom. The hash parameter puts the
            visitor on the form itself. */}
        <button className="btn btn--on-blue" onClick={() => { window.location.hash = 'services?to=contact'; }} style={{ cursor: 'pointer' }}>Get in touch <Arrow /></button>
      </div>
    </section>
    </ScrollReveal>
  );
}


/* ============================================================
   BLOG PAGE
   ============================================================ */
const BLOG_CATEGORIES = ['All', 'Research', 'Insight', 'Design', 'Event', 'Foundation'];

function BlogPage({ route, setRoute }) {
  const [activePost, setActivePost] = React.useState(null);
  const [filter, setFilter] = React.useState('All');
  const [hovIdx, setHovIdx] = React.useState(-1);

  if (activePost) {
    return React.createElement(BlogArticleView, { post: activePost, route, setRoute, onBack: () => setActivePost(null) });
  }

  const featured = BLOG_POSTS.find(p => p.featured);
  const filtered = BLOG_POSTS.filter(p => !p.featured && (filter === 'All' || p.category === filter));

  /* Everything below dereferences `featured`. With BLOG_POSTS empty — its
     state while Insights is held — that is a crash, and a crash in a route
     component blanks the whole site, not just this page. The route is not
     reachable while INSIGHTS_LIVE is false, so this only matters the day
     someone flips the flag before adding a post. It makes that a no-op
     instead of an outage. */
  if (!featured) return null;

  return (
    <>
      {/* Nav + Featured post */}
      <ScrollReveal still>
      <section className="surface">
        <div style={{ padding: '32px 56px 0' }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>
        <div className="surface__inner">
          <div
            onClick={() => setActivePost(featured)}
            style={{ cursor: 'pointer', textDecoration: 'none', color: 'inherit' }}
            onMouseEnter={() => setHovIdx(-2)} onMouseLeave={() => setHovIdx(-1)}>
            <article className="blog-featured-grid" style={{
              display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 0,
              border: '1px solid var(--baii-bone)', borderRadius: 12, overflow: 'hidden',
              background: hovIdx === -2 ? 'var(--baii-blue-95)' : '#fff',
              transform: hovIdx === -2 ? 'translateY(-4px)' : 'translateY(0)',
              boxShadow: hovIdx === -2 ? '0 16px 48px rgba(2, 99, 255, 0.12)' : '0 2px 8px rgba(0,0,0,0.03)',
              transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
            }}>
              <div className="blog-featured-image" style={{ position: 'relative', minHeight: 360, overflow: 'hidden' }}>
                <img src={featured.image} alt="" style={{
                  position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover',
                  filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)',
                  transform: hovIdx === -2 ? 'scale(1.04)' : 'scale(1)',
                  transition: 'transform 0.6s ease',
                }}/>
                <div style={{ position: 'absolute', top: 20, left: 20, background: 'var(--baii-blue)', color: '#fff', padding: '6px 14px', borderRadius: 999, fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase' }}>Featured</div>
              </div>
              <div className="blog-featured-content" style={{ padding: '48px 40px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
                  <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--baii-blue)', background: 'var(--baii-blue-95)', padding: '4px 10px', borderRadius: 999 }}>{featured.category}</span>
                  <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)' }}>{featured.date}</span>
                  <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)' }}>{featured.readMin} min read</span>
                </div>
                {/* The page had no h1 at all. The featured post is the only
                    article on it, so its title is the page's top heading —
                    no copy added, no styling changed. */}
                <h1 style={{ fontFamily: 'var(--font-display)', fontSize: 34, lineHeight: 1.12, letterSpacing: '-0.015em', margin: '0 0 16px', fontWeight: 400 }}>{featured.title}</h1>
                <p style={{ fontFamily: 'var(--font-mono)', fontSize: 14, lineHeight: 1.65, color: 'var(--fg2)', margin: '0 0 28px' }}>{featured.excerpt}</p>
                <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                    <img src={featured.author.photo} alt="" style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover', filter: 'grayscale(1)' }}/>
                    <span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg2)' }}>{featured.author.name}</span>
                  </div>
                  {/* The card as a whole stays a mouse target. The keyboard
                      path is this button: the wrapper cannot become one
                      because a <button> may not contain a heading, and
                      role="button" on the card would make its children
                      presentational and delete the h1 from the outline. */}
                  <button
                    type="button"
                    className="btn-reset"
                    aria-label={`Read: ${featured.title}`}
                    onClick={(e) => { e.stopPropagation(); setActivePost(featured); }}
                    onFocus={() => setHovIdx(-2)} onBlur={() => setHovIdx(-1)}
                    style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--baii-blue)', display: 'inline-flex', alignItems: 'center', gap: 6 }}
                  >Read <Arrow /></button>
                </div>
              </div>
            </article>
          </div>
        </div>
      </section>
      </ScrollReveal>

      {/* Filter + Grid */}
      <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <ScrollReveal still>
          <div className="blog-filter-bar" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 40 }}>
            <h2 className="display-lg" style={{ margin: 0 }}>All writing</h2>
            <div className="blog-filter-buttons" style={{ display: 'flex', gap: 6 }}>
              {BLOG_CATEGORIES.map(cat => (
                <button key={cat} onClick={() => setFilter(cat)} style={{
                  border: 'none', borderRadius: 999, padding: '7px 16px', cursor: 'pointer',
                  fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.06em',
                  background: filter === cat ? 'var(--baii-blue)' : 'var(--baii-blue-95)',
                  color: filter === cat ? '#fff' : 'var(--baii-blue)',
                  transition: 'all 0.2s ease',
                }}>{cat}</button>
              ))}
            </div>
          </div>
          </ScrollReveal>

          <div className="grid-3col" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 20 }}>
            {filtered.map((p, i) => (
              <ScrollReveal still key={p.slug} delay={i * 120}>
              <div onClick={() => setActivePost(p)} style={{ cursor: 'pointer' }}
                onMouseEnter={() => setHovIdx(i)} onMouseLeave={() => setHovIdx(-1)}>
                <article style={{
                  border: '1px solid var(--baii-bone)', borderRadius: 10, overflow: 'hidden',
                  background: hovIdx === i ? 'var(--baii-blue-95)' : '#fff',
                  transform: hovIdx === i ? 'translateY(-4px)' : 'translateY(0)',
                  boxShadow: hovIdx === i ? '0 12px 32px rgba(2, 99, 255, 0.1)' : '0 1px 4px rgba(0,0,0,0.03)',
                  transition: 'all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
                }}>
                  <div style={{ position: 'relative', aspectRatio: '16 / 10', overflow: 'hidden' }}>
                    <img src={p.image} alt="" style={{
                      width: '100%', height: '100%', objectFit: 'cover',
                      filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)',
                      transform: hovIdx === i ? 'scale(1.06)' : 'scale(1)',
                      transition: 'transform 0.5s ease',
                    }}/>
                  </div>
                  <div style={{ padding: '20px 22px 24px' }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
                      <span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--baii-blue)', background: 'var(--baii-blue-95)', padding: '3px 8px', borderRadius: 999 }}>{p.category}</span>
                      <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg3)' }}>{p.readMin} min</span>
                    </div>
                    {/* Keyboard path for the card: the title itself. Same
                        pattern as the glossary rows. */}
                    <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 20, lineHeight: 1.2, letterSpacing: '-0.01em', margin: '0 0 10px', fontWeight: 400 }}>
                      <button type="button" className="btn-reset" onClick={(e) => { e.stopPropagation(); setActivePost(p); }} onFocus={() => setHovIdx(i)} onBlur={() => setHovIdx(-1)}>{p.title}</button>
                    </h3>
                    <p style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.55, color: 'var(--fg2)', margin: '0 0 16px',
                      display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
                    }}>{p.excerpt}</p>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                      <img src={p.author.photo} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', filter: 'grayscale(1)' }}/>
                      <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)' }}>{p.author.name}</span>
                    </div>
                  </div>
                </article>
              </div>
              </ScrollReveal>
            ))}
          </div>
          {filtered.length === 0 && (
            <div style={{ textAlign: 'center', padding: '64px 0', fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--fg3)' }}>
              No posts in this category yet.
            </div>
          )}
        </div>
      </section>
      </ScrollReveal>
    </>
  );
}

function BlogArticleView({ post, route, setRoute, onBack }) {
  return (
    <>
      {/* Article header */}
      <section className="surface">
        <div style={{ padding: '32px 56px 0' }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>
        <div style={{ maxWidth: 720, margin: '0 auto', padding: '48px 24px 56px' }}>
          <button onClick={onBack} style={{
            border: 'none', background: 'none', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 8,
            fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--baii-blue)', marginBottom: 32, padding: 0,
          }}>
            <span style={{ transform: 'rotate(180deg)', display: 'inline-flex' }}><Arrow /></span> Back to all posts
          </button>
          <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--baii-blue)', background: 'var(--baii-blue-95)', padding: '4px 10px', borderRadius: 999 }}>{post.category}</span>
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)' }}>{post.date}</span>
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)' }}>{post.readMin} min read</span>
          </div>
          <h1 style={{ fontFamily: 'var(--font-display)', fontSize: 'clamp(36px, 4vw, 52px)', lineHeight: 1.08, letterSpacing: '-0.02em', margin: '0 0 28px', fontWeight: 400 }}>{post.title}</h1>
          <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
            <img src={post.author.photo} alt="" style={{ width: 44, height: 44, borderRadius: '50%', objectFit: 'cover', filter: 'grayscale(1)' }}/>
            <div>
              <div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 500, color: 'var(--fg1)' }}>{post.author.name}</div>
              <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)' }}>BAII</div>
            </div>
          </div>
        </div>
      </section>

      {/* Hero image */}
      <section className="surface" style={{ padding: 0 }}>
        <div style={{ maxWidth: 900, margin: '0 auto', padding: '0 24px' }}>
          <div style={{ borderRadius: 12, overflow: 'hidden', aspectRatio: '2 / 1' }}>
            <img src={post.image} alt="" style={{
              width: '100%', height: '100%', objectFit: 'cover',
              filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)',
            }}/>
          </div>
        </div>
      </section>

      {/* Article body */}
      <section className="surface">
        <div style={{ maxWidth: 680, margin: '0 auto', padding: '56px 24px 80px' }}>
          {post.body.map((block, i) => {
            if (block.type === 'h2') return (
              <h2 key={i} style={{ fontFamily: 'var(--font-display)', fontSize: 28, lineHeight: 1.2, letterSpacing: '-0.01em', margin: '48px 0 20px', fontWeight: 400 }}>{block.text}</h2>
            );
            if (block.type === 'quote') return (
              <blockquote key={i} style={{
                margin: '40px 0', padding: '24px 0 24px 28px',
                borderLeft: '3px solid var(--baii-blue)',
                fontFamily: 'var(--font-display)', fontSize: 22, lineHeight: 1.4,
                fontStyle: 'italic', color: 'var(--fg1)',
              }}>{block.text}</blockquote>
            );
            return (
              <p key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 15, lineHeight: 1.8, color: 'var(--fg2)', margin: '0 0 20px' }}>{block.text}</p>
            );
          })}

          {/* Article footer */}
          <div style={{ borderTop: '1px solid var(--baii-bone)', marginTop: 56, paddingTop: 32, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
              <img src={post.author.photo} alt="" style={{ width: 40, height: 40, borderRadius: '50%', objectFit: 'cover', filter: 'grayscale(1)' }}/>
              <div>
                <div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 500 }}>{post.author.name}</div>
                <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)' }}>{post.date}</div>
              </div>
            </div>
            <button onClick={onBack} style={{
              border: '1px solid var(--baii-bone)', background: 'none', borderRadius: 999,
              padding: '8px 18px', cursor: 'pointer',
              fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg2)',
              transition: 'all 0.2s ease',
            }}
            onMouseEnter={e => { e.currentTarget.style.background = 'var(--baii-blue)'; e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = 'var(--baii-blue)'; }}
            onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.color = 'var(--fg2)'; e.currentTarget.style.borderColor = 'var(--baii-bone)'; }}
            >All posts <Arrow /></button>
          </div>
        </div>
      </section>
    </>
  );
}


/* ============================================================
   OPEN LETTER PAGE
   ============================================================ */
function OpenLetterPage({ route, setRoute }) {
  const F = window.BAIIForms;
  const fx = F.useFormSubmit(F.TABLES.SIGNATURES);
  const submitted = fx.sent;
  const sig = useSignatories();

  // Rows arrive from the view already sorted: featured first, then
  // display_order. Splitting preserves that curated order in both tiers.
  const featured = React.useMemo(() => sig.rows.filter(s => s.featured), [sig.rows]);
  const others   = React.useMemo(() => sig.rows.filter(s => !s.featured), [sig.rows]);
  const stats    = React.useMemo(() => orgStats(sig.rows), [sig.rows]);

  // The three figures count up together, once, when the block scrolls in.
  const countRef  = React.useRef(null);
  const countLive = useInView(countRef);
  const ready     = sig.status === 'ready';
  const nTotal = useCountUp(ready ? sig.total : null, countLive);
  const nOrgs  = useCountUp(ready ? stats.orgs : null, countLive);
  const nSolo  = useCountUp(ready ? stats.solo : null, countLive);

  return (
    <>
      {/* Hero */}
      <ScrollReveal still>
      <section className="surface">
        <div style={{ padding: '32px 56px 0' }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>
        <div className="surface__inner" style={{ paddingTop: 0 }}>
          {/* Publication banner */}
          <div className="open-letter-banner" style={{
            background: 'var(--baii-blue)', borderRadius: 8, padding: '14px 20px',
            display: 'flex', alignItems: 'center', gap: 12, marginBottom: 40,
          }}>
            <div style={{ width: 6, height: 6, borderRadius: '50%', background: '#fff', opacity: 0.7, flexShrink: 0 }}/>
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: '#fff', letterSpacing: '0.02em' }}>
              Published in Wellcome Open Research (Feb 2026).
              {' '}<a href="https://wellcomeopenresearch.org/articles/11-152" target="_blank" rel="noopener noreferrer" style={{ color: '#fff', fontWeight: 600, textDecoration: 'underline', textUnderlineOffset: 3 }}>Read article</a>
            </span>
          </div>

          <div className="grid-2col" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 48, alignItems: 'center' }}>
            <div>
              <div className="section-index" style={{ marginBottom: 20 }}>
                <span className="section-index__num">00</span><span className="section-index__rule"></span><TypewriterLabel text="Open Letter" delay={300} />
              </div>
              <h1 className="display-xl" style={{ marginBottom: 12 }}>Open Letter</h1>
              <p className="body-mono" style={{ color: 'var(--fg2)', fontSize: 15, lineHeight: 1.6, marginBottom: 32, maxWidth: '48ch' }}>
                A call for behavioral scientists, social scientists, and AI leaders to shape the future of AI together.
              </p>
              <div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
                  <span style={{ fontFamily: 'var(--font-display)', fontSize: 36, fontWeight: 400, color: 'var(--fg1)' }}>{signatoryCount(sig)}</span>
                  <span className="body-mono" style={{ color: 'var(--fg3)', fontSize: 12 }}>signatories</span>
                </div>
                <button onClick={() => document.getElementById('sign-section')?.scrollIntoView({ behavior: 'smooth' })} className="btn btn--primary btn--sm">Sign the letter <ArrowDown /></button>
              </div>
            </div>
            <div style={{ position: 'relative', height: 400, borderRadius: 12, overflow: 'hidden', background: 'var(--baii-blue-95)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
              <img src="assets/library/illustration-hourglass.webp" alt="" style={{
                width: '70%', maxWidth: 280, objectFit: 'contain',
                filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)',
              }}/>
            </div>
          </div>
        </div>
      </section>
      </ScrollReveal>

      {/* Letter body */}
      <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <div style={{ maxWidth: 640, margin: '0 auto', paddingLeft: 32, borderLeft: '1px solid var(--baii-bone)' }}>
            <div className="section-index" style={{ marginBottom: 32 }}>
              <span className="section-index__num">01</span><span className="section-index__rule"></span><TypewriterLabel text="The Letter" delay={300} />
            </div>

            <p className="body-mono" style={{ color: 'var(--fg1)', marginBottom: 32, lineHeight: 1.8, fontWeight: 500, fontSize: 15 }}>
              <span style={{ fontFamily: 'var(--font-display)', fontSize: 48, float: 'left', lineHeight: 0.8, marginRight: 8, marginTop: 4, color: 'var(--baii-blue)' }}>T</span>he future of AI must be shaped not just by those who understand machines, but together with those who understand humans.
            </p>

            <p className="body-mono" style={{ color: 'var(--fg2)', marginBottom: 20, lineHeight: 1.8 }}>
              Artificial Intelligence has entered a new era. From diagnostic decisions in healthcare to hiring algorithms, credit scoring, and sentencing recommendation, AI is both making and influencing decisions that profoundly affect our shared experience. Yet these systems are designed primarily by those who understand algorithms, not human behavior.
            </p>

            <p className="body-mono" style={{ color: 'var(--fg2)', marginBottom: 20, lineHeight: 1.8 }}>
              These decisions already affect millions of lives daily, but the conversation around AI remains heavily focused on computational power, technological breakthroughs and performance on quantifiable tasks such as coding, mathematics, or knowledge retrieval. This open letter poses a fundamental question: Where is the human part of this equation?
            </p>

            <p className="body-mono" style={{ color: 'var(--fg2)', marginBottom: 20, lineHeight: 1.8 }}>
              Today, we issue a call, not for a pause or slowdown in AI, but for the immediate and accelerated integration of behavioral, cognitive and social sciences into the development, design, deployment, monitoring and evaluation of AI systems.
            </p>

            {/* h2, not h3 — these two follow the page h1 directly. All type
                is set inline, so nothing about the rendering changes. */}
            <h2 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginTop: 48, marginBottom: 20, fontWeight: 400 }}>The Critical Gap</h2>

            <p className="body-mono" style={{ color: 'var(--fg2)', marginBottom: 20, lineHeight: 1.8 }}>
              AI systems are not just algorithms. They increasingly mirror, influence, and actively shape our behaviors, emotions, and decisions. As AI models approach human-level performance on complex tasks and operate autonomously for extended periods, our understanding of cognition, decision-making, and human behavior becomes indispensable.
            </p>

            <p className="body-mono" style={{ color: 'var(--fg2)', marginBottom: 20, lineHeight: 1.8 }}>
              Without systematic integration of behavioral and social sciences, we risk deploying AI systems that fundamentally misunderstand human behavior, clash with human values, and widen societal divides rather than bridging them.
            </p>

            <h2 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginTop: 48, marginBottom: 20, fontWeight: 400 }}>Areas for Action</h2>

            <div style={{ display: 'flex', flexDirection: 'column', gap: 20, marginBottom: 32 }}>
              {[
                ['Human-AI Interactions', 'Designing interactions that maintain human autonomy, integrate emotional intelligence without manipulation, and preserve social connections essential for well-being.'],
                ['AI Model Development', 'Addressing cognitive biases that AI systems inadvertently mimic — anchoring, framing, confirmation bias — while operating without the emotional context central to human decision-making.'],
                ['Hyper-Personalization', 'Ensuring real-time adaptive systems support emotional wellbeing and decision-making aligned with users\' goals, rather than exploiting psychological states.'],
                ['AI Alignment & Societal Impact', 'Bridging the gap between AI capabilities and authentic human values, addressing job displacement and productivity divides with behavioral insight.'],
                ['Behavioral Safety Evaluation', 'Developing standards for safe, responsible AI behavior in healthcare, mental health, education, and wellbeing — evaluating real-world interactions, not just training.'],
              ].map(([title, desc], i) => (
                <div key={i}>
                  <p className="body-mono" style={{ color: 'var(--fg1)', fontWeight: 500, margin: '0 0 4px' }}>
                    <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--baii-blue)', fontWeight: 600, marginRight: 10 }}>{String(i + 1).padStart(2, '0')}</span>
                    {title}
                  </p>
                  <p className="body-mono" style={{ color: 'var(--fg2)', lineHeight: 1.7, margin: 0 }}>{desc}</p>
                </div>
              ))}
            </div>

            <blockquote style={{
              margin: '56px 0', padding: '32px 0 32px 32px',
              borderLeft: '4px solid var(--baii-blue)',
              fontFamily: 'var(--font-display)', fontSize: 26, lineHeight: 1.4,
              fontStyle: 'italic', color: 'var(--fg1)',
            }}>
              Together, we can develop AI systems that genuinely enhance human well-being — systems that complement our cognitive strengths, respect our psychological needs, and align with our diverse values.
            </blockquote>

            <p className="body-mono" style={{ color: 'var(--fg2)', lineHeight: 1.8, marginBottom: 32 }}>
              The window for thoughtfully guiding this transformation is narrow, and the moment for action is now.
            </p>

            <div className="open-letter-actions btn-group-mobile" style={{ borderTop: '1px solid var(--baii-bone)', paddingTop: 24, display: 'flex', gap: 12 }}>
              <a href="https://wellcomeopenresearch.org/articles/11-152" target="_blank" rel="noopener noreferrer" className="btn btn--primary btn--sm">Read in Wellcome Open Research <Arrow /></a>
              <button onClick={() => document.getElementById('sign-section')?.scrollIntoView({ behavior: 'smooth' })} className="btn btn--hollow btn--sm">Sign the letter <ArrowDown /></button>
            </div>
          </div>
        </div>
      </section>
      </ScrollReveal>

      {/* Signatories */}
      <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">02</span><span className="section-index__rule"></span><TypewriterLabel text="Signatories" delay={300} />
          </div>
          <h2 className="display-lg" style={{ marginBottom: 10 }}>Who has signed.</h2>
          <p className="body-mono" style={{ fontSize: 13, color: 'var(--fg2)', lineHeight: 1.65, maxWidth: '62ch', marginBottom: 34 }}>
            Cognitive scientists, behavioral researchers and AI practitioners who have co-signed the open letter.
          </p>

          {/* Count block. The numeral holds the '500+' fallback until the fetch
              lands, so a failed load never renders a wrong precise number. */}
          <div ref={countRef} style={{ marginBottom: 46 }}>
            <span className="signatory-count-n">{nTotal == null ? SIGNATORY_COUNT_FALLBACK : nTotal}</span>
            <div style={{ height: 1, background: 'var(--baii-graphite)', marginBottom: 13 }} />
            <div className="signatory-count-meta">
              <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 500, letterSpacing: '0.2em', textTransform: 'uppercase', color: 'var(--fg1)' }}>Signatories</span>
              {ready && (
                <>
                  <span className="signatory-count-sec"><b>{nOrgs}</b> organisations</span>
                  <span className="signatory-count-sec"><b>{nSolo}</b> the sole signatory from theirs</span>
                </>
              )}
            </div>
          </div>

          {/* Institution logos */}
          <div style={{ display: 'flex', alignItems: 'center', gap: 28, marginBottom: 40, flexWrap: 'wrap' }}>
            {RESEARCH_INSTITUTIONS.map((inst) => (
              <img key={inst.name} src={inst.logo} alt={inst.name} title={inst.name} style={{
                height: 24, width: 'auto', objectFit: 'contain',
                filter: 'grayscale(1)', opacity: 0.4,
              }}/>
            ))}
          </div>

          {/* Loading / error / loaded. */}
          {sig.status === 'loading' && (
            <div className="body-mono" style={{ color: 'var(--fg3)', fontSize: 13, padding: '28px 0' }}>
              Loading signatories…
            </div>
          )}

          {sig.status === 'error' && (
            <div style={{
              padding: '20px 24px', borderRadius: 8,
              border: '1px solid var(--baii-bone)', background: '#fff',
            }}>
              <div className="body-mono" style={{ fontSize: 13, color: 'var(--fg1)', marginBottom: 6 }}>
                The signatory list could not be loaded.
              </div>
              <div className="body-mono" style={{ fontSize: 12, color: 'var(--fg3)', marginBottom: 16, maxWidth: '60ch' }}>
                This is a problem on our side, not a sign that the list is empty. The full
                list of signatories is also published with the letter in Wellcome Open Research.
              </div>
              <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
                <button type="button" className="btn btn--hollow btn--sm" onClick={() => signatoryStore.reload()}>
                  Try again
                </button>
                <a className="btn btn--hollow btn--sm" href="https://wellcomeopenresearch.org/articles/11-152" target="_blank" rel="noopener noreferrer">
                  Read the article <Arrow />
                </a>
              </div>
            </div>
          )}

          {ready && featured.length > 0 && (
            <>
              <div className="signatory-tier">Featured</div>
              {/* Grid, not column-count: column-count flows column-major and
                  would put display_order 31 top-right, where it reads as the
                  second most prominent slot. Grid flows across — 1 2 / 3 4 —
                  so the order set in the admin is the order a reader sees. */}
              <div className="signatory-board">
                {featured.map((s) => {
                  const aff = affiliationLabel(s.affiliation);
                  return (
                    <div key={s.id} className="signatory-person">
                      <div className="signatory-name">{s.name}</div>
                      <div className="signatory-meta">
                        {s.title}{s.title && aff ? <span style={{ color: 'var(--fg3)' }}> · </span> : null}
                        {aff && <span style={{ color: 'var(--fg3)' }}>{aff}</span>}
                      </div>
                    </div>
                  );
                })}
              </div>
            </>
          )}

          {ready && others.length > 0 && (
            <>
              <div className="signatory-tier">All signatories</div>
              <p className="signatory-prose">
                {others.map((s, i) => {
                  const aff = affiliationLabel(s.affiliation);
                  return (
                    <React.Fragment key={s.id}>
                      {i > 0 && <span className="signatory-sep"> · </span>}
                      <b>{s.name}</b>{aff && <span> {aff}</span>}
                    </React.Fragment>
                  );
                })}
              </p>
            </>
          )}
        </div>
      </section>
      </ScrollReveal>

      {/* Sign form */}
      <ScrollReveal still>
      <section id="sign-section" className="surface" style={{ background: 'var(--baii-blue)' }}>
        <div className="surface__inner">
          <div className="section-index" style={{ marginBottom: 20, color: 'rgba(255,255,255,0.5)' }}>
            <span style={{ color: 'rgba(255,255,255,0.8)' }}>03</span><span className="section-index__rule" style={{ background: 'rgba(255,255,255,0.3)' }}></span><TypewriterLabel text="Add your name" delay={300} />
          </div>
          <div style={{ maxWidth: 560, margin: '0 auto' }}>
            <h2 className="display-lg" style={{ color: '#fff', marginBottom: 16 }}>Add your signature.</h2>
            <p style={{ fontFamily: 'var(--font-mono)', fontSize: 14, color: 'rgba(255,255,255,0.7)', marginBottom: 36, lineHeight: 1.6 }}>
              If you are a researcher, practitioner, or policymaker who believes AI development should be informed by behavioral science, we invite you to add your name.
            </p>

            {submitted ? (
              <div style={{ background: 'rgba(255,255,255,0.1)', borderRadius: 12, padding: '32px', textAlign: 'center' }}>
                <div style={{ fontFamily: 'var(--font-display)', fontSize: 22, color: '#fff', marginBottom: 8 }}>Thank you.</div>
                <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'rgba(255,255,255,0.7)' }}>We have your signature. Your name appears once we confirm it.</p>
              </div>
            ) : (
              <form
                className="form-on-blue"
                onSubmit={e => {
                  e.preventDefault();
                  /* The four inputs are uncontrolled — they carry name attributes
                     matching the column names, so FormData reads them directly and
                     no React state is needed. `source` is deliberately NOT sent:
                     anon has no insert privilege on that column and the database
                     default 'open_letter' applies. Sending it returns 401/42501. */
                  const fd = new FormData(e.currentTarget);
                  fx.submit({
                    name: fd.get('name'),
                    title: fd.get('title'),
                    affiliation: fd.get('affiliation'),
                    role: fd.get('role'),
                    email: fd.get('email'),
                    /* The checkbox is `required`, so the browser blocks submit
                       while it is unchecked and this is always true here. Sent
                       anyway rather than hardcoded: the column is the consent
                       record, and it should reflect the control, not our
                       assumption about it. `consent_text`/`consent_at`/
                       `consent_source` are deliberately NOT sent — they are not
                       in anon's column grant, so their server-side defaults
                       apply and the browser cannot choose what it claims the
                       signer agreed to. */
                    consent_publish: fd.get('consent_publish') != null,
                  });
                }}
                style={{ display: 'flex', flexDirection: 'column', gap: 14 }}
              >
                <F.Honeypot hp={fx.hp} />
                {/* All four already required; kept that way. `affiliation`, `role` and
                    `email` are nullable in open_letter_signatures only so the 533-row
                    legacy import can land — new signatures still have to supply them,
                    enforced by CHECK (email is not null or source = 'import') and by
                    `source` being absent from anon's column grant. */}
                <div className="field-row">
                  <label className="field-label" htmlFor="sign-name">Full name</label>
                  <input id="sign-name" name="name" type="text" placeholder="Full name" required style={{
                    border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(255,255,255,0.08)',
                    borderRadius: 8, padding: '12px 16px', color: '#fff',
                    fontFamily: 'var(--font-mono)', fontSize: 14, outline: 'none', width: '100%',
                  }}/>
                </div>
                {/* Added 2026-08-11. `title` was always in anon's column grant and
                    is SELECTed by open_letter_signatures_public, so a collected
                    title is a PUBLISHED title — which is why the consent label and
                    the consent_text column default both had to gain the word in the
                    same change (migrations/2026-08-11-form-fields.sql). This also
                    closes a gap the other way: 532 of the 533 legacy signatories
                    have a title and were open wording that promised to publish it,
                    while the rebuilt form had stopped asking. */}
                <div className="field-row">
                  <label className="field-label" htmlFor="sign-title">Title <span className="opt">(optional)</span></label>
                  <input id="sign-title" name="title" type="text" placeholder="Professor, Director of Research…" style={{
                    border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(255,255,255,0.08)',
                    borderRadius: 8, padding: '12px 16px', color: '#fff',
                    fontFamily: 'var(--font-mono)', fontSize: 14, outline: 'none', width: '100%',
                  }}/>
                </div>
                <div className="field-row">
                  <label className="field-label" htmlFor="sign-affiliation">Affiliation / Institution</label>
                  <input id="sign-affiliation" name="affiliation" type="text" placeholder="Affiliation / Institution" required style={{
                    border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(255,255,255,0.08)',
                    borderRadius: 8, padding: '12px 16px', color: '#fff',
                    fontFamily: 'var(--font-mono)', fontSize: 14, outline: 'none', width: '100%',
                  }}/>
                </div>
                <label className="field-label" htmlFor="sign-role" style={{ marginBottom: -6 }}>Role</label>
                <select id="sign-role" name="role" required defaultValue="" style={{
                  border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(255,255,255,0.08)',
                  borderRadius: 8, padding: '12px 16px', color: 'rgba(255,255,255,0.85)',
                  fontFamily: 'var(--font-mono)', fontSize: 14, outline: 'none', width: '100%',
                }}>
                  {/* "Select…" rather than "Role" — the field now carries a
                      visible ROLE label, so repeating it inside the control read
                      as a chosen value rather than an empty one. */}
                  <option value="">Select…</option>
                  <option value="Researcher">Researcher</option>
                  <option value="Practitioner">Practitioner</option>
                  <option value="Policymaker">Policymaker</option>
                  <option value="Other">Other</option>
                </select>
                <div className="field-row">
                  <label className="field-label" htmlFor="sign-email">Email address <span className="opt">(never published)</span></label>
                  <input id="sign-email" name="email" type="email" placeholder="Email" required style={{
                    border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(255,255,255,0.08)',
                    borderRadius: 8, padding: '12px 16px', color: '#fff',
                    fontFamily: 'var(--font-mono)', fontSize: 14, outline: 'none', width: '100%',
                  }}/>
                </div>
                {/* Publication consent, wording v3 (2026-08-11) — gained "title"
                    when the title field was added back, because
                    open_letter_signatures_public SELECTs that column, so a
                    collected title is a published one.

                    This label MUST stay in step with the consent_text column
                    default: the column records what the label promised. The 533
                    legacy rows deliberately keep the older string they were
                    actually open ("name, title, and organization",
                    consent_source 'legacy-production-form') — only the DEFAULT
                    moved, so they are untouched.
                    See migrations/2026-08-11-form-fields.sql §5. */}
                <label htmlFor="sign-consent" style={{
                  display: 'flex', alignItems: 'flex-start', gap: 10, marginTop: 6,
                  cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: 13,
                  lineHeight: 1.55, color: 'rgba(255,255,255,0.8)',
                }}>
                  <input id="sign-consent" name="consent_publish" type="checkbox" required style={{
                    accentColor: '#fff', width: 15, height: 15, marginTop: 2, flexShrink: 0,
                  }}/>
                  <span>
                    I agree to having my name, title, role, and affiliation published
                    as a signatory to this open letter.{' '}
                    <a href="#privacy" target="_blank" rel="noopener noreferrer" style={{
                      color: '#fff', textDecoration: 'underline', textUnderlineOffset: '2px',
                    }}>Privacy policy</a>
                  </span>
                </label>
                <button type="submit" className="btn btn--on-blue" style={{ marginTop: 8 }} disabled={fx.sending} aria-busy={fx.sending}>
                  Sign the letter <Arrow color="#fff" />
                </button>
                <F.ErrorNote error={fx.error} onBlue />
              </form>
            )}
          </div>
        </div>
      </section>
      </ScrollReveal>

    </>
  );
}


/* ============================================================
   NEWSLETTER PAGE
   ============================================================ */
const NEWSLETTER_SECTIONS = [
  { title: 'A paper', body: 'One piece of research worth reading, with our commentary.' },
  { title: 'A working draft', body: "Something we're developing — shared before it's finished." },
  { title: 'An event', body: "Something happening that's worth your time." },
  { title: 'A question', body: "One thing we're sitting with, offered without an answer." },
];

function NewsletterPage({ route, setRoute }) {
  const [email, setEmail] = React.useState('');
  const F = window.BAIIForms;
  const fx = F.useFormSubmit(F.TABLES.NEWSLETTER);
  const sent = fx.sent;

  return (
    <>
      {/* Hero */}
      <section className="surface hero-loaded" style={{ overflow: 'hidden', position: 'relative', background: 'var(--baii-paper)' }}>
        <div className="subpage-hero-pad" style={{ position: 'relative', zIndex: 1, padding: '32px 56px 64px' }}>
          <SubPageNav route={route} setRoute={setRoute} />
          <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', paddingTop: 100, paddingBottom: 40 }}>
            <div className="hero-title section-index" style={{ marginBottom: 40, animationDelay: '50ms' }}>
              <span className="section-index__num">00</span><span className="section-index__rule"></span><TypewriterLabel text="Newsletter" delay={200} />
            </div>
            <h1 className="hero-title display-xl" style={{ marginBottom: 16, animationDelay: '150ms' }}>The BAII Brief</h1>
            <p className="hero-subtitle" style={{ fontFamily: 'var(--font-display)', fontSize: 22, fontStyle: 'italic', color: 'var(--fg2)', margin: '0 0 24px', animationDelay: '250ms' }}>For people who want AI to know us better.</p>
            <p className="hero-subtitle body-mono" style={{ color: 'var(--fg2)', maxWidth: '52ch', margin: '0 0 12px', animationDelay: '300ms' }}>
              The BAII Brief is coming: a short, careful letter with a new paper, a working draft, an event worth your time, and one question we're sitting with. Leave your email and you'll get the first issue.
            </p>
            <div className="hero-subtitle" style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)', letterSpacing: '0.14em', textTransform: 'uppercase', marginBottom: 32, animationDelay: '350ms' }}>
              ~4 min read · No spam, ever
            </div>

            <form onSubmit={e => { e.preventDefault(); fx.submit({ email: email, source: 'newsletter_page' }); }} className="hero-ctas" style={{
              display: 'flex', gap: 8, padding: 8, border: '1px solid var(--baii-graphite)',
              borderRadius: 999, background: '#fff', width: '100%', maxWidth: 480, animationDelay: '450ms',
            }}>
              <F.Honeypot hp={fx.hp} />
              <label className="vh-label" htmlFor="newsletter-email">Email address</label>
              <input id="newsletter-email" name="email" type="email" required placeholder="you@institution.org" value={email} onChange={e => setEmail(e.target.value)} style={{
                flex: 1, border: 0, outline: 'none', background: 'transparent',
                padding: '12px 16px', fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--fg1)',
              }}/>
              <button type="submit" className="btn btn--primary btn--sm" disabled={fx.sending} aria-busy={fx.sending}>
                {sent ? 'Subscribed' : 'Subscribe'} {!sent && <Arrow />}
              </button>
            </form>
            {/* The pill is a flex row; the error sits under it, like the confirmation. */}
            {fx.error && <div style={{ width: '100%', maxWidth: 480, marginTop: 12, textAlign: 'left' }}><F.ErrorNote error={fx.error} /></div>}
            {sent && <p className="body-mono" style={{ color: 'var(--baii-blue)', marginTop: 12 }}>You're on the list. We're still writing the first issue.</p>}
          </div>
        </div>
      </section>

      {/* What's inside */}
      <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <ScrollReveal still>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">02</span><span className="section-index__rule"></span><TypewriterLabel text="What's inside" delay={300} />
          </div>
          <h2 className="display-lg" style={{ marginBottom: 32 }}>What you'll find in each issue.</h2>
          </ScrollReveal>

          <div className="grid-newsletter-sections" style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 32 }}>
            {NEWSLETTER_SECTIONS.map((s, i) => (
              <div key={i} style={{ borderLeft: '2px solid var(--baii-blue-95)', paddingLeft: 20 }}>
                <div style={{
                  width: 40, height: 40, borderRadius: '50%', background: 'var(--baii-blue-95)',
                  display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 12,
                  fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600, color: 'var(--baii-blue)',
                }}>{String(i + 1).padStart(2, '0')}</div>
                <p className="body-mono" style={{ fontWeight: 500, color: 'var(--fg1)', marginBottom: 4, fontSize: 14 }}>{s.title}</p>
                <p className="body-mono" style={{ color: 'var(--fg2)', fontSize: 12, lineHeight: 1.6 }}>{s.body}</p>
              </div>
            ))}
          </div>
        </div>
      </section>
      </ScrollReveal>

      {/* Bottom subscribe CTA */}
      <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <div style={{ background: 'var(--baii-blue)', borderRadius: 16, padding: '48px 40px', textAlign: 'center' }}>
            <h2 style={{ fontFamily: 'var(--font-display)', fontSize: 28, color: '#fff', marginBottom: 12, fontWeight: 400 }}>Be there for the first issue.</h2>
            <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'rgba(255,255,255,0.8)', marginBottom: 24, maxWidth: '40ch', margin: '0 auto 24px' }}>
              Leave your email and you'll get the BAII Brief the day it launches.
            </p>
            <button type="button" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="btn btn--on-blue">Subscribe <Arrow /></button>
          </div>
        </div>
      </section>
      </ScrollReveal>
    </>
  );
}


/* ============================================================
   CONTACT PAGE
   ============================================================ */
/* Rebuilt 2026-08-20. The previous page took 0 submissions between launch and
   that date while Newsletter took 22, Join 7 and the Open Letter 4 — the most
   prominent CTA on every page converted nobody in eight days. It carried a
   Subject dropdown nobody opened and a callout that sent its most valuable
   visitor to the TOP of a five-section page to hunt for a form.

   Three ways in, and each one opens its own form HERE. Nothing hands off, so
   "dumped at the bottom of Services" stops being possible rather than being
   fixed. Left column states and identifies; right column chooses; the blue
   panel below is where the work happens — the same left/right split the rest
   of the site uses, not the centred layout the mock explored.

   Three CHECK constraints govern what may be sent. Widen the constraint BEFORE
   adding an option to any of them or every submission using it returns 400:
     contact_messages.source   services_contact | contact_page
     contact_messages.subject  General inquiry | Press | Speaking | Partnership | Other
     join_interest.areas       the eight labels below, verbatim
     join_interest.contributions  the five labels below, verbatim               */

const CONTACT_WAYS = [
  { key: 'work', n: '01', title: 'Work with us',
    hint: 'Measure what your AI is doing to the people using it.',
    formTitle: 'Services enquiry',
    note: 'We reply by email, usually within a few days.' },
  { key: 'join', n: '02', title: 'Join us',
    hint: 'For researchers and practitioners who want to contribute.',
    formTitle: 'Express interest',
    note: 'A member of the Institute follows up personally.' },
  { key: 'else', n: '03', title: 'Everything else',
    hint: 'Press, speaking, partnerships, and anything unlisted.',
    formTitle: 'Write to us',
    note: 'hello@behavioralai-institute.org works too.' },
];

const JOIN_AREAS = ['Behavioral & Cognitive Science', 'AI/ML Research', 'Product & Design', 'Engineering', 'Clinical & Health', 'Policy & Governance', 'Communications', 'Other'];
const JOIN_CONTRIBUTIONS = ['Advisory & consulting', 'Community & education', 'Industry partnership', 'Research & publications', 'Tool & framework development'];
const CONTACT_SUBJECTS = ['General inquiry', 'Press', 'Speaking', 'Partnership', 'Other'];

/* A ruled field. The boxed inputs this page used to carry read as an admin
   screen; a rule and a label do the same job and let the blue panel breathe. */
function RuledField({ id, label, opt, children }) {
  return (
    <div className="cf">
      <label className="cf__label" htmlFor={id}>{label}{opt ? <span className="opt"> (optional)</span> : null}</label>
      {children}
    </div>
  );
}

function ContactWayCard({ way, selected, pinged, onPick }) {
  return (
    <button
      type="button"
      className={`cway${selected ? ' is-on' : ''}${pinged ? ' ping' : ''}`}
      onClick={onPick}
      aria-expanded={selected}
    >
      <span className="cway__mark" />
      <span className="cway__n">{way.n}</span>
      <span className="cway__title">{way.title}</span>
      {/* way.hint is no longer rendered — Samuel dropped the descriptions on
          2026-08-22 so the three choices fit one compact row and the form sits
          high on the page. The strings stay in CONTACT_WAYS: they are the only
          written explanation of what each route is for, and they will be
          wanted again if these ever become cards. */}
    </button>
  );
}

function ContactPage({ route, setRoute }) {
  const F = window.BAIIForms;
  /* Three instances, not two. "Work with us" and "Everything else" both write
     contact_messages, but a single hook shares one `sent` flag between them —
     so submitting one made the OTHER open onto "Message received" with no form
     underneath it. Each hook is a closure over its own state; calling it twice
     for the same table is what keeps the two panes independent. */
  const fxWork = F.useFormSubmit(F.TABLES.CONTACT);
  const fxJoin = F.useFormSubmit(F.TABLES.JOIN);
  const fxMisc = F.useFormSubmit(F.TABLES.CONTACT);

  const [open, setOpen] = React.useState('work');
  /* The panel is permanent — it swaps content rather than opening and closing,
     so a visitor lands on a live form instead of an empty blue box. */
  const [focus, setFocus] = React.useState(null);
  const [handIn, setHandIn] = React.useState(false);
  const [nudged, setNudged] = React.useState(false);
  const [sparks, setSparks] = React.useState(null);
  const [pinged, setPinged] = React.useState(-1);

  const [work, setWork] = React.useState({ name: '', email: '', organization: '', role: '', message: '' });
  const [join, setJoin] = React.useState({ name: '', email: '', institution: '', workLink: '', areas: [], contributions: [], areasOther: '', description: '', motivation: '' });
  const [misc, setMisc] = React.useState({ name: '', email: '', organization: '', subject: 'General inquiry', message: '' });

  const sparkTimer = React.useRef(0);

  const reduced = typeof prefersReducedMotion === 'function' ? prefersReducedMotion() : false;
  const way = CONTACT_WAYS.find(w => w.key === open) || null;
  const fx = open === 'join' ? fxJoin : open === 'work' ? fxWork : fxMisc;

  const toggleIn = (key, opt) => setJoin(f => ({
    ...f, [key]: f[key].includes(opt) ? f[key].filter(a => a !== opt) : [...f[key], opt],
  }));

  React.useEffect(() => {
    const t = setTimeout(() => setHandIn(true), 140);
    return () => clearTimeout(t);
  }, []);

  React.useEffect(() => () => clearTimeout(sparkTimer.current), []);

  /* The fingertip. Quiet by design: nothing signals it, the hit area is the tip
     alone, and one touch at a time so rapid clicks cannot turn it into confetti. */
  const touchTip = () => {
    if (sparks) return;
    if (!reduced) {
      const dots = [];
      for (let i = 0; i < 14; i++) {
        const a = (-0.55 + (i / 14) * 1.1) * Math.PI;   // weighted the way the finger points
        const dist = 34 + (i % 4) * 13;
        dots.push({ dx: -Math.cos(a) * dist, dy: Math.sin(a) * dist, delay: (i % 5) * 22 });
      }
      setSparks(dots);
      sparkTimer.current = setTimeout(() => setSparks(null), 1100);
    }
    CONTACT_WAYS.forEach((_, i) => {
      setTimeout(() => {
        setPinged(i);
        setTimeout(() => setPinged(p => (p === i ? -1 : p)), 620);
      }, 160 + i * 130);
    });
  };

  const submit = (e) => {
    e.preventDefault();
    if (open === 'work') {
      fxWork.submit({
        name: work.name, email: work.email, organization: work.organization,
        role: work.role, message: work.message, source: 'services_contact',
      });
    } else if (open === 'join') {
      fxJoin.submit({
        name: join.name, email: join.email, institution: join.institution,
        work_link: join.workLink, areas: join.areas, contributions: join.contributions,
        /* only while the chip is on, so deselecting cannot smuggle stale text in */
        areas_other: join.areas.includes('Other') ? (join.areasOther.trim() || null) : null,
        description: join.description, motivation: join.motivation.trim() || null,
        source: 'join_us',
      });
    } else {
      fxMisc.submit({
        name: misc.name, email: misc.email, organization: misc.organization,
        subject: misc.subject, message: misc.message, source: 'contact_page',
      });
    }
  };

  const inp = (k) => ({ className: 'cf__input', onFocus: () => setFocus(k), onBlur: () => setFocus(null) });

  return (
    <>
      <ScrollReveal still>
      <section className="surface contact-surface" style={{ background: 'var(--baii-paper)' }}>
        {/* Top right, opposite the title — the band between the nav and the
        grid is the only place on the page it does not fight something.
        The illustration points right, so it is mirrored to point INTO the
        page; the fingertip, the nudge and the spark are all mirrored with
        it. */}
        <div className={`chand${handIn ? ' is-in' : ''}`} aria-hidden={sparks ? undefined : 'true'}>
        <div className={`chand__nudge${nudged ? ' nudged' : ''}`}>
        <div className="chand__bob">
        <img src="assets/illustrations/pointing-hand.png" alt="" className="chand__img" />
        <button
        type="button" className="chand__tip" aria-label="Touch the fingertip"
        onMouseEnter={() => setNudged(true)} onMouseLeave={() => setNudged(false)}
        onFocus={() => setNudged(true)} onBlur={() => setNudged(false)}
        onClick={touchTip}
        />
        {sparks && (
        <span className="cspark">
        {sparks.map((d, i) => (
        <i key={i} style={{ '--dx': d.dx + 'px', '--dy': d.dy + 'px', animationDelay: d.delay + 'ms' }} />
        ))}
        </span>
        )}
        </div>
        </div>
        </div>

        <div style={{ padding: '32px 56px 0', position: 'relative', zIndex: 2 }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>

        <div className="surface__inner" style={{ position: 'relative', zIndex: 2 }}>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">00</span><span className="section-index__rule"></span><TypewriterLabel text="Contact" delay={300} />
          </div>
          <h1 className="display-xl contact-h1">The three ways to reach us.</h1>

          <div className="contact-grid">

            {/* The question, then the three answers. One column: the choice
                sits directly above the form it opens, so on every width the
                consequence of a tap is on screen. Replaced a two-column layout
                (choices left, form right) which worked on a desktop and lost
                all its feedback the moment it stacked. */}
            <div className="contact-choose">
              <p className="contact-prompt">Why do you want to reach out?</p>
              <div className="cways">
                {CONTACT_WAYS.map((w, i) => (
                  <ContactWayCard
                    key={w.key} way={w}
                    selected={open === w.key}
                    pinged={pinged === i}
                    onPick={() => setOpen(w.key)}
                  />
                ))}
              </div>

            </div>

            {/* The form, permanently — it swaps content rather than opening and
                closing, so a visitor lands on a live form rather than an empty
                box. */}
            <div className="contact-right">
              {way && (
                  <div className="cpanel">
                    <h2 className="cpanel__title">{way.formTitle}</h2>
                    {!fx.sent ? (
                      <form onSubmit={submit} className="cpanel__form">
                        <F.Honeypot hp={fx.hp} />

                        {open === 'work' && (<>
                          <div className="cf-row">
                            <RuledField id="c-w-name" label="Name"><input id="c-w-name" name="name" type="text" required placeholder="Your name" value={work.name} onChange={e => setWork({ ...work, name: e.target.value })} {...inp('wn')} /></RuledField>
                            <RuledField id="c-w-email" label="Email"><input id="c-w-email" name="email" type="email" required placeholder="you@company.com" value={work.email} onChange={e => setWork({ ...work, email: e.target.value })} {...inp('we')} /></RuledField>
                          </div>
                          <div className="cf-row">
                            {/* Required, matching /services: this pane writes the same
                              source, and an enquiry about measuring AI "in your
                              organisation" that names neither the person nor it is
                              not one we can answer. Decided 2026-08-11. */}
                          <RuledField id="c-w-org" label="Organisation"><input id="c-w-org" name="organization" type="text" required placeholder="Where you work" value={work.organization} onChange={e => setWork({ ...work, organization: e.target.value })} {...inp('wo')} /></RuledField>
                            <RuledField id="c-w-role" label="Your role"><input id="c-w-role" name="role" type="text" required placeholder="Head of People, CTO&hellip;" value={work.role} onChange={e => setWork({ ...work, role: e.target.value })} {...inp('wr')} /></RuledField>
                          </div>
                          <RuledField id="c-w-msg" label="How can we help?"><textarea id="c-w-msg" name="message" required rows={4} placeholder="A sentence or two about what you need is plenty." value={work.message} onChange={e => setWork({ ...work, message: e.target.value })} {...inp('wm')} /></RuledField>
                        </>)}

                        {open === 'join' && (<>
                          <div className="cf-row">
                            <RuledField id="c-j-name" label="Name"><input id="c-j-name" name="name" type="text" required placeholder="Your name" value={join.name} onChange={e => setJoin({ ...join, name: e.target.value })} {...inp('jn')} /></RuledField>
                            <RuledField id="c-j-email" label="Email"><input id="c-j-email" name="email" type="email" required placeholder="you@institution.org" value={join.email} onChange={e => setJoin({ ...join, email: e.target.value })} {...inp('je')} /></RuledField>
                          </div>
                          <div className="cf-row">
                            <RuledField id="c-j-inst" label="Institution" opt><input id="c-j-inst" name="institution" type="text" placeholder="Where you work" value={join.institution} onChange={e => setJoin({ ...join, institution: e.target.value })} {...inp('ji')} /></RuledField>
                            <RuledField id="c-j-link" label="Link to your work"><input id="c-j-link" name="work_link" type="url" required placeholder="LinkedIn, Scholar, ORCID&hellip;" value={join.workLink} onChange={e => setJoin({ ...join, workLink: e.target.value })} {...inp('jl')} /></RuledField>
                          </div>
                          <div role="group" aria-labelledby="c-j-areas">
                            <div id="c-j-areas" className="cf__grouplabel">Which areas would you like to work in? <span className="opt">(select all that apply)</span></div>
                            <div className="cchips">
                              {JOIN_AREAS.map(o => (
                                <button type="button" key={o} className="cchip" aria-pressed={join.areas.includes(o)} onClick={() => toggleIn('areas', o)}>{o}</button>
                              ))}
                            </div>
                            {join.areas.includes('Other') && (
                              <div style={{ marginTop: 14 }}>
                                <RuledField id="c-j-other" label="Which other area?"><input id="c-j-other" name="areas_other" type="text" required maxLength={200} placeholder="Tell us the area" value={join.areasOther} onChange={e => setJoin({ ...join, areasOther: e.target.value })} {...inp('jo')} /></RuledField>
                              </div>
                              )}
                            </div>
                            <div role="group" aria-labelledby="c-j-contrib">
                              <div id="c-j-contrib" className="cf__grouplabel">How would you like to contribute? <span className="opt">(select all that apply)</span></div>
                              <div className="cchips">
                                {JOIN_CONTRIBUTIONS.map(o => (
                                  <button type="button" key={o} className="cchip" aria-pressed={join.contributions.includes(o)} onClick={() => toggleIn('contributions', o)}>{o}</button>
                                ))}
                              </div>
                            </div>
                            <RuledField id="c-j-desc" label="Describe your work"><textarea id="c-j-desc" name="description" required rows={3} placeholder="In a few sentences, what do you work on?" value={join.description} onChange={e => setJoin({ ...join, description: e.target.value })} {...inp('jd')} /></RuledField>
                            <RuledField id="c-j-why" label="Why are you interested in joining BAII?"><textarea id="c-j-why" name="motivation" required rows={3} maxLength={2000} placeholder="What draws you to this work?" value={join.motivation} onChange={e => setJoin({ ...join, motivation: e.target.value })} {...inp('jw')} /></RuledField>
                              </>)}

                          {open === 'else' && (<>
                            <div className="cf-row">
                              <RuledField id="c-e-name" label="Name"><input id="c-e-name" name="name" type="text" required placeholder="Your name" value={misc.name} onChange={e => setMisc({ ...misc, name: e.target.value })} {...inp('en')} /></RuledField>
                              <RuledField id="c-e-email" label="Email"><input id="c-e-email" name="email" type="email" required placeholder="you@institution.org" value={misc.email} onChange={e => setMisc({ ...misc, email: e.target.value })} {...inp('ee')} /></RuledField>
                            </div>
                            <div className="cf-row">
                              <RuledField id="contact-org" label="Organisation" opt><input id="contact-org" name="organization" type="text" placeholder="Where you work" value={misc.organization} onChange={e => setMisc({ ...misc, organization: e.target.value })} {...inp('eo')} /></RuledField>
                              <RuledField id="c-e-sub" label="What is it about">
                                <select id="c-e-sub" name="subject" value={misc.subject} onChange={e => setMisc({ ...misc, subject: e.target.value })} {...inp('es')}>
                                  {CONTACT_SUBJECTS.map(o => <option key={o}>{o}</option>)}
                                </select>
                              </RuledField>
                            </div>
                            <RuledField id="c-e-msg" label="Message"><textarea id="c-e-msg" name="message" required rows={4} placeholder="A sentence or two is plenty." value={misc.message} onChange={e => setMisc({ ...misc, message: e.target.value })} {...inp('em')} /></RuledField>
                          </>)}

                          <div className="cpanel__send">
                            <button type="submit" className="btn btn--primary" disabled={fx.sending} aria-busy={fx.sending}>
                              {open === 'join' ? 'Express interest' : open === 'work' ? 'Send enquiry' : 'Send message'} <Arrow color="#fff" />
                            </button>
                            <span className="cpanel__note">{way.note}</span>
                          </div>
                          <F.ErrorNote error={fx.error} />
                        </form>
                      ) : (
                        <div style={{ padding: '18px 0 8px' }}>
                          <p className="body-mono" style={{ color: 'var(--fg1)', fontWeight: 500, fontSize: 15 }}>
                            {open === 'join' ? 'Thank you for your interest.' : 'Message received.'}
                          </p>
                          <p className="body-mono" style={{ color: 'var(--fg2)', marginTop: 8 }}>
                            {open === 'join' ? 'We have your details, and we’ll be in touch.' : 'We’ll come back to you by email.'}
                          </p>
                        </div>
                      )}
                    </div>
              )}
            </div>

            {/* Below the form now, not between the chooser and it. Someone who
                does not want a form can still find an address; someone who does
                is no longer reading past this to reach it. */}
            <div className="contact-direct">
              <a className="tap-link" href="mailto:hello@behavioralai-institute.org">hello@behavioralai-institute.org</a>
              <a className="tap-link" href="https://www.linkedin.com/company/behavioralai-institute/" target="_blank" rel="noopener noreferrer">LinkedIn &middot; @behavioralai-institute</a>
              <span>Paris &middot; Stockholm &middot; London</span>
            </div>
          </div>

        </div>
      </section>
      </ScrollReveal>
    </>
  );
}


/* ============================================================
   JOIN US PAGE
   ============================================================ */
const FELLOWSHIP_TYPES = [
  { n: '01', title: 'Honorary Fellowship', body: 'A formal affiliation for established researchers and practitioners. Contribute to working groups, co-author publications, and shape BAII\'s research agenda.' },
  { n: '02', title: 'Associate Fellowship', body: 'For early-career researchers and practitioners building expertise in Behavioral AI. Access our network, mentorship, and collaboration opportunities.' },
  { n: '03', title: 'Project Collaboration', body: 'Propose or join a specific research project. We welcome external collaborators on studies, benchmarks, and evaluation frameworks.' },
];

const PROFILES_SOUGHT = [
  { title: 'Behavioral & Cognitive Scientists', body: 'Researchers studying decision-making, trust, attention, habit formation, or social cognition — especially in technology-mediated contexts.' },
  { title: 'AI/ML Researchers', body: 'Engineers and researchers building AI systems who want to understand and measure their behavioral impact on users.' },
  { title: 'Design & Product Practitioners', body: 'UX researchers, product designers, and product managers working on AI-powered products who think about human outcomes.' },
  { title: 'Policy & Governance Experts', body: 'People working on AI regulation, ethics frameworks, or institutional governance who need behavioral evidence in their work.' },
];

/* One selectable chip. Extracted when the Join form gained a second
   multi-select — two copies of a 12-property inline style that must stay
   identical is one copy too many. `group` becomes the input's name attribute,
   so the two groups stay distinguishable in the DOM. */
function JoinChip({ group, opt, checked, onToggle }) {
  return (
    <label style={{
      display: 'inline-flex', alignItems: 'center', gap: 8, padding: '9px 14px',
      borderRadius: 999, cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: 13,
      color: '#fff',
      border: checked ? '1px solid rgba(255,255,255,0.55)' : '1px solid rgba(255,255,255,0.15)',
      background: checked ? 'rgba(255,255,255,0.16)' : 'rgba(255,255,255,0.05)',
      transition: 'all 0.15s',
    }}>
      <input type="checkbox" name={group} value={opt} checked={checked} onChange={onToggle}
             style={{ accentColor: '#fff', width: 14, height: 14 }} />
      {opt}
    </label>
  );
}

function JoinUsPage({ route, setRoute }) {
  const [form, setForm] = React.useState({ name: '', email: '', institution: '', workLink: '', area: [], contribution: [], areaOther: '', description: '', motivation: '' });
  const F = window.BAIIForms;
  const fx = F.useFormSubmit(F.TABLES.JOIN);
  const sent = fx.sent;
  const [focusField, setFocusField] = React.useState(null);
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const toggleIn = (key, opt) => setForm(f => ({
    ...f, [key]: f[key].includes(opt) ? f[key].filter(a => a !== opt) : [...f[key], opt],
  }));

  /* The same nine declarations were repeated inline on every control here. With
     the form now carrying eight of them, one function is both shorter and the
     only way to be sure the focus ring is identical on all of them. */
  const joinInput = (k) => ({
    width: '100%', padding: '12px 16px', borderRadius: 8,
    border: focusField === k ? '1px solid rgba(255,255,255,0.4)' : '1px solid rgba(255,255,255,0.15)',
    background: 'rgba(255,255,255,0.08)', fontFamily: 'var(--font-mono)', fontSize: 13,
    color: '#fff', outline: 'none', transition: 'border-color 0.15s',
  });

  return (
    <>
      {/* Hero */}
      <ScrollReveal still>
      <section className="surface" style={{ background: 'var(--baii-paper)' }}>
        <div style={{ padding: '32px 56px 0' }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>
        <div className="surface__inner" style={{ paddingTop: 0 }}>
          <div className="grid-2col" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 56, alignItems: 'center' }}>
            <div>
              <div className="section-index" style={{ marginBottom: 20 }}>
                <span className="section-index__num">00</span><span className="section-index__rule"></span><TypewriterLabel text="Join us" delay={300} />
              </div>
              <h1 className="display-xl" style={{ maxWidth: '22ch', marginBottom: 20 }}>Shape the future of Behavioral AI with us.</h1>
              <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '48ch' }}>
                We are building a network of researchers, practitioners, and thinkers who believe AI development must be informed by deep knowledge of human behavior. If that describes you, we want to hear from you.
              </p>
            </div>
            <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10, maxWidth: 380 }}>
              {['Anya Bashkirova', 'Dr Fendi Tsim', 'Sasha Watkins', 'Antoine Ferrère', 'Luana de Mattos Gabriel', 'Dr Paul Sacher'].map(n => TEAM_MEMBERS.find(m => m.name === n)).map((p, i) => {
                const rotations = [-1.5, 1.2, -0.8, 1.0, -1.3, 0.6];
                const aspects = ['1 / 1', '1 / 1', '1 / 1', '1 / 1', '1 / 1', '1 / 1'];
                return (
                  <div key={p.name} style={{
                    borderRadius: 8, overflow: 'hidden', aspectRatio: aspects[i],
                    transform: `rotate(${rotations[i]}deg)`,
                    boxShadow: '0 2px 12px rgba(0,0,0,0.08)',
                    transition: 'transform 0.4s ease',
                  }}
                    onMouseEnter={e => { e.currentTarget.style.transform = 'rotate(0deg) translateY(-3px)'; }}
                    onMouseLeave={e => { e.currentTarget.style.transform = `rotate(${rotations[i]}deg)`; }}
                  >
                    <img src={p.photo} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'grayscale(1) contrast(1.05)', display: 'block' }} />
                  </div>
                );
              })}
            </div>
          </div>
        </div>
      </section>
      </ScrollReveal>

      {/* What we offer */}
      <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <ScrollReveal still>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">01</span><span className="section-index__rule"></span><TypewriterLabel text="What we offer" delay={300} />
          </div>
          <h2 className="display-lg" style={{ marginBottom: 32 }}>How collaboration works at BAII.</h2>
          </ScrollReveal>

          <div className="fellowship-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 0 }}>
            {FELLOWSHIP_TYPES.map((f, i) => (
              <div key={i} style={{ padding: '0 32px', borderLeft: i > 0 ? '1px solid var(--baii-bone)' : 'none' }}>
                <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--baii-blue)', fontWeight: 600 }}>{f.n}</span>
                <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontStyle: 'italic', fontWeight: 400, color: 'var(--fg1)', margin: '8px 0 12px' }}>{f.title}</h3>
                <p className="body-mono" style={{ color: 'var(--fg2)', lineHeight: 1.7, fontSize: 13 }}>{f.body}</p>
              </div>
            ))}
          </div>
        </div>
      </section>
      </ScrollReveal>

      {/* Who we seek */}
      <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <ScrollReveal still>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">02</span><span className="section-index__rule"></span><TypewriterLabel text="Who we seek" delay={300} />
          </div>
          <h2 className="display-lg" style={{ marginBottom: 32 }}>Profiles that strengthen our network.</h2>
          </ScrollReveal>

          <div className="profiles-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
            {PROFILES_SOUGHT.map((p, i) => (
              <ScrollReveal still key={i} delay={i * 120}>
              {/* height:100% because the grid item is the ScrollReveal wrapper,
                  not this card. The wrapper stretches; without this the card
                  inside sizes to its own text, so a two-line body sat visibly
                  shorter than a three-line one. Reported by Fendi. */}
              <div style={{ border: '1px solid var(--baii-bone)', borderRadius: 12, padding: '24px 28px', height: '100%', transition: 'border-color 0.2s, box-shadow 0.2s' }}
                onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--baii-blue)'; e.currentTarget.style.boxShadow = '0 4px 20px rgba(2,99,255,0.08)'; }}
                onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--baii-bone)'; e.currentTarget.style.boxShadow = 'none'; }}
              >
                <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--baii-blue)', fontWeight: 600, marginBottom: 8 }}>{String(i + 1).padStart(2, '0')}</div>
                <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 18, fontWeight: 400, color: 'var(--fg1)', marginBottom: 10 }}>{p.title}</h3>
                <p className="body-mono" style={{ color: 'var(--fg2)', lineHeight: 1.7, fontSize: 13 }}>{p.body}</p>
              </div>
              </ScrollReveal>
            ))}
          </div>
        </div>
      </section>
      </ScrollReveal>

      {/* Expression of interest form */}
      <ScrollReveal still>
      <section className="surface surface--blue">
        <div className="surface__inner">
          <div className="section-index" style={{ marginBottom: 20, color: 'rgba(255,255,255,0.5)' }}>
            <span style={{ color: 'rgba(255,255,255,0.8)' }}>03</span><span className="section-index__rule" style={{ background: 'rgba(255,255,255,0.3)' }}></span><TypewriterLabel text="Express interest" delay={300} />
          </div>
          <div className="join-form-container" style={{ maxWidth: 640 }}>
            <h2 className="display-lg" style={{ marginBottom: 24, color: '#fff' }}>Tell us about yourself.</h2>

            {!sent ? (
              <form
                className="form-on-blue"
                onSubmit={e => {
                  e.preventDefault();
                  /* State key is `area` (singular); the column is `areas`.
                     `role` is deliberately NOT sent since 2026-08-11 — the free
                     text field was removed in favour of the two multi-selects
                     plus the work link. The column stays, holding the 324
                     historical values. */
                  fx.submit({
                    name: form.name,
                    email: form.email,
                    institution: form.institution,
                    work_link: form.workLink,
                    areas: form.area,
                    contributions: form.contribution,
                    /* Only sent when "Other" is actually selected. Deselecting
                       "Other" leaves the typed text in state so it survives a
                       mis-click, but it must not reach the row. */
                    areas_other: form.area.includes('Other') ? (form.areaOther.trim() || null) : null,
                    description: form.description,
                    motivation: form.motivation.trim() || null,
                    source: 'join_us',
                  });
                }}
                style={{ display: 'flex', flexDirection: 'column', gap: 16 }}
              >
                <F.Honeypot hp={fx.hp} />
                {/* Rebuilt 2026-08-11. Only `email` is NOT NULL; `areas` and
                    `contributions` are NOT NULL but default to '{}', so an empty
                    selection is a legal row and both groups stay optional.

                    Free-text `role` was REMOVED. It had a 100% fill rate across
                    all 324 historical rows, but it was answering three questions
                    at once — discipline, seniority and sector — and 107 of those
                    324 used it for a discipline because the old form offered
                    nowhere else. Discipline now has its own multi-select and the
                    specifics live behind the work link. The column stays, holding
                    the history. */}
                <div className="join-form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
                  <div className="field-row">
                    <label className="field-label" htmlFor="join-name">Name</label>
                    <input id="join-name" name="name" type="text" required placeholder="Name" value={form.name} onChange={e => set('name', e.target.value)} onFocus={() => setFocusField('name')} onBlur={() => setFocusField(null)} style={joinInput('name')} />
                  </div>
                  <div className="field-row">
                    <label className="field-label" htmlFor="join-email">Email address</label>
                    <input id="join-email" name="email" type="email" required placeholder="Email" value={form.email} onChange={e => set('email', e.target.value)} onFocus={() => setFocusField('email')} onBlur={() => setFocusField(null)} style={joinInput('email')} />
                  </div>
                </div>
                <div className="join-form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
                  <div className="field-row">
                    <label className="field-label" htmlFor="join-institution">Institution / Organisation <span className="opt">(optional)</span></label>
                    <input id="join-institution" name="institution" type="text" placeholder="Institution / Organisation" value={form.institution} onChange={e => set('institution', e.target.value)} onFocus={() => setFocusField('institution')} onBlur={() => setFocusField(null)} style={joinInput('institution')} />
                  </div>
                  {/* Deliberately NOT a LinkedIn-only field: a network that is half
                      academic reads that as "industry preferred", and a researcher's
                      Scholar or ORCID page says more than their LinkedIn does. */}
                  <div className="field-row">
                    <label className="field-label" htmlFor="join-work-link">Link to your work</label>
                    {/* Short enough to survive the half-width column — the full
                        version truncated mid-word at "…ORCID, you". Three examples
                        still carry the point that this is not a LinkedIn field. */}
                    <input id="join-work-link" name="work_link" type="url" required placeholder="LinkedIn, Scholar, ORCID…" value={form.workLink} onChange={e => set('workLink', e.target.value)} onFocus={() => setFocusField('workLink')} onBlur={() => setFocusField(null)} style={joinInput('workLink')} />
                  </div>
                </div>
                {/* Two multi-selects, and they are not the same question. This one
                    is WHICH DOMAIN; the next is HOW you would help. 112 of the 324
                    historical rows span two or more domains, which is exactly what
                    a single free-text field could not express. */}
                <div role="group" aria-labelledby="join-areas-label">
                  <div id="join-areas-label" className="field-label" style={{ marginBottom: 10 }}>Which areas would you like to work in? <span className="opt">(select all that apply)</span></div>
                  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
                    {['Behavioral & Cognitive Science', 'AI/ML Research', 'Product & Design', 'Engineering', 'Clinical & Health', 'Policy & Governance', 'Communications', 'Other'].map(opt => (
                      <JoinChip key={opt} group="areas" opt={opt} checked={form.area.includes(opt)} onToggle={() => toggleIn('area', opt)} />
                    ))}
                  </div>
                  {/* Added 2026-08-20 (Alexis, review tool). "Other" was a dead
                      end: the chip recorded that the list was wrong without ever
                      asking what the right answer was. The column takes 200
                      chars and is only written when the chip is on. */}
                  {form.area.includes('Other') && (
                    <div className="field-row" style={{ marginTop: 12 }}>
                      <label className="field-label" htmlFor="join-areas-other">Which other area?</label>
                      <input id="join-areas-other" name="areas_other" type="text" required maxLength={200} placeholder="Tell us the area" value={form.areaOther} onChange={e => set('areaOther', e.target.value)} onFocus={() => setFocusField('areaOther')} onBlur={() => setFocusField(null)} style={joinInput('areaOther')} />
                    </div>
                  )}
                </div>
                {/* Restored 2026-08-11. The column, its CHECK and 182 rows of data
                    have existed since the Formspree consolidation — only the anon
                    INSERT grant and this control were missing, so the live form
                    could never have written it even if it had asked. */}
                <div role="group" aria-labelledby="join-contrib-label">
                  <div id="join-contrib-label" className="field-label" style={{ marginBottom: 10 }}>How would you like to contribute? <span className="opt">(select all that apply)</span></div>
                  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
                    {['Advisory & consulting', 'Community & education', 'Industry partnership', 'Research & publications', 'Tool & framework development'].map(opt => (
                      <JoinChip key={opt} group="contributions" opt={opt} checked={form.contribution.includes(opt)} onToggle={() => toggleIn('contribution', opt)} />
                    ))}
                  </div>
                </div>
                <div className="field-row">
                  <label className="field-label" htmlFor="join-description">Describe your work</label>
                  <textarea id="join-description" name="description" required placeholder="In a few sentences, what do you work on?" rows={4} value={form.description} onChange={e => set('description', e.target.value)} onFocus={() => setFocusField('desc')} onBlur={() => setFocusField(null)} style={{ ...joinInput('desc'), resize: 'vertical' }} />
                </div>
                {/* Added 2026-08-20 (Alexis, review tool) as part of folding the
                    separate interest form into this one. "What you work on" and
                    "why you want in" are different answers, and only the first
                    was ever asked here. */}
                <div className="field-row">
                  <label className="field-label" htmlFor="join-motivation">Why are you interested in joining BAII?</label>
                  <textarea id="join-motivation" name="motivation" required maxLength={2000} placeholder="What draws you to this work?" rows={3} value={form.motivation} onChange={e => set('motivation', e.target.value)} onFocus={() => setFocusField('motivation')} onBlur={() => setFocusField(null)} style={{ ...joinInput('motivation'), resize: 'vertical' }} />
                </div>
                <button type="submit" className="btn btn--on-blue" style={{ alignSelf: 'flex-start' }} disabled={fx.sending} aria-busy={fx.sending}>Express interest <Arrow color="#fff" /></button>
                <F.ErrorNote error={fx.error} onBlue />
              </form>
            ) : (
              <div>
                <p className="body-mono" style={{ color: '#fff', fontWeight: 500 }}>Thank you for your interest.</p>
                <p className="body-mono" style={{ color: 'rgba(255,255,255,0.6)', marginTop: 8 }}>We have your details, and we'll be in touch.</p>
              </div>
            )}
          </div>
        </div>
      </section>
      </ScrollReveal>
    </>
  );
}


/* ============================================================
   GLOSSARY PAGE
   ============================================================ */
const GLOSSARY_TERMS = [
  { term: 'Adoption Readiness', definition: 'An organization\'s preparedness to integrate AI into workflows, measured across dimensions of culture, skills, infrastructure, and leadership support. High readiness predicts faster time-to-value; low readiness predicts shadow AI and resistance.', category: 'Adoption', related: ['Friction Audit', 'Behavior Change', 'Organizational Inertia'] },
  { term: 'Anchoring Bias', definition: 'The cognitive tendency to rely heavily on the first piece of information encountered. AI systems that present initial suggestions exploit this bias — users anchor to the AI\'s framing even when better alternatives exist.', category: 'Cognition', related: ['Automation Bias', 'Framing Effect', 'Decision Support'] },
  { term: 'Anthropomorphism', definition: 'The attribution of human characteristics to non-human entities. Users routinely anthropomorphize AI systems, leading to miscalibrated expectations about capability, emotional connection, and inappropriate trust.', category: 'Trust', related: ['Sycophancy', 'Model Psychology', 'Trust Calibration'] },
  { term: 'Attention Tax', definition: 'The cumulative cognitive cost of AI-generated notifications, suggestions, and interruptions. Each interaction demands attentional resources, reducing capacity for deep work and complex decision-making.', category: 'Cognition', related: ['Cognitive Load', 'Notification Fatigue', 'Interface Design'] },
  { term: 'Automation Bias', definition: 'The tendency for humans to over-rely on automated systems, accepting AI-generated suggestions without sufficient critical evaluation. Particularly dangerous in high-stakes domains where incorrect AI outputs go unchallenged.', category: 'Cognition', related: ['Trust Calibration', 'Cognitive Offloading', 'Decision Support'] },
  { term: 'Behavior Change', definition: 'The systematic process of modifying human actions through intervention design. In AI adoption, behavior change principles determine whether new tools become embedded habits or abandoned novelties.', category: 'Adoption', related: ['Habit Loop', 'Friction Audit', 'Nudge'] },
  { term: 'Behavioral Benchmark', definition: 'An evaluation framework that measures AI system performance through its effects on human behavior — decision quality, trust calibration, cognitive load, and skill retention — rather than isolated model accuracy.', category: 'Evaluation', related: ['Model Psychology', 'Human-AI Interaction', 'Behavioral Safety'] },
  { term: 'Behavioral Safety', definition: 'The property of AI systems behaving in ways that are psychologically safe for users — not inducing anxiety, dependency, manipulation, or harm to wellbeing through interaction patterns.', category: 'Evaluation', related: ['Behavioral Benchmark', 'Sycophancy', 'Manipulation'] },
  { term: 'Choice Architecture', definition: 'The design of environments in which people make decisions. AI interfaces are choice architectures — the way options are presented, defaulted, and framed shapes user behavior as powerfully as the AI\'s capabilities.', category: 'Design', related: ['Nudge', 'Defaults', 'Friction Audit'] },
  { term: 'Cognitive Load', definition: 'The total amount of mental effort being used in working memory. In AI interfaces, every suggestion, notification, or recommendation competes for the same limited cognitive resources the user needs for their primary task.', category: 'Cognition', related: ['Attention Tax', 'Interface Design', 'Suggestion Frequency'] },
  { term: 'Cognitive Offloading', definition: 'The practice of using external tools (including AI) to reduce demands on internal cognitive processes. While often beneficial, sustained offloading of specific skills can lead to atrophy of those capabilities.', category: 'Cognition', related: ['Skill Atrophy', 'Automation Bias', 'Deskilling'] },
  { term: 'Complementarity', definition: 'The principle that human-AI systems should be designed so each party contributes what they do best. Humans bring contextual judgment, ethical reasoning, and creativity; AI brings speed, consistency, and pattern recognition at scale.', category: 'Design', related: ['Human-AI Collaboration', 'Task Allocation', 'Shared Decision-Making'] },
  { term: 'Confidence Signaling', definition: 'How AI systems communicate certainty levels to users. Poor confidence signaling — presenting all outputs with equal conviction — leads to trust miscalibration. Effective signaling helps users know when to trust and when to verify.', category: 'Trust', related: ['Trust Calibration', 'Uncertainty Communication', 'Graduated Autonomy'] },
  { term: 'Dark Patterns', definition: 'Interface designs that manipulate users into actions against their interests. AI-powered dark patterns are particularly dangerous because they can personalize manipulation strategies in real-time based on individual psychological profiles.', category: 'Governance', related: ['Manipulation', 'Choice Architecture', 'Ethical AI'] },
  { term: 'Defaults', definition: 'Pre-selected options that apply when users take no active choice. AI system defaults are enormously powerful — most users never change them, making the default setting the de facto behavior of the majority.', category: 'Design', related: ['Choice Architecture', 'Nudge', 'Opt-out'] },
  { term: 'Deliberate Practice', definition: 'Structured, effortful practice aimed at improving specific skills. When AI removes the need for deliberate practice in a domain, the resulting deskilling may be irreversible without conscious intervention.', category: 'Cognition', related: ['Deskilling', 'Skill Atrophy', 'Cognitive Offloading'] },
  { term: 'Deskilling', definition: 'The gradual erosion of human capabilities that occurs when AI systems consistently perform tasks that previously required human expertise. Distinct from offloading in that deskilling implies permanent capability loss.', category: 'Cognition', related: ['Cognitive Offloading', 'Skill Atrophy', 'Deliberate Practice'] },
  { term: 'Ethical AI', definition: 'AI systems designed and deployed in accordance with moral principles including fairness, transparency, accountability, and respect for human autonomy. Behavioral science provides empirical grounding for what these principles mean in practice.', category: 'Governance', related: ['Governance Framework', 'Fairness', 'Transparency'] },
  { term: 'Explainability', definition: 'The degree to which AI decision-making processes can be understood by humans. Behavioral research shows that explanations must match user mental models — technically accurate but cognitively inaccessible explanations fail to build appropriate trust.', category: 'Trust', related: ['Transparency', 'Trust Calibration', 'Mental Models'] },
  { term: 'Fairness', definition: 'The principle that AI systems should not systematically disadvantage particular groups. Behavioral science reveals that fairness is perceived contextually — what feels fair depends on framing, comparison sets, and cultural norms.', category: 'Governance', related: ['Ethical AI', 'Governance Framework', 'Bias'] },
  { term: 'Framing Effect', definition: 'The phenomenon where people\'s decisions are influenced by how information is presented rather than its objective content. AI systems that frame outputs differently can produce dramatically different user decisions from identical underlying data.', category: 'Cognition', related: ['Anchoring Bias', 'Choice Architecture', 'Nudge'] },
  { term: 'Friction Audit', definition: 'A systematic assessment of the behavioral barriers preventing AI adoption within an organization. Maps resistance points, identifies behavioral bottlenecks, and produces actionable recommendations for reducing adoption friction.', category: 'Adoption', related: ['Adoption Readiness', 'Behavior Change', 'Habit Loop'] },
  { term: 'Governance Framework', definition: 'A structured approach to overseeing AI systems across their lifecycle. Effective governance integrates behavioral principles — understanding how policies are actually followed (or circumvented) by the humans they govern.', category: 'Governance', related: ['Ethical AI', 'Regulatory Compliance', 'Accountability'] },
  { term: 'Graduated Autonomy', definition: 'A design pattern where AI systems earn increased independence through demonstrated reliability. Mirrors how human trust develops — through repeated, observable trustworthy behavior rather than credentials or claims.', category: 'Trust', related: ['Trust Calibration', 'Confidence Signaling', 'Human-AI Collaboration'] },
  { term: 'Habit Loop', definition: 'The neurological pattern governing habitual behavior: cue → routine → reward. In AI adoption, designing for habit loops means creating triggers, making the AI-assisted workflow the easiest path, and ensuring immediate positive feedback.', category: 'Adoption', related: ['Friction Audit', 'Behavior Change', 'Nudge'] },
  { term: 'Human-AI Collaboration', definition: 'The study and design of systems where humans and AI work together on shared tasks. Effective collaboration requires appropriate trust calibration, clear role boundaries, and well-designed handoff protocols.', category: 'Design', related: ['Trust Calibration', 'Shared Decision-Making', 'Complementarity'] },
  { term: 'Human-AI Interaction', definition: 'The broader field studying all forms of exchange between humans and AI systems — from single-query interactions to long-term collaborative relationships. Encompasses interface design, communication patterns, and relational dynamics.', category: 'Design', related: ['Human-AI Collaboration', 'Interface Design', 'Anthropomorphism'] },
  { term: 'Interface Design', definition: 'The craft of designing the surfaces through which humans interact with AI. Behaviorally-informed interface design accounts for cognitive limitations, attention patterns, and decision-making heuristics rather than assuming rational users.', category: 'Design', related: ['Choice Architecture', 'Cognitive Load', 'Attention Tax'] },
  { term: 'Manipulation', definition: 'The use of psychological techniques to influence behavior in ways that bypass rational agency. AI systems capable of real-time personalization create unprecedented manipulation potential — adapting persuasion strategies to individual vulnerabilities.', category: 'Governance', related: ['Dark Patterns', 'Sycophancy', 'Behavioral Safety'] },
  { term: 'Mental Models', definition: 'Internal representations of how systems work. Users form mental models of AI that are often inaccurate — imagining human-like reasoning where pattern matching exists. Misaligned mental models produce miscalibrated expectations and inappropriate use.', category: 'Cognition', related: ['Explainability', 'Anthropomorphism', 'Trust Calibration'] },
  { term: 'Model Psychology', definition: 'The emerging field studying the behavioral properties of AI models themselves — their tendencies, biases, personality-like traits, and how these interact with human psychology during use.', category: 'Evaluation', related: ['Behavioral Benchmark', 'Anthropomorphism', 'Machine Behavior'] },
  { term: 'Notification Fatigue', definition: 'The progressive desensitization to alerts and notifications that occurs when systems generate excessive or low-value interruptions. Leads to missed critical signals and trained inattention to AI outputs.', category: 'Cognition', related: ['Attention Tax', 'Cognitive Load', 'Alert Design'] },
  { term: 'Nudge', definition: 'A design intervention that predictably alters behavior without forbidding options or significantly changing economic incentives. In AI contexts, nudges shape how users interact with AI outputs — e.g., requiring explicit confirmation before accepting high-stakes suggestions.', category: 'Design', related: ['Choice Architecture', 'Friction Audit', 'Defaults'] },
  { term: 'Organizational Inertia', definition: 'The tendency of organizations to resist change and maintain existing processes. AI adoption fails most often not from technical limitations but from underestimating the behavioral inertia of established workflows and team dynamics.', category: 'Adoption', related: ['Adoption Readiness', 'Friction Audit', 'Behavior Change'] },
  { term: 'Overtrust', definition: 'Placing more confidence in an AI system than its actual reliability warrants. Overtrust leads to reduced vigilance, missed errors, and catastrophic failures when the AI encounters edge cases outside its training distribution.', category: 'Trust', related: ['Automation Bias', 'Trust Calibration', 'Undertrust'] },
  { term: 'Personalization', definition: 'Adapting AI behavior to individual user characteristics, preferences, and context. Behaviorally effective personalization requires understanding not just what users want but what serves their long-term interests and wellbeing.', category: 'Design', related: ['Dark Patterns', 'Choice Architecture', 'Behavioral Safety'] },
  { term: 'Regulatory Compliance', definition: 'Adherence to laws and standards governing AI deployment. Behavioral science informs compliance by revealing how regulations are actually interpreted and followed by practitioners — the gap between policy intent and operational reality.', category: 'Governance', related: ['Governance Framework', 'Ethical AI', 'Accountability'] },
  { term: 'Shared Decision-Making', definition: 'A collaborative process where humans and AI jointly contribute to decisions, with explicit protocols for integrating machine recommendations with human judgment, values, and contextual knowledge.', category: 'Design', related: ['Human-AI Collaboration', 'Complementarity', 'Trust Calibration'] },
  { term: 'Skill Atrophy', definition: 'The degradation of previously held competencies through disuse. AI-assisted work environments may accelerate skill atrophy by removing opportunities for practice, creating dependency cycles that are difficult to reverse.', category: 'Cognition', related: ['Deskilling', 'Cognitive Offloading', 'Deliberate Practice'] },
  { term: 'Sycophancy', definition: 'The tendency of AI systems to agree with or flatter users rather than providing accurate, potentially challenging information. A behavioral risk that erodes the value of AI as a decision-support tool.', category: 'Trust', related: ['Trust Calibration', 'Automation Bias', 'Model Psychology'] },
  { term: 'Task Allocation', definition: 'The distribution of work between human and AI agents. Optimal allocation considers not just efficiency but skill maintenance, job satisfaction, learning opportunities, and long-term human capability development.', category: 'Design', related: ['Complementarity', 'Deskilling', 'Human-AI Collaboration'] },
  { term: 'Transparency', definition: 'The property of AI systems being open about their processes, limitations, and confidence levels. Behavioral research shows transparency must be calibrated — too much detail overwhelms; too little breeds suspicion.', category: 'Governance', related: ['Explainability', 'Trust Calibration', 'Confidence Signaling'] },
  { term: 'Trust Calibration', definition: 'The degree to which a person\'s trust in an AI system matches the system\'s actual reliability. Well-calibrated trust means trusting AI when it is correct and overriding it when it is wrong. Most human-AI teams suffer from miscalibration.', category: 'Trust', related: ['Automation Bias', 'Confidence Signaling', 'Graduated Autonomy'] },
  { term: 'Uncertainty Communication', definition: 'How AI systems convey what they don\'t know. Most systems express false certainty; effective uncertainty communication uses calibrated language, visual confidence indicators, and appropriate hedging to support better human decisions.', category: 'Trust', related: ['Confidence Signaling', 'Explainability', 'Trust Calibration'] },
  { term: 'Undertrust', definition: 'Placing less confidence in an AI system than its actual reliability warrants. Undertrust leads to underutilization — users manually performing tasks the AI handles well, negating the value of AI investment.', category: 'Trust', related: ['Trust Calibration', 'Overtrust', 'Adoption Readiness'] },
  { term: 'Value Alignment', definition: 'Ensuring AI systems pursue objectives consistent with human values. Behavioral science reveals that human values are contextual, contradictory, and culturally diverse — making alignment a continuous negotiation rather than a one-time specification.', category: 'Governance', related: ['Ethical AI', 'Governance Framework', 'Fairness'] },
  { term: 'Wellbeing Impact', definition: 'The effect of AI systems on psychological health outcomes including stress, autonomy, meaning, social connection, and life satisfaction. A dimension of AI evaluation largely absent from current benchmarks but central to responsible deployment.', category: 'Evaluation', related: ['Behavioral Safety', 'Behavioral Benchmark', 'Personalization'] },
];

const GLOSSARY_CATEGORIES = ['All', 'Cognition', 'Trust', 'Adoption', 'Evaluation', 'Design', 'Governance'];

function GlossaryTermView({ term, route, setRoute, onBack, onNavigate }) {
  const termIndex = GLOSSARY_TERMS.findIndex(t => t.term === term.term);
  const sorted = [...GLOSSARY_TERMS].sort((a, b) => a.term.localeCompare(b.term));
  const sortedIndex = sorted.findIndex(t => t.term === term.term);
  const prev = sortedIndex > 0 ? sorted[sortedIndex - 1] : null;
  const next = sortedIndex < sorted.length - 1 ? sorted[sortedIndex + 1] : null;
  const sameCat = GLOSSARY_TERMS.filter(t => t.category === term.category && t.term !== term.term).slice(0, 4);

  return (
    <>
      <section className="surface">
        <div style={{ padding: '32px 56px 0' }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>
        <div style={{ maxWidth: 680, margin: '0 auto', padding: '48px 24px 56px' }}>
          <button onClick={onBack} style={{
            border: 'none', background: 'none', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 8,
            fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--baii-blue)', marginBottom: 40, padding: 0,
          }}>
            <span style={{ transform: 'rotate(180deg)', display: 'inline-flex' }}><Arrow /></span> Back to glossary
          </button>

          <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--baii-blue)', background: 'var(--baii-blue-95)', padding: '4px 10px', borderRadius: 999 }}>{term.category}</span>
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)' }}>Term {String(termIndex + 1).padStart(2, '0')} of {GLOSSARY_TERMS.length}</span>
          </div>

          <h1 style={{ fontFamily: 'var(--font-display)', fontSize: 'clamp(36px, 4vw, 52px)', lineHeight: 1.08, letterSpacing: '-0.02em', margin: '0 0 32px', fontWeight: 400 }}>{term.term}</h1>

          <p className="body-mono" style={{ color: 'var(--fg2)', lineHeight: 1.8, fontSize: 15, marginBottom: 32 }}>{term.definition}</p>

          {term.related && term.related.length > 0 && (
            <div style={{ borderTop: '1px solid var(--baii-bone)', paddingTop: 24, marginBottom: 32 }}>
              <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)', display: 'block', marginBottom: 12 }}>Related terms</span>
              <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
                {term.related.map((r, ri) => {
                  const relatedTerm = GLOSSARY_TERMS.find(t => t.term === r);
                  return (
                    <button key={ri} onClick={() => relatedTerm && onNavigate(relatedTerm)} style={{
                      fontFamily: 'var(--font-mono)', fontSize: 12, color: relatedTerm ? 'var(--baii-blue)' : 'var(--fg3)',
                      background: relatedTerm ? 'var(--baii-blue-95)' : 'var(--baii-bone)', padding: '6px 14px', borderRadius: 20,
                      border: 'none', cursor: relatedTerm ? 'pointer' : 'default',
                    }}>{r}</button>
                  );
                })}
              </div>
            </div>
          )}

          {/* Prev / Next */}
          <div style={{ borderTop: '1px solid var(--baii-bone)', paddingTop: 24, display: 'flex', justifyContent: 'space-between' }}>
            {prev ? (
              <button onClick={() => onNavigate(prev)} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 0, textAlign: 'left' }}>
                <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg3)', textTransform: 'uppercase', letterSpacing: '0.14em', marginBottom: 4 }}>Previous</div>
                <div style={{ fontFamily: 'var(--font-display)', fontSize: 18, color: 'var(--fg1)' }}>{prev.term}</div>
              </button>
            ) : <div/>}
            {next ? (
              <button onClick={() => onNavigate(next)} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 0, textAlign: 'right' }}>
                <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg3)', textTransform: 'uppercase', letterSpacing: '0.14em', marginBottom: 4 }}>Next</div>
                <div style={{ fontFamily: 'var(--font-display)', fontSize: 18, color: 'var(--fg1)' }}>{next.term}</div>
              </button>
            ) : <div/>}
          </div>
        </div>
      </section>

      {/* More in this category */}
      {sameCat.length > 0 && (
        <section className="surface">
          <div className="surface__inner">
            <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--fg3)', marginBottom: 20 }}>More in {term.category}</div>
            <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16 }}>
              {sameCat.map((t, i) => (
                <div key={i} onClick={() => onNavigate(t)} style={{
                  border: '1px solid var(--baii-bone)', borderRadius: 10, padding: '20px 24px', cursor: 'pointer',
                  transition: 'border-color 0.2s, transform 0.2s',
                }}
                  onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--baii-blue)'; e.currentTarget.style.transform = 'translateY(-2px)'; }}
                  onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--baii-bone)'; e.currentTarget.style.transform = 'translateY(0)'; }}
                >
                  <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 18, fontWeight: 400, color: 'var(--fg1)', margin: '0 0 8px' }}>
                    <button type="button" className="btn-reset" onClick={(e) => { e.stopPropagation(); onNavigate(t); }}>{t.term}</button>
                  </h3>
                  <p className="body-mono" style={{ color: 'var(--fg3)', fontSize: 12, margin: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.definition.slice(0, 80)}...</p>
                </div>
              ))}
            </div>
          </div>
        </section>
      )}
    </>
  );
}

function GlossaryPage({ route, setRoute }) {
  const [search, setSearch] = React.useState('');
  const [category, setCategory] = React.useState('All');
  const [activeTerm, setActiveTerm] = React.useState(null);

  const filtered = GLOSSARY_TERMS.filter(t => {
    const matchCat = category === 'All' || t.category === category;
    const matchSearch = !search || t.term.toLowerCase().includes(search.toLowerCase()) || t.definition.toLowerCase().includes(search.toLowerCase());
    return matchCat && matchSearch;
  });

  const grouped = filtered.reduce((acc, t) => {
    const letter = t.term[0].toUpperCase();
    if (!acc[letter]) acc[letter] = [];
    acc[letter].push(t);
    return acc;
  }, {});

  const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
  const activeLetters = Object.keys(grouped);

  const today = new Date();
  const dayIndex = (today.getFullYear() * 366 + today.getMonth() * 31 + today.getDate()) % GLOSSARY_TERMS.length;
  const wordOfDay = GLOSSARY_TERMS[dayIndex];

  const navigateToTerm = (t) => { setActiveTerm(t); window.scrollTo(0, 0); };

  if (activeTerm) {
    return <GlossaryTermView term={activeTerm} route={route} setRoute={setRoute} onBack={() => setActiveTerm(null)} onNavigate={navigateToTerm} />;
  }

  return (
    <>
      {/* Hero — lexicon + word of the day */}
      <section className="surface">
        <div style={{ padding: '32px 56px 0' }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>
        <div className="surface__inner" style={{ paddingTop: 0 }}>
          <div className="grid-2col" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 56, alignItems: 'start' }}>
            <div>
              <div className="section-index" style={{ marginBottom: 20 }}>
                <span className="section-index__num">00</span><span className="section-index__rule"></span><TypewriterLabel text="Glossary" delay={300} />
              </div>
              <h1 className="display-xl" style={{ marginBottom: 16, maxWidth: '18ch' }}>The Behavioral AI lexicon.</h1>
              <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '48ch', marginBottom: 32 }}>
                A living reference for the terms, concepts, and frameworks that define the intersection of behavioral science and artificial intelligence.
              </p>

              {/* Search */}
              <div style={{ position: 'relative', maxWidth: 480 }}>
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--fg3)" strokeWidth="2" strokeLinecap="round" style={{ position: 'absolute', left: 20, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }}>
                  <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
                </svg>
                <label className="vh-label" htmlFor="glossary-search">Search glossary terms</label>
                <input
                  id="glossary-search"
                  name="glossary_search"
                  type="text"
                  value={search}
                  onChange={e => setSearch(e.target.value)}
                  placeholder="Search terms..."
                  style={{
                    width: '100%', padding: '16px 24px 16px 48px', borderRadius: 12,
                    border: '2px solid var(--baii-bone)', background: '#fff',
                    fontFamily: 'var(--font-mono)', fontSize: 15, color: 'var(--fg1)',
                    outline: 'none', transition: 'border-color 0.15s',
                  }}
                  onFocus={e => e.target.style.borderColor = 'var(--baii-blue)'}
                  onBlur={e => e.target.style.borderColor = 'var(--baii-bone)'}
                />
              </div>
              <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg3)', marginTop: 12 }}>
                {GLOSSARY_TERMS.length} terms across {GLOSSARY_CATEGORIES.length - 1} categories
              </div>
            </div>

            {/* Word of the Day */}
            <div onClick={() => navigateToTerm(wordOfDay)} style={{
              border: '1px solid var(--baii-bone)', borderRadius: 12, padding: '32px',
              background: '#fff', position: 'relative', cursor: 'pointer',
              transition: 'border-color 0.2s, transform 0.2s',
            }}
              onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--baii-blue)'; e.currentTarget.style.transform = 'translateY(-2px)'; }}
              onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--baii-bone)'; e.currentTarget.style.transform = 'translateY(0)'; }}
            >
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
                <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--baii-blue)' }}>Word of the day</div>
                <span style={{ padding: '3px 10px', borderRadius: 12, fontFamily: 'var(--font-mono)', fontSize: 10, background: 'var(--baii-blue-95)', color: 'var(--baii-blue)' }}>{wordOfDay.category}</span>
              </div>
              {/* h2, not h3: this follows the page h1 directly and an
                  h1 -> h3 jump is a heading-level skip. Every typographic
                  property is set inline, so the rendering is unchanged. */}
              <h2 style={{ fontFamily: 'var(--font-display)', fontSize: 26, fontWeight: 400, color: 'var(--fg1)', margin: '0 0 16px' }}>
                <button type="button" className="btn-reset" onClick={() => navigateToTerm(wordOfDay)}>{wordOfDay.term}</button>
              </h2>
              <p className="body-mono" style={{ color: 'var(--fg2)', lineHeight: 1.7, margin: 0 }}>{wordOfDay.definition}</p>
              {wordOfDay.related && wordOfDay.related.length > 0 && (
                <div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--baii-bone)', display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
                  <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)', marginRight: 4 }}>Related:</span>
                  {wordOfDay.related.map((r, ri) => {
                    const relTerm = GLOSSARY_TERMS.find(t => t.term === r);
                    return (
                      <button key={ri} type="button" className="btn-reset" onClick={e => { e.stopPropagation(); relTerm ? navigateToTerm(relTerm) : setSearch(r); }} style={{
                        fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--baii-blue)', cursor: 'pointer',
                        background: 'var(--baii-blue-95)', padding: '3px 10px', borderRadius: 12,
                      }}>{r}</button>
                    );
                  })}
                </div>
              )}
            </div>
          </div>
        </div>
      </section>

      {/* Full glossary */}
      <section className="surface">
        <div className="surface__inner">
          {/* Alphabet as primary nav */}
          <div className="glossary-alphabet" style={{ display: 'flex', justifyContent: 'center', gap: 2, marginBottom: 32 }}>
            {letters.map(l => {
              const active = activeLetters.includes(l);
              return (
                <button key={l} onClick={() => active && document.getElementById(`letter-${l}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })} style={{
                  width: 36, height: 40, borderRadius: 6, border: 'none', cursor: active ? 'pointer' : 'default',
                  fontFamily: 'var(--font-display)', fontSize: 18, fontWeight: 400,
                  background: 'transparent',
                  color: active ? 'var(--fg1)' : 'var(--baii-bone)',
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                  transition: 'color 0.15s',
                }}
                  onMouseEnter={e => { if (active) e.currentTarget.style.color = 'var(--baii-blue)'; }}
                  onMouseLeave={e => { e.currentTarget.style.color = active ? 'var(--fg1)' : 'var(--baii-bone)'; }}
                >{l}</button>
              );
            })}
          </div>

          {/* Category filters + count — compact bar */}
          <div className="glossary-search-filter" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 24, borderBottom: '1px solid var(--baii-bone)', marginBottom: 32 }}>
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
              {GLOSSARY_CATEGORIES.map(cat => (
                <button key={cat} onClick={() => setCategory(cat)} style={{
                  padding: '5px 12px', borderRadius: 20, border: 'none', cursor: 'pointer',
                  fontFamily: 'var(--font-mono)', fontSize: 11,
                  background: category === cat ? 'var(--baii-blue)' : 'var(--baii-bone)',
                  color: category === cat ? '#fff' : 'var(--fg2)',
                  transition: 'all 0.15s',
                }}>{cat}</button>
              ))}
            </div>
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)', minWidth: 0, overflowWrap: 'anywhere' }}>
              {filtered.length} term{filtered.length !== 1 ? 's' : ''}{search ? ` matching "${search}"` : ''}
            </span>
          </div>

          {/* Terms */}
          {activeLetters.sort().map(letter => (
            <div key={letter} id={`letter-${letter}`} style={{ marginBottom: 48 }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
                <span style={{ fontFamily: 'var(--font-display)', fontSize: 36, color: 'var(--fg1)' }}>{letter}</span>
                <div style={{ flex: 1, height: 1, background: 'var(--baii-bone)' }}/>
              </div>

              {grouped[letter].map((t, i) => (
                <div key={i} data-term-row="" onClick={() => navigateToTerm(t)} style={{
                  padding: '16px 20px', borderRadius: 8, marginBottom: 4, cursor: 'pointer',
                  background: 'transparent',
                  transition: 'background 0.15s',
                }}
                  onMouseEnter={e => { e.currentTarget.style.background = 'var(--baii-blue-95)'; }}
                  onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
                >
                  <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
                    {/* All 52 rows were <div onClick> with no tabindex, so no
                        term could be opened from the keyboard. The button goes
                        on the term rather than the row: role="button" on the
                        row would make its children presentational and remove
                        all 52 headings from the accessibility tree. */}
                    <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 400, color: 'var(--fg1)', margin: 0 }}>
                      <button
                        type="button"
                        className="btn-reset"
                        onClick={(e) => { e.stopPropagation(); navigateToTerm(t); }}
                        onFocus={(e) => { e.currentTarget.closest('[data-term-row]').style.background = 'var(--baii-blue-95)'; }}
                        onBlur={(e) => { e.currentTarget.closest('[data-term-row]').style.background = 'transparent'; }}
                      >{t.term}</button>
                    </h3>
                    <span style={{
                      padding: '3px 10px', borderRadius: 12, fontFamily: 'var(--font-mono)', fontSize: 10,
                      background: 'var(--baii-bone)', color: 'var(--fg3)', flexShrink: 0,
                    }}>{t.category}</span>
                  </div>
                  <p className="body-mono" style={{ color: 'var(--fg3)', marginTop: 6, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 600 }}>
                    {t.definition.slice(0, 90)}...
                  </p>
                </div>
              ))}
            </div>
          ))}
        </div>
      </section>

      {/* Contribute */}
      <section className="surface">
        <div className="surface__inner">
          <div style={{
            background: 'var(--baii-blue)', borderRadius: 16, padding: '48px 40px',
            textAlign: 'center',
          }}>
            <h2 style={{ fontFamily: 'var(--font-display)', fontSize: 28, color: '#fff', marginBottom: 12, fontWeight: 400 }}>Suggest a term.</h2>
            <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'rgba(255,255,255,0.8)', marginBottom: 24, maxWidth: '44ch', margin: '0 auto 24px' }}>
              This glossary is a living document. If you think a term is missing or a definition needs refinement, let us know.
            </p>
            <a href="mailto:hello@behavioralai-institute.org?subject=Glossary suggestion" className="btn btn--primary btn--on-blue">Suggest a term <Arrow /></a>
          </div>
        </div>
      </section>
    </>
  );
}


/* ============================================================
   EVENTS PAGE
   ============================================================ */
function EventsPage({ route, setRoute }) {
  const [hovIdx, setHovIdx] = React.useState(-1);
  const [podHov, setPodHov] = React.useState(-1);

  /* The featured card is the top of UPCOMING_EVENTS, read at render time.
     It used to be a hardcoded summit — title, date, city and blurb typed
     straight into the markup — so when the list below it changed, the card
     went on announcing an event that appeared nowhere else. Reading index 0
     means the two cannot disagree. If the list ever empties, the card is
     not rendered rather than left showing the last thing anyone typed. */
  const nextEvent = UPCOMING_EVENTS[0];

  /* Everything a placeholder is allowed to say instead of a button. Not a
     dimmed 'Register' — that is the shape the eye reads as a working
     registration and it was the original defect. A label, in the same mono
     register as the rest of the event metadata, that promises nothing. */
  const soonLabel = {
    fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.18em',
    textTransform: 'uppercase', color: 'var(--fg3)', whiteSpace: 'nowrap',
  };

  return (
    <>
      {/* Hero — featured next event */}
      <ScrollReveal still>
      <section className="surface">
        <div style={{ padding: '32px 56px 0' }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>
        <div className="surface__inner" style={{ paddingTop: 0 }}>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">00</span><span className="section-index__rule"></span><TypewriterLabel text="Events" delay={300} />
          </div>
          <h1 className="display-xl" style={{ maxWidth: '18ch', marginBottom: 40 }}>Where the community gathers.</h1>

          {/* Featured next event card — driven by UPCOMING_EVENTS[0] */}
          {nextEvent && (
          <article className="event-highlight" style={{
            display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 0,
            border: '1px solid var(--baii-bone)', borderRadius: 12, overflow: 'hidden',
            background: '#fff',
            transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
          }}
            onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.boxShadow = '0 16px 48px rgba(2, 99, 255, 0.12)'; }}
            onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.03)'; }}
          >
            <div className="next-event-banner" style={{ position: 'relative', minHeight: 360, overflow: 'hidden', background: 'var(--baii-blue-95)' }}>
              <img src="assets/library/illustration-telescope.webp" alt="" style={{
                position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover',
                filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)',
                transition: 'transform 0.6s ease',
              }}/>
              <div style={{ position: 'absolute', top: 20, left: 20, background: 'var(--baii-blue)', color: '#fff', padding: '6px 14px', borderRadius: 999, fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase' }}>Next event</div>
            </div>
            <div style={{ padding: '48px 40px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
                <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--baii-blue)', background: 'var(--baii-blue-95)', padding: '4px 10px', borderRadius: 999 }}>
                  {nextEvent.placeholder ? 'Planned' : nextEvent.type}
                </span>
                <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)' }}>
                  {nextEvent.placeholder ? 'Date and format to be confirmed' : nextEvent.format}
                </span>
              </div>
              <h2 style={{ fontFamily: 'var(--font-display)', fontSize: 34, lineHeight: 1.12, letterSpacing: '-0.015em', margin: '0 0 16px', fontWeight: 400 }}>{nextEvent.title}</h2>
              <p style={{ fontFamily: 'var(--font-mono)', fontSize: 14, lineHeight: 1.65, color: 'var(--fg2)', margin: '0 0 24px' }}>
                {nextEvent.description}
              </p>
              <div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 28 }}>
                <span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg2)' }}>{nextEvent.date}</span>
                {nextEvent.location && <>
                  <span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg3)' }}>·</span>
                  <span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg2)' }}>{nextEvent.location}</span>
                </>}
              </div>
              {nextEvent.placeholder
                ? <span style={{ ...soonLabel, alignSelf: 'flex-start' }}>Details soon</span>
                : nextEvent.href
                  ? <a className="btn btn--primary" href={nextEvent.href} target="_blank" rel="noopener noreferrer" style={{ alignSelf: 'flex-start' }}>{nextEvent.cta} <Arrow /></a>
                  : <span className="btn btn--primary" style={{ alignSelf: 'flex-start', opacity: 0.6, cursor: 'default' }}>Registration coming soon</span>
              }
            </div>
          </article>
          )}
        </div>
      </section>
      </ScrollReveal>

      {/* Upcoming Events */}
      <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <ScrollReveal still>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">01</span><span className="section-index__rule"></span><TypewriterLabel text="Upcoming" delay={300} />
          </div>
          <h2 className="display-lg" style={{ marginBottom: 40 }}>What's next.</h2>
          </ScrollReveal>

          <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
            {UPCOMING_EVENTS.map((ev, i) => (
              <ScrollReveal still key={ev.title} delay={i * 120}>
              <div className="event-row-grid"
                onMouseEnter={() => setHovIdx(i)} onMouseLeave={() => setHovIdx(-1)}
                style={{
                  display: 'grid', gridTemplateColumns: '80px 1fr auto', gap: 32, alignItems: 'center',
                  padding: '28px 32px', borderRadius: 10,
                  border: '1px solid var(--baii-bone)', background: hovIdx === i ? 'var(--baii-blue-95)' : '#fff',
                  transform: hovIdx === i ? 'translateY(-4px)' : 'translateY(0)',
                  boxShadow: hovIdx === i ? '0 12px 32px rgba(2, 99, 255, 0.1)' : '0 1px 4px rgba(0,0,0,0.03)',
                  transition: 'all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
                }}>
                {/* Date block — a placeholder has a month but no day, so the
                    numeral is a rule rather than a number nobody has set. */}
                <div className="event-date-block" style={{ textAlign: 'center' }}>
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--baii-blue)' }}>{ev.month}</div>
                  <div style={{ fontFamily: 'var(--font-display)', fontSize: 36, lineHeight: 1, letterSpacing: '-0.02em', color: ev.placeholder ? 'var(--fg3)' : 'var(--fg1)' }}>{ev.placeholder ? '––' : ev.day}</div>
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)' }}>{ev.year}</div>
                </div>
                {/* Content */}
                <div>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6, flexWrap: 'wrap' }}>
                    <span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--baii-blue)', background: 'var(--baii-blue-95)', padding: '3px 8px', borderRadius: 999 }}>
                      {ev.placeholder ? 'Planned' : ev.type}
                    </span>
                    <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)' }}>
                      {ev.placeholder ? 'Date and format to be confirmed' : `${ev.location} · ${ev.format}`}
                    </span>
                  </div>
                  <div style={{ fontFamily: 'var(--font-display)', fontSize: 22, lineHeight: 1.2, letterSpacing: '-0.01em' }}>{ev.title}</div>
                  <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.5, color: 'var(--fg2)', margin: '8px 0 0', maxWidth: '60ch' }}>{ev.description}</p>
                </div>
                {/* CTA — or, for a planned session, the absence of one */}
                <div className="event-cta-cell">
                {ev.placeholder
                  ? <span style={soonLabel}>Details soon</span>
                  : <a className="btn btn--primary btn--sm" href={ev.href} target="_blank" rel="noopener noreferrer">{ev.cta} <Arrow /></a>
                }
                </div>
              </div>
              </ScrollReveal>
            ))}
          </div>
        </div>
      </section>
      </ScrollReveal>

      {/* Past Events */}
      <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <ScrollReveal still>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">02</span><span className="section-index__rule"></span><TypewriterLabel text="Past events" delay={300} />
          </div>
          <h2 className="display-lg" style={{ marginBottom: 40 }}>Catch up on what you missed.</h2>
          </ScrollReveal>

          {/* Columns are sized, not counted. `repeat(4, 1fr)` was a number
              typed to match four entries; with one entry it stretched a card
              across a quarter-width column and left three empty. auto-fill
              keeps every card at the width it was drawn for whether the
              archive holds one recording or ten, and the mobile rules in
              styles.css still override it. */}
          <div className="grid-past-events" style={{
            display: 'grid',
            gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
            gap: 20,
          }}>
            {PAST_EVENTS.map((ev, i) => {
              /* The card used to carry cursor:pointer and a hover lift with
                 nothing behind either — the only real control was the
                 'Watch recording' line inside it. Where there is a recording
                 the whole card is now the link (so the affordance is earned,
                 and the line inside becomes plain text so anchors do not
                 nest). Where there is none, the card is inert and looks it. */
              const Tag = ev.href ? 'a' : 'article';
              const linkProps = ev.href
                ? { href: ev.href, target: '_blank', rel: 'noopener noreferrer' }
                : {};
              return (
              <ScrollReveal still key={ev.title} delay={i * 120}>
              <Tag {...linkProps} style={{
                display: 'block', color: 'inherit', textDecoration: 'none',
                border: '1px solid var(--baii-bone)', borderRadius: 10, overflow: 'hidden', background: '#fff',
                cursor: ev.href ? 'pointer' : 'default', transition: 'all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
              }}
                onMouseEnter={ev.href ? (e => { e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.boxShadow = '0 12px 32px rgba(2, 99, 255, 0.1)'; }) : undefined}
                onMouseLeave={ev.href ? (e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 1px 4px rgba(0,0,0,0.03)'; }) : undefined}
              >
                <div style={{ position: 'relative', aspectRatio: '16 / 10', overflow: 'hidden' }}>
                  <img src={ev.thumbnail} alt="" style={{
                    width: '100%', height: '100%', objectFit: 'cover',
                    filter: 'grayscale(1) sepia(1) hue-rotate(190deg) saturate(5) brightness(1.02) contrast(1.05)',
                  }}/>
                </div>
                <div style={{ padding: '18px 20px 22px' }}>
                  <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 17, lineHeight: 1.25, letterSpacing: '-0.01em', margin: '0 0 10px', fontWeight: 400 }}>{ev.title}</h3>
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)', display: 'flex', flexWrap: 'wrap', gap: 8 }}>
                    <span>{ev.date}</span><span>·</span><span>{ev.location}</span><span>·</span><span>{ev.duration}</span>
                    {ev.attendees && <><span>·</span><span>{ev.attendees} attendees</span></>}
                  </div>
                  {ev.href && (
                    <div style={{ marginTop: 14, fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--baii-blue)', fontWeight: 500 }}>Watch recording →</div>
                  )}
                </div>
              </Tag>
              </ScrollReveal>
              );
            })}
          </div>
        </div>
      </section>
      </ScrollReveal>

      {/* Podcast */}
      <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <ScrollReveal still>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">03</span><span className="section-index__rule"></span><TypewriterLabel text="Podcast" delay={300} />
          </div>
          <h2 className="display-lg" style={{ marginBottom: 12 }}>The BAII Podcast.</h2>
          </ScrollReveal>
          <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '56ch', marginBottom: 36 }}>
            Long-form conversations with the researchers and practitioners shaping how AI and behavioral science intersect.
          </p>

          <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
            {PODCAST_EPISODES.map((ep, i) => (
              <ScrollReveal still key={ep.title} delay={i * 120}>
              <a className="podcast-row-single" href={ep.url} target="_blank" rel="noopener noreferrer"
                onMouseEnter={() => setPodHov(i)} onMouseLeave={() => setPodHov(-1)}
                style={{
                  display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto auto', gap: 20, alignItems: 'center',
                  padding: '18px 24px',
                  borderBottom: '1px solid var(--baii-bone)',
                  background: podHov === i ? 'var(--baii-blue-95)' : 'transparent',
                  transition: 'background 0.2s ease',
                  textDecoration: 'none', color: 'inherit',
                }}>
                <div>
                  <div style={{ fontFamily: 'var(--font-display)', fontSize: 18, letterSpacing: '-0.01em' }}>{ep.title}</div>
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg3)', marginTop: 4 }}>Interviewed by {ep.host}</div>
                </div>
                <span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg3)' }}>{ep.duration}</span>
                <span className="podcast-play-col" style={{ fontFamily: 'var(--font-mono)', fontSize: 18, width: 64, color: podHov === i ? 'var(--baii-blue)' : 'var(--fg3)', transition: 'color 0.2s', textAlign: 'center' }}>▶</span>
              </a>
              </ScrollReveal>
            ))}
          </div>

          <div className="btn-group-mobile" style={{ display: 'flex', gap: 12, marginTop: 28 }}>
            <a className="btn btn--primary btn--sm" href="https://www.youtube.com/@behavioralaiinstitute" target="_blank" rel="noopener noreferrer">Watch on YouTube <Arrow /></a>
            <span className="btn btn--hollow btn--sm" style={{ opacity: 0.6, cursor: 'default' }}>Spotify — coming soon</span>
          </div>
        </div>
      </section>
      </ScrollReveal>

      {/* Community Links */}
      <ScrollReveal still>
      <section className="surface">
        <div className="surface__inner">
          <ScrollReveal still>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">04</span><span className="section-index__rule"></span><TypewriterLabel text="Stay connected" delay={300} />
          </div>
          <h2 className="display-lg" style={{ marginBottom: 40 }}>Where to find us.</h2>
          </ScrollReveal>

          <div className="community-links-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 0, position: 'relative' }}>
            {COMMUNITY_LINKS.map((link, i) => (
              <div key={link.platform} style={{
                padding: '0 40px',
                borderLeft: i > 0 ? '1px solid var(--baii-bone)' : 'none',
              }}>
                <div style={{ fontFamily: 'var(--font-display)', fontSize: 22, fontStyle: 'italic', marginBottom: 14 }}>{link.platform}</div>
                <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.6, color: 'var(--fg2)', margin: '0 0 20px' }}>{link.body}</p>
                {link.href === '#'
                  ? <span className="btn btn--primary btn--sm" style={{ opacity: 0.6, cursor: 'default' }}>{link.cta}</span>
                  : <a className="btn btn--primary btn--sm" href={link.href} target="_blank" rel="noopener noreferrer">{link.cta} <Arrow /></a>
                }
              </div>
            ))}
          </div>
        </div>
      </section>
      </ScrollReveal>

      {/* Brief */}
      <ScrollReveal still>
      <BriefCard setRoute={setRoute} />
      </ScrollReveal>
    </>
  );
}


/* ============================================================
   Internal-only routes — deliberately NOT in this file.

   #design-system and #admin (asset library + style guide) live in
   admin.jsx, which .vercelignore keeps out of the deployment and
   index.html only loads on localhost. This site has no build step —
   app.jsx is served as readable source — so a client-side route check
   would hide the page while still publishing its code. Removing the
   file from the upload is the only measure that actually removes it.

   Nothing is deleted: run the site locally and both routes work.
   PRD baii-launch-5-hide-pages.
   ============================================================ */


/* ============================================================
   PRIVACY & DATA RIGHTS  —  #privacy and #data-rights
   ------------------------------------------------------------
   DRAFT, written 2026-07-31 for PRD baii-launch-4-privacy-gdpr.

   The copy describes the data practice designed in
   ~/docs/plans/baii/forms-data-model-2026-07-30.md and
   ~/docs/plans/baii/forms-schema-2026-07-30.sql, plus the client-side
   checks in forms.js. It is not a template.

   RESOLVED 2026-08-11. All 19 <Confirm> chips are answered and
   PRIVACY_DRAFT is false. The `Confirm` component is deliberately kept
   though nothing uses it: it is the mechanism for the next unverified
   fact, and it renders as an amber box precisely so a new one cannot be
   shipped by accident. `tests/launch-readiness.test.mjs` fails if a chip
   reappears or the draft flag flips back without being noticed.

   One fact was resolved by REMOVING the claim rather than answering it:

   - Whether to self-host React and Babel. The policy now states what is
     true today — they load from unpkg — instead of holding a decision.

   One was removed on 2026-08-11 and ANSWERED on 2026-08-12:

   - The institute's registered legal form. It was removed because the
     ledger's "loi du 1 juillet 1901, confirmed" cited an internal decision
     record, not the register — and three requests to Antoine went
     unanswered. It is now read off the French national entity register:
     association déclarée, SIREN 990097784. Both legal pages state it, and
     the full provenance is in the note above PolicySection 01. This is the
     shape the ledger is for: a claim that looks identical before and after
     verification is exactly the one that has to be removed until sourced.

   HOW EACH REMAINING FACT WAS ESTABLISHED (2026-08-11):
   Supabase region eu-west-2 London, from BAII_POSTGRES_URL_DIRECT and a
   reverse lookup. Google Workspace, from the domain's MX and SPF records.
   Vercel Pro, from the teams API. unpkg, from index.html. Retention
   periods are Samuel's, recorded here because nothing else records them.

   ⚠️ THE RETENTION PERIODS ARE NOW PROMISES. Three of them ("we review
   these once a year") are only true if someone actually reviews. That is
   hub task pt-msobrm2f-hu2p, due 2027-08-11 and yearly after. The 30-day
   IP-hash line is only true once baii-admin's purge cron is DEPLOYED —
   it is written and unshipped as of today. This is the same trap the
   flag's own history warns about below: text that describes a system
   nobody built.

      ⚠️ This notice used to say the forms were "not connected to a
      database yet, so nothing you type is stored". That warning right
      here said to change it the moment the forms went live — and when
      baii-launch-2-forms-supabase landed, the flag was left alone. The
      site then spent time publishing an affirmatively FALSE statement
      about data handling, which is worse than publishing nothing. It
      was corrected on 2026-08-01.

      The lesson is the flag's meaning, not its value: PRIVACY_DRAFT
      must never describe the state of a SYSTEM, only the state of this
      TEXT. A boolean in this file cannot know whether the forms write
      to Supabase, so it must not claim to.
   ============================================================ */

const PRIVACY_DRAFT = false;

const PRIVACY_CONTACT = 'hello@behavioralai-institute.org';

/* An unverified fact. Deliberately loud. */
function Confirm({ children }) {
  return (
    <span style={{
      fontFamily: 'var(--font-mono)', fontSize: '0.86em', lineHeight: 1.4,
      background: '#FFF3D4', color: '#7A5600', border: '1px solid #E5C978',
      borderRadius: 4, padding: '1px 6px', display: 'inline',
    }}>[[CONFIRM: {children}]]</span>
  );
}

function PolicyDraftNotice({ children }) {
  if (!PRIVACY_DRAFT) return null;
  return (
    <div style={{
      border: '1px solid #E5C978', background: '#FFF9EC', borderRadius: 8,
      padding: '16px 20px', marginBottom: 28, maxWidth: '66ch',
    }}>
      <div style={{
        fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em',
        textTransform: 'uppercase', color: '#7A5600', marginBottom: 8,
      }}>Draft for review</div>
      {children || (
        <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.65, color: 'var(--fg1)', margin: 0 }}>
          Some details in this policy are still being confirmed, including our
          registered postal address, processor settings, and how long we keep
          each kind of record. Those are marked in the text below. The forms on
          this site are live and store what you submit in our database. Planned
          launch processors are identified as planned and must be checked
          against production before this draft is approved.
        </p>
      )}
    </div>
  );
}

function PolicySection({ num, label, title, children }) {
  return (
    <section className="surface">
      <div className="surface__inner">
        <div className="section-index" style={{ marginBottom: 20 }}>
          <span className="section-index__num">{num}</span><span className="section-index__rule"></span>{label}
        </div>
        <h2 className="display-md" style={{ marginBottom: 24, maxWidth: '22ch' }}>{title}</h2>
        <div style={{ maxWidth: '66ch', display: 'flex', flexDirection: 'column', gap: 18 }}>
          {children}
        </div>
      </div>
    </section>
  );
}

function PolicyP({ children }) {
  return <p className="body-mono" style={{ color: 'var(--fg2)', margin: 0 }}>{children}</p>;
}

/* label / value rows, used for the controller block and the form cards */
function PolicyRow({ label, children }) {
  return (
    <div style={{ display: 'flex', gap: 12, alignItems: 'baseline', flexWrap: 'wrap' }}>
      <span style={{
        fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.16em',
        textTransform: 'uppercase', color: 'var(--fg3)', flex: '0 0 96px',
      }}>{label}</span>
      <span style={{
        fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.6,
        color: 'var(--fg1)', flex: '1 1 200px', minWidth: 0,
      }}>{children}</span>
    </div>
  );
}

function PolicyCard({ eyebrow, children }) {
  return (
    <div style={{
      border: '1px solid var(--baii-bone)', borderRadius: 8, background: '#fff',
      padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 12,
    }}>
      <div style={{
        fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em',
        textTransform: 'uppercase', color: 'var(--baii-blue)',
      }}>{eyebrow}</div>
      {children}
    </div>
  );
}

function PolicyBullets({ items }) {
  return (
    <ul style={{ margin: 0, paddingLeft: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
      {items.map((t, i) => (
        <li key={i} className="body-mono" style={{ color: 'var(--fg2)' }}>{t}</li>
      ))}
    </ul>
  );
}


function PrivacyPage({ route, setRoute }) {
  const forms = [
    {
      eyebrow: 'Newsletter signup',
      where: 'The BAII Brief page, and the signup box at the foot of most pages',
      collects: 'Your email address, and nothing else.',
      why: 'To send you the BAII Brief.',
      basis: 'Your consent.',
      kept: <>Until you unsubscribe. We then mark your address unsubscribed and keep it marked, rather than deleting the record, so that a later import cannot quietly add you back. Ask us and we will delete it outright instead.</>,
    },
    {
      eyebrow: 'Contact form',
      where: 'Contact page',
      collects: 'Your name if you give one, your email address, your organisation if you give one, the subject you choose, and your message.',
      why: 'To read your message and answer it.',
      basis: 'Our legitimate interest in replying to people who write to us.',
      kept: 'For as long as we need it to answer you and handle any follow-up. We review the messages we hold once a year and delete those we no longer need.',
    },
    {
      eyebrow: 'Contact form',
      where: 'Services page',
      collects: 'Your name, your organisation, your role if you give one, your email address, and a description of what you are working on.',
      why: 'To read your message and answer it.',
      basis: 'Our legitimate interest in replying to people who write to us.',
      kept: 'For as long as we need it to answer you and handle any follow-up. We review the messages we hold once a year and delete those we no longer need.',
    },
    {
      eyebrow: 'Open letter signature',
      where: 'Open Letter page',
      collects: 'Your full name, your title if you give one, your affiliation, your role, and your email address.',
      why: 'To publish your signature on the open letter and to reach you about it.',
      basis: 'Your consent.',
      kept: <>For as long as the open letter is published. If we ever withdraw the letter, we delete the signature records with it. If you withdraw, we remove the public entry immediately after validating the request, then keep a minimal note that you signed and withdrew, and the date, for one year so we can show the request was handled. After that we delete it.</>,
      published: 'Your name, title, affiliation and role are published on this site once we have checked the entry. Your email address is never published.',
    },
    {
      eyebrow: 'Join us',
      where: 'Join Us page',
      collects: <>Your name if you give one, your email address, your institution if you give one, a link to your work if you give one, the areas you tick and what you type if you pick &ldquo;Other&rdquo;, the ways you say you would like to contribute, anything you write in the description box, and your reason for wanting to join if you give one. Records imported from the previous site also hold whether you offered to help pro bono, and a free-text role from the older version of this form.</>,
      why: 'To assess your interest in a fellowship or a collaboration, and to reply.',
      basis: 'Your consent, and steps taken at your request before any agreement.',
      kept: 'For as long as we are considering your interest, and while it may still be relevant to a fellowship or collaboration round. We review these once a year and delete the ones that are no longer live.',
    },
  ];

  return (
    <>
      {/* Hero */}
      <section className="surface">
        <div style={{ padding: '32px 56px 0' }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>
        <div className="surface__inner" style={{ paddingTop: 0 }}>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">00</span><span className="section-index__rule"></span><TypewriterLabel text="Privacy" delay={300} />
          </div>
          <h1 className="display-xl" style={{ marginBottom: 24, maxWidth: '16ch' }}>How we handle your data.</h1>
          <PolicyDraftNotice />
          <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '58ch', marginBottom: 24 }}>
            This policy covers behavioralai-institute.org. It sets out what the
            forms on this site collect, why we keep it, how long we keep it, and
            who else can see it. If you would rather ask a person, write to{' '}
            <a href={`mailto:${PRIVACY_CONTACT}`} style={{ color: 'var(--baii-blue)', textDecoration: 'none' }}>{PRIVACY_CONTACT}</a>.
          </p>
          <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 24 }}>
            <button className="btn btn--hollow btn--sm" onClick={() => setRoute('data-rights')} style={{ cursor: 'pointer' }}>
              Your data and your rights <Arrow />
            </button>
          </div>
          <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--fg3)' }}>
            Last updated 11 August 2026
          </div>
        </div>
      </section>

      {/* The legal FORM is stated, and it is read off the register — not off the
          fact ledger, which listed "loi du 1 juillet 1901" as confirmed while
          citing an internal decision record rather than a register entry.
          Verified 2026-08-12 against the French national entity register
          (recherche-entreprises.api.gouv.fr, over SIRENE/RNA):

            THE BEHAVIORAL AI INSTITUTE (BAII)
            SIREN 990097784 · SIRET 99009778400013
            nature juridique 9220 = association déclarée (loi of 1 July 1901)
            10 rue de la Paix, 75002 Paris · declared 2025-07-25 · état A
            NAF 72.20Z, recherche-développement en sciences humaines et sociales

          The register address matches the one published below character for
          character. Beware the near-miss row PARIS BEHAVIORAL INSTITUTE
          'UNINFLUENCED BY AI' (SIREN 101704427) — a different entity, and the
          thing a future check is most likely to confirm against by mistake.

          NOT retrieved: the RNA number (W + 9 digits). The legal class is
          settled without it; it is only needed to cite a registration number.

          This also settles the "independent, non-profit" claim in index.html
          (meta description, og:description, twitter:description) and at
          app.jsx:1106 — an association déclarée is a non-profit form, so that
          copy is accurate as written and stays. */}
      <PolicySection num="01" label="Who is responsible" title="Who holds your data.">
        <PolicyP>
          The Behavioral AI Institute, established in France, decides what this
          site collects and why. That makes the Institute the controller under
          the GDPR.
        </PolicyP>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12, borderTop: '1px solid var(--baii-bone)', paddingTop: 18 }}>
          <PolicyRow label="Entity">The Behavioral AI Institute</PolicyRow>
          <PolicyRow label="Legal form">Association déclarée under the French law of 1 July 1901</PolicyRow>
          <PolicyRow label="SIREN">990097784</PolicyRow>
          <PolicyRow label="Address">10 rue de la Paix, 75002 Paris, France</PolicyRow>
          <PolicyRow label="Country">France</PolicyRow>
          <PolicyRow label="Contact"><a href={`mailto:${PRIVACY_CONTACT}`} style={{ color: 'var(--baii-blue)', textDecoration: 'none' }}>{PRIVACY_CONTACT}</a></PolicyRow>
          <PolicyRow label="DPO">DPO services are provided by lumenx SAS. Write to <a href={`mailto:${PRIVACY_CONTACT}`} style={{ color: 'var(--baii-blue)', textDecoration: 'none' }}>{PRIVACY_CONTACT}</a> and your message reaches them.</PolicyRow>
        </div>
      </PolicySection>

      <PolicySection num="02" label="What we collect" title="What each form collects, and why.">
        <PolicyP>
          Six forms on this site feed four sets of records. We collect nothing
          from you anywhere else on the site.
        </PolicyP>
        {/* Added 2026-08-11. Every record currently held arrived by import from
            the previous site, not from a form here — 533 signatures, 324
            expressions of interest, 133 newsletter addresses, and not one
            organic submission. Section 06 disclosed that for signatures only;
            the other two sets had no disclosure at all, and the data-rights
            page opened by asserting the opposite. GDPR Art. 14 expects an
            answer to "where did you get this" when it did not come from the
            person directly. Kept short here and detailed per-form below. */}
        <PolicyP>
          Most of what we hold today did not come from these forms. The previous
          version of this site collected newsletter signups, expressions of
          interest and open-letter signatures through a different provider
          (Formspree), and those records were moved into the system described
          here in August 2026. If you gave us your details before then, that is
          where they came from. Where an imported record carries something the
          form above no longer asks for, it is named in that form&rsquo;s entry.
        </PolicyP>
        <div className="grid-2col" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, maxWidth: 'none' }}>
          {forms.map((f, i) => (
            <PolicyCard key={i} eyebrow={f.eyebrow}>
              <PolicyRow label="Where">{f.where}</PolicyRow>
              <PolicyRow label="Collected">{f.collects}</PolicyRow>
              <PolicyRow label="Purpose">{f.why}</PolicyRow>
              <PolicyRow label="Basis">{f.basis}</PolicyRow>
              {f.published && <PolicyRow label="Published">{f.published}</PolicyRow>}
              <PolicyRow label="Kept">{f.kept}</PolicyRow>
            </PolicyCard>
          ))}
        </div>
      </PolicySection>

      <PolicySection num="03" label="Technical data" title="What every submission records in addition.">
        <PolicyP>
          Alongside the fields you fill in, each submission stores four technical
          values:
        </PolicyP>
        <PolicyBullets items={[
          'The page you submitted the form from.',
          'The address of the page that sent you here, when your browser provides one.',
          'Your browser’s user-agent string, which names your browser, its version and your operating system.',
          'A salted one-way hash of your IP address. We do not store the address itself and we cannot recover it from the hash.',
        ]} />
        <PolicyP>
          We record these to keep automated submissions out of the forms and to
          see which page a message came from. The basis is our legitimate
          interest in forms that work. The IP hash is deleted after 30 days.
        </PolicyP>
        <PolicyP>
          Two checks also run inside your browser before anything is sent. One is
          a hidden field that you never see and that automated scripts tend to
          fill in. The other is a timer that measures how long passes between
          your first keystroke in a form and sending it: anything faster than a
          quarter of a second is a script rather than a person, so we ask you to
          press again. Nothing you have typed is discarded either way. Your
          browser separately keeps a
          short list of your own recent submission times, stored under keys
          beginning <code style={{ fontFamily: 'var(--font-mono)', background: 'var(--baii-paper)', padding: '1px 5px', borderRadius: 3 }}>baii_submits_</code>,
          so that one browser cannot flood a form. That list stays on your device
          and is never sent to us.
        </PolicyP>
      </PolicySection>

      <PolicySection num="04" label="Cookies" title="This site sets no cookies.">
        <PolicyP>
          There is no analytics script, no tracking pixel, no advertising tag and
          no third-party measurement on this site. Nothing here builds a profile
          of you or follows you to another site. Because nothing is stored on
          your device for tracking, we do not ask you to accept cookies.
        </PolicyP>
        <PolicyP>Two things still reach other companies:</PolicyP>
        <PolicyBullets items={[
          <>The React and Babel libraries that run this site load from unpkg.com. Your browser’s request to unpkg tells that service your IP address and user-agent. Nothing you type is involved, and unpkg sets nothing on your device. Everything else the page loads — fonts, images, stylesheets — comes from this site.</>,
          <>Vercel hosts the site and keeps server-side request logs that include IP addresses. We are on Vercel’s Pro plan, which retains those runtime logs for one day.</>,
        ]} />
        <PolicyP>
          If analytics are ever added, this section stops being true and a
          consent banner becomes necessary.
        </PolicyP>
      </PolicySection>

      <PolicySection num="05" label="Sharing" title="Who else can see it.">
        <PolicyBullets items={[
          <><strong style={{ color: 'var(--fg1)', fontWeight: 500 }}>Supabase</strong> stores the form records for us as a processor, in its London region (eu-west-2). Only the institute can read them, using a server key that is not present in this site’s code. The key that ships in the page can add a row and cannot read one back.</>,
          <><strong style={{ color: 'var(--fg1)', fontWeight: 500 }}>Vercel</strong> hosts the site and serves every page request.</>,
          <><strong style={{ color: 'var(--fg1)', fontWeight: 500 }}>Resend</strong> delivers the acknowledgment we send you when you use a form. It receives your email address and, where the form asks for it, your name.</>,
          <><strong style={{ color: 'var(--fg1)', fontWeight: 500 }}>Brevo</strong> sends the updates we email to newsletter subscribers, open letter signatories, people who asked to get involved, and event registrants. It holds your email address and, where we have it, your name, and it records whether an email was opened and whether you unsubscribed. Brevo is a French company and keeps this data in the European Union.</>,
          <><strong style={{ color: 'var(--fg1)', fontWeight: 500 }}>Slack</strong> receives a short alert when a form is submitted: which form, and enough of what you gave us to tell one alert from the next. Anything you write in a message or description stays out.</>,
          <><strong style={{ color: 'var(--fg1)', fontWeight: 500 }}>Google Workspace</strong> provides our email, so it holds anything you send us at an address on this domain.</>,
        ]} />
        <PolicyP>
          We do not sell your data, share it with advertisers, or use it to train
          models.
        </PolicyP>
        <PolicyP>
          The database sits in London. The United Kingdom is covered by the
          European Commission’s adequacy decision, so storing your record there
          needs no further safeguard. Supabase, Vercel, Resend, Slack and Google
          are United States companies, and their staff can reach the data they
          hold for support and maintenance. Where that takes data outside the
          European Economic Area, the safeguard we rely on is the Standard
          Contractual Clauses in each provider’s data processing terms.
        </PolicyP>
      </PolicySection>

      <PolicySection num="06" label="The open letter" title="Signing the letter is public.">
        <PolicyP>
          Once we have checked your entry, your name, your title, your affiliation
          and your role appear on the Open Letter page. Anyone can read them, copy
          them, and find them through a search engine. Your email address stays in
          the private record and is never open on the site.
        </PolicyP>
        <PolicyP>
          You can withdraw your consent at any time. We remove your name from
          the public list without undue delay—immediately after validating the
          request. We then keep a minimal note that you signed and withdrew, and
          the date, for one year so that we can show the request was handled.
          After that we delete it.
        </PolicyP>
        {/* Resolved 2026-07-31 from evidence rather than from memory: the live
            production form carries a required, unchecked-by-default checkbox
            with exactly this wording, so every one of the 533 has affirmatively
            consented to publication. Recorded per-row in the database as
            consent_source='legacy-production-form'. This used to be a
            [[CONFIRM]] asking what those signatories were told. */}
        <PolicyP>
          The signatures already published were collected before this policy
          existed, through a form that asked for this consent in these words:
          “I agree to having my name, title, and organization published as a
          signatory to this open letter.” Ticking it was required, so every
          published name carries that agreement, and we hold a record of it
          against each signature.
        </PolicyP>
      </PolicySection>

      <PolicySection num="07" label="Your rights" title="You can see it, correct it, or have it deleted.">
        <PolicyP>
          The GDPR gives you rights over the data we hold about you, and all of
          them are free to use. The next page lists each one and says exactly how
          to use it.
        </PolicyP>
        <div>
          <button className="btn btn--primary btn--sm" onClick={() => setRoute('data-rights')} style={{ cursor: 'pointer' }}>
            Your data and your rights <Arrow />
          </button>
        </div>
      </PolicySection>

      <PolicySection num="08" label="Changes" title="When this policy changes.">
        <PolicyP>
          We will update this page when our practice changes and note the date at
          the top. If a change affects data you have already given us, we will
          write to you about it.
        </PolicyP>
      </PolicySection>
    </>
  );
}


function DataRightsPage({ route, setRoute }) {
  const rights = [
    { name: 'Access', body: 'Ask for a copy of everything we hold about you, and for an explanation of where it came from and who has seen it.' },
    { name: 'Rectification', body: 'Ask us to correct anything wrong. Signatories use this most: a misspelled name, an old affiliation, the wrong role.' },
    { name: 'Erasure', body: 'Ask us to delete your record. For a signature, that also takes your name off the public list.' },
    { name: 'Portability', body: 'Ask for your data as a machine-readable file, or ask us to send it directly to someone else.' },
    { name: 'Restriction', body: 'Ask us to keep your data but stop using it while a question about it is being settled.' },
    { name: 'Objection', body: 'Object to our use of your data where we rely on legitimate interest. That covers answering your message and blocking automated submissions.' },
    { name: 'Withdrawal of consent', body: 'Withdraw your consent at any time for the newsletter and for your signature. Withdrawing does not undo anything done while the consent stood.' },
  ];

  return (
    <>
      {/* Hero */}
      <section className="surface">
        <div style={{ padding: '32px 56px 0' }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>
        <div className="surface__inner" style={{ paddingTop: 0 }}>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">00</span><span className="section-index__rule"></span><TypewriterLabel text="Your data · GDPR" delay={300} />
          </div>
          <h1 className="display-xl" style={{ marginBottom: 24, maxWidth: '16ch' }}>Your data and your rights.</h1>
          <PolicyDraftNotice />
          <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '58ch', marginBottom: 24 }}>
            If we hold data about you, it is because you filled in a form — either
            on this site, or on the version of it that came before, from which
            those records were imported. This page lists the rights the GDPR
            gives you over that data and says how to use each one. You do not
            need to give a reason and there is no charge.
          </p>
          <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
            <button className="btn btn--hollow btn--sm" onClick={() => setRoute('privacy')} style={{ cursor: 'pointer' }}>
              What we collect <Arrow />
            </button>
          </div>
        </div>
      </section>

      <PolicySection num="01" label="Your rights" title="Seven rights, in plain terms.">
        <div className="grid-2col" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, maxWidth: 'none' }}>
          {rights.map((r, i) => (
            <PolicyCard key={i} eyebrow={r.name}>
              <p className="body-mono" style={{ color: 'var(--fg2)', margin: 0, fontSize: 14 }}>{r.body}</p>
            </PolicyCard>
          ))}
        </div>
      </PolicySection>

      <PolicySection num="02" label="How to ask" title="How to use any of them.">
        <PolicyP>
          Write to{' '}
          <a href={`mailto:${PRIVACY_CONTACT}`} style={{ color: 'var(--baii-blue)', textDecoration: 'none' }}>{PRIVACY_CONTACT}</a>.
          Tell us what you want and which email address you used on the form. That
          address is how we find your record.
        </PolicyP>
        <PolicyP>
          We answer within one month. If a request turns out to be complicated we
          may take up to two months more, and we will tell you inside the first
          month if that happens.
        </PolicyP>
        <PolicyP>
          We may ask you to confirm that the email address is yours. We will not
          ask for a copy of an identity document unless you have asked for a full
          copy of your data and we cannot otherwise establish who you are.
        </PolicyP>
      </PolicySection>

      <PolicySection num="03" label="Common requests" title="The four we expect most.">
        <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
          <PolicyCard eyebrow="Take my name off the open letter">
            <p className="body-mono" style={{ color: 'var(--fg2)', margin: 0, fontSize: 14 }}>
              Write to us. We remove your name from the public list without
              undue delay—immediately after validating the request. We keep the
              consent record only for the retention period stated in the privacy
              policy, then delete it.
            </p>
          </PolicyCard>
          <PolicyCard eyebrow="Correct my title or affiliation">
            <p className="body-mono" style={{ color: 'var(--fg2)', margin: 0, fontSize: 14 }}>
              Send us the correction and we edit the entry. Some published entries
              were shortened by us for length rather than submitted that way, so
              tell us if you want the full version restored.
            </p>
          </PolicyCard>
          <PolicyCard eyebrow="Stop the newsletter">
            <p className="body-mono" style={{ color: 'var(--fg2)', margin: 0, fontSize: 14 }}>
              Write to us and we will remove your address. Once the BAII Brief
              starts going out, every issue will also carry an unsubscribe link.
            </p>
          </PolicyCard>
          <PolicyCard eyebrow="Delete everything">
            <p className="body-mono" style={{ color: 'var(--fg2)', margin: 0, fontSize: 14 }}>
              Say so, and we delete every record that carries your email address
              across all four sets: newsletter, messages, signature, and interest
              in joining.
            </p>
          </PolicyCard>
        </div>
      </PolicySection>

      <PolicySection num="04" label="How we work" title="What we do not do with it.">
        <PolicyBullets items={[
          'No automated decision-making and no profiling. A person reads every message.',
          'Signature moderation is a human check that the entry is a real person. No algorithm decides whether a name is published.',
          'No scoring, ranking or segmenting of the people in our records.',
          'No transfer of your data to anyone for their own purposes.',
        ]} />
        <PolicyP>
          Only the institute can read the records. The database key that ships in
          this website can add a row and cannot read one back, so the source of
          this page cannot be used to retrieve anyone else’s data. The public
          signatory list is the only part of the database that anyone else can
          read, and it carries no email addresses.
        </PolicyP>
      </PolicySection>

      <PolicySection num="05" label="Complaints" title="If we get it wrong.">
        <PolicyP>
          Tell us first and we will try to put it right. You also have the right
          to complain to a data protection authority, either in the country where
          you live or where you work. Our lead supervisory authority is the French
          data protection authority, the CNIL.
        </PolicyP>
      </PolicySection>
    </>
  );
}


function TermsPage({ route, setRoute }) {
  return (
    <>
      <section className="surface">
        <div style={{ padding: '32px 56px 0' }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>
        <div className="surface__inner" style={{ paddingTop: 0 }}>
          <div className="section-index" style={{ marginBottom: 20 }}>
            <span className="section-index__num">00</span><span className="section-index__rule"></span><TypewriterLabel text="Terms" delay={300} />
          </div>
          <h1 className="display-xl" style={{ marginBottom: 24, maxWidth: '16ch' }}>Terms of Use.</h1>
          <PolicyDraftNotice />
          <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '60ch', marginBottom: 24 }}>
            These terms govern your use of behavioralai-institute.org and the
            material the Behavioral AI Institute publishes here. Please read
            them alongside our privacy policy.
          </p>
          <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 24 }}>
            <button className="btn btn--hollow btn--sm" onClick={() => setRoute('privacy')} style={{ cursor: 'pointer' }}>
              Read our privacy policy <Arrow />
            </button>
          </div>
          <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--fg3)' }}>
            Effective 11 August 2026
          </div>
        </div>
      </section>

      <PolicySection num="01" label="About these terms" title="Who these terms are between.">
        <PolicyP>
          The Behavioral AI Institute is established in France. In these terms,
          “the Institute”, “we”, “us” and “our” refer to the Behavioral AI
          Institute.
        </PolicyP>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12, borderTop: '1px solid var(--baii-bone)', paddingTop: 18 }}>
          <PolicyRow label="Entity">The Behavioral AI Institute</PolicyRow>
          <PolicyRow label="Legal form">Association déclarée under the French law of 1 July 1901</PolicyRow>
          <PolicyRow label="SIREN">990097784</PolicyRow>
          <PolicyRow label="Address">10 rue de la Paix, 75002 Paris, France</PolicyRow>
          <PolicyRow label="Contact"><a href="mailto:hello@behavioralai-institute.org" style={{ color: 'var(--baii-blue)', textDecoration: 'none' }}>hello@behavioralai-institute.org</a></PolicyRow>
        </div>
        <PolicyP>
          By using this site, you agree to these terms. If you do not agree,
          please do not use the site.
        </PolicyP>
      </PolicySection>

      <PolicySection num="02" label="Using the site" title="Use it lawfully and in good faith.">
        <PolicyP>
          You may read, link to, and quote our public material for lawful
          research, education, commentary, and other non-commercial purposes,
          provided you identify the source and do not suggest that the Institute
          endorses you or your work.
        </PolicyP>
        <PolicyP>You must not:</PolicyP>
        <PolicyBullets items={[
          'interfere with the site, its security, or another person’s access to it;',
          'use the site to submit unlawful, misleading, infringing, or harmful material;',
          'impersonate another person or misrepresent an affiliation;',
          'collect personal data from the public signatory list for unsolicited marketing; or',
          'use the Institute’s name, marks, or material in a way that implies endorsement without written permission.',
        ]} />
      </PolicySection>

      <PolicySection num="03" label="Our material" title="Research to inform, not professional advice.">
        <PolicyP>
          Unless a page says otherwise, the Institute owns or licenses the site’s
          text, visual identity, illustrations, and other material. Copyright and
          other intellectual-property rights remain with their owners.
        </PolicyP>
        <PolicyP>
          Our research, articles, glossary entries, events, and other public
          material are general information. They are not medical, legal,
          financial, or other professional advice, and should not be treated as
          a substitute for advice fitted to a particular situation.
        </PolicyP>
        <PolicyP>
          If you want to reproduce substantial material, use it commercially,
          translate it, or adapt it, ask us first at{' '}
          <a href="mailto:hello@behavioralai-institute.org" style={{ color: 'var(--baii-blue)', textDecoration: 'none' }}>hello@behavioralai-institute.org</a>.
        </PolicyP>
      </PolicySection>

      <PolicySection num="04" label="What you submit" title="Your material stays yours.">
        <PolicyP>
          You retain ownership of material you submit through this site. You give
          us only the permission reasonably needed to receive it, review it,
          reply to you, and use it for the purpose stated beside the form.
        </PolicyP>
        <PolicyP>
          If you sign the Open Letter, the separate consent beside that form
          allows us to publish your name, affiliation, and role. You can withdraw
          that consent at any time. We remove your name from the public list
          without undue delay—immediately after validating the request—and
          handle the remaining consent record as described in our privacy policy.
        </PolicyP>
        <PolicyP>
          You are responsible for ensuring that anything you submit is accurate,
          lawful, and yours to share. Our handling of personal data is governed
          by our privacy policy and data-rights page.
        </PolicyP>
      </PolicySection>

      <PolicySection num="05" label="Links and availability" title="What sits outside our control.">
        <PolicyP>
          This site links to third-party websites and services. A link is not an
          endorsement, and those services have their own terms and privacy
          practices. We are not responsible for material or services they control.
        </PolicyP>
        <PolicyP>
          We take reasonable care over what we publish, but research develops and
          public material can become incomplete or out of date. We may correct,
          change, suspend, or remove site material, and we do not promise that the
          site will always be available or error-free.
        </PolicyP>
        <PolicyP>
          Nothing in these terms excludes or limits a responsibility that cannot
          lawfully be excluded or limited.
        </PolicyP>
      </PolicySection>

      <PolicySection num="06" label="Law and changes" title="French law, with your mandatory rights preserved.">
        <PolicyP>
          French law governs these terms to the extent it may lawfully do so.
          Mandatory consumer rights and rules about which courts have jurisdiction
          continue to apply. If there is a dispute, please contact us first so we
          can try to resolve it directly.
        </PolicyP>
        <PolicyP>
          We may update these terms when the site or the law changes. We will post
          the revised terms here and change the effective date above. Changes apply
          from that date, not retrospectively.
        </PolicyP>
      </PolicySection>
    </>
  );
}



/* ============================================================
   THINKSLOP — autumn webinar (Tier 3: hash-only, no links anywhere)
   ============================================================

   Copy approved in draft 2026-08-20 and lifted verbatim from
   ~/docs/handovers/2026-08-20-baii-event-page.md. Do not paraphrase it here;
   edit the handover and re-lift, so the Luma listing and this page cannot
   drift apart.

   NOT a Behavioral AI Horizon event. Horizon is the Institute's look-back /
   look-forward session, once or twice a year (Samuel, 2026-08-20). Do not
   brand this as Horizon and do not fold it into that series.

   WHAT IS NOT SETTLED, as of 2026-08-21 — read before publishing anything:
     - No date. Samuel's invitation to Marc offers "end of September or the
       first half of October" and says the date is built around his
       availability. So this page carries NO day, NO month numeral.
     - Marc Zao-Sanders was invited 2026-08-20 and has not replied. He
       presents, so his acceptance gates the whole format.
     - Fendi Tsim has never been asked. Named in two meetings as a
       participant, which is not the same thing.
     - Helen Mankin said yes verbally 2026-08-11. Whether a public
       appearance needs Google DeepMind clearance has never been raised.
     - Antoine as moderator was recommended in the brief, not discussed
       with him.
     - No Luma listing exists, so there is no registration URL. The CTA
       points at the live BAII calendar (luma.com/behavioralai), which is
       corroborated, instead of a dimmed button that promises a form.
   Samuel's instruction 2026-08-21: build the page assuming Marc, Helen and
   Fendi are the speakers, and remove them later if any of them declines.

   The event-data sourcing rule above UPCOMING_EVENTS applies here in full:
   a date, a venue, an attendance figure or a registration link is either
   corroborated or absent. Never estimated, never inferred from a plan.      */
const THINKSLOP = {
  type: 'Webinar',
  title: 'Thinkslop',
  tagline: 'How AI is changing the way we think, for better and for worse.',
  format: 'Online · 60 minutes',
  /* Deliberately not a date. See the block above. */
  when: 'Autumn 2026 · date to be confirmed',
  calendarHref: 'https://luma.com/behavioralai',

  body: [
    'In at least a quarter of the most common ways people use AI today, they are handing over part of the thinking itself.',
    'Marc Zao-Sanders calls this thinkslop, to distinguish it from the low quality output usually described as AI slop. Thinkslop happens earlier, at the point where someone declines to engage with a problem at all, because a passable answer arrives in two seconds for no effort.',
    'That is one half of the picture. The other half is that some people think better with these tools than they did without them. The research so far points at how the tool gets used: AI used as support appears to build skill, while handing a task over wholesale appears to prevent the learning that would have come from doing it.',
    'Most of the public argument runs between two positions, neither of which has much evidence behind it: that using AI carries no cognitive cost at all, or that using it will erode your mind. The people actually deploying these tools inside organisations are left without a usable answer.',
    'This session brings data to the question. Marc presents findings from AI in the Wild, his study of how people really use AI, and then joins Helen Mankin and Fendi Tsim for a moderated discussion of the harder problem underneath: where the line sits between offloading work and offloading thought, and how anyone would measure it.',
  ],

  /* NOT IN THE APPROVED DRAFT. Samuel's copy of 2026-08-20 has a description,
     an audience, an agenda and five panel questions, but no takeaways — the
     Luma listing does not need them and a webinar page does.

     These four are derived from the approved description and the panel
     questions, and each one is traceable to a sentence in them: 01 to Marc's
     AI in the Wild findings, 02 to panel question 1, 03 to the "AI used as
     support appears to build skill" paragraph, 04 to panel question 4. No
     claim here is new. But the WORDS are mine, not Samuel's, and they are the
     first thing a visitor reads, so they need his eye before this is public.

     The 12,637 figure appears here as well as in Marc's bio. Both need
     verifying with him — see the speakers note below. */
  takeaways: [
    { title: 'What people actually do with AI',
      body: 'The 2026 findings from AI in the Wild, drawn from real use cases rather than from what people say they use it for, and how much of it is thinking rather than work.' },
    { title: 'Where offloading turns into thinkslop',
      body: 'The line between handing over a task and handing over the judgment behind it, and why that line does not sit in the same place for everyone.' },
    { title: 'Which uses build skill, and which prevent it',
      body: 'What the research so far separates: AI used as support, which appears to build capability, and AI handed a task wholesale, which appears to prevent the learning that doing it would have produced.' },
    { title: 'What to measure, starting now',
      body: 'What to track in an organisation already rolling AI out to its people, and why a baseline taken after the rollout is worth very little.' },
  ],

  audience: 'People rolling out AI inside organisations and accountable for what happens next. Researchers working on human and AI interaction. Anyone who has noticed a change in how they think since they started using these tools and wants better than an anecdote.',

  agenda: [
    { dur: '5 min',  title: 'Why we are asking this', body: 'Samuel Salzer on the two positions the public argument is stuck between.' },
    { dur: '20 min', title: 'What the data shows',    body: 'Marc Zao-Sanders presents the 2026 findings from AI in the Wild.' },
    /* Antoine was removed from this session by Samuel on 2026-08-21; he was the
       recommended moderator and had never been asked. Samuel took the role
       himself the same day, so he now both opens and moderates — which is why
       his speaker chip reads "Opens & moderates" rather than "Opens". */
    { dur: '25 min', title: 'Panel: where is the line', body: 'Marc, Helen and Fendi, moderated by Samuel Salzer.' },
    { dur: '10 min', title: 'Audience questions',     body: 'Open floor, with live polling.' },
  ],

  /* NOT RENDERED since 2026-08-21 — Samuel removed the "What the panel will be
     asked" section. The copy is his and approved, and it is exactly what a Luma
     listing wants, so it stays here rather than being deleted out of the repo.
     If nothing has used it by the time the listing exists, delete it then. */
  questions: [
    'Where is the line between offloading work and offloading thought, and is it the same line for everyone?',
    'Which uses of AI appear to improve thinking rather than replace it, and what do those uses have in common?',
    'Thinkslop shows up in Marc’s data as a behaviour. What would it take to measure it inside a real organisation?',
    'Organisations are rolling out AI assistants to entire workforces without taking any baseline measurement first. What should they be tracking, starting now?',
    'What would you tell someone who wants the gains without the erosion?',
  ],

  /* `member` reads the bio straight out of TEAM_MEMBERS so this page and the
     About page cannot disagree about a named person's job — the failure the
     handover calls out, where a garbled transcript put Helen on a "Future of
     Work team" she does not lead. The site bio is the authority.

     Marc is the one exception: he is not a team member, so his copy is
     inline. VERIFY WITH MARC BEFORE PUBLISHING — the 12,637 figure and the
     2026 use-case ranking both come from secondary coverage, because the HBR
     article is paywalled. Primary sources: hbr.org/2026/06/how-people-are-
     really-using-ai-in-2026 and marczaosanders.substack.com/p/thinkslop.
     He also has no photo in this repo; sourcing one needs his permission. */
  speakers: [
    { member: null, name: 'Marc Zao-Sanders', part: 'Presents',
      /* Everyone else's role line comes from TEAM_MEMBERS. Marc is not a team
         member, so his is here — drawn from the approved bio below, and
         deliberately not upgraded: the copy says he "runs" AI in the Wild with
         Sara Biuk, so this does not call him its founder. */
      role: 'Author of Timeboxing · Filtered.com',
      bio: 'Marc runs AI in the Wild, a longitudinal study of how people actually use AI, with Sara Biuk. Its third edition analysed 12,637 real use cases drawn from Reddit, LinkedIn and news reporting, and was published in Harvard Business Review in June 2026. He is the author of Timeboxing and a board director at Filtered.com.' },
    { member: 'Helen Mankin',    part: 'Panel' },
    { member: 'Dr Fendi Tsim',   part: 'Panel' },
    { member: 'Samuel Salzer',   part: 'Opens & moderates' },
  ],
};

/* "Where's your line?" — an interactive that let the reader sort five AI uses
   into work-versus-thinking — lived here until 2026-08-21. Samuel shelved it
   for the page and redirected it to LinkedIn; the copy and the mechanic are
   written up at
   ~/docs/handovers/2026-08-20-baii-event-page/copy/thinkslop-linkedin-post.md
   It was working when it was removed (select, deselect and the running tally
   were all verified), so this is a scope decision, not a repair. */

/* A speaker row. Photo left, name and bio right, stacked down the page —
   the shape a webinar page uses when the speakers ARE the draw, rather than
   the five-across card row this held before. Falls back to initials when
   there is no photo in the repo, rather than a broken <img> or a stock
   silhouette: Marc is the only person here without one, and an honest
   monogram reads better than either. */
function SpeakerRow({ entry }) {
  const m = entry.member ? TEAM_MEMBERS.find(t => t.name === entry.member) : null;
  const name = m ? m.name : entry.name;
  const bio = m ? m.bio : entry.bio;
  const role = m ? m.role : entry.role;
  const photo = m ? m.photo : null;
  const link = m ? m.linkedin : null;
  const initials = name.replace(/^Dr\s+/, '').split(' ').map(w => w[0]).join('');
  return (
    <div className="event-row-grid" style={{
      display: 'grid', gridTemplateColumns: '132px 1fr', gap: 28, alignItems: 'start',
      paddingTop: 28, borderTop: '1px solid var(--baii-bone)',
    }}>
      <div style={{ borderRadius: 10, overflow: 'hidden', aspectRatio: '1 / 1', background: 'var(--baii-blue-95)' }}>
        {photo
          ? <img src={photo} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'grayscale(1) contrast(1.05)', display: 'block' }}/>
          : <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--font-display)', fontSize: 38, color: 'var(--baii-blue-70)' }}>{initials}</div>}
      </div>
      <div>
        <div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--baii-blue)', background: 'var(--baii-blue-95)', padding: '3px 8px', borderRadius: 999, display: 'inline-block', marginBottom: 10 }}>{entry.part}</div>
        <h3 style={{ fontFamily: 'var(--font-display)', fontSize: 24, lineHeight: 1.2, letterSpacing: '-0.01em', margin: '0 0 4px', fontWeight: 400 }}>{name}</h3>
        {role && <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg3)', marginBottom: 12 }}>{role}</div>}
        <p className="body-mono" style={{ color: 'var(--fg2)', fontSize: 14, lineHeight: 1.7, margin: 0, maxWidth: '58ch' }}>{bio}</p>
        {link && (
          <a href={link} target="_blank" rel="noopener noreferrer"
             style={{ display: 'inline-block', marginTop: 12, fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--baii-blue)' }}>
            LinkedIn →
          </a>
        )}
      </div>
    </div>
  );
}

/* Per-POSITION face treatment in the hero — not per person, so that removing
   a speaker (as happened to Antoine on 2026-08-21) reshuffles the scatter
   rather than leaving a hole in it. Position 0 is the presenter's slot and is
   the largest.

   Angles stay between 2 and 3 degrees. Past about 5 the group stops reading as
   arranged and starts reading as broken, and the rotated corners begin to clip
   the panel, which has overflow:hidden. */
const FACE_LAYOUT = [
  { w: '100%', rot: -2.6, mt: 0,  ml: 0 },
  { w: '84%',  rot: 2.4,  mt: 20, ml: 10 },
  { w: '80%',  rot: 2.0,  mt: 2,  ml: 12 },
  { w: '92%',  rot: -2.2, mt: 12, ml: 0 },
];

/* Speaker names get weight wherever they appear in the running copy. Done as a
   renderer rather than by putting <strong> into THINKSLOP.body, so the approved
   copy stays plain strings that can be diffed against the Luma listing and the
   handover without markup in the way.

   Longest-first alternation matters: a bare 'Marc' listed before
   'Marc Zao-Sanders' would match the first four letters and split his surname
   off. String.split with a capturing group returns match segments at the odd
   indices, which is what the i % 2 test reads. */
const THINKSLOP_NAME_RE = /(Marc Zao-Sanders|Helen Mankin|Fendi Tsim|Samuel Salzer|Marc)/;
function withNames(text) {
  return String(text).split(THINKSLOP_NAME_RE).map((part, i) =>
    i % 2 === 1 ? <strong key={i}>{part}</strong> : part
  );
}

function ThinkslopPage({ route, setRoute }) {
  /* Repeated three times in the hero and the sidebar. Declared once so the
     duration, the format and the absence of a date cannot drift apart. */
  const facts = [
    ['Date',         'To be confirmed'],
    ['Format',       'Online'],
    ['Duration',     '60 minutes'],
    ['Registration', 'Opens soon'],
  ];

  return (
    <>
      {/* ---- Hero ----
           A dark band carrying the title, the faces and the one real action,
           over a light body — the shape the reference uses.

           The band is a PANEL inside a white surface rather than the surface
           itself, because the nav sits in here: SubPageNav takes a `light` prop
           for the wordmark, but MainNav has no light variant, so its pill would
           keep dark-on-white styling over a blue ground and read as broken.
           Nav stays on white; everything below it goes blue.                  */}
      <ScrollReveal still>
      <section className="surface">
        <div style={{ padding: '32px 56px 0' }}>
          <SubPageNav route={route} setRoute={setRoute} />
        </div>
        <div className="surface__inner" style={{ paddingTop: 0 }}>
          <div className="event-hero-panel" style={{
            background: 'var(--baii-hero-gradient)', borderRadius: 16,
            padding: '48px 52px', overflow: 'hidden',
          }}>

            {/* Two columns, both vertically centred against each other: title
                block centre-left, faces centre-right.

                The columns are 1.15 / 0.85, not even. The title has to fit the
                left one — at its previous full-width size "Thinkslop" ran about
                750px and could not — so the left column is given the extra
                width and the faces gave some up, which is also what makes them
                slightly smaller. See .event-hero-title for the size this costs.

                alignItems:center is safe HERE, though it was not when the title
                sat full-width above: back then the left column held only chips
                and the signup card, was far shorter than the faces, and
                centring opened a hole above it. With the title inside it the
                left column is now the taller of the two. */}
            <div className="grid-2col" style={{ display: 'grid', gridTemplateColumns: '1.15fr 0.85fr', gap: 48, alignItems: 'center' }}>

              {/* ---- Left: what it is, and the one thing to do about it ---- */}
              <div>
                <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase', color: '#fff', background: 'rgba(255,255,255,0.16)', padding: '5px 12px', borderRadius: 999, display: 'inline-block', marginBottom: 22 }}>
                  {THINKSLOP.type}
                </div>
                <h1 className="event-hero-title">{THINKSLOP.title}</h1>
                <p className="event-hero-sub">{THINKSLOP.tagline}</p>

                {/* Chips carry what is settled. The date is not, so it says so
                    rather than being quietly dropped from the row. */}
                <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', margin: '30px 0 24px' }}>
                  {['60 minutes', 'Online', 'Date to be confirmed'].map(t => (
                    <span key={t} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'rgba(255,255,255,0.9)', border: '1px solid rgba(255,255,255,0.34)', borderRadius: 999, padding: '6px 14px' }}>{t}</span>
                  ))}
                </div>

                {/* The signup panel. White on the blue so it is the brightest
                    object in the band, which is the job the reference gives it.

                    NO EMAIL FIELD, deliberately. The reference captures an
                    address inline and registers you; there is no Luma listing
                    for this session, so an input here would either go nowhere
                    or quietly join people to the Brief under a different
                    promise. Following the calendar is the real path — it is
                    live, corroborated, and it does notify. If an inline capture
                    is wanted later it needs a decision about where the address
                    lands and a line on the privacy page, not just a field. */}
                {/* Tightened 2026-08-21: 440px wide and ~30 words became 340px
                    and six. The box gets its weight from being small around a
                    full-width button, not from explaining itself — the two-line
                    paragraph that used to sit above the button was competing
                    with the one thing the box exists to do.

                    REMOVED with it: the "or get the Brief" secondary control.
                    Two calls to action in a box this size split the attention
                    they were meant to concentrate. The Brief is not orphaned —
                    the closing section at the foot of the page still routes to
                    it — but it is no longer reachable from the hero, which is a
                    real reduction and is recorded here as one.

                    The caption sits BELOW the button, not above: everything
                    above a button delays it. */}
                <div style={{ background: '#fff', borderRadius: 12, padding: '18px 20px', maxWidth: 340 }}>
                  <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--fg3)', marginBottom: 14 }}>
                    Registration opens soon
                  </div>
                  <a className="btn btn--primary" href={THINKSLOP.calendarHref} target="_blank" rel="noopener noreferrer"
                     style={{ width: '100%', justifyContent: 'center' }}>
                    Follow our calendar <Arrow />
                  </a>
                  <p className="body-mono" style={{ fontSize: 11, lineHeight: 1.5, color: 'var(--fg3)', margin: '10px 0 0', textAlign: 'center' }}>
                    The date is announced there first.
                  </p>
                </div>
              </div>

              {/* ---- Right: the faces ----
                   Marc leads because he presents, and he is the one without a
                   photo in this repo — so the monogram is first, which is
                   honest but is also the first thing to fix once he sends one. */}
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', columnGap: 14, rowGap: 16, alignItems: 'start', maxWidth: 400, marginLeft: 'auto' }}>
                {THINKSLOP.speakers.map((s, i) => {
                  const m = s.member ? TEAM_MEMBERS.find(t => t.name === s.member) : null;
                  const name = m ? m.name : s.name;
                  const photo = m ? m.photo : null;
                  const initials = name.replace(/^Dr\s+/, '').split(' ').map(w => w[0]).join('');
                  /* Indexed by position, not by person, and wrapped with a
                     modulo so adding or removing a speaker cannot run off the
                     end of the table. Position 0 is the largest because that
                     is the presenter's slot. */
                  const L = FACE_LAYOUT[i % FACE_LAYOUT.length];
                  return (
                    <div key={name} className="face-tile"
                      style={{ width: L.w, marginTop: L.mt, marginLeft: L.ml, transform: `rotate(${L.rot}deg)` }}>
                      <div style={{
                        aspectRatio: '1 / 1', borderRadius: 10, overflow: 'hidden',
                        background: 'rgba(255,255,255,0.12)',
                        border: '1px solid rgba(255,255,255,0.22)',
                        boxShadow: '0 10px 30px rgba(0,0,0,0.18)',
                      }}>
                        {photo
                          ? <img src={photo} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'grayscale(1) contrast(1.05)', display: 'block' }}/>
                          : <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--font-display)', fontSize: 34, color: 'rgba(255,255,255,0.75)' }}>{initials}</div>}
                      </div>
                      <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: '#fff', marginTop: 9, lineHeight: 1.3 }}>{name}</div>
                      <div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.6)', marginTop: 2 }}>{s.part}</div>
                    </div>
                  );
                })}
              </div>

            </div>
          </div>
        </div>
      </section>
      </ScrollReveal>



      {/* ---- Body: everything the reader reads, plus a sidebar that keeps the
           facts and the one real action in view the whole way down ---- */}
      <section className="surface">
        <div className="surface__inner">
          <div className="grid-2col" style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 320px', gap: 56, alignItems: 'start' }}>

            {/* ======== MAIN COLUMN ========
                Each section carries a numbered mono LABEL and a serif TITLE,
                and they do different jobs — the same split every other page on
                this site uses (see EventsPage: "Upcoming" / "What's next.",
                "Past events" / "Catch up on what you missed.").

                The label names the category. The title is the line with a voice
                in it. They are never allowed to say the same thing twice: that
                duplication — "What you'll learn" over "What you take away." —
                is what made this look like two headings stacked, and it is why
                the titles were briefly deleted on 2026-08-21 before Samuel
                asked for them back. Keep both, keep them different. */}
            <div className="event-doc">

              {/* 01 — Why this matters. Leads now: the argument earns the
                  reader's attention before the takeaways tell them what they
                  will get out of it. This and the takeaways were the other way
                  round until 2026-08-21. */}
              <ScrollReveal still>
              <div className="section-index" style={{ marginBottom: 26 }}>
                <span className="section-index__num">01</span><span className="section-index__rule"></span><TypewriterLabel text="Why this matters" delay={300} />
              </div>
              <h2 className="display-lg" style={{ marginBottom: 32, maxWidth: '20ch' }}>Where does thinking stop being yours.</h2>
              </ScrollReveal>
              <div style={{ marginBottom: 28, maxWidth: '62ch' }}>
                {THINKSLOP.body.map((p, i) => (
                  <ScrollReveal still key={i} delay={i * 80}>
                  <p className="body-mono" style={{ color: i === 0 ? 'var(--fg1)' : 'var(--fg2)', fontSize: i === 0 ? 17 : 15, lineHeight: 1.75, margin: '0 0 20px' }}>{withNames(p)}</p>
                  </ScrollReveal>
                ))}
              </div>
              {/* The word the session is named after, defined in the same
                  declarative register as the entries on #glossary. Written as a
                  definition rather than lifted verbatim from THINKSLOP.body[1] —
                  an exact copy sat two lines from its own source and read as a
                  stutter. It adds no claim the approved copy does not already
                  make, and it is attributed. If Marc revises the term, revise
                  this with it. */}
              <ScrollReveal still>
              <aside style={{ border: '1px solid var(--baii-blue-85)', borderLeft: '3px solid var(--baii-blue)', borderRadius: 12, background: 'var(--baii-blue-95)', padding: '24px 28px', marginBottom: 72, maxWidth: '62ch' }}>
                <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--baii-blue)', marginBottom: 12 }}>The term</div>
                <p style={{ fontFamily: 'var(--font-display)', fontSize: 26, lineHeight: 1.15, color: 'var(--fg1)', margin: '0 0 2px' }}>thinkslop</p>
                <p style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontStyle: 'italic', color: 'var(--fg3)', margin: '0 0 14px' }}>noun</p>
                <p className="body-mono" style={{ color: 'var(--fg2)', fontSize: 14, lineHeight: 1.7, margin: '0 0 14px' }}>
                  Declining to engage with a problem at all, because a passable answer
                  arrives in two seconds for no effort. Distinct from AI slop, which
                  describes low quality output. Thinkslop happens earlier, before there
                  is any output to judge.
                </p>
                <p style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--fg3)', margin: 0 }}>
                  Marc Zao-Sanders
                </p>
              </aside>
              </ScrollReveal>

              {/* 02 — What you take away */}
              <ScrollReveal still>
              <div className="section-index" style={{ marginBottom: 26 }}>
                <span className="section-index__num">02</span><span className="section-index__rule"></span><TypewriterLabel text="What you'll learn" delay={300} />
              </div>
              <h2 className="display-lg" style={{ marginBottom: 32, maxWidth: '20ch' }}>What you take away.</h2>
              </ScrollReveal>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 22, marginBottom: 72 }}>
                {THINKSLOP.takeaways.map((t, i) => (
                  <ScrollReveal still key={t.title} delay={i * 90}>
                  <div style={{ display: 'grid', gridTemplateColumns: '28px 1fr', gap: 14, alignItems: 'baseline' }}>
                    <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--baii-blue)', fontWeight: 600 }}>{String(i + 1).padStart(2, '0')}</span>
                    <div>
                      <div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600, color: 'var(--fg1)', marginBottom: 6 }}>{t.title}</div>
                      <p className="body-mono" style={{ color: 'var(--fg2)', fontSize: 14, lineHeight: 1.7, margin: 0, maxWidth: '56ch' }}>{withNames(t.body)}</p>
                    </div>
                  </div>
                  </ScrollReveal>
                ))}
              </div>

              {/* 03 — Agenda. Merged with the speakers on 2026-08-21 and split
                  back apart the same day at Samuel's request: they answer
                  different questions (what happens when, versus who is doing
                  it) and a reader scanning for one should not have to read
                  past the other. */}
              <ScrollReveal still>
              <div className="section-index" style={{ marginBottom: 26 }}>
                <span className="section-index__num">03</span><span className="section-index__rule"></span><TypewriterLabel text="Agenda" delay={300} />
              </div>
              <h2 className="display-lg" style={{ marginBottom: 32 }}>How the hour runs.</h2>
              </ScrollReveal>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 72 }}>
                {THINKSLOP.agenda.map((a, i) => (
                  <ScrollReveal still key={a.title} delay={i * 100}>
                  <div className="event-row-grid" style={{
                    display: 'grid', gridTemplateColumns: '88px 1fr', gap: 24, alignItems: 'baseline',
                    padding: '22px 26px', borderRadius: 10, border: '1px solid var(--baii-bone)', background: '#fff',
                  }}>
                    <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--baii-blue)' }}>{a.dur}</div>
                    <div>
                      <div style={{ fontFamily: 'var(--font-display)', fontSize: 20, lineHeight: 1.25 }}>{a.title}</div>
                      <p className="body-mono" style={{ color: 'var(--fg2)', fontSize: 13, lineHeight: 1.6, margin: '6px 0 0', maxWidth: '56ch' }}>{withNames(a.body)}</p>
                    </div>
                  </div>
                  </ScrollReveal>
                ))}
              </div>

              {/* 04 — Speakers */}
              <ScrollReveal still>
              <div className="section-index" style={{ marginBottom: 26 }}>
                <span className="section-index__num">04</span><span className="section-index__rule"></span><TypewriterLabel text="Speakers" delay={300} />
              </div>
              <h2 className="display-lg" style={{ marginBottom: 32 }}>Who is in the room.</h2>
              </ScrollReveal>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
                {THINKSLOP.speakers.map((s, i) => (
                  <ScrollReveal still key={s.member || s.name} delay={i * 80}>
                    <SpeakerRow entry={s} />
                  </ScrollReveal>
                ))}
              </div>

            </div>

            {/* ======== SIDEBAR ========
                sticky, so the facts and the one real action stay in view for a
                page this long. align-self:start is what lets a grid item stick;
                a stretched item is already full height and never moves.

                top:88 is measured, not guessed. The portalled .site-header is
                position:fixed at top:24 with a height of 47, so its bottom edge
                sits at 71 — a sidebar sticking at 24 spent the whole scroll
                underneath it. 88 clears that with 17px of air. If the header's
                height or offset changes, re-measure this. */}
            <aside className="event-aside" style={{ position: 'sticky', top: 88, alignSelf: 'start' }}>
              <div style={{ border: '1px solid var(--baii-bone)', borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
                <div style={{ padding: '22px 24px', borderBottom: '1px solid var(--baii-bone)', background: 'var(--baii-blue-95)' }}>
                  <div style={{ fontFamily: 'var(--font-display)', fontSize: 21, color: 'var(--fg1)' }}>Attend</div>
                </div>
                <div style={{ padding: '20px 24px' }}>
                  {facts.map(([k, v], i) => (
                    <div key={k} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '9px 0', borderTop: i > 0 ? '1px solid var(--baii-bone)' : 'none' }}>
                      <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--fg3)' }}>{k}</span>
                      <span className="body-mono" style={{ fontSize: 13, color: 'var(--fg1)', textAlign: 'right' }}>{v}</span>
                    </div>
                  ))}
                  <a className="btn btn--primary" href={THINKSLOP.calendarHref} target="_blank" rel="noopener noreferrer"
                     style={{ width: '100%', justifyContent: 'center', marginTop: 18 }}>
                    Follow our calendar <Arrow />
                  </a>
                </div>
              </div>

              <div style={{ border: '1px solid var(--baii-bone)', borderRadius: 12, padding: '22px 24px', marginTop: 16 }}>
                <div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--fg3)', marginBottom: 12 }}>Who should come</div>
                <p className="body-mono" style={{ fontSize: 13, lineHeight: 1.7, color: 'var(--fg2)', margin: 0 }}>
                  {THINKSLOP.audience}
                </p>
              </div>
            </aside>

          </div>
        </div>
      </section>


      {/* ---- Closing CTA ---- */}
      <ScrollReveal still>
      <section className="surface surface--blue">
        <div className="surface__inner">
          <div className="section-index" style={{ marginBottom: 20, color: 'rgba(255,255,255,0.5)' }}>
            <span style={{ color: 'rgba(255,255,255,0.8)' }}>05</span><span className="section-index__rule" style={{ background: 'rgba(255,255,255,0.3)' }}></span><TypewriterLabel text="Attend" delay={300} />
          </div>
          <h2 className="display-lg" style={{ color: '#fff', marginBottom: 20, maxWidth: '20ch' }}>We will announce the date here first.</h2>
          <p className="body-mono" style={{ color: 'rgba(255,255,255,0.75)', maxWidth: '52ch', marginBottom: 32 }}>
            Follow the Institute’s calendar and you will get the invitation the moment
            registration opens. The Brief carries it too, along with what we publish
            in between.
          </p>
          <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
            <a className="btn btn--on-blue" href={THINKSLOP.calendarHref} target="_blank" rel="noopener noreferrer">Follow our calendar <Arrow /></a>
            <button className="btn btn--on-blue" onClick={() => setRoute('newsletter')} style={{ cursor: 'pointer' }}>Get the Brief <Arrow /></button>
          </div>
        </div>
      </section>
      </ScrollReveal>
    </>
  );
}



/* ============================================================
   HEAD OF PARTNERSHIPS — open role (Tier 3: hash-only, no links anywhere)
   ============================================================

   Source: ~/Downloads/BAII — Senior Fellow Head of Partnerships.docx,
   supplied by Samuel 2026-08-21.

   DELIBERATELY PLAIN. Samuel asked for this to read like the document, not
   like the rest of the site: one column, one white surface, no hero image,
   no cards, no statistics pulled out for emphasis, no hover states, no
   tinted section bands, no numbered section index. A job description is
   read once, top to bottom, by someone deciding whether to write an email.
   Everything the other pages do to hold a browsing visitor is friction here.
   If you are tempted to "bring this in line" with #thinkslop or #join, it is
   not out of line — it is the way it is on purpose. Ask him first.

   What that costs, stated plainly so nobody rediscovers it as a bug: this
   page uses none of the site's section furniture (surface--blue, ScrollReveal,
   TypewriterLabel, section-index). That is why it looks different in the
   source as well as on screen.

   Two corrections against the source, both plain errors rather than rewrites:
     - "positively impact the future of AI in organization" -> "organisations".
     - Duplicated section headings ("About us" appearing as both the heading
       and the first line of its own body) dropped; the heading carries it.
   "Compensation to be discussed depending on time allocation" was the last
   bullet under "Who you are", where it is not a description of a person. It
   now closes "The opportunity", which is where the terms belong.

   The signatory count is the ONLY number on this page that is not typed. It
   is read live from the database via useSignatories. The docx says "500+",
   which was true when it was written and is not what the database holds. */
const HOP_DUTIES = [
  { title: 'Build the relationships',
    body: 'Turn our founders’ networks and inbound interest into genuine conversations, then into partnerships that lead to pilots.' },
  { title: 'Land the first pilots',
    body: 'Lead the conversations, shape engagements with the delivery team, and bring partners on board.' },
  { title: 'Shape how we grow',
    body: 'Which organisations we serve first, how we package the work, and how one engagement leads to the next.' },
  { title: 'Open doors in the public and private sector',
    body: 'From enterprises rolling out AI internally to public-sector organisations serving citizens.' },
  { title: 'Grow the movement',
    body: 'Use our charter membership, thought leadership, and events to draw people in, not cold outreach.' },
  { title: 'Represent BAII with credibility',
    body: 'You speak with senior executives, so you can translate our research into the outcomes they care about.' },
];

/* The first two carry a bold lead-in in the source and the rest do not.
   That is preserved rather than regularised: they are the two the role
   actually turns on, and the document leans on them for that reason. */
const HOP_PROFILE = [
  { title: 'Established',
    body: 'You have reached a point where a fixed salary is not what you are optimising for. You can take this on for the mission and the upside, not out of necessity.' },
  { title: 'Moved by the mission',
    body: 'You want your work to matter. Protecting people from the downside of AI, and proving its upside, is the kind of cause you want to spend your energy on.' },
  { title: null,
    body: 'A relationship-builder with a track record of landing meaningful, high-value engagements, ideally in consulting, research, or enterprise services.' },
  { title: null,
    body: 'Comfortable championing a new idea with senior stakeholders, where you help them see a need they had not named.' },
  { title: null,
    body: 'Credible with executives on AI, behaviour, or organisational change. You do not need to be a scientist, but you must respect the science.' },
  { title: null,
    body: 'Entrepreneurial and self-directed. A network in enterprise, the public sector, or the AI ecosystem is a strong advantage.' },
];

const HOP_MAILTO = 'mailto:team@behavioralai-institute.org?subject=' +
  encodeURIComponent('Senior Fellow, Head of Partnerships');

function HeadOfPartnershipsPage({ route, setRoute }) {
  const sig = useSignatories();

  /* DO NOT animate this number with useCountUp, which was tried here and
     removed on 2026-08-21. That hook returns 0 — not null — whenever the value
     is loaded but its in-view gate has not fired yet, so no `!= null` guard
     catches it. On #open-letter the figure sits in a large stat display where a
     momentary 0 reads as a counter starting. Here it sits mid-sentence, and the
     page rendered "An open letter signed by 0 behavioral scientists." It stays
     that way permanently if IntersectionObserver never fires.

     A factual claim does not get an animation that can make it false. The
     figure is rendered settled, from signatoryPhrase, which gives the live
     database count once loaded and the source document's '500+' before that
     and if the fetch fails. */

  /* One measure, one rhythm, declared once. Everything on this page is either
     a paragraph, a heading, or a list item, so three objects cover it. */
  const H = { fontFamily: 'var(--font-display)', fontSize: 26, lineHeight: 1.2, letterSpacing: '-0.01em', fontWeight: 400, color: 'var(--fg1)', margin: '52px 0 18px' };
  const P = { fontFamily: 'var(--font-mono)', fontSize: 15, lineHeight: 1.75, color: 'var(--fg2)', margin: '0 0 18px' };
  const LI = { ...P, margin: '0 0 14px', paddingLeft: 4 };

  return (
    <section className="surface">
      <div style={{ padding: '32px 56px 0' }}>
        <SubPageNav route={route} setRoute={setRoute} />
      </div>
      <div className="surface__inner" style={{ paddingTop: 0 }}>
        {/* 68ch, not the full grid. A job description is read, not scanned.
            min() rather than a bare 68ch: monospace makes 1ch ≈ 0.6em, so 68ch
            is ~612px, which is wider than a phone. A bare max-width would let
            the column set the page's scroll width and push the whole layout
            sideways. */}
        <article className="role-doc" style={{ maxWidth: 'min(68ch, 100%)' }}>

          <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--fg3)', marginBottom: 18 }}>
            Open role
          </div>
          <h1 style={{ fontFamily: 'var(--font-display)', fontSize: 44, lineHeight: 1.1, letterSpacing: '-0.02em', fontWeight: 400, color: 'var(--fg1)', margin: '0 0 16px' }}>
            Senior Fellow, Head of Partnerships
          </h1>
          <p style={{ fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.7, color: 'var(--fg2)', margin: 0 }}>
            Remote / Worldwide · Founding team · Part-time up to full-time
          </p>

          {/* A div, not an <hr>. The accent segment is an ::after, and
              pseudo-elements on <hr> do not render reliably — Chrome dropped it
              silently here. role="separator" keeps the semantics the <hr> had. */}
          <div className="doc-rule" role="separator" style={{ borderTop: '1px solid var(--baii-bone)', margin: '40px 0 0' }} />

          <h2 style={H}>About us</h2>
          <p style={P}>
            The Behavioral AI Institute is a non-profit that measures the behavioral
            impact of AI on people, the risks and the benefits. Fewer than 3% of AI
            researchers have behavioral science backgrounds, yet AI systems now shape
            how millions of people think, decide, and act.
          </p>
          <p style={P}>
            We aim to be the independent authority that measures the human side, and
            advises both the people building AI and the people using it. Most
            organisations measure AI by adoption and productivity. Our assessment asks
            the harder questions: is AI helping people make better decisions, or worse
            ones? Is it sharpening how they think, or eroding it? What is it doing to
            the way a team collaborates and trusts each other, to engagement and
            burnout? And what are the effects not just this quarter, but over years?
          </p>
          <p style={P}>
            Research is our foundation, and we are now putting that science to work.
            We are building our first offering: a manual, framework-based audit of that
            impact, for organisations rolling out AI to their people in both the private
            and public sector.
          </p>
          <p style={P}>
            {/* Live from the database. signatoryPhrase gives the real figure once
                loaded and '500+' before that and if the fetch fails, which is what
                the source document said. Never type this number. */}
            The momentum is real. An open letter signed by{' '}
            <strong>{signatoryPhrase(sig)} behavioral scientists</strong>,
            published peer-reviewed research with prestigious universities,
            a growing network of volunteer researchers, and early interest from leading
            organisations. We are now looking to sustain the organisation, strengthen
            its independence, and positively shape the future of AI in organisations by
            developing this service.
          </p>

          <h2 style={H}>The opportunity</h2>
          {/* Opens the section in the source document rather than sitting in the
              body, so it keeps that job here. The only typographic flourish on
              the page, and it is one the source already had. */}
          <p style={{ fontFamily: 'var(--font-display)', fontSize: 21, fontStyle: 'italic', lineHeight: 1.4, color: 'var(--fg1)', margin: '0 0 20px' }}>
            Help shape how AI shapes us.
          </p>
          <p style={P}>
            AI is reaching billions of people with almost no one measuring what it does
            to them. This role is how we change that, sustainably.
          </p>
          <p style={P}>
            You will build the partnerships that let us keep measuring the human impact
            of AI. <strong>Starting from close to zero</strong>, you will have the freedom
            to shape how we reach the organisations that need this most, and to set the
            pace for our first year and the ambition well beyond it. You will{' '}
            <strong>work directly with the President</strong> and alongside the founding
            team, helping shape pricing and the offering with our delivery and research
            leads. <strong>Every partnership you build funds the mission</strong> and the
            independent research behind it.
          </p>
          <p style={P}>
            The role is <strong>flexible, from part time up to full time</strong>, and can
            be based in different geographies. Compensation is to be discussed, depending
            on time allocation.
          </p>

          <h2 style={H}>What you will do</h2>
          <ul style={{ margin: 0, paddingLeft: 22 }}>
            {HOP_DUTIES.map(d => (
              <li key={d.title} style={LI}>
                <strong>{d.title}.</strong> {d.body}
              </li>
            ))}
          </ul>

          <h2 style={H}>Who you are</h2>
          <ul style={{ margin: 0, paddingLeft: 22 }}>
            {HOP_PROFILE.map(p => (
              <li key={p.body} style={LI}>
                {p.title && <strong>{p.title}. </strong>}
                {p.body}
              </li>
            ))}
          </ul>

          <h2 style={H}>How to apply</h2>
          {/* The address moves out of the sentence and onto a button. It was
              reachable before — as a link inside a paragraph, at the bottom of
              a page of prose, which is where an action goes to be missed. */}
          <div className="doc-apply">
            <p style={{ fontFamily: 'var(--font-display)', fontSize: 21, fontStyle: 'italic', lineHeight: 1.4, color: 'var(--fg1)', margin: '0 0 14px' }}>
              Let’s talk.
            </p>
            <p style={{ ...P, margin: '0 0 24px' }}>
              Send a short note on why this role fits you, and one example of a hard
              thing you sold. No formal cover letter needed.
            </p>
            {/* A short label, not the address. .btn is inline-flex with
                line-height:1, so a long label cannot wrap gracefully — and
                "team@behavioralai-institute.org" plus padding and the arrow is
                ~320px, wider than the ~306px this box has inside a 390px
                phone. The address goes below as plain text, where it can wrap,
                be selected and be copied. */}
            <a className="btn btn--primary" href={HOP_MAILTO}>
              Apply by email <Arrow />
            </a>
            <p style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.6, color: 'var(--fg2)', margin: '16px 0 0', wordBreak: 'break-word' }}>
              or write to team@behavioralai-institute.org
            </p>
          </div>

          {/* The source document signs off with the Institute's name and URL.
              Dropped: the site footer renders directly below this and already
              carries both, so on a page it reads as the same line twice. */}

        </article>
      </div>
    </section>
  );
}


/* ============================================================
   Routing
   ============================================================ */

/* Every route the public site is allowed to resolve. A hash that is not
   in this list renders the 404 page — previously it rendered nothing at
   all, leaving a bare footer on a blank page. */
/* 'insights' is absent by design while INSIGHTS_LIVE is false. Training and
   Events are Tier 3 — held, hash-only, unlinked — but they are FINISHED pages,
   so resolving them is harmless. An Insights page with no posts is not: an
   empty section reached by a stale link reads worse than a clean 404. */
/* 'thinkslop' and 'head-of-partnerships' are Tier 3 on the same terms as
   training and events: resolvable by direct URL, linked from nowhere. Both
   are FINISHED pages, so resolving them is harmless. Samuel, 2026-08-21:
   reachable by URL, absent from every nav. Do NOT add either to NAV_ITEMS,
   MobileNav or the footer without asking him first. */
const PUBLIC_ROUTES = [
  'home', 'about', 'research', 'services', 'training',
  'events', 'open-letter', 'glossary',
  'newsletter', 'contact', 'join',
  'privacy', 'terms', 'data-rights',
  'thinkslop', 'head-of-partnerships',
  ...(INSIGHTS_LIVE ? ['insights'] : []),
];

/* Resolvable only when admin.jsx has been loaded, which happens on
   localhost only — see index.html. In production the file is not
   deployed, so these fall through to the 404 like any other bad hash. */
/* Renamed from 'admin' on 2026-08-22. This surface is an asset library and a
   design-system reference — it operates nothing. The REAL admin is a separate,
   deployed, signed-in application (~/baii-admin) that moderates signatures and
   watches the submission pipeline. Two things called "admin" is how Submissions
   got built in the wrong one. See SURFACES.md. */
const INTERNAL_ROUTES = ['design-system', 'internal'];

/* A hash may carry parameters after the route name: `#about?person=luana`.
   resolveRoute used to match the WHOLE hash against the allowlist, so any
   trailing character fell through to the 404 — which is what blocked the
   person deep-link. Split the parameters off BEFORE the allowlist check.

   The allowlist itself is deliberately untouched: a parameter can only ever
   decorate a route that was already resolvable, so this cannot reach a held
   Tier 3 route (training, events) that carries no links by decision. */
function parseHash(hash) {
  const raw = String(hash || '').replace(/^#/, '').trim();
  const qi = raw.indexOf('?');
  return {
    base: (qi === -1 ? raw : raw.slice(0, qi)).toLowerCase(),
    params: new URLSearchParams(qi === -1 ? '' : raw.slice(qi + 1)),
  };
}

function hashParam(key) {
  return parseHash(window.location.hash).params.get(key);
}

function resolveRoute(hash) {
  const { base } = parseHash(hash);
  if (!base) return 'home';
  if (PUBLIC_ROUTES.indexOf(base) !== -1) return base;
  if (INTERNAL_ROUTES.indexOf(base) !== -1 && window.BAII_INTERNAL) return base;
  return 'not-found';
}

function NotFoundPage({ route, setRoute }) {
  return (
    <section className="surface" style={{ overflow: 'hidden' }}>
      <div style={{ padding: '32px 56px 0' }}>
        <SubPageNav route={route} setRoute={setRoute} />
      </div>
      <div className="surface__inner" style={{ paddingTop: 0, minHeight: '44vh' }}>
        <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--fg3)', marginBottom: 20 }}>
          404 — Page not found
        </div>
        <h1 className="display-xl" style={{ marginBottom: 16 }}>This page doesn’t exist.</h1>
        <p className="body-mono" style={{ color: 'var(--fg2)', maxWidth: '52ch', marginBottom: 36 }}>
          The address may be out of date, or the page may have moved. Everything we
          publish can be reached from here.
        </p>
        <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
          <button className="btn btn--primary" onClick={() => setRoute('home')} style={{ cursor: 'pointer' }}>Back to home <Arrow /></button>
          <button className="btn btn--hollow" onClick={() => setRoute('research')} style={{ cursor: 'pointer' }}>Research <Arrow /></button>
          {INSIGHTS_LIVE && <button className="btn btn--hollow" onClick={() => setRoute('insights')} style={{ cursor: 'pointer' }}>Insights <Arrow /></button>}
          <button className="btn btn--hollow" onClick={() => setRoute('contact')} style={{ cursor: 'pointer' }}>Get in touch <Arrow /></button>
        </div>
      </div>
    </section>
  );
}


/* Per-route <title>. Every label below is already the site's own words — a
   NAV_ITEMS / FOOTER_NAV_LINKS / FOOTER_LEGAL_LINKS label or the page's own h1
   — so this introduces no new voice.

   Scope, precisely: this changes the browser tab, the history entry, and what
   a screen reader announces after navigation. It does NOT change link
   previews. LinkedIn, Slack and X read the static index.html and never run
   this file, so what they show is the og: block there — one card for the whole
   site. Setting og: tags from here would look right in a browser and change
   nothing an unfurler sees. */
const SITE_NAME = 'Behavioral AI Institute';
const HOME_TITLE = 'Behavioral AI Institute — What AI does to people.';
const ROUTE_TITLES = {
  home: HOME_TITLE,
  about: 'About',
  research: 'Research',
  services: 'Services',
  training: 'Training',
  insights: 'Insights',
  events: 'Events',
  thinkslop: 'Thinkslop',
  'head-of-partnerships': 'Senior Fellow, Head of Partnerships',
  'open-letter': 'Open Letter',
  glossary: 'Glossary',
  newsletter: 'The BAII Brief',
  contact: 'Contact',
  join: 'Join Us',
  privacy: 'Privacy',
  terms: 'Terms of Use',
  'data-rights': 'Your data and your rights',
  'not-found': 'Page not found',
};

function titleFor(route) {
  const label = ROUTE_TITLES[route];
  if (!label) return HOME_TITLE;
  return route === 'home' ? label : label + ' — ' + SITE_NAME;
}

/* ============================================================
   App — root composition
   ============================================================ */
function App() {
  const [route, setRoute] = React.useState(() => resolveRoute(window.location.hash));
  const mounted = React.useRef(false);
  React.useEffect(() => {
    window.scrollTo(0, 0);
    document.title = titleFor(route);
    /* Leave the bad hash in the address bar so the visitor can see what
       they landed on; rewriting it to #not-found would hide the cause. */
    if (route !== 'not-found') {
      const want = '#' + route;
      /* Compare the BASE, not the whole hash, so a parameter that decorates
         the current route survives. Comparing the full string rewrote
         `#about?person=x` straight back to `#about` and closed the panel the
         link had just opened. */
      if (parseHash(window.location.hash).base !== route) {
        if (mounted.current) {
          window.location.hash = route;
        } else {
          /* First paint. A bare URL carries no hash, so assigning one here
             PUSHED a second entry: history.length was 3 on a fresh tab and the
             first Back press only stripped the hash, so it looked like nothing
             happened. Replace that entry instead. Every later route change
             still pushes, so Back keeps walking back through the routes. */
          history.replaceState(
            history.state, '',
            window.location.pathname + window.location.search + want
          );
        }
      }
    }
    mounted.current = true;
  }, [route]);
  React.useEffect(() => {
    const onHash = () => setRoute(resolveRoute(window.location.hash));
    window.addEventListener('hashchange', onHash);
    return () => window.removeEventListener('hashchange', onHash);
  }, []);
  const Internal = window.BAII_INTERNAL || null;
  /* The nav is rendered inside each route's hero, so "skip to content" cannot
     be a plain #main-content jump — that would land the visitor before the
     nav, not after it. Move focus to the route's h1 instead; every public
     route has one. */
  const skipToContent = (e) => {
    e.preventDefault();
    const main = document.getElementById('main-content');
    const target = (main && main.querySelector('h1')) || main;
    if (!target) return;
    target.setAttribute('tabindex', '-1');
    target.focus();
    target.scrollIntoView({ block: 'start' });
  };

  return (
    <div className="page-bg">
      <a className="skip-link" href="#main-content" onClick={skipToContent}>Skip to content</a>
      <div className="page">
        <main id="main-content" key={route} className="route-content">
          {route === 'home' && (<>
            <HeroCard route={route} setRoute={setRoute} />
            <MissionCard />
            <ResearchCard setRoute={setRoute} />
            <NetworkCard setRoute={setRoute} />
            <FourWaysCard setRoute={setRoute} />
            <EventsCard setRoute={setRoute} />
            <BriefCard setRoute={setRoute} />
          </>)}
          {route === 'research' && (<>
            <ResearchHeroCard route={route} setRoute={setRoute} />
            <ResearchPublicationsCard />
            <WorkAreasCard />
            <ResearchCommunityCard setRoute={setRoute} />
            <BriefCard setRoute={setRoute} />
          </>)}
          {route === 'about' && (<>
            <GrainFilterDef />
            <AboutHeroCard route={route} setRoute={setRoute} />
            <PrinciplesCard />
            <PeopleCard />
            <BriefCard setRoute={setRoute} />
          </>)}
          {route === 'training' && (<>
            <TrainingHeroCard route={route} setRoute={setRoute} />
            <AdoptionSection />
            <TrainingCoursesCard setRoute={setRoute} />
            <TrainingCredentialsCard />
            <TrainingCTACard />
          </>)}
          {route === 'insights' && (<>
            <BlogPage route={route} setRoute={setRoute} />
            <BriefCard setRoute={setRoute} />
          </>)}
          {route === 'events' && (<>
            <EventsPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'thinkslop' && (<>
            <ThinkslopPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'head-of-partnerships' && (<>
            <HeadOfPartnershipsPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'open-letter' && (<>
            <OpenLetterPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'glossary' && (<>
            <GlossaryPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'newsletter' && (<>
            <NewsletterPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'contact' && (<>
            <ContactPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'join' && (<>
            <JoinUsPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'services' && (<>
            <WorkHeroCard route={route} setRoute={setRoute} />
            <AudienceCardsSection />
            <ServiceCardsSection />
            <CredentialsSection />
            <ContactSection />
          </>)}
          {route === 'privacy' && (<>
            <PrivacyPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'terms' && (<>
            <TermsPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'data-rights' && (<>
            <DataRightsPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'not-found' && (<>
            <NotFoundPage route={route} setRoute={setRoute} />
          </>)}
          {/* Internal, local-only. Internal is defined by admin.jsx,
              which is not part of the deployment. */}
          {route === 'design-system' && Internal && (<>
            <Internal.DesignSystemPage route={route} setRoute={setRoute} />
          </>)}
          {route === 'internal' && Internal && (<>
            <Internal.InternalPage route={route} setRoute={setRoute} />
          </>)}
        </main>
        <FooterStrip setRoute={setRoute} />
      </div>
    </div>
  );
}
