/* ============================================================
   app.jsx
   ============================================================
   PURPOSE:  Root React app. Renders:
              - splash (one-time per session)
              - stage with active slide (about / iframe / preview / collage)
              - floating chrome (avatar, sections, ops hint, works rail)
              - strict 3-line caption block at the bottom
              - chat overlay (genspark.complete)
              - cursor dot

   CONSUMES: window.SITE_DATA (from data.js)
             window.genspark.complete (built-in LLM helper)
             React 18 (UMD), ReactDOM 18 (UMD), Babel (in HTML)

   OUTPUTS:  Mounts into #root (defined in Harry Chen.html).

   ROUTING / STATE:
     - section (string)   — one of "about" | "builder" | "creator" | "artist"
     - workIdx (number)   — current work index within the section's works[]
     - chatOpen (boolean) — chat overlay visible
     Persisted in localStorage under "harry-site-state".

   IFRAME STRATEGY:
     IframeSlide tries to load viz.src. If the iframe doesn't fire
     onload within 4 seconds, OR throws on access, we hide the iframe
     and show viz.posterImg (a static long screenshot) inside a
     scrollable fallback container.

   LAST EDIT: 2026-06-28 — Restructured: About first, Artist new section.
              Caption strict 3 lines. Ops hint moved into top-right
              cluster above sections. Avatar hidden on About. Iframe
              with screenshot fallback. Chrome at ~35% opacity at rest.
   ============================================================ */

const { useState, useEffect, useRef, useCallback } = React;
const D = window.SITE_DATA;

/* ============================================================
   Splash (one-time per session)
   ============================================================ */
function Splash({ onDone }) {
  const [gone, setGone] = useState(false);
  useEffect(() => {
    const t1 = setTimeout(() => setGone(true), 1400);
    const t2 = setTimeout(() => onDone(), 2000);
    return () => { clearTimeout(t1); clearTimeout(t2); };
  }, [onDone]);
  return (
    <div className={"splash" + (gone ? " gone" : "")}>
      <div className="splash-mark" />
      <div className="splash-name">Harry Chen</div>
    </div>
  );
}

/* ============================================================
   IframeSlide — try the live site, fall back to screenshot
   ============================================================ */
function IframeSlide({ src, posterImg, imgBg, imgFit, active }) {
  const [loaded, setLoaded] = useState(false);
  const [failed, setFailed] = useState(false);
  const timerRef = useRef(null);

  useEffect(() => {
    if (!active) {
      // Reset state when this slide isn't active — prevents stale
      // "STATIC PREVIEW" badges from bleeding through during transitions.
      if (timerRef.current) clearTimeout(timerRef.current);
      setLoaded(false);
      setFailed(false);
      return;
    }
    setLoaded(false);
    setFailed(false);
    // X-Frame-Options blocks won't fire onload — fall back after 4s
    timerRef.current = setTimeout(() => {
      // If still not loaded, treat as failed
      setLoaded(prev => {
        if (!prev) setFailed(true);
        return prev;
      });
    }, 4000);
    return () => clearTimeout(timerRef.current);
  }, [active, src]);

  const onLoad = (e) => {
    if (timerRef.current) clearTimeout(timerRef.current);
    // Detect X-Frame-Options / CSP frame-ancestors blocks. The load event
    // fires whether the browser refused to embed or actually loaded the page.
    // Heuristic:
    //   - Same-origin success  → contentDocument is a real Document
    //   - Cross-origin success → contentDocument access THROWS SecurityError
    //   - Blocked (XFO/CSP)    → contentDocument is null, contentWindow exists,
    //                            no SecurityError when probing
    const iframe = e.currentTarget;
    let blocked = false;
    try {
      const doc = iframe.contentDocument;
      if (doc === null) {
        // Try touching contentWindow.location — if it throws SecurityError,
        // the iframe genuinely loaded a cross-origin page (good). Otherwise it's blocked.
        try {
          const _ = iframe.contentWindow.location.origin;
          // Reached here without throwing → blocked (we can read the location).
          blocked = true;
        } catch (e2) {
          // SecurityError → real cross-origin frame loaded fine
          blocked = false;
        }
      }
    } catch {
      blocked = true;
    }
    if (blocked) setFailed(true);
    else setLoaded(true);
  };

  return (
    <div className="iframe-wrap">
      {!failed && (
        <iframe
          className={"viz-iframe" + (loaded ? " loaded" : "")}
          src={src}
          loading="lazy"
          referrerPolicy="no-referrer"
          sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
          onLoad={onLoad}
          title=""
        />
      )}
      {!failed && !loaded && (
        <div className="viz-loading">
          <span>Loading live site</span>
          <span className="bar" />
        </div>
      )}
      {failed && (
        <>
          <div className="iframe-fallback" style={{ background: imgBg || "var(--bg-alt)" }}>
            <img
              src={posterImg}
              alt=""
              style={imgFit === "cover" ? { height: "100%", objectFit: "cover" } : {}}
            />
          </div>
          <div className="iframe-fallback-tag">Scroll ↓</div>
        </>
      )}
    </div>
  );
}

/* ============================================================
   PreviewScrollSlide — scrollable static screenshot.
   Used for sites that block iframe embedding (X-Frame-Options / CSP).

   AUTO-SCROLL: when the slide becomes active, the container auto-
   scrolls down ~one viewport per beat, then loops back to the top
   when it reaches the bottom. The visitor sees the entire page
   without having to grab the scrollbar — much clearer affordance
   than the scrollbar alone on macOS (where it auto-hides).
   ============================================================ */
function PreviewScrollSlide({ viz, openHref, active }) {
  const containerRef = useRef(null);
  const bg = viz.imgBg || "var(--bg-alt)";

  useEffect(() => {
    const el = containerRef.current;
    if (!active || !el) return;
    // Reset to the top whenever this slide becomes active.
    el.scrollTop = 0;

    const step = () => {
      const max = el.scrollHeight - el.clientHeight;
      if (max <= 0) return;
      const next = el.scrollTop + el.clientHeight * 0.9;
      if (el.scrollTop >= max - 4) {
        // At (or past) the bottom — wrap around to the top.
        el.scrollTo({ top: 0, behavior: "smooth" });
      } else {
        el.scrollTo({ top: Math.min(max, next), behavior: "smooth" });
      }
    };

    // Wait ~1.7s on first reveal (visitor reads the top), then step
    // every 2.7s — original cadence reduced by 1/3 for faster turns.
    const initial = setTimeout(step, 1700);
    const timer = setInterval(step, 2700);
    return () => { clearTimeout(initial); clearInterval(timer); };
  }, [active]);

  return (
    <div className="iframe-wrap">
      <div className="iframe-fallback" ref={containerRef} style={{ background: bg }}>
        <img src={viz.posterImg} alt="" />
      </div>
      <div className="iframe-fallback-tag">Scroll ↓</div>
    </div>
  );
}

/* ============================================================
   PreviewSlide — full-bleed static poster
   ============================================================ */
function PreviewSlide({ viz, openHref }) {
  const fit = viz.imgFit || "cover";
  const bg = viz.imgBg || "var(--bg-alt)";
  return (
    <a
      className="preview-canvas"
      href={openHref}
      target="_blank"
      rel="noopener noreferrer"
      style={{ background: bg }}
    >
      <img className="preview-img" src={viz.posterImg} alt="" style={{ objectFit: fit }} />
    </a>
  );
}

/* ============================================================
   VideoSlide — autoplay muted loop of a local MP4. Fills the stage
   uniformly; click goes to the live site.
   ============================================================ */
function VideoSlide({ viz, openHref }) {
  const bg = viz.imgBg || "#000";
  return (
    <a
      className="preview-canvas video"
      href={openHref}
      target="_blank"
      rel="noopener noreferrer"
      style={{ background: bg }}
    >
      <video
        className="viz-video"
        src={viz.src}
        autoPlay
        loop
        muted
        playsInline
        preload="auto"
      />
    </a>
  );
}

/* ============================================================
   PreviewStackedSlide — two (or more) images stacked vertically,
   each filling its share of the slide. Edge-to-edge, no frame.
   Used when a single screenshot is too wide/short to fill the
   tall stage slot on its own — pair it with a cropped portrait.
   ============================================================ */
function PreviewStackedSlide({ viz, openHref }) {
  const stack = viz.stack || [];
  return (
    <a
      className="preview-canvas stacked"
      href={openHref}
      target="_blank"
      rel="noopener noreferrer"
    >
      {stack.map((s, i) => (
        <div key={i} className="stack-slot" style={{ background: s.bg || "var(--white)" }}>
          <img
            src={s.src}
            alt=""
            style={{
              objectFit: s.fit || "cover",
              objectPosition: s.pos || "center",
            }}
          />
        </div>
      ))}
    </a>
  );
}

/* ============================================================
   CollageSlide — left half (wordmark / poster / quote), right half (logos)
   ============================================================ */
function CollageSlide({ viz }) {
  const logos = viz.logos || [];
  let cols, rows;
  // 4 logos → 2×2 grid (user prefers it over the vertical stack);
  // 3 or fewer → single column rows.
  if (logos.length <= 1)       { cols = 1; rows = 1; }
  else if (logos.length === 2) { cols = 1; rows = 2; }
  else if (logos.length === 3) { cols = 1; rows = 3; }
  else                         { cols = 2; rows = 2; }
  const gridCls = `collage-grid cols-${cols} rows-${rows}`;

  /* Hero half — three modes */
  let hero = null;
  if (viz.heroMode === "wordmark") {
    hero = (
      <div className="collage-hero wordmark">
        <img src={viz.wordmark} alt="" />
      </div>
    );
  } else if (viz.heroMode === "quote") {
    const eq = viz.quote;
    // Daily-writing equation. The whole line is set in Fraunces
    // (matching "innovation capacity"), and the right side stacks
    // one factor per line so the left column uses its full height.
    const parts = eq.equation.split(" = ");
    const factors = (parts[1] || "").split(" × ");
    return (
      <div className="collage">
        <div className="collage-hero quote">
          <div className="q-note">{eq.note}</div>
          <div className="q-equation">
            <span className="q-lhs">{parts[0]} =</span>
            <span className="q-factors">
              {factors.map((f, i) => (
                <span key={i} className="q-factor">
                  {i > 0 && <span className="q-mul">×</span>}
                  <span className="q-word">{f}</span>
                </span>
              ))}
            </span>
          </div>
        </div>
        <CollageGrid logos={logos} gridCls={gridCls} cellFrame={viz.cellFrame} />
      </div>
    );
  } else if (viz.heroMode === "poster") {
    hero = (
      <div className="collage-hero poster">
        <img src={viz.posterImg} alt="" />
      </div>
    );
  } else if (viz.heroMode === "stacked") {
    // Two images stacked vertically, edge-to-edge, no frame.
    // Each slot fills its share of the hero half. Per-slot `maxW`
    // (string, e.g. "70%") lets data narrow the visual frame so two
    // images of different aspect ratios still read at matching width.
    const stack = viz.stack || [];
    hero = (
      <div className="collage-hero stacked">
        {stack.map((s, i) => {
          const imgStyle = {
            objectFit: s.fit || "cover",
            objectPosition: s.pos || "center",
          };
          if (s.maxW) imgStyle.maxWidth = s.maxW;
          if (s.maxH) imgStyle.maxHeight = s.maxH;
          return (
            <div key={i} className="stack-slot" style={{ background: s.bg || "var(--white)" }}>
              <img src={s.src} alt="" style={imgStyle} />
            </div>
          );
        })}
      </div>
    );
  }

  return (
    <div className="collage">
      {hero}
      <CollageGrid logos={logos} gridCls={gridCls} cellFrame={viz.cellFrame} />
    </div>
  );
}

/* Splits a fans string like "3,533 followers" into a bold number + small unit. */
function FansLine({ text }) {
  if (!text) return null;
  // Match leading numbers + commas; everything after is the unit
  const m = text.match(/^([\d,]+)\s*(.*)$/);
  if (!m) return <span className="cell-fans">{text}</span>;
  const [, num, unit] = m;
  return (
    <span className="cell-fans">
      <strong>{num}</strong>
      {unit && <span>{unit}</span>}
    </span>
  );
}

function CollageGrid({ logos, gridCls, cellFrame }) {
  const hasFrame = !!cellFrame;
  return (
    <div className={gridCls + (hasFrame ? " has-frame" : "")}>
      {logos.map((l, i) => (
        <a key={i} className={"collage-cell" + (hasFrame ? " framed" : "")} href={l.href} target="_blank" rel="noopener noreferrer">
          {/* Decorative pattern border — rotated bauhaus poster framing
              the content. The inner content sits on a white "hole"
              that punches through the pattern. */}
          {hasFrame && (
            <span className="cell-frame-art" aria-hidden="true">
              <img src={cellFrame} alt="" />
            </span>
          )}
          <div className="cell-logo-wrap">
            {l.typeLogo === "in" ? (
              <div className="linkedin-mark">in</div>
            ) : (
              <img src={l.src} alt={l.label} />
            )}
          </div>
          <div className="cell-meta">
            <div className="cell-meta-row">
              <div className="cell-name">{l.label}</div>
              <FansLine text={l.fans} />
            </div>
            <div className="cell-cta">Visit ↗</div>
          </div>
        </a>
      ))}
    </div>
  );
}

/* ============================================================
   WorkSlide — picks the right viz mode
   ============================================================ */
function WorkSlide({ work, active }) {
  if (!work) return null;
  const v = work.viz;
  return (
    <div className={"slide" + (active ? " in" : "")} aria-hidden={!active}>
      {v.mode === "iframe"          && <IframeSlide src={v.src} posterImg={v.posterImg} imgBg={v.imgBg} imgFit={v.imgFit} active={active} />}
      {v.mode === "preview-scroll"  && <PreviewScrollSlide viz={v} openHref={work.primaryHref} active={active} />}
      {v.mode === "preview"         && <PreviewSlide viz={v} openHref={work.primaryHref} />}
      {v.mode === "preview-stacked" && <PreviewStackedSlide viz={v} openHref={work.primaryHref} />}
      {v.mode === "video"           && <VideoSlide viz={v} openHref={work.primaryHref} />}
      {v.mode === "collage"         && <CollageSlide viz={v} />}
    </div>
  );
}

/* ============================================================
   AboutSlide — bauhaus portrait + four-city journey + bio + education
   Below the portrait, three section jumpers (Builder / Creator /
   Artist) rendered as inline-SVG bauhaus shapes in the brand palette.
   ============================================================ */
function AboutSlide({ active, onChangeSection }) {
  const a = D.about;
  const jumpers = [
    {
      id: "builder",
      label: "Builder",
      caption: "AI Features and Landing Pages",
      svg: `<svg viewBox="0 0 24 24" fill="none">
              <rect x="2" y="2" width="20" height="20" fill="#002FA7"/>
            </svg>`,
    },
    {
      id: "creator",
      label: "Creator",
      caption: "Podcast, Video, Writing",
      svg: `<svg viewBox="0 0 24 24" fill="none">
              <circle cx="12" cy="12" r="10" fill="#A78BD9"/>
            </svg>`,
    },
    {
      id: "artist",
      label: "Artist",
      caption: "Hip Hop",
      svg: `<svg viewBox="0 0 24 24" fill="none">
              <polygon points="12,3 22,21 2,21" fill="#0a0a0a"/>
            </svg>`,
    },
  ];
  return (
    <div className={"about-view" + (active ? " in" : "")}>
      <div className="about-inner">
        <div className="about-left">
          <div className="about-photo">
            <img src={a.heroImg} alt={a.name} />
          </div>
          <div className="about-jumpers">
            {jumpers.map(j => (
              <button
                key={j.id}
                className="about-jumper"
                onClick={() => onChangeSection && onChangeSection(j.id)}
              >
                <span className="about-jumper-icon" dangerouslySetInnerHTML={{ __html: j.svg }} />
                <span className="about-jumper-text">
                  <span className="about-jumper-label">{j.label}</span>
                  <span className="about-jumper-caption">{j.caption}</span>
                </span>
                <span className="about-jumper-arr">↗</span>
              </button>
            ))}
          </div>
        </div>
        <div className="about-text">
          <h1 className="about-name">{a.name}</h1>
          <div className="about-zh">{a.zh}</div>

          {/* First paragraph (the four-cities biography) */}
          {a.paragraphs[0] && (
            <div className="about-paras about-paras-lead">
              <p dangerouslySetInnerHTML={{ __html: a.paragraphs[0] }} />
            </div>
          )}

          {/* Cities + education sit DIRECTLY UNDER the first paragraph
              — they're the visual proof for that "four heart-cities"
              biography, so anchoring them here grounds the rest of
              the page rather than burying them at the bottom. */}
          <div className="cities">
            {D.cities.map((c, i) => (
              <div key={i} className={"city" + (i === D.cities.length - 1 ? " active" : "")}>
                <span className="city-icon" dangerouslySetInnerHTML={{ __html: c.svg }} />
                <h3 className="city-name">{c.city}</h3>
                <div className="city-stage">{c.heart}</div>
              </div>
            ))}
          </div>

          <div className="about-edu">
            {a.education.map((e, i) => (
              <div key={i} className="about-edu-row">
                <span className="deg">{e.degree}</span>
                <span className="school">{e.field} · {e.school}</span>
              </div>
            ))}
          </div>

          {/* Remaining paragraphs continue the bio below the journey. */}
          {a.paragraphs.length > 1 && (
            <div className="about-paras">
              {a.paragraphs.slice(1).map((p, i) => (
                <p key={i} dangerouslySetInnerHTML={{ __html: p }} />
              ))}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

/* ============================================================
   CaptionBlock — STRICT 3 lines, left-aligned, no borders inside
     line 1: title · titleSuffix [zh]
     line 2: blurb
     line 3: Visit URL ↗
   Hidden on About — About already shows full bio in its own view.
   ============================================================ */
/* WorkTitle is rendered in the bottom works-rail; there the kind word
   still sits inline in front of the work title. The caption-card uses
   a different layout (kind as its own line above the title). */
function WorkTitle({ work, className }) {
  return (
    <span className={className}>
      {work.kind && <span className="work-kind">{work.kind} · </span>}
      {work.title}
    </span>
  );
}

function CaptionBlock({ section, work }) {
  if (section === "about") return null;
  if (!work) return null;
  return (
    <div className="caption-card">
      {/* Attribute (kind) sits on its OWN line, small purple serif —
          same word as appears in front of the bottom-rail label. */}
      {work.kind && <div className="cap-kind-line">{work.kind}</div>}
      {/* Work name. Supports literal "\n" for two-line cards like
          "OtherWise\n他山之玉" — CSS handles the line break. */}
      <h2 className="cap-title-line">{work.title}</h2>
      {/* Blurb is HTML — Klein-blue <strong> emphasis matches the
          About body copy style. */}
      <div
        className="cap-blurb-line"
        dangerouslySetInnerHTML={{ __html: work.blurb }}
      />
      {!work.hideVisit && (
        <a
          className="cap-visit-line"
          href={work.primaryHref}
          target="_blank"
          rel="noopener noreferrer"
        >
          <span className="verb">Visit</span>
          <span className="url">{work.urlLabel}</span>
          <span className="arr">↗</span>
        </a>
      )}
    </div>
  );
}

/* ============================================================
   AvatarZone — top-left, hidden when section === "about"
   ============================================================ */
function AvatarZone({ onClick, hide }) {
  return (
    <div className={"avatar-zone" + (hide ? " hide" : "")} onClick={onClick} role="button" tabIndex={0}>
      <div className="avatar"><img src="assets/harry-portrait.jpg" alt="Harry Chen" /></div>
      <div className="avatar-meta">
        <div className="avatar-name">Harry Chen</div>
        <div className="avatar-sub"></div>
      </div>
      <div className="avatar-tooltip">Ask me anything →</div>
    </div>
  );
}

/* ============================================================
   TopRight — ops hint stacked above sections switcher
   ============================================================ */
function TopRight({ section, onChangeSection }) {
  const isAbout = section === "about";
  return (
    <div className="top-right">
      {/* Section pills FIRST (top) — the primary nav */}
      <div className="sections">
        {D.sections.map(s => (
          <div key={s.id}
               className={"section-link" + (s.id === section ? " active" : "")}
               onClick={() => onChangeSection(s.id)}>
            <span className="num">{s.num}</span>
            <span>{s.label}</span>
          </div>
        ))}
      </div>
      {/* Ops hint UNDERNEATH — secondary keyboard helper */}
      <div className="ops-hint">
        {!isAbout && (
          <>
            <span className="group">
              <span className="key">←</span><span className="key">→</span>
              <span>work</span>
            </span>
            <span className="div">·</span>
          </>
        )}
        <span className="group">
          <span className="key">↑</span><span className="key">↓</span>
          <span>section</span>
        </span>
      </div>
    </div>
  );
}

/* ============================================================
   WorksRail — left, vertical squares (hidden on About)
   ============================================================ */
function WorksRail({ works, currentIdx, onPick, hide }) {
  if (!works) return null;
  return (
    <div className={"works-rail" + (hide ? " hide" : "")}>
      {works.map((w, i) => (
        <div key={w.id}
             className={"work-dot" + (i === currentIdx ? " active" : "")}
             onClick={() => onPick(i)}>
          <span className="work-num">{w.num}</span>
          <div className="sq" />
          <span className="work-label"><WorkTitle work={w} /></span>
        </div>
      ))}
    </div>
  );
}

/* ============================================================
   CursorDot — custom Klein-blue square cursor
   ============================================================ */
function CursorDot() {
  const ref = useRef(null);
  useEffect(() => {
    const el = ref.current; if (!el) return;
    let raf = 0, tx = 0, ty = 0, x = 0, y = 0;
    const move = e => { tx = e.clientX; ty = e.clientY; };
    const tick = () => {
      x += (tx - x) * 0.25; y += (ty - y) * 0.25;
      // Triangle: anchor the tip near the cursor point (top-center
      // of the shape) instead of the geometric center, so the tip
      // feels like the pointer.
      el.style.transform = `translate(${x}px, ${y}px) translate(-50%, -10%)`;
      raf = requestAnimationFrame(tick);
    };
    const over = e => {
      const t = e.target; if (!t.closest) return;
      if (t.closest("input, textarea, iframe")) { el.classList.add("hidden"); return; }
      el.classList.remove("hidden");
      if (t.closest("a, button, .work-dot, .section-link, .avatar-zone, .chat-suggestion, .chat-send, .collage-cell, .preview-canvas"))
        el.classList.add("big");
      else el.classList.remove("big");
    };
    window.addEventListener("mousemove", move);
    window.addEventListener("mouseover", over);
    raf = requestAnimationFrame(tick);
    return () => { cancelAnimationFrame(raf);
      window.removeEventListener("mousemove", move);
      window.removeEventListener("mouseover", over);
    };
  }, []);
  return <div className="cursor-dot" ref={ref} />;
}

/* ============================================================
   ChatOverlay — bottom-of-page Q&A using genspark.complete
   ============================================================ */
function ChatOverlay({ open, onClose }) {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [busy, setBusy] = useState(false);
  const inputRef = useRef(null);
  const scrollRef = useRef(null);

  useEffect(() => {
    if (open) setTimeout(() => inputRef.current?.focus(), 320);
  }, [open]);

  useEffect(() => {
    if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
  }, [messages, busy]);

  const send = useCallback(async (text) => {
    const t = (text || input).trim();
    if (!t || busy) return;
    setInput("");
    const next = [...messages, { role: "user", body: t }];
    setMessages(next);
    setBusy(true);
    try {
      const chatMessages = [
        { role: "system", content: D.chatSystem },
        ...next.map(m => ({ role: m.role === "user" ? "user" : "assistant", content: m.body })),
      ];
      const reply = await window.genspark.complete({ messages: chatMessages });
      setMessages(m => [...m, { role: "assistant", body: reply }]);
    } catch (e) {
      setMessages(m => [...m, { role: "assistant", body: "Hmm, couldn't reach the model just now. Try again in a moment." }]);
    } finally { setBusy(false); }
  }, [input, busy, messages]);

  return (
    <div className={"chat-overlay" + (open ? " open" : "")} aria-hidden={!open}>
      <div className="chat-close" onClick={onClose}>
        <span>Close</span><span className="x">×</span>
      </div>
      <div className="chat-body">
        <div className={"chat-header" + (messages.length ? " dim" : "")}>
          <div className="avatar-lg"><img src="assets/harry-portrait.jpg" alt="" /></div>
          <div className="title">Ask me anything.</div>
          <div className="sub">A version of me, trained on what I've shared</div>
        </div>
        {messages.length === 0 && (
          <div className="chat-suggestions">
            {D.chatSuggestions.map((s, i) => (
              <button key={i} className="chat-suggestion" onClick={() => send(s)}>{s}</button>
            ))}
          </div>
        )}
        <div className="chat-messages" ref={scrollRef}>
          {messages.map((m, i) => (
            <div key={i} className={"msg " + m.role}>
              <div className="role">{m.role === "user" ? "You" : "Harry"}</div>
              <div className="body">{m.body}</div>
            </div>
          ))}
          {busy && (
            <div className="msg assistant">
              <div className="role">Harry</div>
              <div className="body" style={{ color: "var(--ink-4)" }}>typing…</div>
            </div>
          )}
        </div>
        <div className="chat-input-wrap">
          <input
            ref={inputRef}
            className="chat-input"
            placeholder="Type a question — Chinese or English"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Enter") send(); }}
            disabled={busy}
          />
          <button className="chat-send" disabled={busy || !input.trim()} onClick={() => send()}>↵</button>
        </div>
      </div>
    </div>
  );
}

/* ============================================================
   App — top-level orchestrator
   ============================================================ */
function App() {
  const loadState = () => { try { return JSON.parse(localStorage.getItem("harry-site-state") || "{}"); } catch { return {}; } };
  const initial = loadState();
  const [section, setSection] = useState(initial.section || "about");
  const [workIdx, setWorkIdx] = useState(typeof initial.workIdx === "number" ? initial.workIdx : 0);
  const [chatOpen, setChatOpen] = useState(false);
  const [splashDone, setSplashDone] = useState(() => {
    try { return sessionStorage.getItem("harry-splash-seen") === "1"; } catch { return false; }
  });

  useEffect(() => {
    localStorage.setItem("harry-site-state", JSON.stringify({ section, workIdx }));
  }, [section, workIdx]);

  // Expose chat opener for the About caption's "Ask me anything"
  useEffect(() => {
    window.__openHarryChat = () => setChatOpen(true);
    return () => { delete window.__openHarryChat; };
  }, []);

  const currentWorks = D.works[section] || null;
  const safeIdx = currentWorks ? Math.min(workIdx, currentWorks.length - 1) : 0;
  const currentWork = currentWorks ? currentWorks[safeIdx] : null;
  const isAbout = section === "about";

  const changeSection = useCallback((id) => {
    if (id === section) return;
    setSection(id);
    setWorkIdx(0);
  }, [section]);

  const changeWork = useCallback((newIdx) => {
    if (!currentWorks) return;
    setWorkIdx(Math.max(0, Math.min(currentWorks.length - 1, newIdx)));
  }, [currentWorks]);

  /* Keyboard */
  useEffect(() => {
    const onKey = (e) => {
      const tag = (e.target.tagName || "").toLowerCase();
      if (tag === "input" || tag === "textarea") {
        if (e.key === "Escape" && chatOpen) setChatOpen(false);
        return;
      }
      if (chatOpen) {
        if (e.key === "Escape") setChatOpen(false);
        return;
      }
      // ← → switch works inside the current section
      if (e.key === "ArrowRight") {
        if (currentWorks) changeWork(workIdx + 1);
      } else if (e.key === "ArrowLeft") {
        if (currentWorks) changeWork(workIdx - 1);
      // ↑ ↓ (and Tab) switch sections
      } else if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) {
        e.preventDefault();
        const i = D.sections.findIndex(s => s.id === section);
        const next = D.sections[(i + 1) % D.sections.length];
        changeSection(next.id);
      } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) {
        e.preventDefault();
        const i = D.sections.findIndex(s => s.id === section);
        const next = D.sections[(i - 1 + D.sections.length) % D.sections.length];
        changeSection(next.id);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [chatOpen, currentWorks, workIdx, section, changeWork, changeSection]);

  return (
    <div className="app">
      {!splashDone && (
        <Splash onDone={() => {
          try { sessionStorage.setItem("harry-splash-seen", "1"); } catch {}
          setSplashDone(true);
        }} />
      )}

      <CursorDot />

      <div className={"stage" + (isAbout ? " no-caption" : "")}>
        {isAbout ? (
          <AboutSlide active onChangeSection={changeSection} />
        ) : (
          currentWorks && currentWorks.map((w, i) => (
            <WorkSlide key={`${section}-${w.id}`} work={w} active={i === safeIdx} />
          ))
        )}
      </div>

      {/* Floating chrome */}
      <AvatarZone onClick={() => setChatOpen(true)} hide={isAbout} />
      <TopRight section={section} onChangeSection={changeSection} />
      <WorksRail works={currentWorks} currentIdx={safeIdx} onPick={changeWork} hide={isAbout} />

      {/* Bottom caption block */}
      <CaptionBlock section={section} work={currentWork} />

      <ChatOverlay open={chatOpen} onClose={() => setChatOpen(false)} />
    </div>
  );
}

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