// Main App — RalphX marketing site.
//
// CONCEPT: One macOS window pins to the viewport. As you scroll, the same app
// COMPOSES in front of you — chrome lands, sidebar fills, content threads in,
// right panel slides over, view switches to a different tab. Each scroll
// "beat" reveals one more layer of the real product.
//
// Narrative copy floats on the side and crossfades per beat, with a soft
// backdrop so text reads cleanly over the screenshot.

const { useState, useEffect, useMemo, createContext } = React;

// =========================================================================
// BEATS — each beat is one scroll viewport; together they compose the UI.
// `state` flags drive what's visible/animated at that moment.
// =========================================================================
// `breadcrumb` mirrors the v27 chrome path; `rail` highlights the matching
// icon in the left rail. Both stay constant during the morph between beats —
// they snap to the destination value at the slot boundary.
const BEATS = [
  {
    id: "launcher",
    step: "01", eyebrow: "Start a new run", side: "left",
    title: <>Tell the agent what to <span className="accent">build, fix or ship.</span></>,
    blurb: "Pick a preset, pick a project, pick a runtime. Brief the agent the way you'd brief a teammate, flip Open PR when done, and let it cook.",
    state: { breadcrumb: ["Workspace", "Agents", "New run"], rail: "agents", showSidebar: true, view: "launcher", showRightPanel: false, threadProgress: 1, dim: 0.5 },
  },
  {
    id: "sidebar",
    step: "02", eyebrow: "Persistent conversations", side: "left",
    title: <>Every task lives in <span className="accent">its own thread.</span></>,
    blurb: "Projects, agents and history sit side-by-side. Come back days later and pick up exactly where each agent left off — same context, same shell, same scrollback.",
    state: { breadcrumb: ["Workspace", "Agents", "Add execution bar"], rail: "agents", showSidebar: true, view: "agents", showRightPanel: false, threadProgress: 0, dim: 0.55 },
  },
  {
    id: "agents",
    step: "03", eyebrow: "Plan, verify, commit", side: "right",
    title: <>One thread. <span className="accent">From idea to draft PR.</span></>,
    blurb: "Watch the agent move through Plan → Verification → Proposals → Tasks → Commit & Publish. The whole loop lives in a single conversation you can scroll back through.",
    state: { breadcrumb: ["Workspace", "Agents", "Add execution bar"], rail: "agents", showSidebar: true, view: "agents", showRightPanel: false, threadProgress: 1, dim: 0.5 },
  },
  {
    id: "kanban",
    step: "04", eyebrow: "Orchestrate the swarm", side: "left",
    title: <>A board for the <span className="accent">whole agent fleet.</span></>,
    blurb: "Draft → Ready → In progress → In review → Done. Watch tasks flow across columns as agents pick them up, escalate, and ship. Pause the swarm whenever.",
    state: { breadcrumb: ["Workspace", "reefagent-mcp-content", "Tasks"], rail: "kanban", showSidebar: false, view: "kanban", showRightPanel: false, threadProgress: 1, dim: 0.5 },
  },
  {
    id: "terminal",
    step: "05", eyebrow: "Live, isolated workspaces", side: "right",
    title: <>Every agent gets <span className="accent">its own machine.</span></>,
    blurb: "Branch, worktree, sandboxed shell. The terminal slides up so you can watch any run, copy any command, or jump in and pair with the agent.",
    state: { breadcrumb: ["Workspace", "Agents", "Update Codex models"], rail: "agents", showSidebar: true, view: "terminal", showRightPanel: false, threadProgress: 1, dim: 0.5 },
  },
  {
    id: "commit",
    step: "06", eyebrow: "Review & ship", side: "right",
    title: <>Every run ends in a <span className="accent">draft PR.</span></>,
    blurb: "When the agent's done, RalphX commits its workspace, opens a draft PR on GitHub, and lays the diff out for review. You stay in control of the merge.",
    state: { breadcrumb: ["Workspace", "Agents", "Replace chat focus pills"], rail: "agents", showSidebar: true, view: "artifacts", showRightPanel: true, threadProgress: 1, dim: 0.5 },
  },
  {
    id: "settings",
    step: "07", eyebrow: "Tune the swarm", side: "left",
    title: <>Pick the model. <span className="accent">Lock the lane.</span></>,
    blurb: "Route ideation to Codex or Claude per lane. Set effort, approval, sandbox per agent role. RalphX MCP keeps Codex on the leash; everything else is yours.",
    state: { breadcrumb: ["Workspace", "Kanban"], rail: "kanban", showSidebar: false, view: "kanban", showRightPanel: false, showSettingsModal: true, threadProgress: 1, dim: 0.5 },
  },
];

// =========================================================================
// useNarrativeState — single rAF loop computes activeBeat + per-beat progress.
// =========================================================================
// Apple-style ease-in-out (cubic) — used for state blends.
const easeInOut = (x) => x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
// Decelerate-only — used for scroll motion so the page glides into the
// destination instead of mechanically symmetrically easing both ends.
const easeOutCubic = (x) => 1 - Math.pow(1 - x, 3);

// Two-step beat model.
//   morph (0..MORPH_RATIO):  UI animates from previous beat's state to current.
//                            No text visible.
//   stable (MORPH_RATIO..1): UI settled at current beat. Text fades in/out.
// Per-beat slot is one viewport tall.
const MORPH_RATIO = 0.40;
// Where dot-clicks and snaps land — center of the readable phase.
const BEAT_READ_LOCAL = 0.70;
// Text fade window inside the stable phase.
const TEXT_FADE_IN_END = MORPH_RATIO + 0.10;  // 0.50
const TEXT_FADE_OUT_START = 0.92;
const TEXT_REVEAL_DELAY_MS = 3000;
const TEXT_DELAY_FADE_MS = 450;

const getNow = () =>
  (typeof performance !== "undefined" && performance.now)
    ? performance.now()
    : Date.now();

// Returns the scrollY for a given beat index — see the new getBeatScrollY
// defined alongside useStageState below (it targets the unified stage).
//
// (Old narrative-bbox-based version removed.)

// useStageState — scroll progress for the unified hero+narrative stage.
//
// Layout: <section #stage> contains a sticky <div .stage-pin> (height 100vh)
// followed by a <div #narrative .narrative-spacer> (height N*100vh).
// The pin engages once we scroll past stage.offsetTop and disengages after
// scrolling N*100vh further.
//
// Returns:
//   heroProgress: 0..1 over the FIRST viewport-height (the "intro" region).
//                 Drives window scale/translate from hero size → pin size,
//                 plus the fade-out of hero copy.
//   activeIdx, beatProgress, blendT: computed off the narrative range that
//                 begins right after the intro region.
function useStageState(beatCount) {
  const [s, setS] = useState({
    activeIdx: 0, beatProgress: 0, sectionProgress: 0, blendT: 0, heroProgress: 0, vh: 800,
  });
  useEffect(() => {
    let raf = 0;
    const tick = () => {
      raf = 0;
      const stage = document.getElementById("stage");
      if (!stage) return;
      const vh = window.innerHeight;

      // Hero progress: 0 at top of page, 1 once we've scrolled past the intro
      // region (1 vh worth from the start of the stage).
      const stageTop = stage.offsetTop;
      const heroDistance = stage.offsetHeight - beatCount * vh - vh; // typically vh
      const heroSpan = vh; // hero-to-pin morph happens over the first viewport.
      const heroProgress = Math.max(0, Math.min(1, (window.scrollY - stageTop) / heroSpan));

      // Narrative t=0 begins after we've scrolled hero region (one vh past stage top),
      // t=1 ends when pin disengages (N*vh past hero end).
      const narrativeStart = stageTop + vh;
      const narrativeTotal = beatCount * vh;
      const t = Math.max(0, Math.min(1, (window.scrollY - narrativeStart) / narrativeTotal));

      const exact = t * beatCount;
      const idx = Math.min(beatCount - 1, Math.floor(exact));
      const local = exact - idx;
      // blendT: 0..1 across the morph phase (0..MORPH_RATIO), then held at 1
      // throughout the stable phase. First slot has no previous beat to morph
      // from, so it's always fully settled at BEATS[0].
      const blendT = idx === 0
        ? 1
        : (local < MORPH_RATIO ? easeInOut(local / MORPH_RATIO) : 1);

      setS({ activeIdx: idx, beatProgress: local, sectionProgress: t, blendT, heroProgress, vh });
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(tick); };
    tick();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [beatCount]);
  return s;
}

// getBeatScrollY — absolute document Y for beat `i`'s reading sweet-spot
// (inside the stable phase, where text is fully visible). Dot clicks and
// snap-to-rest both target this position.
function getBeatScrollY(i, beatCount) {
  const stage = document.getElementById("stage");
  if (!stage) return 0;
  const vh = window.innerHeight;
  const narrativeStart = stage.offsetTop + vh; // skip hero region
  return narrativeStart + ((i + BEAT_READ_LOCAL) / beatCount) * (beatCount * vh);
}

// Reduced-motion respected: when the user prefers reduced motion we jump
// instead of easing, and the snap-on-stop hook becomes a no-op.
const prefersReducedMotion = () =>
  typeof window !== "undefined" &&
  window.matchMedia &&
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// Smooth-scroll to a target Y over a duration with ease-in-out.
// Used by the dot-nav and the snap-after-pause helper.
// Sets window.__rxSmoothScrolling while running so the snap hook can yield.
function smoothScrollTo(targetY, duration = 700) {
  if (prefersReducedMotion()) {
    window.scrollTo(0, targetY);
    return;
  }
  const startY = window.scrollY;
  const dy = targetY - startY;
  if (Math.abs(dy) < 1) return;
  const t0 = performance.now();
  let cancelled = false;
  window.__rxSmoothScrolling = true;
  const onUserScroll = () => { cancelled = true; };
  // Cancel only on real user input (wheel/touch/keys) — not on the scrolls
  // we ourselves drive via window.scrollTo below.
  window.addEventListener("wheel", onUserScroll, { once: true, passive: true });
  window.addEventListener("touchmove", onUserScroll, { once: true, passive: true });
  window.addEventListener("keydown", onUserScroll, { once: true });
  const cleanup = () => {
    window.removeEventListener("wheel", onUserScroll);
    window.removeEventListener("touchmove", onUserScroll);
    window.removeEventListener("keydown", onUserScroll);
    window.__rxSmoothScrolling = false;
  };
  const step = (now) => {
    if (cancelled) { cleanup(); return; }
    const k = Math.min(1, (now - t0) / duration);
    const y = startY + dy * easeOutCubic(k);
    window.scrollTo(0, y);
    if (k < 1) requestAnimationFrame(step);
    else cleanup();
  };
  requestAnimationFrame(step);
}

// After scroll-stop, snap to the nearest reading position — but ONLY when the
// user has settled inside the morph zone (first MORPH_RATIO of a slot). If
// they've stopped inside the stable zone (text visible, scene settled), leave
// them alone.
function useScrollSnap(beatCount) {
  useEffect(() => {
    if (prefersReducedMotion()) return;
    let stopT;
    const onScroll = () => {
      if (window.__rxSmoothScrolling) return;
      clearTimeout(stopT);
      stopT = setTimeout(() => {
        if (window.__rxSmoothScrolling) return;
        const stage = document.getElementById("stage");
        if (!stage) return;
        const vh = window.innerHeight;
        const stageTop = stage.offsetTop;
        const narrativeStart = stageTop + vh;
        const narrativeEnd = stageTop + stage.offsetHeight - vh;
        const y = window.scrollY;
        if (y < narrativeStart - 4 || y > narrativeEnd + 4) return;

        const t = (y - narrativeStart) / (narrativeEnd - narrativeStart);
        const exactGlobal = t * beatCount;
        const beatIdx = Math.min(beatCount - 1, Math.floor(exactGlobal));
        const local = exactGlobal - beatIdx;

        // Stable zone — user is reading, leave alone.
        const STABLE_FUZZ = 0.04;
        if (local >= MORPH_RATIO + STABLE_FUZZ) return;

        // Morph zone — pick the nearer reading position (previous slot's or
        // this slot's). Halfway through the morph zone is the tipping point.
        const halfMorph = MORPH_RATIO / 2;
        const targetIdx = local < halfMorph
          ? Math.max(0, beatIdx - 1)
          : beatIdx;
        const target = getBeatScrollY(targetIdx, beatCount);
        const px = Math.abs(target - y);
        if (px < vh * 0.04) return;
        const duration = Math.max(420, Math.min(900, 420 + px * 0.55));
        smoothScrollTo(target, duration);
      }, 350);
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      window.removeEventListener("scroll", onScroll);
      clearTimeout(stopT);
    };
  }, [beatCount]);
}

// Compose state — each beat slot animates FROM the previous beat's reveal
// state TO the current beat's. View, breadcrumb, and rail snap to the
// destination on slot boundary; what blends across the morph phase is
// sidebar/right-panel reveal, thread progress, and dim. blendT comes in
// pre-shaped: 0..1 across the morph phase, held at 1 throughout the stable.
function useComposeState(activeIdx, beatProgress, blendT) {
  const fromIdx = Math.max(0, activeIdx - 1);
  const from = BEATS[fromIdx].state;
  const to = BEATS[activeIdx].state;
  const t = blendT;
  const fromSb = from.showSidebar ? 1 : 0;
  const toSb = to.showSidebar ? 1 : 0;
  const fromRp = from.showRightPanel ? 1 : 0;
  const toRp = to.showRightPanel ? 1 : 0;
  const fromSm = from.showSettingsModal ? 1 : 0;
  const toSm = to.showSettingsModal ? 1 : 0;
  return {
    breadcrumb: to.breadcrumb,
    rail: to.rail,
    showSidebar: from.showSidebar || to.showSidebar,
    sidebarReveal: fromSb + (toSb - fromSb) * t,
    view: to.view,
    showRightPanel: from.showRightPanel || to.showRightPanel,
    rightPanelReveal: fromRp + (toRp - fromRp) * t,
    showSettingsModal: from.showSettingsModal || to.showSettingsModal,
    settingsModalReveal: fromSm + (toSm - fromSm) * t,
    threadProgress: from.threadProgress + (to.threadProgress - from.threadProgress) * t,
    dim: from.dim + (to.dim - from.dim) * t,
  };
}

// =========================================================================
// MorphingWindow — the pinned macOS window. Composes layers based on state.
// =========================================================================
function MorphingWindow({ activeIdx, beatProgress, blendT }) {
  const state = useComposeState(activeIdx, beatProgress, blendT);
  const view = state.view;
  const tasks = TASKS_BY_SCENE[view] || TASKS_BY_SCENE.agents;
  const activeTaskId = tasks.find((t) => t.status === "active")?.id;

  // Pick the body component for the active view.
  const Body = ({ launcher: SceneLauncher, agents: SceneAgents, kanban: SceneKanban, terminal: SceneTerminal, artifacts: SceneArtifacts }[view]) || SceneAgents;

  return (
    <ComposeCtx.Provider value={{ ...state, beatProgress }}>
      <div className="macwin">
        <WindowChrome breadcrumb={state.breadcrumb} />
        <div
          className={`ws-body ${state.showRightPanel ? "has-rightpanel" : ""}`}
          style={{
            // Bind grid columns to the reveal values so hidden panels don't
            // reserve grid space (which would offset the main pane sideways).
            // Rail (48px) is always visible; sidebar (280px) reveals from beat 02.
            gridTemplateColumns: state.showRightPanel
              ? `48px ${state.sidebarReveal * 280}px 1fr ${state.rightPanelReveal * 320}px`
              : `48px ${state.sidebarReveal * 280}px 1fr`,
          }}
        >
          {/* Icon rail — always visible. */}
          <IconRail activeId={state.rail} />

          {/* Projects panel — slides in from beat 02. */}
          <div className="ws-sidebar-wrap" style={{
            opacity: state.sidebarReveal,
            transform: `translate3d(${(1 - state.sidebarReveal) * -40}px, 0, 0)`,
            filter: `blur(${(1 - state.sidebarReveal) * 8}px)`,
            pointerEvents: state.sidebarReveal > 0.5 ? "auto" : "none",
            overflow: "hidden",
          }}>
            <TaskSidebar tasks={tasks} activeId={activeTaskId} />
          </div>

          {/* Main pane — keyed on view so internal animations reset cleanly. */}
          <Body key={view} />

          {/* Right panel slides in for the commit beat. */}
          {state.showRightPanel && (
            <div className="ws-rightpanel" style={{
              opacity: state.rightPanelReveal,
              transform: `translate3d(${(1 - state.rightPanelReveal) * 40}px, 0, 0)`,
              filter: `blur(${(1 - state.rightPanelReveal) * 8}px)`,
            }}>
              <SceneArtifactsRight />
            </div>
          )}
        </div>
        {view !== 'launcher' && view !== 'terminal' && view !== 'kanban' && view !== 'settings' && <Composer />}

        {/* Settings modal — overlays the body (kanban) for beat 07. */}
        {state.showSettingsModal && (
          <SettingsModal reveal={state.settingsModalReveal} />
        )}
      </div>
    </ComposeCtx.Provider>
  );
}

// Compose context — scenes read this to drive their internal animations.
const ComposeCtx = createContext({ threadProgress: 0, beatProgress: 0 });
// Bridge: scenes that use useStage() get our compose state.
window.useStage = () => {
  const c = React.useContext(ComposeCtx);
  return { progress: c.threadProgress, active: true, range };
};

// =========================================================================
// Scene copy overlay — fixed to viewport, with backdrop for legibility.
// =========================================================================
function SceneCopyOverlay({ activeIdx, beatProgress, overlayReady }) {
  const beat = BEATS[activeIdx];
  const copyReady = overlayReady && beatProgress >= MORPH_RATIO;
  const [delayClock, setDelayClock] = useState(() => ({ startedAt: null, now: getNow() }));

  useEffect(() => {
    if (!copyReady) {
      setDelayClock({ startedAt: null, now: getNow() });
      return;
    }

    const startedAt = getNow();
    let raf = 0;
    let timeout = 0;

    const update = () => {
      const now = getNow();
      setDelayClock({ startedAt, now });
      if (now - startedAt < TEXT_REVEAL_DELAY_MS + TEXT_DELAY_FADE_MS) {
        raf = requestAnimationFrame(update);
      }
    };

    setDelayClock({ startedAt, now: startedAt });

    if (prefersReducedMotion()) {
      timeout = window.setTimeout(() => {
        setDelayClock({ startedAt, now: getNow() });
      }, TEXT_REVEAL_DELAY_MS);
    } else {
      raf = requestAnimationFrame(update);
    }

    return () => {
      if (raf) cancelAnimationFrame(raf);
      if (timeout) window.clearTimeout(timeout);
    };
  }, [activeIdx, copyReady]);

  // Two-step beat: text only appears in the stable phase (after the morph).
  //   fade-in:  MORPH_RATIO .. TEXT_FADE_IN_END   (≈0.40 → 0.50)
  //   hold:     TEXT_FADE_IN_END .. TEXT_FADE_OUT_START
  //   fade-out: TEXT_FADE_OUT_START .. 1.0        (≈0.92 → 1.00)
  const fadeIn = Math.max(0, Math.min(1,
    (beatProgress - MORPH_RATIO) / (TEXT_FADE_IN_END - MORPH_RATIO)
  ));
  const fadeOut = Math.max(0, Math.min(1,
    (1 - beatProgress) / (1 - TEXT_FADE_OUT_START)
  ));
  const delayElapsed = delayClock.startedAt == null ? 0 : delayClock.now - delayClock.startedAt;
  const delayProgress = prefersReducedMotion()
    ? (delayElapsed >= TEXT_REVEAL_DELAY_MS ? 1 : 0)
    : easeOutCubic(Math.max(0, Math.min(1,
      (delayElapsed - TEXT_REVEAL_DELAY_MS) / TEXT_DELAY_FADE_MS
    )));
  const reveal = Math.min(fadeIn, delayProgress);
  const opacity = Math.min(reveal, fadeOut);
  const y = (1 - reveal) * 26 - (1 - fadeOut) * 14;
  const blur = Math.max((1 - reveal) * 6, (1 - fadeOut) * 4);

  return (
    <div className={`scene-copy-overlay copy-${beat.side}`} aria-live={opacity > 0.01 ? "polite" : "off"}>
      {/* Backdrop wash so copy is legible over the window screenshot */}
      <div className={`scene-copy-backdrop backdrop-${beat.side}`} style={{ opacity: opacity * 0.95 }} />
      <div className={`scene-copy ${beat.side}`}
           aria-hidden={opacity <= 0.01}
           style={{ opacity, transform: `translate3d(0, ${y}px, 0)`, filter: `blur(${blur}px)` }}>
        <div className="scene-eyebrow"><span className="step">{beat.step}</span> {beat.eyebrow}</div>
        <h2 className="scene-title">{beat.title}</h2>
        <p className="scene-blurb">{beat.blurb}</p>
      </div>
    </div>
  );
}

// =========================================================================
// App
// =========================================================================
// Pulls the latest release from the public GitHub API and exposes
// per-arch .dmg download URLs. Falls back to the releases page if the
// fetch fails (rate limit, offline, no release yet) or an arch-specific
// asset isn't found.
const RELEASES_URL = "https://github.com/aigentive/ralphx.app/releases";
function useLatestRelease() {
  const [release, setRelease] = useState(null);
  useEffect(() => {
    let cancelled = false;
    fetch("https://api.github.com/repos/aigentive/ralphx.app/releases/latest")
      .then((r) => (r.ok ? r.json() : null))
      .then((data) => { if (!cancelled) setRelease(data); })
      .catch(() => { if (!cancelled) setRelease(null); });
    return () => { cancelled = true; };
  }, []);
  const findAsset = (matchers) => {
    if (!release?.assets) return null;
    return release.assets.find((a) => {
      const n = (a.name || "").toLowerCase();
      return n.endsWith(".dmg") && matchers.some((m) => n.includes(m));
    });
  };
  const arm64 = findAsset(["arm64", "aarch64", "apple-silicon", "applesilicon"]);
  const x64 = findAsset(["x64", "x86_64", "intel"]);
  return {
    version: release?.tag_name || null,
    arm64Url: arm64?.browser_download_url || RELEASES_URL,
    x64Url: x64?.browser_download_url || RELEASES_URL,
  };
}

function App() {
  const { activeIdx, beatProgress, blendT, heroProgress, vh } = useStageState(BEATS.length);
  useScrollSnap(BEATS.length);
  const { arm64Url, x64Url, version } = useLatestRelease();

  const goToBeat = (i) => {
    const target = getBeatScrollY(i, BEATS.length);
    const px = Math.abs(target - window.scrollY);
    // Scale duration with distance so far-away dots glide instead of whipping.
    const duration = Math.max(520, Math.min(900, 520 + px * 0.45));
    smoothScrollTo(target, duration);
  };

  // Keyboard nav — Arrow Up/Down + PageUp/PageDown step between beats while
  // the stage is engaged. Bails out for inputs and when scroll is outside
  // the stage range so the rest of the page still scrolls naturally.
  useEffect(() => {
    const onKey = (e) => {
      if (e.target.matches?.("input, textarea, select, [contenteditable=true]")) return;
      const stage = document.getElementById("stage");
      if (!stage) return;
      const stageTop = stage.offsetTop;
      const stageBottom = stageTop + stage.offsetHeight;
      const y = window.scrollY;
      // Only hijack while we're inside (or just entering) the stage.
      if (y < stageTop - 4 || y > stageBottom - vh * 0.5) return;
      let dir = 0;
      if (e.key === "ArrowDown" || e.key === "PageDown") dir = 1;
      else if (e.key === "ArrowUp" || e.key === "PageUp") dir = -1;
      if (!dir) return;
      const next = activeIdx + dir;
      if (next < 0 || next > BEATS.length - 1) return;
      e.preventDefault();
      goToBeat(next);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [activeIdx, vh]);

  // Hero copy fade: starts fully visible, fades out by the time we're 60%
  // into the intro region (so the window has center stage before beat 1).
  const heroCopyOpacity = Math.max(0, 1 - heroProgress / 0.55);
  const heroCopyY = heroProgress * -40;

  // Window morph — at heroProgress=0 the window sits close under the hero
  // copy and is scaled UP slightly so the product is the dominant visual.
  // As you scroll, it slides up to "pin size" centered for the narrative
  // beats, shrinking to default scale.
  //
  // Lift is computed in PIXELS (not vh) so the gap between hero copy bottom
  // and window top stays visually consistent across viewport sizes. On short
  // viewports a vh-based lift produced near-overlap; on tall viewports it
  // produced an excessive gap.
  const winScale = 1.06 - 0.06 * heroProgress;     // 1.06 → 1.00 (big → default)
  const HERO_COPY_PAD_VH = 0.06;                   // .hero-copy padding-top
  const HERO_COPY_CONTENT_PX = 320;                // approx logo+title+lede+actions+meta+gaps
  const TARGET_GAP_PX = 90;                        // pixel gap between hero copy bottom and window top at hero
  const winNaturalH = Math.min(720, vh - 120);
  const winScaledH = winNaturalH * winScale;
  const winCenteredTopPx = (vh - winScaledH) / 2;  // where window top sits when centered (no lift) at hero scale
  const heroCopyBottomPx = vh * HERO_COPY_PAD_VH + HERO_COPY_CONTENT_PX;
  const heroLiftPx = Math.max(0, heroCopyBottomPx + TARGET_GAP_PX - winCenteredTopPx);
  const winLiftPx = (1 - heroProgress) * heroLiftPx;
  const winShadowOpacity = 0.55 - 0.15 * heroProgress;
  const sceneCopyOpacity = Math.max(0, (heroProgress - 0.5) / 0.4);
  const sceneCopyReady = heroProgress > 0.6;

  return (
    <div className="page">
      <div className="ambient-glow" aria-hidden="true" />

      <nav className="top-nav">
        <a href="#" className="brand"><img src="logo.png" alt="" />RalphX</a>
        <div className="top-nav-actions">
          {/* Sticky download — fades in once the hero CTA scrolls out so the user
              never loses a path to convert. */}
          <a
            className="nav-btn nav-btn-white nav-btn-compact"
            href={arm64Url}
            rel="noopener"
            aria-hidden={heroProgress < 0.6}
            tabIndex={heroProgress < 0.6 ? -1 : 0}
            style={{
              opacity: Math.max(0, (heroProgress - 0.6) / 0.25),
              pointerEvents: heroProgress > 0.7 ? "auto" : "none",
              transform: `translate3d(0, ${(1 - Math.min(1, Math.max(0, (heroProgress - 0.6) / 0.25))) * -6}px, 0)`,
            }}
          >
            <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M16.4 1.5c.1 1.1-.3 2.2-1 3-.7.8-1.8 1.4-2.9 1.3-.1-1.1.4-2.2 1.1-3 .7-.8 1.8-1.3 2.8-1.3zM20.5 17.6c-.5 1.2-.8 1.7-1.5 2.8-1 1.5-2.4 3.4-4.1 3.4-1.5 0-1.9-1-4-1-2 0-2.5 1-4 1-1.7 0-3-1.7-4-3.2-2.9-4.3-3.2-9.4-1.4-12.1 1.3-1.9 3.3-3 5.2-3 2 0 3.2 1.1 4.8 1.1 1.5 0 2.5-1.1 4.7-1.1 1.7 0 3.6.9 4.9 2.5-4.3 2.4-3.6 8.6-.6 9.6z" /></svg>
            Download
          </a>
          <a className="nav-btn nav-btn-ghost" href="https://github.com/aigentive/ralphx.app" rel="noopener">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.6.5.5 5.6.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.4-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.9.1 3.2.7.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6 4.6-1.5 7.9-5.9 7.9-10.9C23.5 5.6 18.4.5 12 .5z" /></svg>
            GitHub
          </a>
        </div>
      </nav>

      {/* Right-side scene dots — clickable, scroll-anchored to each beat.
          Hidden during hero (heroProgress < 0.5) so they don't compete. */}
      <div
        className="scene-dots"
        role="tablist"
        aria-label="Scene navigation"
        style={{
          opacity: Math.max(0, (heroProgress - 0.4) / 0.4),
          pointerEvents: heroProgress > 0.5 ? "auto" : "none",
        }}
      >
        {BEATS.map((b, i) => (
          <button
            key={b.id}
            type="button"
            role="tab"
            aria-selected={i === activeIdx}
            aria-label={`${b.step} — ${b.eyebrow}`}
            className={`dot ${i === activeIdx ? "is-active" : ""}`}
            onClick={() => goToBeat(i)}
          >
            <span className="dot-label" aria-hidden="true">
              <span className="dot-label-step">{b.step}</span>
              {b.eyebrow}
            </span>
          </button>
        ))}
      </div>

      {/* STAGE — unified hero + narrative. The macOS window is sticky here for
          the entire range; its size and position are interpolated by scroll
          (large + lifted in hero, smaller + centered during beats).
          Height = hero (1vh) + beats (Nvh) + outro (1vh). The trailing vh is
          required so the sticky pin (range = stageHeight - 100vh) stays
          engaged through the LAST beat's reading position; without it, beat N
          falls past the pin's disengage point and the window unsticks. */}
      <section id="stage" className="stage" style={{ height: `${(2 + BEATS.length) * 100}vh` }}>
        {/* Sticky pin holds the window AND the scene copy overlay so they
            track together through the entire stage. */}
        <div className="stage-pin">
          {/* Hero copy — overlaid on the first viewport, fades as scroll begins. */}
          <div
            className="hero-copy"
            style={{
              opacity: heroCopyOpacity,
              transform: `translate3d(0, ${heroCopyY}px, 0)`,
              pointerEvents: heroCopyOpacity > 0.1 ? "auto" : "none",
            }}
          >
            <img src="logo.png" alt="" className="hero-logo" width="56" height="56" />
            <h1 className="hero-title">The best way to <span className="accent">ship software with AI.</span></h1>
            <p className="hero-lede">A native Mac desktop for AI dev work. Spawn agents in their own workspaces, orchestrate them across a Kanban board, and review draft PRs as they land.</p>
            <div className="hero-actions">
              <a className="nav-btn nav-btn-white download-btn" href={arm64Url} rel="noopener">
                <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M16.4 1.5c.1 1.1-.3 2.2-1 3-.7.8-1.8 1.4-2.9 1.3-.1-1.1.4-2.2 1.1-3 .7-.8 1.8-1.3 2.8-1.3zM20.5 17.6c-.5 1.2-.8 1.7-1.5 2.8-1 1.5-2.4 3.4-4.1 3.4-1.5 0-1.9-1-4-1-2 0-2.5 1-4 1-1.7 0-3-1.7-4-3.2-2.9-4.3-3.2-9.4-1.4-12.1 1.3-1.9 3.3-3 5.2-3 2 0 3.2 1.1 4.8 1.1 1.5 0 2.5-1.1 4.7-1.1 1.7 0 3.6.9 4.9 2.5-4.3 2.4-3.6 8.6-.6 9.6z" /></svg>
                <span className="download-btn-main">
                  <span className="download-btn-label">Download for Mac</span>
                  <span className="download-btn-meta">Apple Silicon · .dmg</span>
                </span>
              </a>
              <a className="hero-arch-alt" href={x64Url} rel="noopener">On Intel? Get the .dmg →</a>
            </div>
            <div className="hero-release-meta">
              {version && <><a href={RELEASES_URL} rel="noopener">{version}</a> · </>}
              <a href="https://github.com/aigentive/ralphx.app" rel="noopener">Open source · Apache 2</a>
            </div>
          </div>

          {/* The macOS window — sits low (peeking from bottom) during hero,
              rises into centered pin position as you scroll. */}
          <div
            className="pinned-window-frame"
            style={{
              transform: `translate3d(0, ${winLiftPx}px, 0) scale(${winScale})`,
              boxShadow: `0 ${30 + 20 * heroProgress}px ${80 + 40 * heroProgress}px hsla(0 0% 0% / ${winShadowOpacity})`,
            }}
          >
            <MorphingWindow activeIdx={activeIdx} beatProgress={beatProgress} blendT={blendT} />
          </div>

          {/* Scene-copy overlay — sits on top during narrative beats only.
              Faded out during the hero range so the hero copy reads cleanly. */}
          <div style={{
            opacity: sceneCopyOpacity,
            pointerEvents: sceneCopyReady ? "auto" : "none",
            position: "absolute", inset: 0,
          }}>
            <SceneCopyOverlay activeIdx={activeIdx} beatProgress={beatProgress} overlayReady={sceneCopyReady} />
          </div>

          {/* Scroll hint — tells users what's coming, not just to scroll. */}
          <div
            className="scroll-hint"
            style={{ opacity: Math.max(0, 1 - heroProgress * 4) }}
            aria-hidden="true"
          >
            <span className="scroll-hint-step">{BEATS[0].step}</span>
            <span>{BEATS[0].eyebrow}</span>
          </div>
        </div>

        {/* Narrative spacer region — drives beat scroll progress. The
            sticky pin above stays glued to top:0 throughout this.
            Height = beats (Nvh) + outro (1vh). The trailing vh keeps the pin
            stuck through the last beat's reading position. */}
        <div id="narrative" className="narrative-spacer" style={{ height: `${(1 + BEATS.length) * 100}vh` }} />
      </section>

      {/* CTA */}
      <div className="cta-wrap">
        <div className="cta-content">
          <h2>Open source. <span className="accent">Yours to fork.</span></h2>
          <p>RalphX ships under Apache 2. Run it on your Mac, fork it, point it at any provider — Codex, Claude, GPT, your own. The desktop app is yours.</p>
          <div className="cta-actions">
            <a className="nav-btn nav-btn-primary" href="https://github.com/aigentive/ralphx.app" rel="noopener">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.6.5.5 5.6.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.4-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.9.1 3.2.7.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6 4.6-1.5 7.9-5.9 7.9-10.9C23.5 5.6 18.4.5 12 .5z" /></svg>
              Star on GitHub
            </a>
          </div>
        </div>
      </div>

      <footer className="site-foot">
        <div className="site-foot-line">© 2026 RalphX · <a href="https://github.com/aigentive/ralphx.app">GitHub</a></div>
        <a className="made-by" href="https://aigentive.ai" target="_blank" rel="noopener">
          Made by <img src="aigentive.svg" alt="" /> <span>AIGENTIVE</span>
        </a>
      </footer>
    </div>
  );
}

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