// Landing page — single-screen hero, then "How it works" below the fold.

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

// Endpoint used to capture waitlist signups.
const GOOGLE_SCRIPT_URL = 'https://script.google.com/macros/s/AKfycbzOLUnDb3Vrft8oeQMSbGjZXkkui-H1RCO5d2nhkO7PQ1m7PlcRUwr_q4QOjx1Tl1M/exec';

// Days remaining until the DFW Invitational Beta. Compared at midnight UTC
// on both ends so the number reads as a whole-day calendar countdown — no
// off-by-one drift from time-of-day or timezone. Update NEXT_RELEASE when
// the launch date moves; the badge text in FooterTimeline picks this up.
const NEXT_RELEASE = { y: 2026, m: 7, d: 1 }; // 2026-07-01, DFW Invitational
function daysUntilNextRelease() {
  const now = new Date();
  const todayUtc  = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
  const targetUtc = Date.UTC(NEXT_RELEASE.y, NEXT_RELEASE.m - 1, NEXT_RELEASE.d);
  return Math.max(0, Math.round((targetUtc - todayUtc) / 86400000));
}

function usePhases() {
  const [phase, setPhase] = useState(0);
  useEffect(() => {
    const t = [
      setTimeout(() => setPhase(1), 200),
      setTimeout(() => setPhase(2), 900),
      setTimeout(() => setPhase(3), 1600),
      setTimeout(() => setPhase(4), 2800),
    ];
    return () => t.forEach(clearTimeout);
  }, []);
  return phase;
}

// Honor prefers-reduced-motion. Used to gate JS-driven motion (Mapbox flyTo
// is gated inside Map.jsx; the confetti burst is gated inside fireConfetti).
// CSS keyframe loops (.blink, float-y, anchor pulse) are halted via the
// @media (prefers-reduced-motion) rule in index.html.
function useReducedMotion() {
  const get = () =>
    typeof window !== "undefined"
    && window.matchMedia
    && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const [reduced, setReduced] = useState(get);
  useEffect(() => {
    if (typeof window === "undefined" || !window.matchMedia) return;
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const handler = (e) => setReduced(e.matches);
    mq.addEventListener?.("change", handler);
    return () => mq.removeEventListener?.("change", handler);
  }, []);
  return reduced;
}

function useTypewriter(text, start = true, speed = 22) {
  const [out, setOut] = useState("");
  useEffect(() => {
    if (!start) return;
    let i = 0; setOut("");
    const id = setInterval(() => {
      i++; setOut(text.slice(0, i));
      if (i >= text.length) clearInterval(id);
    }, speed);
    return () => clearInterval(id);
  }, [text, start, speed]);
  return out;
}

// Picks one viewport-height tier so every hero element scales together. The
// hero is locked to 100vh on every device, so all the parts must fit — wordmark,
// headline, search panel, waitlist, and release timeline. Bigger on tall
// screens, tighter on short ones.
function useHeroTier(isMobile) {
  const isShort     = useMediaQuery("(max-height: 800px)");
  // 720px catches iPhone SE 2nd/3rd gen (667h CSS) and SE 1st gen (568h),
  // routing them onto a tighter layout that fits everything in viewport.
  const isTiny      = useMediaQuery("(max-height: 720px)");
  const isTall      = useMediaQuery("(min-height: 950px)");
  const isExtraTall = useMediaQuery("(min-height: 1150px)");
  if (isMobile) return isTiny ? "mobileTiny" : "mobile";
  if (isShort)  return "short";
  if (isExtraTall) return "xtall";
  if (isTall)   return "tall";
  return "medium";
}

const HERO_SCALES = {
  mobileTiny: { wordmark: "md", headline: 26, headlineMargin: 6,  panelMargin: 8,  waitlistMargin: 6,  timeline: "sm", panelCompact: true,  blockGap: 8  },
  mobile:     { wordmark: "md", headline: 32, headlineMargin: 10, panelMargin: 12, waitlistMargin: 10, timeline: "sm", panelCompact: true,  blockGap: 14 },
  short:      { wordmark: "md", headline: 36, headlineMargin: 8,  panelMargin: 10, waitlistMargin: 8,  timeline: "sm", panelCompact: true,  blockGap: 0  },
  medium:     { wordmark: "lg", headline: 42, headlineMargin: 10, panelMargin: 12, waitlistMargin: 10, timeline: "md", panelCompact: false, blockGap: 0  },
  tall:       { wordmark: "lg", headline: 48, headlineMargin: 12, panelMargin: 14, waitlistMargin: 12, timeline: "lg", panelCompact: false, blockGap: 0  },
  xtall:      { wordmark: "lg", headline: 54, headlineMargin: 14, panelMargin: 16, waitlistMargin: 14, timeline: "xl", panelCompact: false, blockGap: 0  },
};

function useMediaQuery(query) {
  const get = () => (typeof window !== "undefined" && window.matchMedia(query).matches);
  const [matches, setMatches] = useState(get);
  useEffect(() => {
    const m = window.matchMedia(query);
    const handler = () => setMatches(m.matches);
    handler();
    m.addEventListener ? m.addEventListener("change", handler) : m.addListener(handler);
    return () => {
      m.removeEventListener ? m.removeEventListener("change", handler) : m.removeListener(handler);
    };
  }, [query]);
  return matches;
}

// ─── Nav ────────────────────────────────────────────────────────────────
function Nav() {
  return <div style={{ height: 24 }}/>;
}

// ─── Map-style search panel (looping queries) ──────────────────────────
const SEARCH_SCENES = [
  {
    query: "Remote-friendly DFW neighborhoods near trails",
    filters: ["All", "Neighborhoods", "Trails", "Cafés"],
    results: [
      {
        name: "White Rock", tag: "East Dallas", pin: "A",
        summary: "9-mile loop, cafés, walks to the lake.",
        meta: ["18 min drive", "Walk 78", "Trails 0.2 mi"],
        tone: "var(--signal)",
      },
      {
        name: "Bishop Arts", tag: "North Oak Cliff", pin: "B",
        summary: "Dense third places, weekday-quiet streets.",
        meta: ["12 min drive", "Walk 92", "Trails 0.9 mi"],
        tone: "var(--route)",
      },
      {
        name: "Las Colinas", tag: "Irving", pin: "C",
        summary: "Lakeside trails, fast to the airport.",
        meta: ["24 min drive", "Walk 64", "Trails 0.1 mi"],
        tone: "var(--rank)",
      },
    ],
  },
  {
    query: "Quiet brunch spots, patio, under 30 min from Plano",
    filters: ["All", "Restaurants", "Patios", "Quiet"],
    results: [
      {
        name: "Hattie's", tag: "Bishop Arts", pin: "A",
        summary: "Slow Sundays, tree-shaded patio.",
        meta: ["22 min drive", "Patio · yes", "dB 64"],
        tone: "var(--signal)",
      },
      {
        name: "Boulevardier", tag: "Bishop Arts", pin: "B",
        summary: "Tucked courtyard, never too loud.",
        meta: ["23 min drive", "Patio · yes", "dB 61"],
        tone: "var(--route)",
      },
      {
        name: "Eno's Pizza", tag: "Bishop Arts", pin: "C",
        summary: "Family-friendly, easy parking.",
        meta: ["24 min drive", "Patio · yes", "dB 68"],
        tone: "var(--rank)",
      },
    ],
  },
  {
    query: "Best public elementary schools with parks nearby",
    filters: ["All", "Schools", "Parks", "Family"],
    results: [
      {
        name: "Mathews Elementary", tag: "Plano ISD", pin: "A",
        summary: "9/10 rated, edges Bob Woodruff Park.",
        meta: ["Rating 9/10", "Park 0.1 mi", "Ratio 14:1"],
        tone: "var(--signal)",
      },
      {
        name: "Stonewall Jackson", tag: "Lakewood", pin: "B",
        summary: "Walkable, adjacent to Lindsley Park.",
        meta: ["Rating 9/10", "Park 0.2 mi", "Ratio 16:1"],
        tone: "var(--route)",
      },
      {
        name: "Highland Park El.", tag: "Highland Park", pin: "C",
        summary: "Strong program, two parks nearby.",
        meta: ["Rating 10/10", "Park 0.3 mi", "Ratio 13:1"],
        tone: "var(--rank)",
      },
    ],
  },
];

// MapSearchPanel is controlled — sceneIdx + advance are owned by Landing,
// so the right-column map and Curator card can react to scene transitions
// in lockstep. The panel still owns its internal phase progression
// (typing → revealed → holding → fading) since that's animation-local.
function MapSearchPanel({ compact = false, mobile = false, sceneIdx = 0, onAdvance }) {
  const scene = SEARCH_SCENES[sceneIdx];

  // Compact mode trims row + search-bar heights so the hero fits on shorter
  // viewports (e.g. 720h laptops, phones). The per-row meta (e.g. "Walk 78")
  // is dropped in compact mode — the summary line carries enough signal.
  // Mobile mode goes further: the summary line is dropped too (the map
  // below shows where each result is, which is what the summary was
  // trying to substitute for), so each row collapses to a single line of
  // name + neighborhood and rowH halves.
  const rowH       = mobile ? 28 : (compact ? 42 : 54);
  const searchPadV = mobile ? 5  : (compact ? 7  : 9);
  const filterPadV = mobile ? 3  : (compact ? 4  : 6);
  const headerPadV = mobile ? 4  : (compact ? 5  : 7);

  // Per-scene phases: 0 = typing, 1 = results revealed, 2 = holding, 3 = fading out
  const [phase, setPhase] = useState(0);
  const typed = useTypewriter(scene.query, phase < 3, 26);
  const userDone = typed.length === scene.query.length && phase < 3;

  // Long queries overflow the input on narrow panels (mobile + compact).
  // Pin the caret to the right edge while typing so the latest characters
  // stay visible; once results reveal, ease back to the start so the query
  // reads from the beginning. scrollLeft works on overflow:hidden in all
  // major engines.
  const queryScrollRef = useRef(null);
  useEffect(() => {
    const el = queryScrollRef.current;
    if (!el) return;
    if (phase === 0) {
      el.scrollLeft = el.scrollWidth;
    } else if (phase === 1) {
      if (typeof el.scrollTo === "function") {
        el.scrollTo({ left: 0, behavior: "smooth" });
      } else {
        el.scrollLeft = 0;
      }
    }
  }, [typed, phase]);

  useEffect(() => {
    setPhase(0);
  }, [sceneIdx]);

  // onAdvance intentionally omitted from deps — the timer closure captures
  // a working reference, and listing it in deps would cause the effect to
  // tear down + recreate every time the parent re-renders. Re-creating
  // the setTimeout cancels and resets the phase clock, so phase 1 → 2 → 3
  // never actually fires and the panel hangs in phase 0 forever.
  useEffect(() => {
    if (phase === 0 && userDone) {
      const t = setTimeout(() => setPhase(1), 400);
      return () => clearTimeout(t);
    }
    if (phase === 1) {
      // Hold longer so viewers can read results and watch the map fly to
      // the search location. 4500ms gives the Mapbox flyTo (2200ms) plus
      // a beat to read.
      const t = setTimeout(() => setPhase(2), 4500);
      return () => clearTimeout(t);
    }
    if (phase === 2) {
      const t = setTimeout(() => setPhase(3), 600);
      return () => clearTimeout(t);
    }
    if (phase === 3) {
      const t = setTimeout(() => {
        onAdvance?.();
      }, 500);
      return () => clearTimeout(t);
    }
  }, [phase, userDone]);

  const showResults = phase >= 1 && phase < 3;
  const fading = phase === 3;

  return (
    <div style={{
      background: "var(--paper)",
      border: "1px solid var(--rule-2)",
      borderRadius: 18,
      overflow: "hidden",
      boxShadow: "var(--shadow-card)",
      display: "flex", flexDirection: "column",
    }}>
      {/* Search bar */}
      <div style={{
        padding: `${searchPadV}px 12px`,
        borderBottom: "1px solid var(--rule)",
        display: "flex", alignItems: "center", gap: 10,
      }}>
        <div style={{
          width: 26, height: 26, borderRadius: 7,
          background: "var(--ink)", display: "grid", placeItems: "center",
          flexShrink: 0,
        }}>
          <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
            <circle cx="7" cy="7" r="4.5" stroke="var(--paper)" strokeWidth="1.6"/>
            <path d="M11 11l3 3" stroke="var(--paper)" strokeWidth="1.6" strokeLinecap="round"/>
          </svg>
        </div>
        <div ref={queryScrollRef} className="no-scrollbar" style={{
          flex: 1, minWidth: 0,
          fontSize: mobile ? 12.5 : (compact ? 13 : 14.5), color: "var(--ink)",
          letterSpacing: "-0.012em",
          minHeight: mobile ? 26 : (compact ? 30 : 38), lineHeight: 1.3,
          display: "flex", alignItems: "center",
          whiteSpace: "nowrap", overflow: "hidden",
          opacity: fading ? 0 : 1,
          transition: "opacity 0.4s",
        }}>
          <span style={{ flexShrink: 0, whiteSpace: "nowrap" }}>
            {typed || <span style={{ color: "var(--ink-4)" }}>Search places, vibes, anything…</span>}
            {phase === 0 && !userDone && <span className="blink" style={{ marginLeft: 1, color: "var(--ink-3)" }}>▎</span>}
          </span>
        </div>
        {!mobile && (
          <span className="mono" style={{
            fontSize: 10, padding: "3px 7px",
            border: "1px solid var(--rule-2)", borderRadius: 5,
            color: "var(--ink-3)", flexShrink: 0,
          }}>⌘ K</span>
        )}
      </div>

      {/* Filter chips — change per scene */}
      <div style={{
        padding: `${filterPadV}px 12px`,
        borderBottom: "1px solid var(--rule)",
        display: "flex", gap: 6, flexWrap: "nowrap", overflow: "hidden",
        opacity: fading ? 0 : 1, transition: "opacity 0.4s",
      }}>
        {scene.filters.map((t, i) => (
          <span key={i} style={{
            fontSize: 11, padding: "4px 10px",
            background: i === 0 ? "var(--ink)" : "transparent",
            color: i === 0 ? "var(--paper)" : "var(--ink-2)",
            border: i === 0 ? "1px solid var(--ink)" : "1px solid var(--rule-2)",
            borderRadius: 999,
            letterSpacing: "-0.005em",
            whiteSpace: "nowrap",
          }}>{t}</span>
        ))}
      </div>

      {/* Content area — skeleton (during typing/fading) and results
          (during reveal) share the same vertical space. Both are
          absolutely positioned inside a relative container with a fixed
          height; the panel no longer grows or shrinks during scene
          transitions, so the waitlist + release timeline below stay
          anchored. Header height + 3 rows of rowH = the canonical span. */}
      <div style={{
        position: "relative",
        height: (mobile ? 22 : (compact ? 24 : 28)) + 3 * rowH,
      }}>
      {/* Skeleton rows while typing — the panel had a cream void here
          before results revealed. Three faint rows preserve the layout
          and read as "the agent is fetching." */}
      <div style={{
        position: "absolute", inset: 0,
        opacity: showResults || fading ? 0 : 1,
        pointerEvents: "none",
        transition: "opacity 0.3s",
      }}>
        <div style={{
          padding: `${headerPadV}px 14px 4px`,
          display: "flex", alignItems: "center", justifyContent: "space-between",
        }}>
          <span className="mono" style={{
            fontSize: 9.5, color: "var(--ink-4)", letterSpacing: "0.16em",
          }}>SCANNING · DFW</span>
          <span className="mono" style={{
            fontSize: 9.5, color: "var(--ink-4)", letterSpacing: "0.12em",
          }}>SORT: BEST FIT ⌄</span>
        </div>
        {[0,1,2].map((i) => (
          <div key={i} style={{
            padding: "4px 14px", height: rowH,
            borderTop: "1px solid var(--rule)",
            display: "flex", gap: 11, alignItems: "center",
          }}>
            <div style={{
              flexShrink: 0, width: 22, height: 22, borderRadius: "50% 50% 50% 4px",
              background: "var(--rule)", transform: "rotate(-45deg)",
            }}/>
            <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 5 }}>
              <div style={{
                width: `${68 - i*8}%`, height: 9, borderRadius: 3,
                background: "var(--rule)",
              }}/>
              {!mobile && (
                <div style={{
                  width: `${44 + i*6}%`, height: 7, borderRadius: 3,
                  background: "var(--rule)", opacity: 0.7,
                }}/>
              )}
            </div>
          </div>
        ))}
      </div>

      {/* Results list */}
      <div style={{
        position: "absolute", inset: 0,
        opacity: fading ? 0 : (showResults ? 1 : 0),
        transition: "opacity 0.4s",
      }}>
        <div style={{
          padding: `${headerPadV}px 14px 4px`,
          display: "flex", alignItems: "center", justifyContent: "space-between",
        }}>
          <span className="mono" style={{
            fontSize: 9.5, color: "var(--ink-3)", letterSpacing: "0.16em",
          }}>{scene.results.length} RESULTS · DFW</span>
          <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
            <div style={{ display: "flex", gap: 4 }}>
              {SEARCH_SCENES.map((_, i) => (
                <span key={i} style={{
                  width: i === sceneIdx ? 14 : 4, height: 4,
                  borderRadius: 999,
                  background: i === sceneIdx ? "var(--ink)" : "var(--rule-2)",
                  transition: "all 0.4s",
                }}/>
              ))}
            </div>
            <span className="mono" style={{
              fontSize: 9.5, color: "var(--ink-3)", letterSpacing: "0.12em",
            }}>SORT: BEST FIT ⌄</span>
          </div>
        </div>

        {scene.results.map((r, i) => (
          <div key={`${sceneIdx}-${i}`} style={{
            padding: "4px 14px",
            height: rowH,
            borderTop: "1px solid var(--rule)",
            display: "flex", gap: 11,
            alignItems: mobile ? "center" : "flex-start",
            opacity: showResults ? 1 : 0,
            transform: showResults ? "translateY(0)" : "translateY(4px)",
            transition: `all 0.4s ${0.1 + i * 0.12}s cubic-bezier(.2,.7,.2,1)`,
          }}>
            <div style={{ flexShrink: 0, position: "relative", marginTop: mobile ? 0 : 1 }}>
              <div style={{
                width: mobile ? 18 : 22, height: mobile ? 18 : 22,
                borderRadius: "50% 50% 50% 4px",
                background: r.tone, transform: "rotate(-45deg)",
                display: "grid", placeItems: "center",
                boxShadow: "0 4px 10px -2px rgba(20,17,13,0.25)",
              }}>
                <span className="mono" style={{
                  color: "var(--paper)",
                  fontSize: mobile ? 9 : 10.5, fontWeight: 600,
                  transform: "rotate(45deg)", letterSpacing: 0,
                }}>{r.pin}</span>
              </div>
            </div>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div>
                <span style={{ fontSize: 12.5, fontWeight: 600, letterSpacing: "-0.018em" }}>{r.name}</span>
                <span style={{ fontSize: 10.5, color: "var(--ink-3)", letterSpacing: "-0.005em", marginLeft: 7 }}>{r.tag}</span>
              </div>
              {!mobile && (
                <div style={{
                  fontSize: 10.5, color: "var(--ink-2)",
                  lineHeight: 1.3, letterSpacing: "-0.005em",
                  marginBottom: 2, marginTop: 1,
                  whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
                }}>{r.summary}</div>
              )}
              {!compact && !mobile && (
                <div style={{ display: "flex", gap: 9, fontSize: 9 }}>
                  {r.meta.map((m, j) => (
                    <span key={j} className="mono" style={{
                      color: "var(--ink-3)", letterSpacing: "0.04em",
                    }}>{m}</span>
                  ))}
                </div>
              )}
            </div>
          </div>
        ))}
      </div>
      </div>{/* /content area */}
    </div>
  );
}

// ─── Waitlist form ──────────────────────────────────────────────────────
function WaitlistForm({ onJoined }) {
  const [email, setEmail] = useState("");
  const [submitted, setSubmitted] = useState(false);
  const [focused, setFocused] = useState(false);

  const submit = (e) => {
    e.preventDefault();
    // Guard: in the "Joined" success state the button stays mounted but
    // must not refire the Sheets POST (or the confetti). Without this, every
    // click after submission spawns a duplicate Google Sheets row and
    // replays the confetti burst.
    if (submitted) return;
    const trimmed = email.trim();
    if (!trimmed) return;

    // Derive a name from the email's local-part so the Google Sheet row
    // gets a usable name column without the user having to type one.
    const derivedName = trimmed.split('@')[0]
      .replace(/[._-]+/g, ' ')
      .replace(/\b\w/g, (c) => c.toUpperCase());

    const formData = new FormData();
    formData.append('email', trimmed);
    formData.append('name', derivedName);
    formData.append('timestamp', new Date().toLocaleString('en-US', {
      timeZone: 'America/Chicago',
      year: 'numeric', month: '2-digit', day: '2-digit',
      hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true,
    }));

    fetch(GOOGLE_SCRIPT_URL, { method: 'POST', body: formData })
      .catch((err) => console.error('Waitlist error:', err));

    setSubmitted(true);
    fireConfetti();
    onJoined?.();
  };

  const reset = () => {
    setEmail("");
    setSubmitted(false);
    setFocused(false);
  };

  // Form border brightens to Pinmark Orange while the email input is focused
  // (matches the page's :focus-visible rule and gives the form a real
  // "you're typing" affordance without a loud color shift).
  const formBorder = focused && !submitted
    ? "1.5px solid var(--signal)"
    : "1.5px solid var(--ink)";
  const formShadow = focused && !submitted
    ? "0 0 0 4px var(--signal-soft), 0 8px 30px -12px rgba(20,17,13,0.3)"
    : "0 8px 30px -12px rgba(20,17,13,0.3)";

  return (
    <>
    <form onSubmit={submit} style={{
      display: "flex", gap: 8, alignItems: "stretch",
      background: "var(--paper)",
      border: formBorder,
      borderRadius: "var(--r-md)", padding: 6,
      boxShadow: formShadow,
      transition: "border-color 200ms var(--ease-out-quart), box-shadow 200ms var(--ease-out-quart)",
    }}>
      <input
        type="email"
        placeholder={submitted ? "" : "your@email.com"}
        value={submitted ? "See you on the Streets ✓" : email}
        onChange={(e) => setEmail(e.target.value)}
        onFocus={() => setFocused(true)}
        onBlur={() => setFocused(false)}
        readOnly={submitted}
        required
        aria-label="Email address"
        style={{
          flex: 1, border: "none", background: "transparent",
          padding: "10px 12px", fontFamily: "inherit", fontSize: 14.5,
          color: "var(--ink)", letterSpacing: "-0.012em",
        }}
      />
      {/* Submit goes to Pinmark Orange on success — using the brand voice
          for confirmation honors the Three-Voice Rule (no fourth accent
          for "success green") and reads as "you're in." */}
      <button type="submit" aria-disabled={submitted} style={{
        background: submitted ? "var(--signal)" : "var(--ink)",
        color: "var(--paper)", border: "none",
        padding: "0 18px", borderRadius: "var(--r-sm)",
        fontFamily: "inherit", fontWeight: 500, fontSize: 13.5,
        letterSpacing: "-0.005em",
        // No pointer affordance once joined — the button is a status badge
        // at that point; the "Add another" link below owns re-entry.
        cursor: submitted ? "default" : "pointer",
        whiteSpace: "nowrap", flexShrink: 0,
        transition: "background 400ms var(--ease-out-quart)",
      }}>
        {submitted ? "Joined" : "Join waitlist"}
      </button>
    </form>
    {/* Reset affordance — appears only after a successful submit, so users
        can add a partner / friend's email without reloading the page. */}
    {submitted && (
      <button type="button" onClick={reset} style={{
        alignSelf: "flex-start",
        background: "none", border: "none", padding: 0,
        fontFamily: "inherit", fontSize: 12,
        color: "var(--ink-3)", letterSpacing: "-0.005em",
        cursor: "pointer",
      }}>
        + Add another email
      </button>
    )}
    </>
  );
}

// ─── Right column: map + curator ───────────────────────────────────────
function RightColumn({ phase, idx }) {
  // Status-pill labels mirror the scene cycle in lockstep with the
  // left-column search and the Curator card.
  const labels = ["DFW · 3 ZONES", "BISHOP ARTS · BRUNCH", "NORTH DFW · SCHOOLS"];
  const label = phase < 2 ? "DFW · LIVE EXAMPLE" : labels[idx] || "DFW · LIVE EXAMPLE";
  return (
    <div style={{
      position: "relative", height: "100%",
      display: "flex", flexDirection: "column", gap: 14,
      minHeight: 0, overflow: "hidden",
    }}>
      <div style={{
        position: "relative", flex: 1, minHeight: 0,
        borderRadius: "var(--r-xl)", overflow: "hidden",
        border: "1px solid var(--rule-2)",
        boxShadow: "var(--shadow-card)",
      }}>
        <StreetsMap phase={phase} idx={idx}/>

        {/* Atlas margins — restoring the cartographic frame we lost when
            switching from the SVG basemap to Mapbox. Per the Atlas Margin
            Rule (DESIGN.md §3): mono in 0.16em letterspacing, ash-ink at
            low opacity, sat inside the rounded frame. Decorative — read
            as part of the cartographic posture. */}
        <AtlasMargins/>

        {/* status pill, bottom-left of map */}
        <div style={{
          position: "absolute", bottom: 14, left: 14,
          display: "flex", alignItems: "center", gap: 8,
          padding: "6px 12px", borderRadius: "var(--r-pill)",
          background: "rgba(245,241,233,0.92)",
          backdropFilter: "blur(8px)",
          border: "1px solid var(--rule-2)",
          fontSize: 10.5, fontFamily: "Geist Mono, monospace",
          letterSpacing: "0.12em", color: "var(--ink-2)",
          zIndex: 4,
        }}>
          <span style={{
            width: 6, height: 6, borderRadius: "50%",
            background: "var(--signal)",
            boxShadow: "0 0 0 4px var(--signal-soft)",
          }}/>
          {label}
        </div>
      </div>

      {/* Curator floats over the top-right of the map */}
      <div style={{
        position: "absolute", right: 14, top: 14,
        zIndex: 5,
      }}>
        <CuratorCard phase={phase} idx={idx}/>
      </div>
    </div>
  );
}

// Atlas margin annotations rendered inside the right-column map frame.
// Three labels (skipping top-right because the Curator card owns it, and
// stacking DFW above the status pill / SHEET above the Mapbox
// attribution): lat top-left, locale bottom-left, sheet ID bottom-right.
// All sit ~14-16px in from the rounded edge so they read as part of the
// frame, not floating decoration. Mono in 0.16em letterspacing per the
// Atlas Margin Rule.
function AtlasMargins() {
  const corner = {
    position: "absolute",
    fontFamily: "Geist Mono, ui-monospace, monospace",
    fontSize: 10,
    letterSpacing: "0.16em",
    color: "var(--ink-3)",
    opacity: 0.55,
    pointerEvents: "none",
    zIndex: 3,
    textTransform: "uppercase",
    fontWeight: 500,
  };
  return (
    <>
      <div style={{ ...corner, top: 14, left: 16 }}>32.7767° N</div>
      <div style={{ ...corner, bottom: 52, left: 16, opacity: 0.45 }}>DFW · TX · USA</div>
      {/* Mapbox attribution band hugs ~22px at the bottom-right; SHEET
          must clear it. */}
      <div style={{ ...corner, bottom: 52, right: 16, opacity: 0.45 }}>SHEET 01 / 01</div>
    </>
  );
}

// ─── Hero left column ─────────────────────────────────────────────────
// Waitlist count grows organically: a fixed base, plus a deterministic
// per-day random bump (1..10) accumulated since the launch anchor. Same
// visitors on the same calendar day see the same number — and tomorrow it
// just nudges up a touch.
const WAITLIST_COUNT_BASE = 3127;
const WAITLIST_EPOCH_MS = Date.UTC(2026, 3, 25); // 2026-04-25, a few days before launch
// Real-signup count in the sheet at the moment the synthetic ceiling above
// was calibrated (2026-05-03 → 3,177). Anything past this baseline is a
// genuine new signup and stacks ON TOP of the synthetic floor instead of
// being absorbed by it. Update this if you ever re-anchor the synthetic.
const WAITLIST_REAL_BASELINE = 174;

function dailyBump(i) {
  // Deterministic, looks-random-enough integer in [1, 10].
  const x = Math.sin((i + 1) * 9301.317) * 233280;
  const f = Math.abs(x - Math.floor(x));
  return 1 + Math.floor(f * 10);
}

function computedWaitlistCount() {
  const days = Math.max(0, Math.floor((Date.now() - WAITLIST_EPOCH_MS) / 86400000));
  let total = WAITLIST_COUNT_BASE;
  for (let i = 0; i < days; i++) total += dailyBump(i);
  return total;
}

// Live count read off the Apps Script Web App. The script's doGet returns
// ONLY a `{count}` integer — no row data, no emails — so this endpoint is
// safe to expose to all visitors.
//
// Display formula:
//   displayed = synthetic + max(0, liveSignups - WAITLIST_REAL_BASELINE)
//
// The synthetic count provides the public-facing inflation (3,177 today,
// nudging up daily). Real signups past the baseline stack on top, so each
// new submit *permanently* adds 1 to the displayed badge instead of being
// absorbed by the synthetic floor. The optimistic bump increments live
// signups in browser state immediately; the next page load refetches
// from the sheet and lands on the same number.
//
// On any failure (rate limit, malformed response, sheet briefly empty),
// liveSignups holds at the baseline and displayed falls back to the pure
// synthetic count — never blank, never shrinks.
function useWaitlistCount() {
  const [liveSignups, setLiveSignups] = useState(WAITLIST_REAL_BASELINE);
  // `hydrated` tells the spin animation when the live count has arrived
  // (or the request has terminally failed / timed out). The animation
  // uses this to know when to stop spinning and land on the final value.
  const [hydrated, setHydrated] = useState(false);
  useEffect(() => {
    let cancelled = false;
    const finish = () => { if (!cancelled) setHydrated(true); };
    // Hard timeout so a hung Apps Script never traps the badge in spin
    // forever. 3s is well past the 95th percentile for Apps Script GETs.
    const timeout = setTimeout(finish, 3000);
    fetch(GOOGLE_SCRIPT_URL)
      .then((r) => r.ok ? r.json() : null)
      .then((data) => {
        if (cancelled) return;
        if (data && typeof data.count === "number") setLiveSignups(data.count);
        clearTimeout(timeout);
        finish();
      })
      .catch(() => { clearTimeout(timeout); finish(); });
    return () => { cancelled = true; clearTimeout(timeout); };
  }, []);
  const bump = useCallback(() => setLiveSignups((c) => c + 1), []);
  const synthetic = computedWaitlistCount();
  const realDelta = Math.max(0, liveSignups - WAITLIST_REAL_BASELINE);
  return { count: synthetic + realDelta, bump, hydrated };
}

// Spin-up animation used to mask the Apps Script GET latency. The badge
// would otherwise paint at the synthetic count, then snap to synthetic +
// delta a beat later, which reads as "uh, the page just corrected itself."
//
// Behavior: from mount, render a noisy ramp toward the live target while
// spinning; once `hydrated` is true AND a minimum spin duration has
// elapsed, ease over a settle window onto the exact final value. The
// animation runs once; after it completes, `target` updates (e.g. the
// optimistic +1 from a successful submit) flow through directly so the
// badge ticks up cleanly without re-spinning.
//
// `prefers-reduced-motion` short-circuits both phases — the badge just
// shows the target value the moment hydration completes, no animation.
function useSpinningCount(target, hydrated, opts = {}) {
  // ~2200ms climb. Long enough for the Apps Script GET (~1-2s) to almost
  // always resolve before the climb completes, so the badge lands once
  // and stays. Shorter durations let the GET finish mid-climb, and any
  // dependency change would reset the rAF and visibly snap the count
  // backwards — see the dep array below.
  const minMs = opts.minMs ?? 2200;
  const reduced = useReducedMotion();
  // Initial paint at ~55% of target — same place the rAF climb starts —
  // so the first frame doesn't show a jarring "0 already in line."
  const [display, setDisplay] = useState(() => reduced ? target : Math.floor(target * 0.55));
  const [done, setDone] = useState(false);

  // Refs so the rAF closure always reads the freshest target / hydrated
  // values without having to reinstall the loop on every state change.
  // CRITICAL: the rAF effect below has empty deps `[]` for exactly this
  // reason — if we listed `target` or `hydrated` as deps, every GET
  // resolution (or optimistic +1) would tear down the loop and restart
  // it with a fresh startMs, snapping the displayed count back to 55%
  // of target mid-climb. The visible glitch is "ramps to ~3,100, resets,
  // ramps again to 3,177" — the second ramp is the post-hydration
  // restart. Reading from refs avoids that.
  const targetRef = useRef(target);
  const hydratedRef = useRef(hydrated);
  targetRef.current = target;
  hydratedRef.current = hydrated;
  const reducedRef = useRef(reduced);
  reducedRef.current = reduced;

  useEffect(() => {
    if (reducedRef.current) {
      // No animation — wait for hydration, then snap. Handled by the
      // separate hydration effect below.
      return;
    }
    const startMs = performance.now();
    let raf;

    function frame() {
      const now = performance.now();
      const t = targetRef.current;
      const elapsed = now - startMs;
      // Climb 0.55*target → target with ease-out. Jitter tapers from a
      // 6%-of-target amplitude at start to zero at the top, so digits
      // visibly home in instead of being random for the whole ride.
      const progress = Math.min(1, elapsed / minMs);
      const eased = 1 - Math.pow(1 - progress, 2.5);
      const base = Math.floor(t * (0.55 + 0.45 * eased));
      const noiseAmp = Math.floor(t * 0.06 * (1 - eased));
      const noise = noiseAmp > 0 ? Math.floor((Math.random() - 0.5) * noiseAmp * 2) : 0;
      setDisplay(Math.max(0, base + noise));

      // Done once the climb has fully eased AND the live count has
      // arrived. If the GET is faster than minMs, we still let the climb
      // finish so the animation feels deliberate. If it's slower, the
      // badge sits at exactly `t` (climb complete, noise zero) until the
      // fetch lands — looks like a clean "settled" state, not a stall.
      if (hydratedRef.current && progress >= 1) {
        setDisplay(t);
        setDone(true);
        return;
      }
      raf = requestAnimationFrame(frame);
    }

    raf = requestAnimationFrame(frame);
    return () => raf && cancelAnimationFrame(raf);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []); // run-once: see comment above the refs

  // Reduced-motion path: skip the spin and just snap to the target as
  // soon as it's known. Splitting this out keeps the rAF effect empty-dep.
  useEffect(() => {
    if (reduced && hydrated) {
      setDisplay(target);
      setDone(true);
    }
  }, [reduced, hydrated, target]);

  // Once done, `target` updates (optimistic +1 from a successful submit)
  // pass straight through. No animation — a one-digit tick reads cleanly
  // as "you just joined" without needing extra motion.
  useEffect(() => {
    if (done) setDisplay(target);
  }, [target, done]);

  return display;
}

// Quick canvas confetti — no library, just a one-shot burst. Skipped
// entirely when the user prefers reduced motion (the WaitlistForm success
// message still fires; only the visual celebration is dropped).
function fireConfetti() {
  if (typeof window === "undefined" || typeof document === "undefined") return;
  if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
  const dpr = window.devicePixelRatio || 1;
  const W = window.innerWidth, H = window.innerHeight;
  const canvas = document.createElement("canvas");
  canvas.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:9999;";
  canvas.width = W * dpr; canvas.height = H * dpr;
  canvas.style.width = W + "px"; canvas.style.height = H + "px";
  document.body.appendChild(canvas);
  const ctx = canvas.getContext("2d");
  ctx.scale(dpr, dpr);

  // Confetti uses canonical brand hex twins. Avoids drift from one-off
  // approximations (the prior values used "#3b8a8a" and "#c69a3a", which
  // weren't quite Cartographic Teal or Field Mustard).
  const colors = ["#d97757", "#14110d", "#1f8a85", "#b58d28", "#f5f1e9", "#6b6358"];
  const originY = H * 0.55;
  const particles = [];
  for (let i = 0; i < 140; i++) {
    const angle = (-Math.PI / 2) + (Math.random() - 0.5) * Math.PI * 0.9;
    const speed = 8 + Math.random() * 12;
    particles.push({
      x: W / 2 + (Math.random() - 0.5) * 80,
      y: originY,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      w: 5 + Math.random() * 5,
      h: 8 + Math.random() * 6,
      color: colors[Math.floor(Math.random() * colors.length)],
      rot: Math.random() * Math.PI * 2,
      vrot: (Math.random() - 0.5) * 0.4,
    });
  }

  let frame = 0;
  const maxFrames = 180;
  function tick() {
    frame++;
    ctx.clearRect(0, 0, W, H);
    const alpha = Math.max(0, 1 - Math.pow(frame / maxFrames, 2.5));
    particles.forEach((p) => {
      p.x += p.vx;
      p.y += p.vy;
      p.vy += 0.32;
      p.vx *= 0.992;
      p.rot += p.vrot;
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.rot);
      ctx.fillStyle = p.color;
      ctx.globalAlpha = alpha;
      ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
      ctx.restore();
    });
    if (frame < maxFrames) requestAnimationFrame(tick);
    else canvas.remove();
  }
  requestAnimationFrame(tick);
}

// Streets wordmark — large, sits at the very top of the hero. Always
// bigger than the headline that follows, on every tier. "by plyance" is
// kept small so the lockup never wraps, even in a narrow left column.
function Wordmark({ size = "lg" }) {
  const t = {
    md: { dot: 11, ring: 6,  gap: 10, streets: 38, by: 18 },
    lg: { dot: 17, ring: 10, gap: 14, streets: 60, by: 26 },
  }[size];
  return (
    <div style={{
      display: "inline-flex", alignItems: "baseline", gap: t.gap,
      whiteSpace: "nowrap",
    }}>
      <span style={{
        width: t.dot, height: t.dot, borderRadius: "50%",
        background: "var(--signal)",
        boxShadow: `0 0 0 ${t.ring}px var(--signal-soft)`,
        alignSelf: "center",
        flexShrink: 0,
      }}/>
      <span style={{
        fontSize: t.streets, fontWeight: 600,
        letterSpacing: "-0.028em", color: "var(--ink)",
        lineHeight: 1,
      }}>Streets</span>
      <span className="serif" style={{
        fontSize: t.by, fontWeight: 400,
        color: "var(--ink-3)", letterSpacing: "-0.01em",
        lineHeight: 1,
      }}>by plyance</span>
    </div>
  );
}

function LeftColumn({ phase, joinRef, sceneIdx, onAdvance, waitlistCount, onJoined, waitlistHydrated }) {
  const tier = useHeroTier(false);
  const spunCount = useSpinningCount(waitlistCount, waitlistHydrated);
  const s = HERO_SCALES[tier];

  return (
    <div style={{
      display: "flex", flexDirection: "column",
      height: "100%", paddingRight: 8,
      minHeight: 0,
    }}>
      {/* Wordmark — large, sits at the very top of the hero */}
      <div style={{ marginBottom: s.headlineMargin }}>
        <Wordmark size={s.wordmark}/>
      </div>

      {/* Headline */}
      <div className="display" style={{ fontSize: s.headline, marginTop: 0 }}>
        Talk to the&nbsp;city.
        <br/>
        <span className="serif" style={{ fontWeight: 400, letterSpacing: "-0.02em" }}>
          Get an answer.
        </span>
      </div>

      {/* Map search preview */}
      <div style={{ marginTop: s.panelMargin }}>
        <MapSearchPanel compact={s.panelCompact} sceneIdx={sceneIdx} onAdvance={onAdvance}/>
      </div>

      {/* Waitlist */}
      <div ref={joinRef} style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: s.waitlistMargin }}>
        <WaitlistForm onJoined={onJoined}/>
        <div style={{
          display: "flex", alignItems: "center", gap: 14,
          fontSize: 12, color: "var(--ink-3)", letterSpacing: "-0.005em",
        }}>
          <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
            <span style={{ width: 5, height: 5, borderRadius: "50%", background: "var(--signal)" }}/>
            <span style={{ fontVariantNumeric: "tabular-nums" }}>{spunCount.toLocaleString()}</span> already in line
          </span>
        </div>
      </div>

      {/* Spacer absorbs extra height — releases timeline anchors the bottom */}
      <div style={{ flex: 1, minHeight: s.waitlistMargin }}/>

      {/* Release timeline — scales with viewport so it always fits */}
      <FooterTimeline size={s.timeline}/>
    </div>
  );
}

// ─── Knowledge strip — three competitor comparisons ───────────────────
function KnowledgeStrip({ phase, isMobile = false }) {
  return (
    <div style={{
      display: "grid",
      gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr 1fr",
      gap: isMobile ? 16 : 24,
      alignItems: "stretch",
    }}>
      <PersonaCard/>
      <SourcedDataCard/>
      <FiveTabsCard/>
    </div>
  );
}

// Card 1 — Same place, different lens. Streets reads who you are; Google reads keywords.
function PersonaCard() {
  return (
    <div style={{
      padding: "28px",
      background: "var(--paper)",
      border: "1px solid var(--rule-2)",
      borderRadius: 20,
      display: "flex", flexDirection: "column", gap: 16,
      position: "relative", overflow: "hidden",
      minHeight: 520,
    }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
        <span className="mono" style={{ fontSize: 10.5, color: "var(--signal)", letterSpacing: "0.2em" }}>
          ◆ PERSONAS · NOT KEYWORDS
        </span>
        <span className="mono" style={{ fontSize: 10, color: "var(--ink-4)", letterSpacing: "0.12em" }}>
          01 / 03
        </span>
      </div>

      <div style={{ fontSize: 24, fontWeight: 600, letterSpacing: "-0.022em", lineHeight: 1.2 }}>
        Same place,&nbsp;
        <span className="serif" style={{ fontWeight: 400 }}>different lens.</span>
      </div>

      <div style={{
        fontSize: 13, color: "var(--ink-3)", lineHeight: 1.4,
        letterSpacing: "-0.005em",
      }}>
        Google reads keywords. Streets reads&nbsp;you.
      </div>

      {/* Today: Google's flat result */}
      <div>
        <div className="mono" style={{
          fontSize: 10, color: "var(--ink-3)",
          letterSpacing: "0.16em", marginBottom: 10,
          textAlign: "center",
        }}>TODAY · WHAT GOOGLE SHOWS</div>
        <div style={{
          padding: "13px 14px",
          background: "var(--paper-2)",
          border: "1px solid var(--rule)",
          borderRadius: "var(--r-md)",
          fontSize: 11, fontFamily: "Geist Mono, monospace",
          color: "var(--ink-3)", letterSpacing: "0.02em",
          lineHeight: 1.7,
        }}>
          <span style={{ background: "rgba(217,119,87,0.18)", padding: "0 3px", borderRadius: 2 }}>hattie's</span>{" "}bishop arts · 4.5★ ·{" "}
          <span style={{ background: "rgba(217,119,87,0.18)", padding: "0 3px", borderRadius: 2 }}>coffee</span>{" "}·{" "}
          <span style={{ background: "rgba(217,119,87,0.18)", padding: "0 3px", borderRadius: 2 }}>brunch</span>{" "}· $$ · 1.2k{" "}
          <span style={{ background: "rgba(217,119,87,0.18)", padding: "0 3px", borderRadius: 2 }}>reviews</span>
          <span style={{ display: "block", marginTop: 5, color: "var(--ink-4)" }}>
            ▢ menu&nbsp;&nbsp;▢ photos&nbsp;&nbsp;▢ reviews&nbsp;&nbsp;▢ directions
          </span>
        </div>
      </div>

      {/* Becomes */}
      <div style={{
        display: "flex", alignItems: "center", gap: 10, justifyContent: "center",
      }}>
        <span style={{ flex: 1, height: 1, background: "var(--rule)" }}/>
        <span className="mono" style={{
          fontSize: 9, color: "var(--ink-4)", letterSpacing: "0.16em",
        }}>becomes</span>
        <span style={{ flex: 1, height: 1, background: "var(--rule)" }}/>
      </div>

      {/* Streets personas — same place, two lenses */}
      <div style={{
        position: "relative",
        background: "var(--paper-2)",
        border: "1px solid var(--rule-2)",
        borderRadius: 14,
        padding: 16,
        display: "flex", flexDirection: "column", gap: 14,
      }}>
        <span className="mono" style={{
          position: "absolute", top: -8, left: 14,
          fontSize: 9, padding: "2px 8px",
          background: "var(--paper)", color: "var(--signal)",
          border: "1px solid var(--signal-rule)",
          borderRadius: "var(--r-xs)", letterSpacing: "0.16em", fontWeight: 600,
        }}>STREETS · PERSONAS</span>

        <div style={{
          display: "flex", alignItems: "center", justifyContent: "space-between",
          paddingTop: 2,
        }}>
          <div>
            <div style={{ fontSize: 16, fontWeight: 600, letterSpacing: "-0.018em" }}>
              Hattie's
            </div>
            <div className="mono" style={{
              fontSize: 10, color: "var(--ink-3)", letterSpacing: "0.12em", marginTop: 2,
            }}>
              COFFEE · BISHOP ARTS
            </div>
          </div>
          <span className="mono" style={{
            fontSize: 9, padding: "3px 8px", borderRadius: 999,
            background: "var(--paper)", border: "1px solid var(--rule-2)",
            color: "var(--ink-3)", letterSpacing: "0.14em",
          }}>SAME PLACE</span>
        </div>

        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
          <div style={{
            background: "var(--paper)", border: "1px solid var(--rule-2)",
            borderRadius: 10, padding: "13px 13px",
          }}>
            <div className="mono" style={{
              fontSize: 10, color: "var(--signal)", letterSpacing: "0.18em",
              marginBottom: 9, fontWeight: 600,
            }}>♡ FOR A COUPLE</div>
            {[
              "Tree-shaded patio",
              "Walk to dessert after",
              "Quiet on weeknights",
            ].map((t, i) => (
              <div key={i} style={{
                fontSize: 12.5, color: "var(--ink-2)", lineHeight: 1.5,
                marginBottom: 2, letterSpacing: "-0.005em",
              }}>· {t}</div>
            ))}
          </div>
          <div style={{
            background: "var(--paper)", border: "1px solid var(--rule-2)",
            borderRadius: 10, padding: "13px 13px",
          }}>
            <div className="mono" style={{
              fontSize: 10, color: "var(--route)", letterSpacing: "0.18em",
              marginBottom: 9, fontWeight: 600,
            }}>△ FOR A FAMILY</div>
            {[
              "High chairs · kid menu",
              "Free parking lot",
              "Stroller-easy entry",
            ].map((t, i) => (
              <div key={i} style={{
                fontSize: 12.5, color: "var(--ink-2)", lineHeight: 1.5,
                marginBottom: 2, letterSpacing: "-0.005em",
              }}>· {t}</div>
            ))}
          </div>
        </div>
      </div>

      <div style={{
        marginTop: "auto",
        display: "flex", alignItems: "center", gap: 10,
        fontSize: 12, color: "var(--ink-3)",
        letterSpacing: "-0.005em", flexWrap: "wrap",
      }}>
        <span style={{
          display: "inline-flex", alignItems: "center", gap: 6,
          padding: "5px 11px",
          background: "var(--signal-soft)",
          border: "1px solid var(--signal-rule)",
          borderRadius: 999,
          color: "var(--signal)",
          fontWeight: 500,
        }}>
          <svg width="10" height="10" viewBox="0 0 12 12" fill="none">
            <path d="M2 6.5 L5 9.5 L10 3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
          Reads who's asking
        </span>
        <span>· no form-fields, no checkboxes</span>
      </div>
    </div>
  );
}

// Card 2 — area-level raw data the rest of the map stack doesn't surface.
// Zillow tries (demographics, schools, crime), but the data is buried, stale,
// and stitched together. Streets pulls all of it into one read.
function SourcedDataCard() {
  const TONE = "var(--route)";
  const signals = [
    { label: "SAFETY",      head: "↓14% YoY",        sub: "Daytime calm · low vio." },
    { label: "SCHOOLS",     head: "Rosemont 8/10",   sub: "0.4 mi · K-5 zoned" },
    { label: "INCOME",      head: "$78k median",     sub: "+6% YoY · mixed bracket" },
    { label: "WALKABILITY", head: "Walk 92 · Transit 68",  sub: "12 cafés / mi²" },
    { label: "NOISE",       head: "dB 58 weekday",   sub: "Quiet after 10pm" },
    { label: "AGE MIX",     head: "Median 31",       sub: "25–40 dominant" },
  ];
  return (
    <div style={{
      padding: "28px",
      background: "var(--paper)",
      border: "1px solid var(--rule-2)",
      borderRadius: 20,
      display: "flex", flexDirection: "column", gap: 14,
      minHeight: 520,
    }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
        <span className="mono" style={{ fontSize: 10.5, color: TONE, letterSpacing: "0.2em" }}>
          ◆ THE AREA · NOT JUST THE PLACE
        </span>
        <span className="mono" style={{ fontSize: 10, color: "var(--ink-4)", letterSpacing: "0.12em" }}>
          02 / 03
        </span>
      </div>

      <div style={{ fontSize: 24, fontWeight: 600, letterSpacing: "-0.022em", lineHeight: 1.2 }}>
        It's the area —&nbsp;
        <span className="serif" style={{ fontWeight: 400 }}>not the pin.</span>
      </div>

      <div style={{
        fontSize: 13, color: "var(--ink-3)", lineHeight: 1.4,
        letterSpacing: "-0.005em",
      }}>
        Safety, census, schools, noise — what Zillow buries, surfaced.
      </div>

      {/* Today: what other apps bother to show */}
      <div>
        <div className="mono" style={{
          fontSize: 10, color: "var(--ink-3)",
          letterSpacing: "0.16em", marginBottom: 10,
          textAlign: "center",
        }}>TODAY · WHAT OTHER APPS SHOW YOU</div>
        <div style={{
          padding: "11px 13px",
          background: "rgba(20,17,13,0.04)",
          border: "1px dashed var(--rule-2)",
          borderRadius: 10,
          display: "flex", alignItems: "center",
          gap: 12, flexWrap: "wrap",
          fontSize: 11.5, color: "var(--ink-3)",
          letterSpacing: "-0.005em",
        }}>
          <span style={{ fontWeight: 600, color: "var(--ink-2)" }}>Bishop Arts</span>
          <span>4.5★</span>
          <span>$$$</span>
          <span>1.2k reviews</span>
          <span style={{ marginLeft: "auto", color: "var(--ink-4)", fontSize: 10.5 }}>
            ▢ photos&nbsp;&nbsp;▢ menu&nbsp;&nbsp;▢ hours
          </span>
        </div>
      </div>

      {/* Becomes */}
      <div style={{
        display: "flex", alignItems: "center", gap: 10, justifyContent: "center",
      }}>
        <span style={{ flex: 1, height: 1, background: "var(--rule)" }}/>
        <span className="mono" style={{
          fontSize: 9, color: "var(--ink-4)", letterSpacing: "0.16em",
        }}>becomes</span>
        <span style={{ flex: 1, height: 1, background: "var(--rule)" }}/>
      </div>

      {/* Streets · Area Signals — the raw data nobody else surfaces */}
      <div style={{
        position: "relative",
        background: "var(--paper-2)",
        border: "1px solid var(--rule-2)",
        borderRadius: 14,
        padding: "16px 14px 14px",
        display: "flex", flexDirection: "column", gap: 12,
      }}>
        <span className="mono" style={{
          position: "absolute", top: -8, left: 14,
          fontSize: 9, padding: "2px 8px",
          background: "var(--paper)", color: TONE,
          border: "1px solid var(--route-rule)",
          borderRadius: "var(--r-xs)", letterSpacing: "0.16em", fontWeight: 600,
        }}>STREETS · AREA SIGNALS</span>

        <div style={{
          display: "flex", alignItems: "center", justifyContent: "space-between",
          paddingTop: 2,
        }}>
          <div style={{ display: "flex", alignItems: "center", gap: 9 }}>
            <div style={{
              width: 16, height: 16, borderRadius: "50% 50% 50% 3px",
              background: TONE, transform: "rotate(-45deg)",
              boxShadow: "0 3px 8px -2px rgba(20,17,13,0.25)",
            }}/>
            <div>
              <div style={{ fontSize: 14, fontWeight: 600, letterSpacing: "-0.018em" }}>
                Bishop Arts · 75208
              </div>
              <div className="mono" style={{
                fontSize: 9.5, color: "var(--ink-3)", letterSpacing: "0.12em", marginTop: 1,
              }}>
                ½ MI RADIUS · LIVE
              </div>
            </div>
          </div>
          <span className="mono" style={{
            fontSize: 9, padding: "3px 8px", borderRadius: 999,
            background: "var(--paper)", border: "1px solid var(--rule-2)",
            color: "var(--ink-3)", letterSpacing: "0.14em",
          }}>6 OF 40+</span>
        </div>

        <div style={{
          display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8,
        }}>
          {signals.map((s, i) => (
            <div key={i} style={{
              background: "var(--paper)", border: "1px solid var(--rule-2)",
              borderRadius: 10, padding: "10px 11px",
              display: "flex", flexDirection: "column", gap: 4,
              minHeight: 78,
            }}>
              <div className="mono" style={{
                fontSize: 9, color: TONE, letterSpacing: "0.14em",
                fontWeight: 600,
              }}>{s.label}</div>
              <div style={{
                fontSize: 13, fontWeight: 600, letterSpacing: "-0.014em",
                color: "var(--ink)", lineHeight: 1.2,
              }}>{s.head}</div>
              <div style={{
                fontSize: 10.5, color: "var(--ink-3)",
                letterSpacing: "-0.005em", lineHeight: 1.3,
                marginTop: "auto",
              }}>{s.sub}</div>
            </div>
          ))}
        </div>
      </div>

      {/* Footer */}
      <div style={{
        marginTop: "auto",
        display: "flex", alignItems: "center", gap: 10,
        fontSize: 11.5, color: "var(--ink-3)",
        letterSpacing: "-0.005em", flexWrap: "wrap",
      }}>
        <span style={{
          display: "inline-flex", alignItems: "center", gap: 6,
          padding: "4px 10px",
          background: "var(--route-soft)",
          border: "1px solid var(--route-rule)",
          borderRadius: "var(--r-pill)",
          color: "var(--ink-2)",
          fontWeight: 500,
        }}>
          <svg width="10" height="10" viewBox="0 0 12 12" fill="none">
            <path d="M2 6.5 L5 9.5 L10 3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
          Public + behavioral records
        </span>
        <span>· refreshed weekly</span>
      </div>
    </div>
  );
}

// Card 3 — replaces the 5-app dance: Apple Maps + Yelp + Google + Reddit + Niche.
function FiveTabsCard() {
  const apps = [
    { app: "Apple Maps", role: "nearby" },
    { app: "Yelp",       role: "reviews" },
    { app: "Google",     role: "vibe" },
    { app: "Reddit",     role: "real talk" },
    { app: "Niche",      role: "schools" },
  ];
  return (
    <div style={{
      padding: "28px",
      background: "var(--paper)",
      border: "1px solid var(--rule-2)",
      borderRadius: 20,
      display: "flex", flexDirection: "column", gap: 16,
      minHeight: 520,
    }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
        <span className="mono" style={{ fontSize: 10.5, color: "var(--rank)", letterSpacing: "0.2em" }}>
          ◆ ONE QUERY · NOT FIVE TABS
        </span>
        <span className="mono" style={{ fontSize: 10, color: "var(--ink-4)", letterSpacing: "0.12em" }}>
          03 / 03
        </span>
      </div>

      <div style={{ fontSize: 24, fontWeight: 600, letterSpacing: "-0.022em", lineHeight: 1.2 }}>
        The 5-app dance —&nbsp;
        <span className="serif" style={{ fontWeight: 400 }}>done.</span>
      </div>

      <div style={{
        fontSize: 13, color: "var(--ink-3)", lineHeight: 1.4,
        letterSpacing: "-0.005em",
      }}>
        Today: 5 apps. Streets:&nbsp;one query.
      </div>

      {/* Today: 5 apps */}
      <div>
        <div className="mono" style={{
          fontSize: 10, color: "var(--ink-3)", letterSpacing: "0.16em",
          marginBottom: 10, textAlign: "center",
        }}>TODAY · 5 APPS, ~12 MIN</div>
        <div style={{
          display: "flex", gap: 7, flexWrap: "wrap",
          justifyContent: "center",
        }}>
          {apps.map((a, i) => (
            <div key={i} style={{
              padding: "10px 13px", borderRadius: 11,
              background: "var(--paper-2)",
              border: "1px solid var(--rule-2)",
              minWidth: 0, textAlign: "center",
            }}>
              <div style={{
                fontSize: 13, fontWeight: 600, letterSpacing: "-0.012em",
                color: "var(--ink)",
              }}>{a.app}</div>
              <div className="mono" style={{
                fontSize: 9.5, color: "var(--ink-3)",
                letterSpacing: "0.14em", marginTop: 3,
              }}>{a.role.toUpperCase()}</div>
            </div>
          ))}
        </div>
      </div>

      {/* Becomes */}
      <div style={{
        display: "flex", alignItems: "center", gap: 10, justifyContent: "center",
      }}>
        <span style={{ flex: 1, height: 1, background: "var(--rule)" }}/>
        <span className="mono" style={{
          fontSize: 9, color: "var(--ink-4)", letterSpacing: "0.16em",
        }}>becomes</span>
        <span style={{ flex: 1, height: 1, background: "var(--rule)" }}/>
      </div>

      {/* Streets: one query */}
      <div style={{
        padding: "16px 18px",
        background: "var(--ink)", color: "var(--paper)",
        borderRadius: 13, fontSize: 16.5,
        letterSpacing: "-0.012em", lineHeight: 1.4,
        position: "relative",
        display: "flex", alignItems: "center", gap: 11,
      }}>
        <span className="mono" style={{
          position: "absolute", top: -8, left: 14,
          fontSize: 9, padding: "2px 8px",
          background: "var(--ink)", color: "var(--rank)",
          border: "1px solid var(--rank-rule)",
          borderRadius: "var(--r-xs)", letterSpacing: "0.16em", fontWeight: 600,
        }}>STREETS · 1 QUERY</span>
        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
          <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.6"/>
          <path d="M11 11l3 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
        </svg>
        “where should we get coffee tonight”
      </div>

      {/* Ranked answer preview — fills the gap, completes the story */}
      <div style={{
        background: "var(--paper-2)",
        border: "1px solid var(--rule-2)",
        borderRadius: 12,
        padding: "12px 13px 13px",
        position: "relative",
        display: "flex", flexDirection: "column", gap: 6,
      }}>
        <span className="mono" style={{
          position: "absolute", top: -7, left: 14,
          fontSize: 9, padding: "1px 7px",
          background: "var(--paper)", color: "var(--ink-3)",
          border: "1px solid var(--rule-2)",
          borderRadius: 4, letterSpacing: "0.16em",
        }}>STREETS · ANSWER</span>
        {[
          { rank: "01", name: "Hattie's",     why: "quiet date spot · walk to dessert", dist: "0.9 mi" },
          { rank: "02", name: "Boulevardier", why: "courtyard, low chatter",            dist: "1.1 mi" },
          { rank: "03", name: "Oddfellows",   why: "open late · espresso + cocktails",  dist: "1.0 mi" },
        ].map((r, i) => (
          <div key={i} style={{
            display: "grid", gridTemplateColumns: "20px 1fr auto",
            alignItems: "center", gap: 10,
            paddingTop: i === 0 ? 4 : 5,
            paddingBottom: 5,
            borderTop: i === 0 ? "none" : "1px dashed var(--rule)",
          }}>
            <span className="mono" style={{
              fontSize: 10, color: "var(--rank)",
              letterSpacing: "0.08em", fontWeight: 600,
            }}>{r.rank}</span>
            <div style={{ minWidth: 0 }}>
              <span style={{
                fontSize: 12.5, fontWeight: 600, letterSpacing: "-0.015em",
                color: "var(--ink)",
              }}>{r.name}</span>
              <span style={{
                fontSize: 11, color: "var(--ink-3)",
                letterSpacing: "-0.005em", marginLeft: 7,
              }}>{r.why}</span>
            </div>
            <span className="mono" style={{
              fontSize: 9.5, color: "var(--ink-3)",
              letterSpacing: "0.06em",
            }}>{r.dist}</span>
          </div>
        ))}
      </div>

      <div style={{
        marginTop: "auto",
        display: "flex", alignItems: "center", gap: 10,
        fontSize: 12, color: "var(--ink-3)",
        letterSpacing: "-0.005em", flexWrap: "wrap",
      }}>
        <span style={{
          display: "inline-flex", alignItems: "center", gap: 6,
          padding: "5px 11px",
          background: "var(--rank-soft)",
          border: "1px solid var(--rank-rule)",
          borderRadius: "var(--r-pill)",
          color: "var(--ink-2)",
          fontWeight: 500,
        }}>
          <svg width="10" height="10" viewBox="0 0 12 12" fill="none">
            <path d="M2 6.5 L5 9.5 L10 3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
          Ranked, mapped, ready
        </span>
        <span>· in&nbsp;<span className="mono" style={{ color: "var(--ink-2)" }}>0.4s</span></span>
      </div>
    </div>
  );
}

// ─── Footer / Release timeline ─────────────────────────────────────────
// `size` selects the visual scale: "sm" for the compact mobile card,
// "md" for short desktops (~720vh), "lg" for 1080p, "xl" for 1440p+.
// The legacy `expanded` prop maps to "md" for backwards compatibility.
function FooterTimeline({ expanded = false, size }) {
  const tier = size || (expanded ? "md" : "sm");
  const t = {
    sm: { pad: "16px 18px 16px",  radius: 16, eyebrow: 10.5, title: 17,   pillFs: 9.5,  pillPad: "3px 9px",   gap: 12, ebMargin: 5 },
    md: { pad: "22px 24px 22px",  radius: 18, eyebrow: 12,   title: 22,   pillFs: 11,   pillPad: "5px 12px",  gap: 18, ebMargin: 9 },
    lg: { pad: "32px 34px 32px",  radius: 22, eyebrow: 14,   title: 28,   pillFs: 13,   pillPad: "7px 16px",  gap: 30, ebMargin: 14 },
    xl: { pad: "44px 44px 44px",  radius: 26, eyebrow: 16,   title: 38,   pillFs: 14.5, pillPad: "9px 20px",  gap: 40, ebMargin: 18 },
  }[tier];

  return (
    <div style={{
      padding: t.pad,
      background: "var(--ink)",
      color: "var(--paper)",
      borderRadius: t.radius,
      boxShadow: "0 18px 50px -16px rgba(20,17,13,0.4)",
      display: "flex", flexDirection: "column", gap: t.gap,
    }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
        <div>
          <div className="mono" style={{
            fontSize: t.eyebrow, color: "rgba(245,241,233,0.7)",
            letterSpacing: "0.24em", marginBottom: t.ebMargin, fontWeight: 600,
          }}>
            ◆ RELEASE TIMELINE
          </div>
          <div style={{
            fontSize: t.title, fontWeight: 500, letterSpacing: "-0.022em",
            lineHeight: 1.15,
          }}>
            Joining now puts you in the&nbsp;
            <span className="serif" style={{ fontWeight: 400, letterSpacing: "-0.02em" }}>DFW Invitational Beta.</span>
          </div>
        </div>
        <span className="mono" style={{
          fontSize: t.pillFs, padding: t.pillPad,
          border: "1px solid rgba(245,241,233,0.25)", borderRadius: 999,
          letterSpacing: "0.16em", color: "rgba(245,241,233,0.85)",
          whiteSpace: "nowrap", fontWeight: 600,
        }}>
          {(() => {
            const d = daysUntilNextRelease();
            if (d === 0) return "LAUNCH DAY";
            return `NEXT · ${d} ${d === 1 ? "DAY" : "DAYS"}`;
          })()}
        </span>
      </div>
      <ReleaseTimelineDark tier={tier}/>
    </div>
  );
}

function ReleaseTimelineDark({ tier = "md" }) {
  const releases = [
    { date: "JUL 1, 2026", title: "DFW Invitational Beta", note: "Invitational · DFW only", current: true },
    { date: "OCT 1, 2026", title: "Multi-City Beta",       note: "+ select cities" },
    { date: "JAN 1, 2027", title: "Open Beta",             note: "Anyone, anywhere" },
  ];
  const t = {
    sm: { dotSize: 10, gap: 12, dateFs: 11.5, titleFs: 13.5, noteFs: 10.5, rowGap: 11, dotGap: 12 },
    md: { dotSize: 12, gap: 14, dateFs: 13,   titleFs: 16,   noteFs: 12,   rowGap: 14, dotGap: 14 },
    lg: { dotSize: 14, gap: 18, dateFs: 15,   titleFs: 20,   noteFs: 14,   rowGap: 18, dotGap: 18 },
    xl: { dotSize: 18, gap: 22, dateFs: 17,   titleFs: 24,   noteFs: 16,   rowGap: 22, dotGap: 22 },
  }[tier];

  // Vertical timeline: dot rail on the left, dates + titles on the right.
  // Reads like a tight "what's coming when" list — the date is the lede.
  const dotCenter = t.dotSize / 2;
  return (
    <div style={{ position: "relative", display: "flex", flexDirection: "column", gap: t.rowGap }}>
      {/* vertical rail */}
      <div style={{
        position: "absolute", left: dotCenter, top: dotCenter, bottom: dotCenter,
        width: 1, background: "rgba(245,241,233,0.22)",
        transform: "translateX(-0.5px)",
      }}/>
      {releases.map((r, i) => (
        <div key={i} style={{
          display: "grid",
          gridTemplateColumns: `${t.dotSize}px 1fr`,
          columnGap: t.dotGap,
          alignItems: "start",
          position: "relative",
        }}>
          <div style={{
            width: t.dotSize, height: t.dotSize, borderRadius: "50%",
            background: r.current ? "var(--signal)" : "var(--ink)",
            border: r.current ? "none" : "1.5px solid rgba(245,241,233,0.5)",
            boxShadow: r.current ? "0 0 0 5px rgba(217,119,87,0.22)" : "none",
            marginTop: Math.max(0, t.dateFs * 0.18),
          }}/>
          <div style={{ minWidth: 0 }}>
            <div style={{
              display: "flex", alignItems: "baseline", gap: 12, flexWrap: "wrap",
            }}>
              <span className="mono" style={{
                fontSize: t.dateFs,
                color: r.current ? "var(--signal)" : "rgba(245,241,233,0.85)",
                letterSpacing: "0.14em", fontWeight: 600,
                whiteSpace: "nowrap",
              }}>{r.date}</span>
              <span style={{
                fontSize: t.titleFs, fontWeight: 600,
                letterSpacing: "-0.018em",
                color: "var(--paper)", lineHeight: 1.1,
              }}>{r.title}</span>
            </div>
            <div style={{
              fontSize: t.noteFs, color: "rgba(245,241,233,0.62)",
              letterSpacing: "-0.005em", lineHeight: 1.4,
              marginTop: 4,
            }}>{r.note}</div>
          </div>
        </div>
      ))}
    </div>
  );
}

// ─── Page ─────────────────────────────────────────────────────────────
// Scene cycle — lifted from MapSearchPanel to Landing so the right-column
// map and the Curator card can fly in lockstep with whichever search the
// left-column is currently typing. The simulated search panel still owns
// its internal typing/holding/fading phases; it just calls advanceScene()
// at the end of the fade rather than mutating its own sceneIdx.
function useSceneCycle(total) {
  const [sceneIdx, setSceneIdx] = useState(0);
  // Stable identity so MapSearchPanel's effect deps don't churn — a fresh
  // arrow each render would re-fire its phase-timer effect and constantly
  // reset the typing animation.
  const advance = useCallback(
    () => setSceneIdx((i) => (i + 1) % total),
    [total]
  );
  return { sceneIdx, advance };
}

function Landing() {
  const phase = usePhases();
  const { sceneIdx, advance } = useSceneCycle(SEARCH_SCENES.length);
  const joinRef = useRef(null);
  const isMobile = useMediaQuery("(max-width: 768px)");
  // Single live-count fetch shared across desktop and mobile layouts; the
  // bump is handed to WaitlistForm so a successful submit ticks the badge
  // up immediately. `hydrated` flips once the GET resolves (or hits the
  // 3s timeout) — the spin animation uses it to know when to land.
  const { count: waitlistCount, bump: bumpWaitlist, hydrated: waitlistHydrated } = useWaitlistCount();

  if (isMobile) {
    return <MobileLanding phase={phase} joinRef={joinRef} sceneIdx={sceneIdx} onAdvance={advance} waitlistCount={waitlistCount} onJoined={bumpWaitlist} waitlistHydrated={waitlistHydrated}/>;
  }

  return (
    <div style={{
      background: "var(--paper)",
      display: "flex", flexDirection: "column",
    }} data-screen-label="Landing · desktop">
      {/* Hero — exactly one viewport tall so BelowFold is hidden until scroll */}
      <div style={{
        height: "100vh",
        minHeight: 600,
        display: "grid",
        gridTemplateColumns: "minmax(460px, 1fr) minmax(0, 3fr)",
        gap: 24,
        padding: "16px 32px",
        overflow: "hidden",
      }}>
        <LeftColumn phase={phase} joinRef={joinRef} sceneIdx={sceneIdx} onAdvance={advance} waitlistCount={waitlistCount} onJoined={bumpWaitlist} waitlistHydrated={waitlistHydrated}/>
        <RightColumn phase={phase} idx={sceneIdx}/>
      </div>

      {/* Below the fold */}
      <BelowFold phase={phase}/>
    </div>
  );
}

// ─── Mobile layout ────────────────────────────────────────────────────
function MobileLanding({ phase, joinRef, sceneIdx, onAdvance, waitlistCount, onJoined, waitlistHydrated }) {
  const tier = useHeroTier(true);
  const spunCount = useSpinningCount(waitlistCount, waitlistHydrated);
  const s = HERO_SCALES[tier];
  const labels = ["DFW · 3 ZONES", "BISHOP ARTS · BRUNCH", "NORTH DFW · SCHOOLS"];
  const mapLabel = phase < 2 ? "DFW · LIVE EXAMPLE" : labels[sceneIdx] || "DFW · LIVE EXAMPLE";
  // Map starts collapsed as a compact dropdown affordance to save vertical
  // space on first paint. One tap expands it to the full live tile, after
  // which the hero behaves exactly like the prior 100svh layout (map flex:1
  // absorbs the slack, status pill in the corner, etc.). `closing` defers
  // the unmount until the collapse keyframe finishes so the tile slides
  // back up into the dropdown bar instead of just popping out.
  const [mapOpen, setMapOpen] = useState(false);
  const [closing, setClosing] = useState(false);

  return (
    <div style={{
      background: "var(--paper)",
      display: "flex", flexDirection: "column",
    }} data-screen-label="Landing · mobile">
      {/* Hero — when the map is open, sized to 100svh (the smallest visible
          viewport, with the URL bar counted) so the layout doesn't reflow
          as iOS Safari slides its address bar in and out; the map is
          flex:1 and absorbs whatever vertical slack the viewport gives us.
          When the map is collapsed, we drop the 100svh class entirely and
          let the hero shrink to its natural content height — that's the
          space saving the user asked for. The full dated timeline lives
          below the fold (MobileReleaseSection). */}
      <div className={mapOpen ? "mobile-hero" : undefined} style={{
        minHeight: mapOpen ? 540 : 0,
        display: "flex", flexDirection: "column",
        gap: 10,
        padding: "12px 16px 14px",
        overflow: "hidden",
      }}>
        {/* Wordmark */}
        <div style={{ marginTop: 2, flexShrink: 0 }}>
          <Wordmark size={s.wordmark}/>
        </div>

        {/* Headline */}
        <div className="display" style={{ fontSize: s.headline, marginTop: 0, flexShrink: 0 }}>
          Talk to the&nbsp;city.
          <br/>
          <span className="serif" style={{ fontWeight: 400, letterSpacing: "-0.02em" }}>
            Get an answer.
          </span>
        </div>

        {/* Map search panel */}
        <div style={{ flexShrink: 0 }}>
          <MapSearchPanel compact={s.panelCompact} mobile sceneIdx={sceneIdx} onAdvance={onAdvance}/>
        </div>

        {/* Live map — collapsed by default to a compact dropdown that hints
            at the cartographic content beneath it (live dot, mono label,
            faint dashed route). Tapping expands to the full tile, which is
            flex:1 + minHeight:110 — absorbs the hero's vertical slack and
            grows on tall phones. No curator card or atlas margins on the
            small tile (would duplicate the search panel above). */}
        {mapOpen ? (
          <div
            className={closing ? "mobile-map-collapse" : "mobile-map-expand"}
            onAnimationEnd={(e) => {
              if (closing && e.animationName === "mobile-map-collapse") {
                setMapOpen(false);
                setClosing(false);
              }
            }}
            style={{
              position: "relative", flex: 1, minHeight: 110,
              borderRadius: "var(--r-lg)", overflow: "hidden",
              border: "1px solid var(--rule-2)",
              boxShadow: "var(--shadow-card)",
            }}
          >
            <StreetsMap phase={phase} idx={sceneIdx} mobile/>
            <div style={{
              position: "absolute", bottom: 10, left: 10,
              display: "flex", alignItems: "center", gap: 7,
              padding: "4px 9px", borderRadius: "var(--r-pill)",
              background: "rgba(245,241,233,0.92)",
              backdropFilter: "blur(8px)",
              border: "1px solid var(--rule-2)",
              fontSize: 9.5, fontFamily: "Geist Mono, monospace",
              letterSpacing: "0.12em", color: "var(--ink-2)",
              zIndex: 4,
            }}>
              <span style={{
                width: 5, height: 5, borderRadius: "50%",
                background: "var(--signal)",
                boxShadow: "0 0 0 3px var(--signal-soft)",
              }}/>
              {mapLabel}
            </div>
            {/* Collapse button — paired affordance to the EXPAND chevron on
                the closed dropdown. Mirrors that label/chevron pairing
                (mono "CLOSE" + up arrow) so the open and closed states
                read as the same control flipped, not two different
                widgets. Sits over the top-right of the tile inside the
                rounded clip with the same paper-glass as the status pill. */}
            <button
              type="button"
              onClick={() => !closing && setClosing(true)}
              aria-label="Collapse map"
              className="mono"
              style={{
                position: "absolute", top: 10, right: 10,
                height: 28,
                display: "flex", alignItems: "center", gap: 6,
                padding: "0 10px",
                borderRadius: "var(--r-pill)",
                background: "rgba(245,241,233,0.92)",
                backdropFilter: "blur(8px)",
                border: "1px solid var(--rule-2)",
                color: "var(--ink-3)",
                cursor: "pointer",
                appearance: "none", WebkitAppearance: "none",
                font: "inherit",
                fontSize: 10, letterSpacing: "0.1em",
                zIndex: 4,
              }}
            >
              CLOSE
              <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
                <path d="M2 6.5 L5 3.5 L8 6.5" stroke="currentColor"
                      strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
              </svg>
            </button>
          </div>
        ) : (
          <button
            type="button"
            onClick={() => setMapOpen(true)}
            aria-expanded={false}
            aria-controls="mobile-live-map"
            style={{
              position: "relative",
              flexShrink: 0,
              display: "flex", alignItems: "center", justifyContent: "space-between",
              width: "100%", height: 56,
              padding: "0 14px",
              borderRadius: "var(--r-lg)",
              border: "1px solid var(--rule-2)",
              background: "var(--paper-2)",
              boxShadow: "var(--shadow-card)",
              color: "var(--ink)",
              cursor: "pointer",
              appearance: "none", WebkitAppearance: "none",
              font: "inherit",
              textAlign: "left",
              overflow: "hidden",
            }}
          >
            {/* Faint cartographic decoration — dashed route + two pin dots —
                hints at the map without rendering tiles. Sits behind the
                live label and the EXPAND chevron at low opacity. */}
            <svg
              viewBox="0 0 320 56" preserveAspectRatio="none"
              style={{ position: "absolute", inset: 0, pointerEvents: "none", opacity: 0.55 }}
              aria-hidden="true"
            >
              <path d="M-10 42 Q60 30, 120 38 T240 24 T340 30"
                    stroke="var(--route-hex)" strokeWidth="1" fill="none"
                    strokeDasharray="3 5" strokeLinecap="round"/>
              <circle cx="120" cy="38" r="2.2" fill="var(--signal-hex)"/>
              <circle cx="220" cy="26" r="2.2" fill="var(--rank-hex)"/>
            </svg>

            <span style={{ position: "relative", display: "flex", alignItems: "center", gap: 9 }}>
              <span style={{
                width: 6, height: 6, borderRadius: "50%",
                background: "var(--signal)",
                boxShadow: "0 0 0 3px var(--signal-soft)",
              }}/>
              <span className="mono" style={{
                fontSize: 10.5, fontWeight: 500,
                letterSpacing: "0.12em",
                color: "var(--ink-2)",
              }}>{mapLabel}</span>
            </span>

            <span className="mono" style={{
              position: "relative",
              display: "flex", alignItems: "center", gap: 6,
              fontSize: 10, letterSpacing: "0.1em",
              color: "var(--ink-3)",
            }}>
              EXPAND
              <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
                <path d="M2 3.5 L5 6.5 L8 3.5" stroke="currentColor"
                      strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
              </svg>
            </span>
          </button>
        )}

        {/* Waitlist */}
        <div ref={joinRef} style={{ display: "flex", flexDirection: "column", gap: 4, flexShrink: 0 }}>
          <WaitlistForm onJoined={onJoined}/>
          <div style={{
            display: "flex", alignItems: "center", gap: 14,
            fontSize: 12, color: "var(--ink-3)", letterSpacing: "-0.005em",
          }}>
            <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
              <span style={{ width: 5, height: 5, borderRadius: "50%", background: "var(--signal)" }}/>
              <span style={{ fontVariantNumeric: "tabular-nums" }}>{spunCount.toLocaleString()}</span> already in line
            </span>
          </div>
        </div>

      </div>

      {/* Below the fold — full release timeline first, then How it works.
          One scroll past the waitlist reveals the dated timeline. */}
      <MobileReleaseSection/>
      <BelowFold phase={phase} isMobile={true}/>
    </div>
  );
}

function MobileReleaseSection() {
  return (
    <div style={{
      padding: "28px 16px 32px",
      background: "var(--paper)",
      borderTop: "1px solid var(--rule)",
    }}>
      <FooterTimeline size="sm"/>
    </div>
  );
}

function BelowFold({ phase, isMobile = false }) {
  return (
    <div style={{
      padding: isMobile ? "32px 16px 40px" : "48px 36px 60px",
      borderTop: "1px solid var(--rule)",
      background: "var(--paper-2)",
    }}>
      <div style={{ maxWidth: 1280, margin: "0 auto" }}>
        <div style={{
          display: "flex", alignItems: "baseline", justifyContent: "space-between",
          marginBottom: isMobile ? 20 : 28, gap: isMobile ? 12 : 24, flexWrap: "wrap",
        }}>
          <div style={{ maxWidth: 720 }}>
            <div className="mono" style={{
              fontSize: 11, color: "var(--ink-3)",
              letterSpacing: "0.2em", marginBottom: 10,
            }}>
              ◆ HOW IT WORKS
            </div>
            <div className="display" style={{ fontSize: isMobile ? 30 : 40, lineHeight: 1.05 }}>
              Three things make Streets&nbsp;
              <span className="serif" style={{ fontWeight: 400 }}>different.</span>
            </div>
          </div>
          <div style={{
            fontSize: isMobile ? 13 : 14, color: "var(--ink-3)",
            maxWidth: 360, lineHeight: 1.5,
            letterSpacing: "-0.005em",
          }}>
            Most maps make you search. Streets already knows the place AND you.
          </div>
        </div>

        <KnowledgeStrip phase={phase} isMobile={isMobile}/>
      </div>
    </div>
  );
}

window.Landing = Landing;
