const { Wordmark, Button, Quote, Divider } = window.VoxAwenDesignSystem_e48818;

function DropdownItem({ href, onClick, children }) {
  const [hover, setHover] = React.useState(false);
  return (
    <a href={href} onClick={onClick}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={{
        display: "block", padding: "10px 12px", borderRadius: "var(--radius-sm)",
        font: "var(--text-body-sm)", color: hover ? "var(--accent)" : "var(--text-body)",
        textDecoration: "none", whiteSpace: "nowrap",
        transition: "color var(--dur-fast) var(--ease-breath)",
      }}>
      {children}
    </a>
  );
}

// Vox Letters is written but not being shown yet; flip to true to put it back
// in both the desktop row and the phone panel.
const SHOW_LETTERS = false;

// Fallback height of the fixed nav. NavBar measures the real bar and publishes
// it as --nav-h, since the bar is shorter on phones.
const NAV_H = 76;

// Local to this file: Offerings.jsx has its own `prefersReducedMotion`, and two
// top-level consts of the same name across classic scripts would collide.
function reduceMotion() {
  return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}

function navHeight() {
  const el = document.querySelector(".nav");
  return el ? el.offsetHeight : NAV_H;
}

/* Jumps to an in-page section. Full-bleed sections land flush with the top of
   the viewport, since the nav is translucent and meant to float over them;
   everything else clears the bar so its first line isn't hidden underneath. */
function scrollToSection(section) {
  const el = document.getElementById(section.id);
  if (!el) return;
  const reduce = reduceMotion();
  // Full-bleed sections sit under the chrome deliberately; everything else has
  // to clear the nav *and* the section strip when that strip is showing.
  const strip = document.querySelector(".secnav");
  const clear = navHeight() + (strip ? strip.offsetHeight : 0) + 12;
  // A section whose opening state is scroll-driven can resolve its own landing
  // point; the top of its block may not be what the reader should arrive on.
  const resolved = typeof section.target === "function" ? section.target(el) : null;
  const top = resolved != null
    ? resolved
    : window.scrollY + el.getBoundingClientRect().top - (section.flush ? 0 : clear);
  window.scrollTo({ top: Math.max(0, top), behavior: reduce ? "auto" : "smooth" });
}

function NavBar({ current, view, onNav }) {
  const [solid, setSolid] = React.useState(false);
  const [heroLight, setHeroLight] = React.useState(false);
  const [offeringsOpen, setOfferingsOpen] = React.useState(false);
  const [menuOpen, setMenuOpen] = React.useState(false);
  const navRef = React.useRef(null);

  React.useEffect(() => {
    // Any page can opt into a transparent nav by marking its hero with data-hero.
    const hero = document.querySelector("[data-hero]");
    if (!hero) { setSolid(true); setHeroLight(false); return; }
    setSolid(false);
    // Cream nav type over a pale hero is unreadable, so a hero can declare its
    // ground light and the bar keeps its dark colours while transparent.
    setHeroLight(hero.hasAttribute("data-hero-light"));

    // Watching the hero is not enough: .rise climbs over it by a whole screen,
    // so the hero is still intersecting long after the next section has covered
    // it — leaving pale nav type sitting on a light band. The sentinel marks
    // where that section starts, which is the moment the bar has to change.
    const sentinel = document.querySelector("[data-nav-sentinel]");
    if (sentinel) {
      let raf = 0;
      const update = () => { raf = 0; setSolid(sentinel.getBoundingClientRect().top <= NAV_H); };
      const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
      update();
      window.addEventListener("scroll", onScroll, { passive: true });
      window.addEventListener("resize", onScroll);
      return () => {
        window.removeEventListener("scroll", onScroll);
        window.removeEventListener("resize", onScroll);
        if (raf) cancelAnimationFrame(raf);
      };
    }

    const observer = new IntersectionObserver(
      ([entry]) => setSolid(!entry.isIntersecting),
      { rootMargin: "-" + NAV_H + "px 0px 0px 0px", threshold: 0 }
    );
    observer.observe(hero);
    return () => observer.disconnect();
  }, [view]);

  // Publish the real bar height so the strip can sit directly beneath it and
  // scroll targets can clear it. It differs between desktop and phone.
  React.useEffect(() => {
    const publish = () => {
      const h = navRef.current ? navRef.current.offsetHeight : NAV_H;
      document.documentElement.style.setProperty("--nav-h", h + "px");
    };
    publish();
    window.addEventListener("resize", publish);
    return () => window.removeEventListener("resize", publish);
  });

  // Changing page closes the menu; so does Escape. While it is open the page
  // behind it must not scroll.
  React.useEffect(() => { setMenuOpen(false); }, [view]);

  React.useEffect(() => {
    if (!menuOpen) return;
    const onKey = (e) => { if (e.key === "Escape") setMenuOpen(false); };
    const previous = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.body.style.overflow = previous;
    };
  }, [menuOpen]);

  // Each page publishes its own contents into window.pageSections under its
  // view name; only the page on screen is listed. Keyed off `view` (the page
  // being rendered), not `current` — that tracks the homepage's own anchors and
  // is only updated on the home view.
  const sections = (window.pageSections && window.pageSections[view]) || [];
  const onDark = !solid && !menuOpen && !heroLight;

  const linkStyle = (key) => ({
    font: "var(--text-label)", letterSpacing: "var(--ls-label)", textTransform: "uppercase",
    color: onDark ? "var(--text-on-dark)" : (current === key ? "var(--accent)" : "var(--text-muted)"),
    borderBottom: current === key ? "1px solid currentColor" : "1px solid transparent",
    paddingBottom: 3, textDecoration: "none",
  });

  const go = (key) => (e) => { e.preventDefault(); setMenuOpen(false); onNav(key); };

  /* Same order as the homepage's offerings module. */
  const pages = [
    ["offerings", "Graceful Ignited Bodies"],
    ["end-of-life", "Companion Voices"],
    ["consultancy", "Marketing Consultancy"],
  ];

  return (
    <React.Fragment>
    <nav ref={navRef} className={"nav" + (solid ? " nav--solid" : "") + (menuOpen ? " nav--open" : "")}>
      <a className="nav-brand" href="#home" onClick={go("home")}>
        <Wordmark size="sm" phonetic={false} onDark={onDark} />
      </a>

      <div className="nav-links">
        <a href="#home" onClick={go("home")} style={linkStyle("home")}>Home</a>
        <a href="#about" onClick={go("about")} style={linkStyle("about")}>About</a>

        <div style={{ position: "relative", display: "flex", alignItems: "center" }}
          onMouseEnter={() => setOfferingsOpen(true)} onMouseLeave={() => setOfferingsOpen(false)}>
          <a href="#offerings" onClick={go("offerings")}
            style={{ ...linkStyle("offerings"), display: "inline-flex", alignItems: "center", gap: 5 }}>
            Offerings
            <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <polyline points="6 9 12 15 18 9" />
            </svg>
          </a>
          {offeringsOpen && (
            <div style={{ position: "absolute", top: "100%", left: "50%", transform: "translateX(-50%)", paddingTop: 14 }}>
              <div style={{
                background: "var(--surface-card)", border: "1px solid var(--border-soft)", borderRadius: "var(--radius-sm)",
                boxShadow: "var(--shadow-float)", padding: 6, minWidth: 240,
              }}>
                {pages.map(([key, label]) => (
                  <DropdownItem key={key} href={"#" + key} onClick={go(key)}>{label}</DropdownItem>
                ))}

                {/* The page you are on offers its own contents. Only shown there,
                    so a section link never points at a page that isn't loaded. */}
                {sections.length > 0 && (
                  <div style={{ marginTop: 6, paddingTop: 6, borderTop: "1px solid var(--border-soft)" }}>
                    <span style={{
                      display: "block", padding: "6px 12px 4px", font: "var(--text-label)",
                      fontSize: 10, letterSpacing: "var(--ls-label)", textTransform: "uppercase", color: "var(--ink-4)",
                    }}>On this page</span>
                    {sections.map((s) => (
                      <DropdownItem key={s.id} href={"#" + s.id}
                        onClick={(e) => { e.preventDefault(); setOfferingsOpen(false); scrollToSection(s); }}>
                        {s.label}
                      </DropdownItem>
                    ))}
                  </div>
                )}
              </div>
            </div>
          )}
        </div>

        {SHOW_LETTERS && <a href="#letters" onClick={go("letters")} style={linkStyle("letters")}>Letters</a>}
        <a href="#contact" onClick={go("contact")} style={linkStyle("contact")}>Contact</a>
      </div>

      {/* Phone: the row above is hidden and everything moves into a tapped panel.
          A hover dropdown never opens on touch, so this is the only route to the
          offering pages and to this page's sections on a phone. */}
      <button className="nav-burger" type="button"
        aria-label={menuOpen ? "Close menu" : "Open menu"} aria-expanded={menuOpen} aria-controls="nav-panel"
        onClick={() => setMenuOpen((o) => !o)}>
        <span /><span /><span />
      </button>

    </nav>

    {menuOpen && (
        <div className="nav-panel" id="nav-panel">
          <div className="nav-panel-group">
            <a className={"nav-panel-item" + (current === "home" ? " is-active" : "")} href="#home" onClick={go("home")}>Home</a>
            <a className={"nav-panel-item" + (current === "about" ? " is-active" : "")} href="#about" onClick={go("about")}>About</a>
          </div>

          <div className="nav-panel-group">
            <span className="nav-panel-label">Offerings</span>
            {pages.map(([key, label]) => (
              <a key={key} className={"nav-panel-item nav-panel-item--sub" + (view === key ? " is-active" : "")}
                href={"#" + key} onClick={go(key)}>{label}</a>
            ))}
          </div>

          {sections.length > 0 && (
            <div className="nav-panel-group">
              <span className="nav-panel-label">On this page</span>
              {sections.map((s) => (
                <a key={s.id} className="nav-panel-item nav-panel-item--sub" href={"#" + s.id}
                  onClick={(e) => { e.preventDefault(); setMenuOpen(false); requestAnimationFrame(() => scrollToSection(s)); }}>
                  {s.label}
                </a>
              ))}
            </div>
          )}

          <div className="nav-panel-group">
            {SHOW_LETTERS && <a className={"nav-panel-item" + (current === "letters" ? " is-active" : "")} href="#letters" onClick={go("letters")}>Letters</a>}
            <a className={"nav-panel-item" + (current === "contact" ? " is-active" : "")} href="#contact" onClick={go("contact")}>Contact</a>
          </div>
        </div>
      )}
    </React.Fragment>
  );
}

/* The in-page strip. Stays hidden through the hero and the photographic
   sequence — chrome across a full-bleed frame is exactly what that stretch is
   for — and appears once `startId` reaches the bar, where the page turns into a
   document you might want to move around. */
function SectionStrip({ sections, startId }) {
  const [visible, setVisible] = React.useState(false);
  const [active, setActive] = React.useState(null);
  const rowRef = React.useRef(null);

  React.useEffect(() => {
    let raf = 0;
    const update = () => {
      raf = 0;
      const line = navHeight() + 1;
      const start = document.getElementById(startId);
      const footer = document.querySelector("footer");
      let show = !!start && start.getBoundingClientRect().top <= line + 160;
      // Let it go as the footer arrives, so it isn't hovering over the sign-off.
      if (show && footer && footer.getBoundingClientRect().top <= window.innerHeight - 140) show = false;
      setVisible(show);

      let current = null;
      for (const s of sections) {
        const el = document.getElementById(s.id);
        if (el && el.getBoundingClientRect().top <= line + 72) current = s.id;
      }
      setActive(current);
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
    update();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [sections, startId]);

  // On a phone the row is wider than the screen, so the current section can sit
  // out of sight. Bring it back into the row when it changes.
  React.useEffect(() => {
    const row = rowRef.current;
    if (!row || !active || row.scrollWidth <= row.clientWidth) return;
    const item = row.querySelector(".is-active");
    if (!item) return;
    const left = item.offsetLeft - (row.clientWidth - item.offsetWidth) / 2;
    row.scrollTo({ left: Math.max(0, left), behavior: reduceMotion() ? "auto" : "smooth" });
  }, [active]);

  return (
    <div className={"secnav" + (visible ? " secnav--on" : "")} aria-label="On this page" aria-hidden={!visible}>
      <div className="secnav-inner" ref={rowRef}>
        {sections.map((s) => (
          <button key={s.id} type="button" tabIndex={visible ? 0 : -1}
            className={"secnav-item" + (active === s.id ? " is-active" : "")}
            aria-current={active === s.id ? "true" : undefined}
            onClick={() => scrollToSection(s)}>
            {s.label}
          </button>
        ))}
      </div>
    </div>
  );
}

function Footer({ onNav }) {
  return (
    <footer style={{ background: "var(--bg-page-dark)", padding: "72px 48px 48px", display: "flex", flexDirection: "column", alignItems: "center", gap: 28 }}>
      <Wordmark size="md" onDark />
      <p style={{ font: "italic 500 20px/1.5 var(--font-display)", color: "var(--text-muted-on-dark)", margin: 0, textAlign: "center", maxWidth: 480 }}>
        We enter the world with a breath, and leave it with a breath.
      </p>
      <Divider onDark />
      <div style={{ display: "flex", gap: 28 }}>
        <a href="#" onClick={(e) => { e.preventDefault(); onNav("offerings"); }} style={{ font: "var(--text-body-sm)", color: "var(--text-muted-on-dark)" }}>Offerings</a>
        <a href="#" onClick={(e) => { e.preventDefault(); onNav("contact"); }} style={{ font: "var(--text-body-sm)", color: "var(--text-muted-on-dark)" }}>Begin a conversation</a>
      </div>
      <span style={{ font: "var(--text-body-sm)", fontSize: 12, color: "var(--ink-4)" }}>© 2026 Vox Awen</span>
    </footer>
  );
}

function Eyebrow({ children, onDark }) {
  return <span style={{ font: "var(--text-label)", letterSpacing: "var(--ls-label)", textTransform: "uppercase", color: onDark ? "var(--rose)" : "var(--accent)" }}>{children}</span>;
}

const prefersReducedMotion = () =>
  typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// Tracks how far the reader is through a pinned block, as 0..1.
// finishAt lets the move complete early, leaving room for what follows to rise.
function useBlockProgress(varName, finishAt = 1) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (prefersReducedMotion()) { el.style.setProperty(varName, "1"); return; }

    let raf = 0;
    const update = () => {
      raf = 0;
      const span = el.offsetHeight - document.documentElement.clientHeight;
      const raw = span <= 0 ? 0 : -el.getBoundingClientRect().top / span;
      el.style.setProperty(varName, Math.min(1, Math.max(0, raw / finishAt)).toFixed(4));
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
    update();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [varName, finishAt]);
  return ref;
}

/* The chapter plate's picture opens as a curve, then its name travels out from
   the centre rule, finishing at CHAPTER_OPEN of the block's span. The top of the
   block is therefore an unopened frame — not what "What to expect" should land
   on. This resolves the point just past the name, where it is whole and holding.
   The matching stretch is written in story.css under .gib-chapter. */
const CHAPTER_OPEN = 0.50;

function chapterScrollTarget(plateEl) {
  const spanPx = plateEl.offsetHeight - document.documentElement.clientHeight;
  if (spanPx <= 0) return null;
  /* A page can append a hold to the end of the plate, which lengthens the span
     without moving anything. --act is the fraction the work occupies, so the
     landing stays on the same frame rather than sliding into the hold. */
  const act = parseFloat(getComputedStyle(plateEl).getPropertyValue("--act")) || 1;
  // A little past, so it is unambiguously settled rather than just arriving.
  return window.scrollY + plateEl.getBoundingClientRect().top + (CHAPTER_OPEN + 0.08) * act * spanPx;
}

/*
  A chapter plate: a held photograph with the section's name opening across it.

  The two halves of the name sit either side of a centre rule, each in a slot
  that clips, and slide out from the seam as you scroll — so the words unfurl
  from the line rather than fading in. Borrowed from devotionalarts.org, where
  it fires once on entry; here it is tied to scroll progress like everything
  else on this page, so it opens under your own hand and reverses if you scroll
  back.
*/
function ChapterPlate({ id, image, alt, left, right }) {
  /* Raw progress across the whole block, not just the opening: the words are
     derived from the first CHAPTER_OPEN of it in CSS, and the exit veil from
     the last of it. One listener, two jobs. */
  const ref = useBlockProgress("--hp");
  return (
    <div className="gib-chapter" id={id} ref={ref}>
      {/* The bar has to change here, not at the section below. Its dark-or-cream
          choice is made from the hero, and this is a different photograph — on a
          page whose hero declared a light ground the bar would keep its dark
          type and disappear into this frame. */}
      <div data-nav-sentinel aria-hidden="true" />
      {/* Sits under the clipped frame and outside its clip, so as the curve
          begins the hero behind it goes to the page's ground — the two
          photographs never compete. */}
      <div className="gib-chapter-ground" />
      <div className="gib-chapter-pin">
        <img src={image} alt={alt} />
        <div className="gib-chapter-scrim" />
        {/* Under the name, so the name holds on the veil's ground as the frame
            resolves and hands over to the section below. It went above the name
            for a while, to stop cream dissolving into a beige veil on the
            light-ground pages — but those pages set --chapter-veil: 0 now and
            paint no veil at all, so the only veil left is this dark green one,
            where the cream reads. */}
        <div className="gib-chapter-out" />
        <div className="gib-chapter-title" aria-label={left + " " + right}>
          <span className="gib-chapter-slot gib-chapter-slot--l" aria-hidden="true"><span>{left}</span></span>
          <span className="gib-chapter-rule" aria-hidden="true" />
          <span className="gib-chapter-slot gib-chapter-slot--r" aria-hidden="true"><span>{right}</span></span>
        </div>
      </div>
    </div>
  );
}


/*
  Progress for a block that isn't pinned: 0 as its top edge enters the foot of
  the viewport, 1 once it has risen most of the way up. Lets the customised
  frame dissolve in on scroll without buying another screen of scroll height.
*/
function useEntryProgress(varName, span = 0.6) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (reduceMotion()) { el.style.setProperty(varName, "1"); return; }

    let raf = 0;
    const update = () => {
      raf = 0;
      const vh = document.documentElement.clientHeight;
      const raw = (vh - el.getBoundingClientRect().top) / (vh * span);
      el.style.setProperty(varName, Math.min(1, Math.max(0, raw)).toFixed(4));
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
    update();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [varName, span]);
  return ref;
}

// Slow-in, fast-through, settling before the pin releases.
const easeInOutQuad = (t) => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t));

/*
  Pinned hero: a full-bleed image zooms out and dissolves to the section's black
  ground, a left-aligned statement settles in, then the words clear so the next
  section can rise over the top. Shared by the homepage and the offering pages.

    mark   — big uppercase lines across the base of the opening frame
    title  — end statement; first line roman, the rest italic and stepped in
    copy   — supporting paragraph, lower right
    label  — small tracked caps beneath the rule
    markSize / markSizeSm — vw font-size for the mark on wide / small screens.
      The mark wraps, so a larger size on small screens splits it onto more lines.
    layout — how the mark sits over the opening frame.
      undefined : homepage. A short brand word runs edge to edge across the base.
      "quiet"   : small title bottom-left, cue bottom-right.
      "left"    : large title set high on the left, cue directly beneath it.
      The inner pages carry busy documentary photographs with a subject in the
      middle, so an edge-to-edge mark collides with the picture.
    exitAt — where in the pinned range the words start to clear. Inner pages hold
      them later, since the dark section rising after them leaves empty black.
*/
/* `still` is for a hero that hands over to something else — the Graceful Ignited
   Bodies page, where the chapter plate that follows carries the transition. It
   drops the closing frame entirely and holds the photograph full bleed, so the
   page opens on one picture and one name and does nothing until the plate takes
   it. Without it a hero collapses into its own title first, which on that page
   was two transitions stacked. */
function ScrollReveal({ id, image, alt, mark, title, copy, label, markSize, markSizeSm, endSize, endSizeSm, lightGround, layout, still, exitAt = 0.52 }) {
  const ref = React.useRef(null);

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;

    if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      el.classList.add("reveal--static");
      el.style.setProperty("--pe", "1");
      return;
    }

    let raf = 0;
    const update = () => {
      raf = 0;
      // clientHeight, not innerHeight — it matches the svh the layout is sized in.
      const span = el.offsetHeight - document.documentElement.clientHeight;
      const raw = span <= 0 ? 0 : -el.getBoundingClientRect().top / span;

      // The image move finishes in the first 45%, leaving room to read, then exit.
      const t = Math.min(1, Math.max(0, raw / 0.45));
      el.style.setProperty("--pe", easeInOutQuad(t).toFixed(4));

      // Words clear just before the next section starts rising over the stage.
      // Inner pages hold them later, so less empty black follows them.
      const exit = Math.min(1, Math.max(0, (raw - exitAt) / 0.10));
      el.style.setProperty("--px", exit.toFixed(4));
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };

    update();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [exitAt]);

  // Set on the root so the small-screen rule can still override --mark-size.
  const vars = {};
  if (markSize) vars["--mark-size"] = markSize;
  if (markSizeSm) vars["--mark-size-sm"] = markSizeSm;

  return (
    <div id={id} data-hero data-hero-light={lightGround ? "" : undefined}
      className={"reveal" + (still ? " reveal--still" : "")} ref={ref} style={vars}>
      <div className="reveal-stage">
        <div className="reveal-main">
          <img src={image} alt={alt} />
          <div className="reveal-main-scrim" />
          <div className="reveal-main-fade" />
          <div className={"reveal-foot" + (layout ? " reveal-foot--" + layout : "")}>
            {/* Words are separate boxes so the edge-to-edge mark can wrap; the
                label keeps them readable as one phrase to assistive tech. */}
            <h1 className={"reveal-mark" + (layout ? " reveal-mark--" + layout : "")} aria-label={mark.join(" ")}>
              {layout
                ? <span aria-hidden="true">{mark.join(" ")}</span>
                : mark.map((word) => <span key={word} aria-hidden="true">{word}</span>)}
            </h1>
            <span className="reveal-scroll" aria-hidden="true"><b>Scroll</b><i /></span>
          </div>
        </div>

        {still ? null : (
        <div className="reveal-end">
          <p className="reveal-end-title" style={{ "--end-size": endSize, "--end-size-sm": endSizeSm }}>
            {title.map((line, i) => (
              i === 0 ? <span key={line}>{line}</span> : <em key={line}>{line}</em>
            ))}
          </p>
          <div className="reveal-end-copy">
            <p>{copy}</p>
            <hr />
            <span>{label}</span>
          </div>
        </div>
        )}
      </div>
    </div>
  );
}

/* True once the page's chapter plate has finished and unpinned. The plate is a
   page's title card, and a tab arriving over it competes with the two words
   landing — so anything pinned to the edge of the screen waits for the reading
   to begin. Measured off the plate's own box rather than a scroll figure, so it
   holds wherever the plate's height lands. A page with no plate shows straight
   away. */
function useAfterChapter() {
  const [past, setPast] = React.useState(false);

  React.useEffect(() => {
    const el = document.querySelector(".gib-chapter");
    if (!el) { setPast(true); return; }

    let frame = 0;
    const read = () => {
      frame = 0;
      /* clientHeight, not innerHeight: the plate is sized in svh. The pin
         releases when the plate's foot reaches the foot of the viewport. */
      setPast(el.getBoundingClientRect().bottom <= document.documentElement.clientHeight);
    };
    const onScroll = () => { if (!frame) frame = requestAnimationFrame(read); };

    read();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      if (frame) cancelAnimationFrame(frame);
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, []);

  return past;
}

Object.assign(window, { NavBar, SectionStrip, Footer, Eyebrow, ScrollReveal, ChapterPlate, chapterScrollTarget, useAfterChapter });
