/* Main app shell. orchestrates the time-based hero story, phone state,
   notifications, and below-the-fold sections.

   Beat structure (matching outside-phone copy beats):
     0.00 -> 0.30   chaos     "Notification overload."
     0.30 -> 0.55   ask       "Bob filters the noise."
     0.55 -> 0.82   response  "Surfaces what matters."
     0.82 -> 1.00   focus     "Start here."

   Animation model (post-2026-05-02 rework):
   - Time-based, not scroll-based. Animation runs on a fixed wall-clock
     timeline (DURATION_MS) the moment the hero enters the viewport.
     Scroll velocity has no effect on animation pace — fast-scrollers and
     slow-readers see exactly the same cadence.
   - Hero stage = 100vh (one viewport). Previously 1100vh as a scroll
     runway for velocity-clamped progress; that was the wrong shape for
     this problem and made fast-scroll feel like fast-forward.
   - Per-card exit thresholds in NOTIFICATIONS drive the
     24 -> 20 -> 16 -> 10 -> 5 -> 3 -> 0 progression keyed to t.
   - Beat copy cross-fades with 0.05 overlap on each boundary so two
     beats co-exist briefly at transitions.
   - prefers-reduced-motion: skip animation, present final-state. */

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

// Consent + analytics gate. The Vercel Web Analytics script is NOT loaded
// at page load — it is injected only after the visitor explicitly accepts
// the consent banner. heyBob:consent persists the choice for 12 months,
// after which the banner shows again. Storage version lets us re-prompt
// if the policy materially changes.
const CONSENT_KEY = 'heybob:consent';
const CONSENT_VERSION = 1;
const CONSENT_TTL_MS = 365 * 24 * 60 * 60 * 1000;

function readConsent() {
  try {
    const raw = localStorage.getItem(CONSENT_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    if (!parsed || parsed.version !== CONSENT_VERSION) return null;
    if ((Date.now() - parsed.timestamp) > CONSENT_TTL_MS) return null;
    return parsed.state === 'accepted' || parsed.state === 'declined' ? parsed.state : null;
  } catch (e) {
    return null;
  }
}

function writeConsent(state) {
  try {
    localStorage.setItem(CONSENT_KEY, JSON.stringify({
      state,
      version: CONSENT_VERSION,
      timestamp: Date.now(),
    }));
  } catch (e) {}
}

function injectAnalytics() {
  if (document.getElementById('vercel-analytics-script')) return;
  const s = document.createElement('script');
  s.id = 'vercel-analytics-script';
  s.defer = true;
  s.src = '/_vercel/insights/script.js';
  document.head.appendChild(s);
}

function ConsentBanner({ onAccept, onDecline }) {
  return (
    <div className="consent-banner" role="region" aria-label="Cookie consent">
      <p className="consent-copy">
        We count page visits with one privacy-first cookie. No personal data. No third-party tracking. You can decline.
      </p>
      <div className="consent-actions">
        <button type="button" className="consent-btn primary" onClick={onAccept}>Accept</button>
        <button type="button" className="consent-btn" onClick={onDecline}>Decline</button>
      </div>
      <a className="consent-link" href="/privacy">Read the privacy policy</a>
    </div>
  );
}

function App() {
  // Initial stageProgress depends on recentVisitor (declared below in
  // useMemo, but const hoisting pattern works: useState init runs after
  // useMemo). Returning visitors start at t=0.62 (response beat — user
  // message just sent, Bob about to type). Fresh visitors start at 0.
  const initialT = (() => {
    try {
      const last = parseInt(localStorage.getItem('heybob:last-visit') || '0', 10);
      return (Boolean(last) && (Date.now() - last) < 24 * 60 * 60 * 1000) ? 0.62 : 0;
    } catch (e) { return 0; }
  })();
  const [stageProgress, setStageProgress] = useState(initialT);
  // Replay counter — incrementing it re-runs the hero animation effect from
  // t=0 with the full-fresh-visitor timeline. Used by the in-hero Replay
  // button so a returning visitor (or anyone post-animation) can see the
  // full notification-clearing story again.
  const [replayCount, setReplayCount] = useState(0);
  const handleReplay = () => {
    setStageProgress(0);
    setReplayCount(c => c + 1);
  };

  // Consent state. null while we read localStorage (banner hidden), then
  // 'accepted' / 'declined' / 'undecided'. Banner shows only when undecided.
  const [consent, setConsent] = useState(null);
  useEffect(() => {
    const stored = readConsent();
    if (stored === 'accepted') {
      injectAnalytics();
      setConsent('accepted');
    } else if (stored === 'declined') {
      setConsent('declined');
    } else {
      setConsent('undecided');
    }
  }, []);
  const handleAccept = () => {
    writeConsent('accepted');
    injectAnalytics();
    setConsent('accepted');
  };
  const handleDecline = () => {
    writeConsent('declined');
    setConsent('declined');
  };
  const [showTopbarShadow, setShowTopbarShadow] = useState(false);
  const stageRef = useRef(null);
  const reducedMotion = useMemo(
    () => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
    []
  );
  // Returning-visitor check. Synchronous so the very first paint already
  // reflects the right starting state — avoids the chaos-then-flash that
  // a useState/useEffect pair produced. localStorage is wrapped in
  // try/catch (Safari Private mode + embedded webviews throw). On error
  // we treat the user as fresh.
  const recentVisitor = useMemo(() => {
    try {
      const last = parseInt(localStorage.getItem('heybob:last-visit') || '0', 10);
      return Boolean(last) && (Date.now() - last) < 24 * 60 * 60 * 1000;
    } catch (e) {
      return false;
    }
  }, []);

  useEffect(() => { document.body.classList.add('theme-paper'); }, []);

  // Refresh the last-visit timestamp on each load. Pure side effect; the
  // read happens synchronously above. Wrapped in try/catch for the same
  // reason as the read.
  useEffect(() => {
    try { localStorage.setItem('heybob:last-visit', String(Date.now())); } catch (e) {}
  }, []);

  // Honor system dark/light. Toggle theme-dark on body when the OS asks for
  // dark; flip back when it changes. Existing CSS in this file scopes dark
  // styles to body.theme-dark, so this is the only switch needed.
  useEffect(() => {
    const mq = window.matchMedia('(prefers-color-scheme: dark)');
    const apply = (matches) => {
      document.body.classList.toggle('theme-dark', matches);
    };
    apply(mq.matches);
    const handler = (e) => apply(e.matches);
    if (mq.addEventListener) mq.addEventListener('change', handler);
    else if (mq.addListener) mq.addListener(handler); // Safari < 14
    return () => {
      if (mq.removeEventListener) mq.removeEventListener('change', handler);
      else if (mq.removeListener) mq.removeListener(handler);
    };
  }, []);

  // Apple-style scroll reveal: page sections fade-up + translate-y when they
  // enter viewport. One-shot (doesn't re-animate on scroll-back). Respects
  // prefers-reduced-motion via CSS. Each section's children stagger via
  // delay-1..delay-6 utility classes set on .anim-up elements.
  useEffect(() => {
    if (typeof IntersectionObserver === 'undefined') {
      // Older browsers: just show everything
      document.querySelectorAll('.page-section, footer').forEach(el => el.classList.add('is-visible'));
      return;
    }
    const obs = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          entry.target.classList.add('is-visible');
          obs.unobserve(entry.target);
        }
      });
    }, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });

    const targets = document.querySelectorAll('.page-section, footer');
    targets.forEach(el => obs.observe(el));
    return () => obs.disconnect();
  }, []);

  // Time-based hero animation. Decoupled from scroll position. Starts when
  // the hero enters the viewport at >=50% intersection, plays at fixed
  // DURATION_MS, completes regardless of scroll velocity. User can scroll
  // past at any time — the animation continues on its own clock in the
  // (now off-screen) panel. This solves the "fast-forward" feel of the
  // earlier scroll-clamped implementation.
  useEffect(() => {
    if (reducedMotion) {
      setStageProgress(1);
      return;
    }

    // Three modes:
    //   Fresh visitor:    t goes 0.00 → 1.00 over 18s (full chaos→focus story).
    //   Returning (24h):  t goes 0.62 → 1.00 over 6s (skip notifications +
    //                     ask beats; play just the chat exchange).
    //   Replay (any user, after clicking the in-hero Replay button):
    //                     forces the full fresh-visitor timeline regardless
    //                     of localStorage state. replayCount > 0 is the
    //                     trigger; this effect re-runs because replayCount
    //                     is in the dep array.
    const playFresh = !recentVisitor || replayCount > 0;
    const startT = playFresh ? 0 : 0.62;
    const endT = 1.0;
    const DURATION_MS = playFresh ? 18000 : 6000;

    let started = false;
    let startTime = 0;
    let animFrame = null;
    let observer = null;

    const tick = () => {
      const elapsed = performance.now() - startTime;
      const raw = clamp01(elapsed / DURATION_MS);
      const t = startT + (endT - startT) * smootherStep(raw);
      setStageProgress(t);
      if (raw < 1) {
        animFrame = requestAnimationFrame(tick);
      } else {
        animFrame = null;
      }
    };

    const start = () => {
      if (started) return;
      started = true;
      startTime = performance.now();
      animFrame = requestAnimationFrame(tick);
    };

    if (stageRef.current && typeof IntersectionObserver !== 'undefined') {
      observer = new IntersectionObserver((entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
            start();
            if (observer) observer.disconnect();
          }
        });
      }, { threshold: [0, 0.25, 0.5, 0.75, 1] });
      observer.observe(stageRef.current);
    } else {
      start();
    }

    return () => {
      if (animFrame !== null) cancelAnimationFrame(animFrame);
      if (observer) observer.disconnect();
    };
  }, [reducedMotion, recentVisitor, replayCount]);

  // Lightweight scroll listener for the topbar shadow only. Separated from
  // the hero animation loop so neither owns concerns it doesn't need.
  useEffect(() => {
    const onScroll = () => setShowTopbarShadow(window.scrollY > 20);
    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  // Beat boundaries: 0.12 / 0.62 / 0.80
  // 'ask' is half the runway (0.50) — typing is the slowest, most legible
  // beat. 'focus' grew to 0.20 so each Bob message gets real dwell. Chaos
  // shrunk because notifications enter on absolute t (depth-staggered),
  // not on chaos beat width.
  const t = stageProgress;
  let beat = 'chaos';
  let beatProgress = 0;
  // chatProgress is 0..1 across the combined response+focus region (0.62→1.00).
  // BobChat uses it for sub-stages (typing → msg1 → typing → msg2 → typing → msg3)
  // so we can give each Bob reply a typing-indicator that scales with that
  // message's character count instead of equal slots.
  let chatProgress = 0;
  if (t < 0.12)      { beat = 'chaos';    beatProgress = t / 0.12; }
  else if (t < 0.62) { beat = 'ask';      beatProgress = (t - 0.12) / 0.50; }
  else if (t < 0.80) { beat = 'response'; beatProgress = (t - 0.62) / 0.18; chatProgress = (t - 0.62) / 0.38; }
  else               { beat = 'focus';    beatProgress = (t - 0.80) / 0.20; chatProgress = (t - 0.62) / 0.38; }

  const visibleNotifs = useMemo(() => NOTIFICATIONS, []);

  // Outside-phone beat copy. Page voice (Inter 800), not Bob voice. Bob speaks in serif inside the phone.
  const beatCopy = {
    chaos:    { heading: 'Notification overload.' },
    ask:      { heading: 'Bob filters the noise.' },
    response: { heading: 'Surfaces what matters.' },
    focus:    { heading: 'Start here.' },
  };

  return (
    <>
      {/* Visually-hidden H1 ensures the rendered DOM has a top-level heading
          that matches the <noscript> H1 verbatim. Visible page voice lives
          in the scroll story. Without this, JS-rendered DOM has zero H1
          and Googlebot (which runs JS) sees a heading-orphaned page. */}
      <h1 className="sr-only">heyBob. AI Notification Assistant for iOS. Less noise. More finished.</h1>
      <header className={`topbar ${showTopbarShadow ? 'scrolled' : ''}`}>
        <a href="#top" className="wordmark" style={{ textDecoration: 'none' }}>
          <span className="hey">hey</span><span className="bob">Bob</span>
        </a>
        <nav>
          <a href="#how">How it works</a>
          <a href="#voice">Voice</a>
          <a href="#cta">Get it</a>
        </nav>
        <button className="cta" onClick={() => document.getElementById('cta')?.scrollIntoView({ behavior: 'smooth' })}>
          Join waitlist
        </button>
      </header>

      <div className={`progress-rail ${stageProgress > 0.02 && stageProgress < 0.98 ? 'visible' : ''}`}>
        {['chaos', 'ask', 'response', 'focus'].map(b => (
          <div
            key={b}
            className={`dot ${beat === b ? 'active' : (
              ['chaos', 'ask', 'response', 'focus'].indexOf(b) <
              ['chaos', 'ask', 'response', 'focus'].indexOf(beat) ? 'passed' : ''
            )}`}
            title={b}
          ></div>
        ))}
      </div>

      <div className="hero-stage" ref={stageRef} id="top">
        <div className="hero-pin">
          <div className="hero-bg"></div>

          <div className="notif-layer">
            {visibleNotifs.map(n => (
              <GlassNotification
                key={n.id}
                n={n}
                t={t}
                isReducedMotion={reducedMotion}
              />
            ))}
          </div>

          <div className="phone-scaler">
            <Phone beat={beat} beatProgress={beatProgress} chatProgress={chatProgress} t={t} />
          </div>

          <BeatCopy beat={beat} beatProgress={beatProgress} copyMap={beatCopy} />

          <div className={`scroll-cue ${stageProgress > 0.04 ? 'hidden' : ''}`}>
            <span>Scroll</span>
            <span className="line"></span>
          </div>

          {/* Replay button. Only visible once the animation has landed at
              the focus state. Lets returning visitors (who saw the chat-only
              variant) play the full notification-clearing story, and lets
              anyone re-watch on demand. */}
          <button
            type="button"
            className={`replay-btn ${stageProgress >= 0.99 ? 'visible' : ''}`}
            onClick={handleReplay}
            aria-label="Replay animation"
          >
            <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <path d="M2 8a6 6 0 1 0 1.76-4.24"/>
              <path d="M2 2v4h4"/>
            </svg>
            <span>Replay</span>
          </button>
        </div>
      </div>

      <HowItWorks />
      <VoiceSection />
      <div id="cta">
        <CallToAction />
      </div>
      <PageFooter />

      {consent === 'undecided' && (
        <ConsentBanner onAccept={handleAccept} onDecline={handleDecline} />
      )}
    </>
  );
}

/* Cross-fade beat copy. Each beat enters during the first 18% of its window
   and starts fading out in the last 22%, so two beats co-exist briefly at
   the boundary. That overlap is where the silk lives. */
function BeatCopy({ beat, beatProgress, copyMap }) {
  const fadeIn  = clamp01(beatProgress / 0.18);
  const fadeOut = clamp01((beatProgress - 0.78) / 0.22);
  const opacity = easeOutCubic(fadeIn) * (1 - easeInCubic(fadeOut));
  const copy = copyMap[beat];
  return (
    <div className="beat-copy" style={{ opacity, transform: `translateX(-50%) translateY(${(1 - fadeIn) * 14}px)` }}>
      <h2>{copy.heading}</h2>
    </div>
  );
}

(function injectAppStyles(){
  if (document.getElementById('app-styles')) return;
  const css = `
    /* Visually-hidden helper for the SEO H1 — present in the accessibility
       tree and DOM (so Googlebot and screen readers read it) but rendered
       off-screen so it doesn't disrupt the scroll-story visual. */
    .sr-only{
      position: absolute;
      width: 1px; height: 1px;
      padding: 0; margin: -1px;
      overflow: hidden;
      clip: rect(0,0,0,0);
      white-space: nowrap;
      border: 0;
    }
    .notif-layer{
      position: absolute;
      inset: 0;
      pointer-events: none;
      z-index: 32;
    }
    /* Replay button — bottom-right of the hero pin. Hidden until the
       animation reaches the focus end-state (stageProgress >= 0.99) so it
       never competes with the play-through. Translucent paper / dark pill
       to match the topbar treatment. */
    .replay-btn{
      position: absolute;
      bottom: 24px;
      right: 24px;
      z-index: 50;
      display: flex; align-items: center; gap: 7px;
      padding: 8px 14px;
      border-radius: 999px;
      background: rgba(250,250,250,0.85);
      backdrop-filter: saturate(160%) blur(14px);
      -webkit-backdrop-filter: saturate(160%) blur(14px);
      border: 0.5px solid rgba(0,0,0,0.08);
      color: var(--ink);
      font: 500 12px var(--sans);
      letter-spacing: -0.005em;
      cursor: pointer;
      opacity: 0;
      pointer-events: none;
      transform: translateY(6px);
      transition: opacity .45s cubic-bezier(.2,.7,.3,1),
                  transform .45s cubic-bezier(.2,.7,.3,1),
                  background .15s;
    }
    .replay-btn.visible{
      opacity: 1;
      pointer-events: auto;
      transform: translateY(0);
    }
    .replay-btn:hover{ background: rgba(250,250,250,1); }
    .replay-btn svg{ display: block; opacity: 0.7; }
    body.theme-dark .replay-btn{
      background: rgba(15,17,21,0.78);
      border-color: rgba(255,255,255,0.08);
      color: #E5E7EB;
    }
    body.theme-dark .replay-btn:hover{ background: rgba(15,17,21,1); }
    @media (prefers-reduced-motion: reduce){
      .replay-btn{ transition: opacity .2s; transform: none; }
    }

    /* === Consent banner ===
       Fixed at the bottom on all viewports. Paper-blur card matching the
       topbar treatment. Two buttons of equal visual weight (no dark
       patterns); privacy-policy link below for full detail. Animates in
       once after mount. */
    .consent-banner{
      position: fixed;
      left: 24px; bottom: 24px;
      width: min(360px, calc(100vw - 32px));
      z-index: 200;
      display: flex; flex-direction: column; gap: 12px;
      padding: 16px 18px;
      border-radius: 14px;
      background: rgba(250,250,250,0.92);
      backdrop-filter: saturate(180%) blur(20px);
      -webkit-backdrop-filter: saturate(180%) blur(20px);
      border: 0.5px solid rgba(0,0,0,0.08);
      box-shadow: 0 10px 28px rgba(0,0,0,0.10);
      font-family: var(--sans);
      animation: consentBannerIn .55s cubic-bezier(0.22, 1, 0.36, 1) backwards;
    }
    @keyframes consentBannerIn{
      from{ opacity: 0; transform: translateY(12px); }
      to  { opacity: 1; transform: translateY(0); }
    }
    .consent-copy{
      margin: 0;
      font-size: 13.5px;
      line-height: 1.45;
      color: var(--ink);
    }
    .consent-actions{
      display: flex; align-items: center; gap: 8px;
    }
    .consent-btn{
      flex: 1;
      padding: 9px 14px;
      border-radius: 999px;
      font: 600 13px var(--sans);
      cursor: pointer;
      border: 0.5px solid var(--rule);
      background: #fff;
      color: var(--ink);
      transition: background .15s, border-color .15s;
    }
    .consent-btn:hover{ background: #F2F2F2; }
    .consent-btn.primary{
      background: var(--ink);
      color: #fff;
      border-color: var(--ink);
    }
    .consent-btn.primary:hover{ background: #2A2A2A; }
    .consent-link{
      font-size: 11.5px;
      color: var(--dim);
      text-decoration: underline;
      text-underline-offset: 3px;
      align-self: flex-start;
    }
    .consent-link:hover{ color: var(--ink); }
    @media (max-width: 480px){
      .consent-banner{
        left: 12px; right: 12px; bottom: 12px;
        width: auto;
        padding: 14px 16px;
      }
    }
    body.theme-dark .consent-banner{
      background: rgba(15,17,21,0.92);
      border-color: rgba(255,255,255,0.08);
    }
    body.theme-dark .consent-copy{ color: #E5E7EB; }
    body.theme-dark .consent-btn{
      background: rgba(255,255,255,0.06);
      border-color: rgba(255,255,255,0.12);
      color: #E5E7EB;
    }
    body.theme-dark .consent-btn:hover{ background: rgba(255,255,255,0.10); }
    body.theme-dark .consent-btn.primary{
      background: #fff;
      color: #0F1115;
      border-color: #fff;
    }
    body.theme-dark .consent-btn.primary:hover{ background: #E5E7EB; }
    body.theme-dark .consent-link{ color: #9CA3AF; }
    body.theme-dark .consent-link:hover{ color: #fff; }
    @media (prefers-reduced-motion: reduce){
      .consent-banner{ animation: none; }
    }
    /* === Apple-style scroll reveal ===
       Bigger displacement (48px), longer duration (1.1s), snappier easing
       (cubic-bezier(0.22,1,0.36,1) — fast head, slow tail). Blur was
       removed because mid-transition it left body copy looking out-of-
       focus during the user's read. Word-by-word voice quotes still use
       blur because the per-word stagger is fast enough to never linger. */
    .page-section .anim-up,
    footer .anim-up{
      opacity: 0;
      transform: translateY(48px);
      transition: opacity 1.1s cubic-bezier(0.22, 1, 0.36, 1),
                  transform 1.1s cubic-bezier(0.22, 1, 0.36, 1);
      will-change: opacity, transform;
    }
    footer.is-visible .anim-up{ opacity: 1; transform: translateY(0); }
    footer.is-visible .anim-up.delay-1{ transition-delay: .08s; }
    footer.is-visible .anim-up.delay-2{ transition-delay: .16s; }

    /* Topbar + wordmark fade in once on load. */
    .topbar, .wordmark{ animation: fadeInOnLoad .9s cubic-bezier(.2,.7,.3,1) .15s backwards; }
    @keyframes fadeInOnLoad{
      from{ opacity: 0; transform: translateY(-6px); }
      to{ opacity: 1; transform: translateY(0); }
    }
    /* Scroll-cue is positioned absolute and centered with translateX(-50%).
       It needs its own keyframe so the centering transform isn't wiped
       out by the generic fade — that wipe was producing the "shifts to
       centered position" jump on first paint. */
    .scroll-cue{ animation: fadeInScrollCue .9s cubic-bezier(.2,.7,.3,1) .15s backwards; }
    @keyframes fadeInScrollCue{
      from{ opacity: 0; transform: translateX(-50%) translateY(-6px); }
      to  { opacity: 1; transform: translateX(-50%) translateY(0); }
    }
    @media (prefers-reduced-motion: reduce){
      .topbar, .scroll-cue, .wordmark{ animation: none; }
    }
    .page-section.is-visible .anim-up{ opacity: 1; transform: translateY(0); }
    .page-section.is-visible .anim-up.delay-1{ transition-delay: .08s; }
    .page-section.is-visible .anim-up.delay-2{ transition-delay: .18s; }
    .page-section.is-visible .anim-up.delay-3{ transition-delay: .28s; }
    .page-section.is-visible .anim-up.delay-4{ transition-delay: .36s; }
    .page-section.is-visible .anim-up.delay-5{ transition-delay: .44s; }
    .page-section.is-visible .anim-up.delay-6{ transition-delay: .52s; }
    @media (prefers-reduced-motion: reduce){
      .page-section .anim-up{ opacity: 1; transform: none; transition: none; }
    }

    body.theme-dark{ background: #0F1115; color: #E5E7EB; }
    body.theme-dark .hero-bg{
      background:
        radial-gradient(1200px 800px at 50% 50%, rgba(65,182,230,0.18), transparent 60%),
        radial-gradient(900px 600px at 80% 20%, rgba(200,16,46,0.08), transparent 60%),
        #0F1115;
    }
    body.theme-dark .topbar{ background: rgba(15,17,21,0.75); color: #E5E7EB; }
    body.theme-dark .topbar.scrolled{ border-bottom-color: #2A2E36; }
    body.theme-dark .topbar nav{ color: #9CA3AF; }
    body.theme-dark .topbar nav a:hover{ color: #fff; }
    body.theme-dark .beat-copy h2{ color: #fff; }
    body.theme-dark .scroll-cue{ color: #9CA3AF; }
    body.theme-dark .scroll-cue .line{ background: linear-gradient(180deg, transparent, #fff); }
    body.theme-dark .progress-rail .dot{ background: #2A2E36; }
    body.theme-dark .progress-rail .dot.active{ background: #fff; }
    body.theme-dark .progress-rail .dot.passed{ background: #9CA3AF; }
    body.theme-dark .page-section{ border-top-color: #2A2E36; }
    body.theme-dark .hiw-card{ background: #1A1D23; border-left-color: var(--red); }
    body.theme-dark .hiw-title{ color: #fff; }
    body.theme-dark .hiw-body{ color: #9CA3AF; }
    body.theme-dark .hiw-intro{ color: #9CA3AF; }
    body.theme-dark .eyebrow-row{ color: #9CA3AF; }
    body.theme-dark .eyebrow-row .num{ border-color: #2A2E36; color: #fff; }
    body.theme-dark .voice-meta{ color: var(--red); }
    body.theme-dark .voice-section{ background: transparent; }
    body.theme-dark .voice-card{ background: #1A1D23; }
    body.theme-dark .voice-line{ color: #fff; }
    body.theme-dark .voice-intro{ color: #9CA3AF; }
    body.theme-dark footer{ border-top-color: #2A2E36; color: #9CA3AF; }
  `;
  const tag = document.createElement('style');
  tag.id = 'app-styles';
  tag.textContent = css;
  document.head.appendChild(tag);
})();

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