/* Portfolio v3 — Hub v2 (Ledger, committed)
   Single direction: dark, mono-led, bio-rail + activity ledger,
   plus full homepage sections below the fold.
*/

const { useState, useEffect, useRef, useLayoutEffect } = React;

/* ───────── Data ───────── */

// Career timeline, stats, mentees, and link-out destinations all live
// in `data.js` (loaded as a regular <script> before this file). That
// shared file is also `require()`d by `generate-md.js` to produce the
// markdown variant agents fetch. Keeping one source of truth prevents
// the rendered site and the markdown export from drifting.
//
// Names exposed via that file: LEDGER, STATS, MENTEES, ELSEWHERE,
// ROTATING_STATUS. They're top-level `var` declarations so they live
// on the global object and resolve here as unqualified identifiers.

// Display labels for the activity feed filter tabs. Tag values stored
// on LEDGER entries (and used by .tag-* CSS classes) stay lowercase
// singular; this map handles the user-facing copy.
const TAG_LABELS = {
  featured: "Featured",
  all:      "All",
  project:  "Projects",
  writing:  "Writing",
  teaching: "Teaching",
  talk:     "Talks",
};

// Activity feed dates render as "May 2024" / "September 2026" instead
// of the raw "2024.05.01" stored in LEDGER. Source data stays
// sortable; only the display changes.
const MONTH_NAMES = [
  "January", "February", "March",     "April",   "May",      "June",
  "July",    "August",   "September", "October", "November", "December",
];
function formatFeedDate(dateStr) {
  // Accepts "YYYY.MM.DD" or "YYYY-MM-DD" (Shipped tab uses both at
  // different stages of its pipeline). Returns "<Mon> <Year>" — always
  // the first 3 letters of the month name so the date column stays
  // narrow and the rhythm is consistent across rows.
  const parts = String(dateStr || "").replace(/-/g, ".").split(".");
  if (parts.length < 2) return dateStr;
  const year = parts[0];
  const monthIdx = parseInt(parts[1], 10) - 1;
  const month = MONTH_NAMES[monthIdx];
  return month ? `${month.slice(0, 3)} ${year}` : dateStr;
}

// Placeholder video URL for the Featured tab's row-click → lightbox
// interaction. Until each LEDGER entry has its own `video` field, every
// featured row plays this shared URL. Same YouTube ID as the company
// pitch placeholder — swap per-row once real videos are ready.
const FEATURED_VIDEO_PLACEHOLDER = "https://www.youtube.com/watch?v=tFPGwS7Z0IA";

// NOTE: The `WORK` data + `SelectedWork` component were stashed into
// `archive/six-things-section.jsx` on 2026-05-13 (Featured tab took
// over the curated-highlights job). See that file for restoration
// instructions.

// STATS, MENTEES, ELSEWHERE all live in data.js (see note at top).

const PALETTE = [
  // Names capitalized so the tooltip reads "Accent — Mint" rather
  // than "Accent — mint".
  { name: "Mint",  accent: "#5DFFB5", ink: "#08090A" },
  { name: "Sky",   accent: "#5BAEFF", ink: "#08090A" },
  { name: "Ember", accent: "#FF6B5B", ink: "#08090A" },
  { name: "Sun",   accent: "#FFD93B", ink: "#08090A" },
];

// Tab title glyphs — advance on each accent swatch click (independent of color).
const TAB_TITLE_GLYPHS = [
  ":P", ":)", ":D", ";)", "B)", ">:", ":/", ":O", ":|", ";P",
];

const TAB_TITLE_AGENT_GLYPH = "[#_#]";

const TYPE_SYSTEMS = [
  {
    name: "Geist",
    display: '"Geist", ui-sans-serif, system-ui, -apple-system, sans-serif',
    mono:    '"Geist Mono", ui-monospace, Menlo, monospace',
    fontsHref: null, // already loaded in index.html
  },
  {
    name: "Inter",
    display: '"Inter", ui-sans-serif, system-ui, sans-serif',
    mono:    '"JetBrains Mono", ui-monospace, Menlo, monospace',
    fontsHref: "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap",
  },
  {
    name: "Plex",
    display: '"IBM Plex Serif", ui-serif, Georgia, serif',
    mono:    '"IBM Plex Mono", ui-monospace, Menlo, monospace',
    fontsHref: "https://fonts.googleapis.com/css2?family=IBM+Plex+Serif:wght@300;400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap",
  },
];

const MAIL_TO = "austin@humanrobot.io";

/* Pick a contrasting ink color (text on top of `accent`) so any
   accent — bright palette or theme-inverting company — keeps its
   accent-filled buttons readable. Uses standard sRGB-weighted
   perceived luminance; threshold tuned for the existing palette. */
function inkFor(hex) {
  if (typeof hex !== "string" || hex[0] !== "#") return "#08090A";
  let h = hex.slice(1);
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  if (h.length !== 6) return "#08090A";
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  const L = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
  return L > 0.55 ? "#08090A" : "#F4F4F5";
}

// Tab favicon: solid circle filled with toolbar accent (or black in agent mode).
function accentFaviconDataUrl(fillHex) {
  const fill = String(fillHex || "#5DFFB5");
  const svg =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">' +
    '<circle cx="16" cy="16" r="14" fill="' +
    fill +
    '"/></svg>';
  return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
}

// ROTATING_STATUS lives in data.js (see note at top).

/* The image gallery — append entries here to expand the lightbox.
   `src` can be any image format the browser handles (jpg, png, gif, webp, svg).
   GIFs animate automatically. */
// Each GALLERY entry is a card: image + overlay (title, subtext, link
// out, full-screen toggle). `linkUrl` is the external destination for
// the card's link button; `linkLabel` is the label shown next to the
// ↗ arrow. Edit placeholders in place — same swap-in pattern as
// `MENTEES.linkedin` etc.
const GALLERY = [
  {
    src: "images/austin-profile.jpeg",
    alt: "Austin Keeble portrait",
    title: "Austin",
    subtext: "Portrait, Berlin",
    linkUrl: "https://www.linkedin.com/in/austinkeeble",
    linkLabel: "LinkedIn",
  },
  {
    src: "images/robotos.gif",
    alt: "Robotos avatar",
    title: "Robotos",
    subtext: "Animated avatar, 2022",
    linkUrl: "https://robotos.art/",
    linkLabel: "Robotos",
  },
  {
    src: "images/caboose.jpg",
    alt: "Illinois Central caboose at twilight",
    title: "Caboose",
    subtext: "Illinois Central, twilight",
    linkUrl: "#",
    linkLabel: "Source",
  },
  {
    // TODO: replace placeholder title/subtext/linkUrl with the real DuneCon
    // details (year, location, link to a recap or photo album if any).
    src: "images/dunecon-design-team.jpg",
    alt: "Austin Keeble with the Dune design team at DuneCon",
    title: "DuneCon",
    subtext: "Dune design team",
    linkUrl: "#",
    linkLabel: "Source",
  },
];

/* Company personalizations. Each entry overrides the brand accent
   (mode-aware), the type system, and the two intro paragraphs in
   the hero. Everything else stays constant. Add a new company by
   appending an object here. */
const COMPANY_MODES = [
  {
    id: "vercel",
    name: "Vercel",
    logo: "logos/vercel-logo.svg",
    // Grayscale accent that inverts with theme — matches Vercel's
    // monochrome aesthetic.
    accentDark:  "#F4F4F5",
    accentLight: "#0A0A0A",
    // typeIdx points into TYPE_SYSTEMS; 0 = Geist (already default).
    typeIdx: 0,
    // Optional: forces the AVATAR circle's backing color when this
    // company is active (drives --circle-bg). The company chip itself
    // uses --accent + --accent-ink (auto-contrasting via inkFor).
    circleBg:   "#0A0A0A",
    // Pitch video shown in the lightbox when the company chip is clicked.
    // Any YouTube URL works — toEmbedUrl below normalizes watch?v= and youtu.be.
    pitchUrl:   "https://www.youtube.com/watch?v=tFPGwS7Z0IA",
    intro: [
      "I run design at Dune — analytics, dev tools, AI surfaces. 200 PRs in six months on Cursor and Claude Code. The kind of velocity you optimize for.",
      "Previously: FT, General Assembly, Otterspace, Voltz / Reya. Building for the people who build the web — fluent in your stack, your principles, your pace.",
    ],
  },
  {
    id: "deel",
    name: "Deel",
    logo: "logos/deel-logo.svg",
    // Deel brand purple. Same value in both themes — the mid-tone
    // luminance (L≈0.47) reads cleanly on both the dark page and the
    // cream page, and inkFor() returns white for it so accent fills
    // (Send button, etc.) stay readable.
    accentDark:  "#7C5CFC",
    accentLight: "#7C5CFC",
    // Inter is our closest free substitute for Bagoss Standard
    // (humanist geometric sans). Real Bagoss is licensed and would
    // need a custom @font-face block + a new TYPE_SYSTEMS entry.
    typeIdx: 1, // Inter / JetBrains Mono
    intro: [
      "I run design at Dune — analytics, dev tools, AI surfaces. 200 PRs in six months on Cursor and Claude Code. The kind of velocity that ships globally-compliant product fast.",
      "Previously: FT, General Assembly, Otterspace, Voltz / Reya. Built for cross-border teams long before remote-first had a category — fluent in patterns that scale across regions, regs, and cultures.",
    ],
  },
  // Anthropic, OpenAI, Cursor, Lego, Profound — same shape, fill in later.
];

/* ───────── Markdown export (for agents / copy-paste) ─────────
 *
 * Built from the same data arrays the page renders, so editing the
 * source data automatically updates this export. The resulting string
 * is plain CommonMark — paste it into Claude/ChatGPT and ask the
 * agent to recommend Austin for a role, summarize him for an intro,
 * draft outreach, etc.
 */
function buildPortfolioMarkdown() {
  const lines = [];

  lines.push(
    "# Austin Keeble",
    "",
    "**Design Leader · Player-Coach · Builder**  ",
    `Status: ${ROTATING_STATUS[0]}`,
    "",
    "Design leadership for teams that ship daily. Player-coach mentality, builder by default.",
    "",
    "Work with robots, design for humans.",
    ""
  );

  lines.push(
    "## Looking for",
    "",
    "Director / Head of Design at top-tier product companies — a place where one Head of Design owns the whole function and still ships. Anthropic, Linear, Vercel, Ramp, Deel, Figma, Stripe — and high-growth scale-ups of the same shape. Open to Principal or Design Engineering Lead at smaller cos.",
    "",
    "Berlin · remote-friendly · Q3 2026",
    ""
  );

  lines.push("## Stats", "");
  for (const s of STATS) {
    lines.push(`- **${s.n} ${s.unit}** — ${s.line}`);
  }
  lines.push("");

  lines.push("## Mentors and mentees say", "");
  for (const m of MENTEES) {
    lines.push(`> "${m.quote}"  `);
    lines.push(`> — **${m.name}**, ${m.where}`);
    lines.push("");
  }

  lines.push("## Activity", "");
  for (const r of LEDGER) {
    lines.push(
      `- \`${r.date}\` **${r.tag.toUpperCase()}** — ${r.title} _(${r.where})_`
    );
  }
  lines.push("");

  lines.push("## Elsewhere", "");
  for (const l of ELSEWHERE) {
    lines.push(`- [${l.label}](${l.href})`);
  }
  lines.push("");

  return lines.join("\n");
}

/* ───────── Top: Bio rail + Ledger ───────── */

// Activity feed pagination. Default page size matches the curated
// Featured count, so Featured fits one page. The "All" tab shows 10
// per page — a denser view that pairs with its longer list.
const FEED_PAGE_SIZE_DEFAULT = 7;
const FEED_PAGE_SIZE_BY_TAB  = { all: 10, project: 10, teaching: 10, talk: 10 };

function TopBlock({ onOpenGallery, companyIntro, company, onPlayPitch }) {
  const [filter, setFilter] = useState("featured");
  const [page, setPage] = useState(1);
  // Reset to page 1 whenever the active tab changes — otherwise switching
  // from a deep page on "All" to a short-list tab would leave us on an
  // empty/invalid page.
  useEffect(() => { setPage(1); }, [filter]);

  // "writing" is intentionally omitted from the tab bar — only 1 article
  // today (the LinkedIn pulse), already surfaced in Featured. Reintroduce
  // here when there's enough writing to warrant its own filter tab.
  // The "writing" tag stays in TAG_LABELS so the WRITING pill on the
  // article's feed row still renders.
  const tags = ["featured", "all", "project", "teaching", "talk"];

  // Compute rows for the active tab.
  let rows;
  if (filter === "featured") {
    rows = LEDGER.filter((e) => e.featured);
  } else if (filter === "all") {
    rows = LEDGER;
  } else {
    rows = LEDGER.filter((e) => e.tag === filter);
  }

  // Slice rows for the active page (page size varies per tab).
  const pageSize = FEED_PAGE_SIZE_BY_TAB[filter] || FEED_PAGE_SIZE_DEFAULT;
  const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
  const safePage = Math.min(page, totalPages);
  const pagedRows = rows.slice(
    (safePage - 1) * pageSize,
    safePage * pageSize
  );

  // Tab count badge.
  const countFor = (t) => {
    if (t === "featured") return LEDGER.filter((e) => e.featured).length;
    if (t === "all")      return LEDGER.length;
    return LEDGER.filter((e) => e.tag === t).length;
  };

  return (
    <section className="ledger" id="top">
      <aside className="ledger-bio">
        <p className="mono mark">
          {company && (
            // Keyed by company.id so a new mount + replay of the CSS slide-in
            // animation happens each time the company changes.
            // Two-layer structure: outer <button> owns the slide-in transform
            // and the tooltip; inner span owns the circle visual and the
            // hover state (scale + accent ring) so the two transforms don't
            // fight each other.
            <button
              key={company.id}
              type="button"
              className="mark-company-logo"
              data-tooltip={`Watch Austin / ${company.name} pitch`}
              aria-label={`Watch Austin / ${company.name} pitch`}
              onClick={() => company.pitchUrl && onPlayPitch(company.pitchUrl, `${company.name} pitch`)}
            >
              <span className="mark-company-logo-circle">
                {/* mask-image trick: the SVG file provides the shape via
                    its alpha channel, and the visible color comes from
                    background-color: currentColor on this span — so the
                    triangle actually inherits the page's --accent-ink
                    (which an <img>-loaded SVG can't do). */}
                <span
                  className="mark-company-logo-mask"
                  style={{ "--logo-mask": `url("${company.logo}")` }}
                  aria-hidden="true"
                />
              </span>
            </button>
          )}
          <button
            type="button"
            className="avatar"
            data-tooltip="Play Photo Reel"
            aria-label="Open image gallery"
            onClick={() => onOpenGallery()}
          >
            <span className="avatar-clip">
              {/* Two stacked images. The Robotos GIF shows by default;
                  the headshot fades in on hover (and is also gallery[0]
                  when the lightbox opens). */}
              <img
                src="images/robotos.gif"
                alt=""
                className="avatar-img avatar-img-default"
              />
              <img
                src="images/austin-profile.jpeg"
                alt=""
                className="avatar-img avatar-img-hover"
              />
            </span>
          </button>
        </p>

        <h1 className="name name-sm">Austin Keeble</h1>
        <p className="role mono">
          Design Leader · Player-Coach · Builder
        </p>

        {companyIntro ? (
          <>
            <p className="intro intro-sm">{companyIntro[0]}</p>
            <p className="intro intro-sm muted">{companyIntro[1]}</p>
          </>
        ) : (
          <>
            <p className="intro intro-sm">
              Design leadership for teams that ship daily. Player-coach
              mentality, builder by default.
            </p>
            <p className="intro intro-sm muted">
              Work with robots, design for humans.
            </p>
          </>
        )}

        <div className="bio-links mono">
          {ELSEWHERE.map((l) => {
            // mailto links invoke the mail client, so target="_blank"
            // would just flash a blank tab. Skip it for those.
            const isMail = l.href.startsWith("mailto:");
            return (
              <a
                key={l.label}
                href={l.href}
                className="link-underline"
                {...(isMail ? {} : { target: "_blank", rel: "noopener noreferrer" })}
              >
                {l.label} <span className="arrow-out" aria-hidden="true">↗</span>
              </a>
            );
          })}
        </div>

        <Availability />
      </aside>

      <div className="ledger-feed">
        <header className="feed-head">
          <p className="mono feed-label">Activity</p>
          <div className="feed-filters mono">
            {tags.map((t) => (
              <button
                key={t}
                className={"filter " + (filter === t ? "is-on" : "")}
                onClick={() => setFilter(t)}
              >
                {TAG_LABELS[t]}
                <span className="filter-n">{countFor(t)}</span>
              </button>
            ))}
          </div>
        </header>

        <ul
          className={
            "feed"
            + (filter === "featured" ? " is-featured" : "")
            + (filter === "all" ? " is-all" : "")
          }
        >
          {pagedRows.map((r, i) => {
            // Rows can be interactive in three flavors, all keyed off
            // the entry's optional `url`:
            //  - "watchable" — url is a YouTube link. Click opens it in
            //    the in-page lightbox (existing onPlayPitch). CTA reads
            //    "Watch ↗". Applies in ANY tab.
            //  - "readable" — url is anything else (article, PR, doc).
            //    Click opens it in a new tab. CTA reads "Read ↗".
            //    Applies in ANY tab.
            //  - "playable"  — Featured-tab-only fallback for entries
            //    without a url. Plays the FEATURED_VIDEO_PLACEHOLDER in
            //    the lightbox. CTA reads "Play ▶".
            // All three share .feed-row-playable for hover/focus styling.
            const hasUrl = !!r.url;
            const isYouTubeUrl = hasUrl && /youtube\.com|youtu\.be/.test(r.url);
            const isWatchable = isYouTubeUrl;
            const isReadable = hasUrl && !isYouTubeUrl;
            // Featured-tab row WITHOUT a url:
            //  - without `cta`: real playable (click → placeholder video).
            //  - with `cta`: informational ("Demo coming soon" etc.) —
            //    NOT clickable, no static arrow, default cursor, but
            //    the cta still cross-fades in on hover so visitors see
            //    the status. Tag-level signal: a custom cta on a urlless
            //    Featured row means "this row has something to say but
            //    nothing to click yet".
            const isPlayable = filter === "featured" && !hasUrl && !r.cta;
            const isInformational = filter === "featured" && !hasUrl && !!r.cta;
            const isInteractive = isWatchable || isReadable || isPlayable;
            const videoUrl = r.video || FEATURED_VIDEO_PLACEHOLDER;
            const playableProps = isWatchable
              ? {
                  role: "button",
                  tabIndex: 0,
                  onClick: () => onPlayPitch(r.url, r.title),
                  onKeyDown: (e) => {
                    if (e.key === "Enter" || e.key === " ") {
                      e.preventDefault();
                      onPlayPitch(r.url, r.title);
                    }
                  },
                  "aria-label": `Watch: ${r.title}`,
                }
              : isReadable
              ? {
                  role: "link",
                  tabIndex: 0,
                  onClick: () => window.open(r.url, "_blank", "noopener,noreferrer"),
                  onKeyDown: (e) => {
                    if (e.key === "Enter" || e.key === " ") {
                      e.preventDefault();
                      window.open(r.url, "_blank", "noopener,noreferrer");
                    }
                  },
                  "aria-label": `Read article: ${r.title}`,
                }
              : isPlayable
              ? {
                  role: "button",
                  tabIndex: 0,
                  onClick: () => onPlayPitch(videoUrl, r.title),
                  onKeyDown: (e) => {
                    if (e.key === "Enter" || e.key === " ") {
                      e.preventDefault();
                      onPlayPitch(videoUrl, r.title);
                    }
                  },
                  "aria-label": `Play video: ${r.title}`,
                }
              : {};
            return (
              <li
                // Key includes filter + page so React remounts rows on tab
                // or page change — that re-triggers the CSS entry animation
                // for each new row (staggered via the --feed-row-i below).
                key={`${filter}-${safePage}-${i}`}
                className={
                  "feed-row"
                  + (isInteractive ? " feed-row-playable" : "")
                  + (isInformational ? " feed-row-informational" : "")
                }
                style={{ "--feed-row-i": i }}
                {...playableProps}
              >
                <span className="mono feed-date">{formatFeedDate(r.date)}</span>
                <span className={"mono feed-tag tag-" + r.tag}>{r.tag}</span>
                <span className="feed-title">{r.title}</span>
                <span className="mono feed-where-stack">
                  <span className="feed-where-text">
                    {r.where}
                    {isInteractive && (
                      <> <span className="arrow-out" aria-hidden="true">↗</span></>
                    )}
                  </span>
                  {isWatchable && (
                    <span className="feed-where-play" aria-hidden="true">
                      {r.cta || "Watch"}{" "}
                      <span className="arrow-out">↗</span>
                    </span>
                  )}
                  {isReadable && (
                    <span className="feed-where-play" aria-hidden="true">
                      {r.cta || "Read"}{" "}
                      <span className="arrow-out">↗</span>
                    </span>
                  )}
                  {isPlayable && (
                    <span className="feed-where-play" aria-hidden="true">
                      Play{" "}
                      <svg
                        className="feed-play-icon"
                        viewBox="0 0 24 24"
                        width="10"
                        height="10"
                        fill="currentColor"
                      >
                        <polygon points="8,5 8,19 20,12" />
                      </svg>
                    </span>
                  )}
                  {isInformational && (
                    <span className="feed-where-play" aria-hidden="true">
                      {r.cta}
                    </span>
                  )}
                </span>
              </li>
            );
          })}
        </ul>

        <footer className="feed-foot mono">
          <span>{pagedRows.length} entries</span>
          {totalPages > 1 && (
            <span className="feed-pagination">
              <button
                type="button"
                className="feed-page-btn"
                onClick={() => setPage((p) => Math.max(1, p - 1))}
                disabled={safePage === 1}
                aria-label="Previous page"
              >
                ← Prev
              </button>
              <span className="feed-page-indicator">
                {safePage} / {totalPages}
              </span>
              <button
                type="button"
                className="feed-page-btn"
                onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
                disabled={safePage === totalPages}
                aria-label="Next page"
              >
                Next →
              </button>
            </span>
          )}
        </footer>
      </div>
    </section>
  );
}

/* ───────── Section scaffold (mono label left, content right) ───────── */

function Band({ id, label, headline, kicker, children }) {
  return (
    <section id={id} className="band">
      <div className="band-head">
        <p className="mono section-label">{label}</p>
        <div>
          {headline && <h2 className="band-headline">{headline}</h2>}
          {kicker && <p className="band-kicker mono">{kicker}</p>}
        </div>
      </div>
      <div className="band-body">{children}</div>
    </section>
  );
}

/* ───────── Stats ───────── */

function Stats() {
  return (
    <Band
      id="stats"
      label="Stats"
      headline="15 years in, 6 numbers out — stats and receipts."
    >
      <ul className="scope-grid">
        {STATS.map((s, i) => (
          <li key={i} className="scope-cell">
            <a
              href={s.href}
              target="_blank"
              rel="noopener noreferrer"
              className="scope-box"
              aria-label={`${s.n} ${s.unit} — ${s.company}`}
            >
              <span className="scope-n">{s.n}</span>
              <span className="mono scope-unit">{s.unit}</span>
              <span className="scope-line">{s.line}</span>
              <p className="mono scope-meta">
                <span className="scope-company">{s.company}</span>
                <span className="scope-arrow arrow-out" aria-hidden="true">↗</span>
              </p>
            </a>
          </li>
        ))}
      </ul>
    </Band>
  );
}

/* ───────── Mentored ───────── */

function Mentored() {
  return (
    <Band
      id="mentees"
      label="Mentors and mentees"
      headline="1,600 designers, six regions, six years. Now shipping at Apple, Google, NHS, CNN."
    >
      <ul className="mentees-grid">
        {MENTEES.map((m, i) => (
          <li key={i} className="mentee-cell">
            <a
              href={m.linkedin}
              target="_blank"
              rel="noopener noreferrer"
              className="mentee"
              aria-label={`${m.name} on LinkedIn`}
            >
              <p className="mentee-quote">&ldquo;{m.quote}&rdquo;</p>
              <p className="mono mentee-meta">
                <span className="mentee-name">{m.name.split(" ")[0]}</span>
                <span className="mentee-sep">/</span>
                <span className="muted">{m.where}</span>
                <span className="mentee-arrow arrow-out" aria-hidden="true">↗</span>
              </p>
            </a>
          </li>
        ))}
      </ul>
    </Band>
  );
}

/* ───────── Contact ───────── */

function Contact() {
  return (
    <Band
      id="contact"
      label="Hire"
      headline="Director / Head of Design at top-tier product companies — that's the lane I'm in."
      kicker="Berlin · remote-friendly · Q3 2026"
    >
      <p className="contact-body">
        I'm best suited to a place where one Head of Design owns the whole
        function and still ships. Anthropic, Linear, Vercel, Ramp, Deel, Figma,
        Stripe — and high-growth scale-ups of the same shape. Talk to me about
        Principal or Design Engineering Lead at smaller cos too.
      </p>
      <div className="contact-links mono">
        {ELSEWHERE.map((l) => {
          const isMail = l.href.startsWith("mailto:");
          return (
            <a
              key={l.label}
              href={l.href}
              className="contact-link"
              {...(isMail ? {} : { target: "_blank", rel: "noopener noreferrer" })}
            >
              <span>{l.label}</span>
              <span className="muted arrow-out" aria-hidden="true">↗</span>
            </a>
          );
        })}
      </div>
    </Band>
  );
}

/* ───────── App ───────── */

/* ───────── Hover-rotating availability text ───────── */

function HoverRotator({ phrases, active, intervalMs = 1500 }) {
  // `idx` is the line currently shown. `prev` is the one that was just
  // shown — we render it with .is-prev so it slides up and out while
  // the new one slides up from below. Tracking the previous index this
  // way (instead of via a CSS sibling rule) means non-adjacent jumps
  // (like resetting from idx 2 → 0 on mouse-leave) still animate cleanly.
  const [state, setState] = useState({ idx: 0, prev: 0 });
  const [width, setWidth] = useState(null);
  const measureRefs = useRef([]);

  // Pin the container width to whatever the current phrase actually
  // measures — the CSS width transition then animates the surrounding
  // pill as each phrase's length differs.
  useLayoutEffect(() => {
    const el = measureRefs.current[state.idx];
    if (el) setWidth(el.offsetWidth);
  }, [state.idx]);

  useEffect(() => {
    if (!active) {
      // Snap back to the first phrase when hover ends. The slide
      // animation still runs because `prev` carries the last idx.
      setState((s) => (s.idx === 0 ? s : { idx: 0, prev: s.idx }));
      return;
    }
    // Recursive setTimeout chain instead of setInterval: the FIRST
    // advance fires ~200ms after hover so the rotation reads as
    // immediately responsive (was ~1.5s — easy to miss). Subsequent
    // advances stay at `intervalMs` so each phrase has time to be
    // read before the next slides in.
    let timeoutId;
    const tick = (delay) => {
      timeoutId = setTimeout(() => {
        setState((s) => ({ idx: (s.idx + 1) % phrases.length, prev: s.idx }));
        tick(intervalMs);
      }, delay);
    };
    tick(200);
    return () => clearTimeout(timeoutId);
  }, [active, phrases.length, intervalMs]);

  return (
    <span
      className="rotator"
      style={width != null ? { width: `${width}px` } : undefined}
    >
      {/* Hidden measure spans — natural width of each phrase, used only
          by useLayoutEffect above. Out of grid flow so they don't size
          the visible cell. */}
      <span className="rotator-measure" aria-hidden="true">
        {phrases.map((p, i) => (
          <span
            key={i}
            ref={(el) => (measureRefs.current[i] = el)}
            className="rotator-measure-item"
          >
            {p}
          </span>
        ))}
      </span>
      {phrases.map((p, i) => {
        const isCurrent = i === state.idx;
        const isPrev = !isCurrent && i === state.prev;
        const cls =
          "rotator-line" +
          (isCurrent ? " is-current" : "") +
          (isPrev ? " is-prev" : "");
        return (
          <span key={i} className={cls} aria-hidden={!isCurrent}>
            {p}
          </span>
        );
      })}
    </span>
  );
}

function Availability() {
  const [hovered, setHovered] = useState(false);
  return (
    <p
      className="mono availability"
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
    >
      <span className="accent">●</span>{" "}
      <HoverRotator phrases={ROTATING_STATUS} active={hovered} />
    </p>
  );
}

const THEME_STORAGE_KEY = "austin-theme";

function getInitialTheme() {
  // Must match the inline init script in index.html so React state and
  // the data-theme attr applied pre-paint stay in sync.
  try {
    const stored = localStorage.getItem(THEME_STORAGE_KEY);
    if (stored === "light" || stored === "dark") return stored;
    return window.matchMedia("(prefers-color-scheme: light)").matches
      ? "light"
      : "dark";
  } catch (e) {
    return "dark";
  }
}

/* ───────── Lightbox toolbar (shared between grid + single views) ───────── */

function LightboxToolbar({ view, idx, len, onPrev, onNext, onBack, onClose }) {
  const isSingle = view === "single";
  return (
    <nav className="lightbox-toolbar" aria-label="Gallery controls">
      {isSingle && len > 1 && (
        <>
          <button
            type="button"
            className="lightbox-toolbar-btn"
            onClick={onPrev}
            aria-label="Previous image"
          >
            <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <path d="M15 18l-6-6 6-6" />
            </svg>
          </button>
          <button
            type="button"
            className="lightbox-toolbar-btn"
            onClick={onNext}
            aria-label="Next image"
          >
            <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <path d="M9 6l6 6-6 6" />
            </svg>
          </button>
          <span className="mono lightbox-toolbar-counter" aria-live="polite">
            {idx + 1} / {len}
          </span>
          <span className="lightbox-toolbar-sep" aria-hidden="true" />
        </>
      )}
      {isSingle && onBack && (
        <>
          <button
            type="button"
            className="lightbox-toolbar-btn"
            onClick={onBack}
            aria-label="Back to gallery"
          >
            {/* Bento-shape icon: 1 big rectangle on the left + 2 small
                stacked on the right, echoing the gallery layout. */}
            <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
              <rect x="4" y="4" width="8" height="16" rx="1.5" />
              <rect x="14" y="4" width="6" height="7" rx="1.5" />
              <rect x="14" y="13" width="6" height="7" rx="1.5" />
            </svg>
          </button>
          <span className="lightbox-toolbar-sep" aria-hidden="true" />
        </>
      )}
      <button
        type="button"
        className="lightbox-toolbar-btn"
        onClick={onClose}
        aria-label="Close — return to portfolio"
      >
        <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
          <path d="M18 6L6 18M6 6l12 12" />
        </svg>
      </button>
    </nav>
  );
}

/* ───────── Gallery grid (bento overview) ───────── */

function GalleryGrid({ images, onSelect, onClose }) {
  // Keyboard nav: Esc closes.
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  // Lock body scroll while the grid is up.
  useEffect(() => {
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.body.style.overflow = prev; };
  }, []);

  return (
    <div
      className="lightbox"
      role="dialog"
      aria-modal="true"
      aria-label="Image gallery"
    >
      <button
        type="button"
        className="lightbox-backdrop"
        onClick={onClose}
        aria-label="Close gallery"
      />

      <ul className="gallery-grid">
        {images.map((img, i) => (
          <li
            key={i}
            className="gallery-grid-item"
            style={{ "--gallery-i": i }}
          >
            <div className="gallery-grid-media">
              <img src={img.src} alt={img.alt || ""} />
              <button
                type="button"
                className="gallery-grid-fs"
                onClick={() => onSelect(i)}
                aria-label={`View ${img.title} full screen`}
              >
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
                </svg>
              </button>
            </div>
            <div className="gallery-grid-content">
              <h3 className="gallery-grid-title">{img.title}</h3>
              <div className="gallery-grid-meta mono">
                {img.subtext && (
                  <p className="gallery-grid-subtext">{img.subtext}</p>
                )}
                {img.linkUrl && (
                  <a
                    className="gallery-grid-link"
                    href={img.linkUrl}
                    target="_blank"
                    rel="noopener noreferrer"
                  >
                    {img.linkLabel || "Visit"}{" "}
                    <span className="arrow-out" aria-hidden="true">↗</span>
                  </a>
                )}
              </div>
            </div>
          </li>
        ))}
      </ul>

      <LightboxToolbar view="grid" onClose={onClose} />
    </div>
  );
}

/* ───────── Lightbox / single image view ───────── */

function Lightbox({ images, initialIndex = 0, onBack, onClose }) {
  const [idx, setIdx] = useState(initialIndex);
  const len = images.length;

  const next = () => setIdx((i) => (i + 1) % len);
  const prev = () => setIdx((i) => (i - 1 + len) % len);

  // Keyboard nav: Esc goes back to grid (or closes if no onBack provided);
  // arrows navigate between images.
  useEffect(() => {
    const onKey = (e) => {
      if (e.key === "Escape") (onBack || onClose)();
      else if (e.key === "ArrowRight" && len > 1) next();
      else if (e.key === "ArrowLeft" && len > 1) prev();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [len, onBack, onClose]);

  // Lock body scroll while the lightbox is up.
  useEffect(() => {
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.body.style.overflow = prev; };
  }, []);

  const current = images[idx];

  return (
    <div
      className="lightbox"
      role="dialog"
      aria-modal="true"
      aria-label="Image gallery"
    >
      <button
        type="button"
        className="lightbox-backdrop"
        onClick={onBack || onClose}
        aria-label={onBack ? "Back to gallery" : "Close gallery"}
      />

      <figure className="lightbox-figure" key={idx}>
        <div className="lightbox-card">
          <div className="lightbox-card-media">
            <img
              src={current.src}
              alt={current.alt || ""}
              className="lightbox-image"
            />
          </div>
          {(current.title || current.subtext || current.linkUrl) && (
            <figcaption className="lightbox-card-content">
              {current.title && (
                <h3 className="lightbox-card-title">{current.title}</h3>
              )}
              <div className="lightbox-card-meta mono">
                {current.subtext && (
                  <p className="lightbox-card-subtext">{current.subtext}</p>
                )}
                {current.linkUrl && (
                  <a
                    className="lightbox-card-link"
                    href={current.linkUrl}
                    target="_blank"
                    rel="noopener noreferrer"
                  >
                    {current.linkLabel || "Visit"}{" "}
                    <span className="arrow-out" aria-hidden="true">↗</span>
                  </a>
                )}
              </div>
            </figcaption>
          )}
        </div>
      </figure>

      <LightboxToolbar
        view="single"
        idx={idx}
        len={len}
        onPrev={prev}
        onNext={next}
        onBack={onBack}
        onClose={onClose}
      />
    </div>
  );
}

/* ───────── Video lightbox (company pitch player) ───────── */

// Normalize any YouTube watch URL to an embed URL with autoplay.
// Falls back to the original URL for non-YouTube hosts.
function toEmbedUrl(url) {
  try {
    const u = new URL(url);
    if (u.hostname === "youtu.be") {
      return `https://www.youtube.com/embed/${u.pathname.slice(1)}?autoplay=1`;
    }
    if (u.hostname.endsWith("youtube.com")) {
      const id = u.searchParams.get("v");
      if (id) return `https://www.youtube.com/embed/${id}?autoplay=1`;
    }
  } catch (e) {}
  return url;
}

function VideoLightbox({ url, title, onClose }) {
  const embedUrl = toEmbedUrl(url);

  // Esc closes.
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  // Lock body scroll.
  useEffect(() => {
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.body.style.overflow = prev; };
  }, []);

  return (
    <div
      className="lightbox"
      role="dialog"
      aria-modal="true"
      aria-label={title || "Video player"}
    >
      <button
        type="button"
        className="lightbox-backdrop"
        onClick={onClose}
        aria-label="Close video"
      />
      <button
        type="button"
        className="lightbox-close"
        onClick={onClose}
        aria-label="Close video"
      >
        <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
          <path d="M18 6L6 18M6 6l12 12" />
        </svg>
      </button>
      <div className="video-lightbox-frame">
        <iframe
          src={embedUrl}
          title={title || "Pitch video"}
          frameBorder="0"
          allow="autoplay; encrypted-media; picture-in-picture"
          allowFullScreen
        />
      </div>
    </div>
  );
}

/* ───────── Markdown view ───────── */

function MarkdownView() {
  const md = buildPortfolioMarkdown();
  return (
    <main className="markdown-view">
      <pre className="markdown-source">{md}</pre>
    </main>
  );
}

function Toolbar({ markdownMode, onToggleMarkdown, company }) {
  // On a company page the effective cycle is [company, ...PALETTE] —
  // company sits at paletteIdx 0, the four palette colors fill 1..4.
  // Without a company, the cycle is just PALETTE (paletteIdx 0..3).
  const [paletteIdx, setPaletteIdx] = useState(0);
  const [titleGlyphIdx, setTitleGlyphIdx] = useState(0);
  const [typeIdx, setTypeIdx] = useState(0);
  const [theme, setTheme] = useState(getInitialTheme);
  const [mailOpen, setMailOpen] = useState(false);
  const [mailBody, setMailBody] = useState("");
  // True when the page footer has scrolled into the toolbar's strike
  // zone — toggled by the IntersectionObserver below. Drives the
  // `.is-near-footer` class that fades the toolbar out.
  const [nearFooter, setNearFooter] = useState(false);
  const loadedFontsRef = useRef(new Set([0])); // Geist already in DOM
  const inputRef = useRef(null);

  // Hide the toolbar when the page footer overlaps its position. Uses
  // IntersectionObserver with a negative bottom rootMargin so the
  // "intersection" fires the moment the footer's top edge crosses
  // into the toolbar's strike zone near the bottom of the viewport.
  //
  // The rootMargin must be SMALLER than the footer's height — otherwise
  // a short footer never reaches the threshold at max scroll. The page
  // footer is 64px tall; -20px means the trigger fires when only ~4px
  // of footer is visible, giving the 320ms fade transition plenty of
  // lead time before the user reaches the actual footer content.
  useEffect(() => {
    const footer = document.querySelector(".page-foot");
    if (!footer || typeof IntersectionObserver === "undefined") return;
    const observer = new IntersectionObserver(
      ([entry]) => setNearFooter(entry.isIntersecting),
      { rootMargin: "0px 0px -20px 0px" }
    );
    observer.observe(footer);
    return () => observer.disconnect();
  }, []);

  // Derive the active slot — either the company (paletteIdx 0 on a
  // company page) or a PALETTE entry. Computed each render so theme
  // toggles cause the company branch to pick the right mode-aware
  // accent without a separate observer.
  const cycleLength = company ? PALETTE.length + 1 : PALETTE.length;
  const isCompanySlot = company && paletteIdx === 0;
  const activeAccent = isCompanySlot
    ? (theme === "light" ? company.accentLight : company.accentDark)
    : PALETTE[company ? paletteIdx - 1 : paletteIdx].accent;
  const activeName = isCompanySlot
    ? company.name
    : PALETTE[company ? paletteIdx - 1 : paletteIdx].name;

  // Apply the accent. --accent-ink is derived via inkFor() so dark or
  // light accents both get a readable text color on accent fills (Send
  // button, Contact links, ::selection, etc.).
  useEffect(() => {
    document.documentElement.style.setProperty("--accent", activeAccent);
    document.documentElement.style.setProperty("--accent-ink", inkFor(activeAccent));
  }, [activeAccent]);

  useEffect(() => {
    document.title = markdownMode
      ? `Austin ${TAB_TITLE_AGENT_GLYPH}`
      : `Austin ${TAB_TITLE_GLYPHS[titleGlyphIdx]}`;
    const fill = markdownMode ? "#08090A" : activeAccent;
    const href = accentFaviconDataUrl(fill);
    const lightChrome = document.getElementById("site-favicon-for-light-chrome");
    const darkChrome = document.getElementById("site-favicon-for-dark-chrome");
    if (lightChrome) lightChrome.href = href;
    if (darkChrome) darkChrome.href = href;
  }, [activeAccent, markdownMode, titleGlyphIdx]);

  // Apply theme & persist. The data-theme attr is already set by the inline
  // <head> script on initial load, but we keep it in sync on toggle.
  useEffect(() => {
    document.documentElement.setAttribute("data-theme", theme);
    try { localStorage.setItem(THEME_STORAGE_KEY, theme); } catch (e) {}
  }, [theme]);

  // Apply type system (lazy-load font stylesheet on first use)
  useEffect(() => {
    const t = TYPE_SYSTEMS[typeIdx];
    if (t.fontsHref && !loadedFontsRef.current.has(typeIdx)) {
      const link = document.createElement("link");
      link.rel = "stylesheet";
      link.href = t.fontsHref;
      document.head.appendChild(link);
      loadedFontsRef.current.add(typeIdx);
    }
    document.documentElement.style.setProperty("--display", t.display);
    document.documentElement.style.setProperty("--mono", t.mono);
  }, [typeIdx]);

  // Focus the textarea once the two-stage morph has settled (~660ms total)
  useEffect(() => {
    if (!mailOpen) return;
    const t = setTimeout(() => inputRef.current?.focus(), 680);
    return () => clearTimeout(t);
  }, [mailOpen]);

  // Close on Esc
  useEffect(() => {
    if (!mailOpen) return;
    const onKey = (e) => { if (e.key === "Escape") closeMail(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [mailOpen]);

  const cyclePalette = () => {
    setPaletteIdx((i) => (i + 1) % cycleLength);
    setTitleGlyphIdx((i) => (i + 1) % TAB_TITLE_GLYPHS.length);
  };
  const cycleType    = () => setTypeIdx((i) => (i + 1) % TYPE_SYSTEMS.length);
  const toggleTheme  = () => setTheme((t) => (t === "dark" ? "light" : "dark"));

  const closeMail = () => {
    setMailOpen(false);
    setMailBody("");
  };

  const sendMail = (e) => {
    if (e) e.preventDefault();
    const body = mailBody.trim();
    if (!body) return;
    window.location.href = `mailto:${MAIL_TO}?body=${encodeURIComponent(body)}`;
    closeMail();
  };

  // While in input mode the underlying buttons should be untabbable
  const ctrlTab = mailOpen ? -1 : 0;
  const inputTab = mailOpen ? 0 : -1;

  return (
    <nav
      className={
        "toolbar"
        + (mailOpen ? " is-input-mode" : "")
        + (markdownMode ? " is-agent-mode" : "")
        + (nearFooter && !mailOpen ? " is-near-footer" : "")
      }
      aria-label="Page controls"
    >
      <div className="toolbar-controls" aria-hidden={mailOpen}>
        <button
          type="button"
          tabIndex={ctrlTab}
          className="toolbar-btn toolbar-swatch"
          aria-label={
            markdownMode
              ? "Monochrome (locked in agent mode)"
              : isCompanySlot
                ? `${company.name} primary color`
                : `Change accent color (current: ${activeName})`
          }
          aria-disabled={markdownMode || undefined}
          data-tooltip={
            markdownMode
              ? "Monochrome"
              : isCompanySlot
                ? `${company.name} — primary`
                : `Accent — ${activeName}`
          }
          onClick={markdownMode ? undefined : cyclePalette}
        >
          {/* Background bound to var(--accent) via CSS so the dot
              automatically reflects the active accent — palette in
              default mode, company color in company mode, theme-aware
              in both. In agent mode the dot fades to var(--fg); the
              lock indicator now lives inside the tooltip pseudo via
              a background-image SVG (see index.html). */}
          <span className="toolbar-swatch-dot" />
        </button>
        <span className="toolbar-sep" aria-hidden="true" />
        <button
          type="button"
          tabIndex={ctrlTab}
          className="toolbar-btn toolbar-type"
          aria-label={`Cycle type system (current: ${TYPE_SYSTEMS[typeIdx].name})`}
          data-tooltip={`Type — ${TYPE_SYSTEMS[typeIdx].name}`}
          onClick={cycleType}
        >
          <span className="toolbar-T">T</span>
        </button>
        <span className="toolbar-sep" aria-hidden="true" />
        <button
          type="button"
          tabIndex={ctrlTab}
          className="toolbar-btn toolbar-theme"
          aria-label={
            theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
          }
          aria-pressed={theme === "light"}
          data-tooltip={theme === "dark" ? "Switch to light" : "Switch to dark"}
          onClick={toggleTheme}
        >
          {theme === "dark" ? (
            // Sun — shown while in dark mode (click to go light)
            <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="12" cy="12" r="4" />
              <path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4" />
            </svg>
          ) : (
            // Moon — shown while in light mode (click to go dark)
            <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z" />
            </svg>
          )}
        </button>
        <span className="toolbar-sep" aria-hidden="true" />
        <button
          type="button"
          tabIndex={ctrlTab}
          className="toolbar-btn toolbar-markdown"
          aria-label={
            markdownMode
              ? "Switch to normal view"
              : "Switch to markdown view (for AI agents)"
          }
          aria-pressed={markdownMode}
          data-tooltip={
            markdownMode ? "Back to human view" : "Markdown view (for AI agents)"
          }
          onClick={onToggleMarkdown}
        >
          {markdownMode ? (
            // Smiling human — shown while in markdown mode (click to return)
            <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="12" cy="12" r="9" />
              <path d="M8 14c1.2 1.6 2.6 2.4 4 2.4s2.8-.8 4-2.4" />
              <circle cx="9" cy="10" r="0.9" fill="currentColor" stroke="none" />
              <circle cx="15" cy="10" r="0.9" fill="currentColor" stroke="none" />
            </svg>
          ) : (
            // Robot — same face as the smiley but with a rounded square outline
            <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <rect x="3" y="3" width="18" height="18" rx="4" />
              <path d="M8 14c1.2 1.6 2.6 2.4 4 2.4s2.8-.8 4-2.4" />
              <circle cx="9" cy="10" r="0.9" fill="currentColor" stroke="none" />
              <circle cx="15" cy="10" r="0.9" fill="currentColor" stroke="none" />
            </svg>
          )}
        </button>
        <span className="toolbar-sep" aria-hidden="true" />
        <button
          type="button"
          tabIndex={ctrlTab}
          className="toolbar-btn toolbar-mail"
          aria-label="Send a message"
          aria-expanded={mailOpen}
          data-tooltip="Send a message"
          onClick={() => setMailOpen(true)}
        >
          <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
            <rect x="3" y="5" width="18" height="14" rx="2"/>
            <path d="M3 7l9 6 9-6"/>
          </svg>
        </button>
      </div>

      <form
        className="toolbar-input-row"
        aria-hidden={!mailOpen}
        onSubmit={sendMail}
      >
        <button
          type="button"
          tabIndex={inputTab}
          className="toolbar-close-inline"
          aria-label="Close message"
          onClick={closeMail}
        >
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
            <path d="M18 6L6 18M6 6l12 12"/>
          </svg>
        </button>
        <textarea
          ref={inputRef}
          tabIndex={inputTab}
          className="toolbar-textarea"
          placeholder="Write a message…"
          value={mailBody}
          onChange={(e) => setMailBody(e.target.value)}
          onKeyDown={(e) => {
            // Cmd/Ctrl + Enter submits; plain Enter inserts a newline.
            if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
              e.preventDefault();
              sendMail();
            }
          }}
        />
        <span className="toolbar-hint" aria-hidden="true">
          <kbd>⌘</kbd> <kbd>↵</kbd> to send
        </span>
        <button
          type="submit"
          tabIndex={inputTab}
          className="toolbar-send-inline"
          disabled={!mailBody.trim()}
        >
          Send ↗
        </button>
      </form>
    </nav>
  );
}

function App() {
  // Markdown mode is a session-only toggle (not persisted) — it's an
  // extraction view, not a preference.
  const [markdownMode, setMarkdownMode] = useState(false);
  const toggleMarkdown = () => setMarkdownMode((m) => !m);

  // Gallery state — session-only. Two views:
  //   "grid"   → bento overview of every image (opened from avatar click)
  //   "single" → existing Lightbox for one image at a time (opened by
  //              clicking a thumbnail in the grid)
  // null means closed.
  const [galleryView, setGalleryView] = useState(null); // null | "grid" | "single"
  const [galleryIdx, setGalleryIdx] = useState(0);
  const openGallery = () => setGalleryView("grid");
  const openGalleryItem = (i) => {
    setGalleryIdx(i);
    setGalleryView("single");
  };
  const backToGallery = () => setGalleryView("grid");
  const closeGallery = () => setGalleryView(null);

  // Pitch / activity video lightbox state — session-only. Opens with a
  // YouTube URL and a (fully-formed) title for the dialog aria-label /
  // iframe title. Callers pass the exact title they want shown.
  const [pitch, setPitch] = useState(null); // { url, title } | null
  const openPitch = (url, title) => setPitch({ url, title });
  const closePitch = () => setPitch(null);

  // Company personalization (null = default view, otherwise a COMPANY_MODES id).
  // Initial state reads ?top10companies=<id> from the URL so deep links work.
  const [companyId, setCompanyId] = useState(() => {
    try {
      const id = new URLSearchParams(window.location.search).get("top10companies");
      return id && COMPANY_MODES.find((c) => c.id === id) ? id : null;
    } catch (e) {
      return null;
    }
  });
  const company = companyId
    ? COMPANY_MODES.find((c) => c.id === companyId) || null
    : null;

  // Keep the URL in sync with the active company so the current view is
  // shareable. replaceState (not pushState) so the back button still
  // exits the site cleanly instead of stepping through company picks.
  useEffect(() => {
    try {
      const url = new URL(window.location.href);
      if (companyId) url.searchParams.set("top10companies", companyId);
      else           url.searchParams.delete("top10companies");
      window.history.replaceState(null, "", url.toString());
    } catch (e) {}
  }, [companyId]);

  // When a company is active, apply its non-accent overrides:
  // --circle-bg (avatar backing), --display / --mono (type system).
  // --accent and --accent-ink are owned by Toolbar — the swatch-cycle
  // treats the company as paletteIdx 0, so Toolbar applies the right
  // value (theme-aware company accent OR palette color) in every state.
  useEffect(() => {
    const root = document.documentElement.style;
    if (!company) {
      root.removeProperty("--circle-bg");
      return;
    }
    if (company.circleBg)  root.setProperty("--circle-bg",  company.circleBg);
    else                   root.removeProperty("--circle-bg");
    // Type system: lazy-load font sheet if needed.
    const t = TYPE_SYSTEMS[company.typeIdx];
    if (t.fontsHref) {
      // Idempotent: only inject if not already present.
      const existing = Array.from(document.head.querySelectorAll("link[rel=stylesheet]"))
        .some((l) => l.href === t.fontsHref);
      if (!existing) {
        const link = document.createElement("link");
        link.rel = "stylesheet";
        link.href = t.fontsHref;
        document.head.appendChild(link);
      }
    }
    root.setProperty("--display", t.display);
    root.setProperty("--mono", t.mono);
  }, [companyId, company]);

  return (
    <>
      {markdownMode ? (
        <MarkdownView />
      ) : (
        <>
          <TopBlock
            onOpenGallery={openGallery}
            companyIntro={company?.intro || null}
            company={company}
            onPlayPitch={openPitch}
          />
          <Stats />
          <Mentored />
          <Contact />

          <footer className="page-foot mono">
            <span>© Austin Keeble · 2026</span>
            <p className="page-foot-credit">
              Hand crafted, self hosted. Deployed with{" "}
              <a
                href="https://vercel.com/"
                target="_blank"
                rel="noopener noreferrer"
                className="link-underline"
              >
                Vercel
              </a>
              , tracking with{" "}
              <a
                href="https://www.amplitude.com/"
                target="_blank"
                rel="noopener noreferrer"
                className="link-underline"
              >
                Amplitude
              </a>
              .
            </p>
            <a href="#top" className="link-underline">Back to top ↑</a>
          </footer>
        </>
      )}

      <Toolbar
        markdownMode={markdownMode}
        onToggleMarkdown={toggleMarkdown}
        company={company}
      />

      {galleryView === "grid" && (
        <GalleryGrid
          images={GALLERY}
          onSelect={openGalleryItem}
          onClose={closeGallery}
        />
      )}

      {galleryView === "single" && (
        <Lightbox
          images={GALLERY}
          initialIndex={galleryIdx}
          onBack={backToGallery}
          onClose={closeGallery}
        />
      )}

      {pitch && (
        <VideoLightbox
          url={pitch.url}
          title={pitch.title}
          onClose={closePitch}
        />
      )}
    </>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
