<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <!-- Stop the browser from serving a stale index.html while the app is in
       rapid iteration. Butterbase's CDN still caches for ~a minute, but these
       headers tell the browser itself to always revalidate. -->
  <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
  <meta http-equiv="Pragma" content="no-cache" />
  <meta http-equiv="Expires" content="0" />
  <title>Thedi — your daily research scout</title>
  <!-- P02: Noto Sans Tamil for first-class Tamil script. Inter as the English body. -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <!-- font-display: swap keeps text legible while the web font loads. -->
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Tamil:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
  <link rel="dns-prefetch" href="https://fonts.gstatic.com">
  <style>
    :root {
      --bg: #fefefe;
      --surface: #fafaf9;
      --surface-2: #f5f5f4;
      --ink: #1c1917;
      --mute: #78716c;
      --rule: #e7e5e4;

      /* P04: split accent usage.
         --accent      : UI elements (borders, backgrounds, SVG fill) — contrast not required.
         --accent-text : text-on-white — darker amber-600 for WCAG AA (4.5:1 vs #fff).
         --accent-deep : hover/active on primary CTAs. */
      --accent:      #f59e0b;
      --accent-text: #d97706;
      --accent-deep: #b45309;

      --ok: #16a34a;
      --warn: #dc2626;
    }
    * { box-sizing: border-box; }
    html, body {
      margin: 0; padding: 0;
      font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      color: var(--ink);
      background: var(--bg);
      line-height: 1.55;
    }
    /* P02: Tamil script uses Noto Sans Tamil wherever it appears.
       Robust fallback chain: web font → Inter (covers latin) → system fonts
       that ship with some Tamil coverage on major OS versions. */
    .tamil, [lang="ta"] {
      font-family: "Noto Sans Tamil", "Latha", "Tamil Sangam MN",
                   "Nirmala UI", "Segoe UI", "Arial", sans-serif;
    }
    a { color: var(--ink); }
    .wrap { max-width: 700px; margin: 0 auto; padding: 40px 24px; }
    header {
      display: flex; justify-content: space-between; align-items: center;
      padding-bottom: 20px; border-bottom: 1px solid var(--rule);
      gap: 12px;
    }
    h1.logo {
      margin: 0; font-size: 22px; font-weight: 600;
      min-width: 0; /* allow flex-shrink on narrow viewports */
    }

    /* ── Auth slot in header top-right ────────────────────────────────────
       Signed out: ghost "Sign in" button (utility corner, returning users).
       Signed in:  email + chevron → dropdown with Sign out.
       Primary conversion CTA lives in the hero body, unchanged.            */
    .auth-slot { position: relative; flex-shrink: 0; }
    .auth-slot .auth-signin {
      display: inline-flex; align-items: center; gap: 6px;
      padding: 10px 16px; min-height: 44px; /* WCAG 2.5.8 tap target */
      border: 1px solid var(--rule); border-radius: 999px;
      background: #fff; color: var(--ink); text-decoration: none;
      font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit;
      transition: background 120ms, border-color 120ms;
    }
    .auth-slot .auth-signin:hover,
    .auth-slot .auth-signin:focus { background: var(--surface); border-color: var(--mute); }
    .auth-slot .auth-profile {
      display: inline-flex; align-items: center; gap: 8px;
      padding: 8px 12px 8px 8px; min-height: 44px; /* WCAG 2.5.8 tap target */
      border: 1px solid var(--rule); border-radius: 999px;
      background: #fff; color: var(--ink); cursor: pointer;
      font-size: 13px; font-family: inherit;
      transition: background 120ms, border-color 120ms;
    }
    .auth-slot .auth-profile:hover,
    .auth-slot .auth-profile:focus { background: var(--surface); border-color: var(--mute); }
    .auth-slot .auth-avatar {
      width: 24px; height: 24px; border-radius: 50%;
      background: var(--accent); color: var(--ink);
      display: inline-flex; align-items: center; justify-content: center;
      font-size: 11px; font-weight: 700; flex-shrink: 0;
    }
    .auth-slot .auth-email {
      max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    @media (max-width: 640px) {
      .auth-slot .auth-email { max-width: 120px; }
    }
    /* Below 400px the email pushes the caret offscreen; collapse to avatar + caret,
       and tighten the profile button's left padding so the avatar doesn't
       clip against the viewport edge. */
    @media (max-width: 400px) {
      .auth-slot .auth-email { display: none; }
      .auth-slot .auth-profile { padding: 8px 4px 8px 8px; }
      .auth-slot .auth-signin  { padding: 10px 12px; }
    }
    /* Narrow-viewport dropdown safety: cap to viewport width and pull in
       from the right edge so menu doesn't clip on 320–375px devices. */
    @media (max-width: 375px) {
      .auth-slot .auth-menu {
        min-width: 160px; right: -8px;
        max-width: calc(100vw - 16px);
      }
    }
    .auth-slot .auth-caret { color: var(--mute); font-size: 10px; }
    .auth-slot .auth-menu {
      position: absolute; top: calc(100% + 6px); right: 0;
      background: #fff; border: 1px solid var(--rule); border-radius: 8px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.08);
      min-width: 180px; padding: 4px; z-index: 50;
      display: none;
    }
    .auth-slot.open .auth-menu { display: block; }
    .auth-slot .auth-menu button,
    .auth-slot .auth-menu a {
      display: flex; align-items: center; width: 100%; text-align: left;
      padding: 16px 12px; min-height: 48px; /* WCAG 2.5.8 tap target */
      border: 0; background: transparent;
      font-size: 13px; color: var(--ink); cursor: pointer; border-radius: 4px;
      font-family: inherit; text-decoration: none;
    }
    .auth-slot .auth-menu button:hover,
    .auth-slot .auth-menu a:hover { background: var(--surface); }
    .auth-slot .auth-menu .menu-email {
      padding: 8px 12px; font-size: 11px; color: var(--mute);
      border-bottom: 1px solid var(--rule); margin-bottom: 4px;
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    /* "Thedi" English brand + "தேடி" Tamil co-brand. Tamil sits at near-equal
       visual weight — it's the product's actual name, not a decorative gloss. */
    h1.logo .brand-en { font-family: "Inter", sans-serif; font-weight: 700; letter-spacing: -0.2px; color: var(--accent-text); }
    h1.logo .brand-ta {
      font-family: "Noto Sans Tamil", sans-serif;
      font-weight: 600; color: var(--accent-text); font-size: 18px;
      margin-left: 8px; opacity: 0.85;
    }
    .tag { font-size: 12px; color: var(--mute); }
    h2 { margin-top: 30px; font-size: 18px; }
    .hero { font-size: 18px; color: var(--mute); margin: 24px 0; }
    .hero strong { color: var(--ink); }

    /* ── Buttons ───────────────────────────────────────────────────────────
       Revised per design handoff 2026-04-21 (mocks/revised_landing.html):
       - Primary keeps turmeric fill but gains a cocoa-ink label (#3a2416)
         and a subtle inset amber rim for depth.
       - New .btn.cocoa variant (dark brown fill, amber-cream label) for the
         landing sign-in — complements amber body accents instead of
         competing with them.
       - Small Google-color dot (.g) sits inside the sign-in capsule. */
    .btn {
      display: inline-flex; align-items: center; justify-content: center; gap: 8px;
      padding: 12px 20px; min-height: 44px; /* WCAG tap target */
      background: var(--ink); color: white;
      text-decoration: none; border-radius: 6px; font-size: 14px;
      border: 0; cursor: pointer; font-family: inherit; font-weight: 500;
      transition: background 120ms ease, color 120ms ease, box-shadow 120ms ease, transform 80ms ease;
    }
    .btn:hover { background: #292524; }
    .btn:active { transform: translateY(1px); }
    .btn.primary {
      background: var(--accent); color: #3a2416; font-weight: 600;
      box-shadow: inset 0 0 0 1px #e89208, 0 1px 0 rgba(58,36,22,0.08);
    }
    .btn.primary:hover { background: var(--accent-deep); color: #fff; }
    .btn.cocoa {
      background: #3a2416; color: #fef3c7; font-weight: 500;
      box-shadow: inset 0 0 0 1px #2a1810, 0 1px 0 rgba(58,36,22,0.12);
    }
    .btn.cocoa:hover { background: #2a1810; color: #fff; }
    .btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--rule); }
    .btn.ghost:hover { background: var(--surface); border-color: var(--mute); }
    .btn:disabled { opacity: 0.5; cursor: wait; }
    /* Google's four-color dot, used inside sign-in capsules. 14px by default. */
    .btn .g {
      width: 14px; height: 14px; display: inline-block; border-radius: 50%;
      background: conic-gradient(from 0deg, #ea4335 0 25%, #fbbc05 25% 50%, #34a853 50% 75%, #4285f4 75% 100%);
      flex-shrink: 0;
    }

    /* WCAG 2.4.7 focus-visible — visible keyboard indicator on every interactive.
       Uses --accent-deep (#b45309) which clears 3:1 contrast against white/surface;
       the lighter --accent was borderline. */
    *:focus-visible { outline: 2px solid var(--accent-deep); outline-offset: 2px; }

    /* ── Kolam hero (P01) ─────────────────────────────────────────────────
       A pulli-kolam-inspired motif: a 5-dot grid with a single continuous
       curved path that draws itself on page load via stroke-dasharray.
       Strokes in the turmeric --accent. Small and GPU-cheap. Reduced-motion
       users see the final static drawing immediately.                       */
    .kolam-hero {
      display: flex; justify-content: center; align-items: center;
      margin: 10px 0 30px; padding: 20px 0;
    }
    .kolam-hero svg { width: 140px; height: 140px; overflow: visible; }
    /* Two-petal kolam (design handoff 2026-04-21): two crossed lobes in deep
       turmeric, four corner dots framing them, a lit center dot. Thicker
       9px strokes give the petals a hand-drawn chalk-line presence. */
    .kolam-hero .k-path {
      fill: none;
      stroke: var(--accent-text);
      stroke-width: 9;
      stroke-linecap: round;
      stroke-linejoin: round;
    }
    .kolam-hero .k-dot    { fill: var(--accent-text); }
    .kolam-hero .k-center { fill: var(--accent); }
    /* Animated variant — draw the petals, then fade in dots, then the heart. */
    .kolam-hero.animate .k-path {
      stroke-dasharray: 520;
      stroke-dashoffset: 520;
      animation: kolam-draw 2.6s ease-out 0.2s forwards;
    }
    .kolam-hero.animate .k-dot {
      opacity: 0;
      animation: kolam-dot-in 0.4s ease-out 2.2s forwards;
    }
    .kolam-hero.animate .k-center {
      opacity: 0;
      animation: kolam-dot-in 0.5s ease-out 2.6s forwards;
    }
    @keyframes kolam-draw   { to { stroke-dashoffset: 0; } }
    @keyframes kolam-dot-in { to { opacity: 1; } }
    @media (prefers-reduced-motion: reduce) {
      .kolam-hero.animate .k-path   { stroke-dashoffset: 0; animation: none; }
      .kolam-hero.animate .k-dot    { opacity: 1; animation: none; }
      .kolam-hero.animate .k-center { opacity: 1; animation: none; }
    }

    /* ── Finding cards ───────────────────────────────────────────────────── */
    .finding {
      margin: 18px 0; padding: 16px; border-left: 3px solid var(--accent);
      background: var(--surface); border-radius: 4px;
    }
    .finding .meta {
      font-size: 11px; color: var(--mute); text-transform: uppercase;
      letter-spacing: 0.5px; display: flex; gap: 8px; flex-wrap: wrap;
      align-items: center;
    }
    .finding .rank-chip {
      font-weight: 600; color: var(--ink);
    }
    /* P06: source badge */
    .finding .source-badge {
      display: inline-flex; align-items: center;
      padding: 2px 8px; border-radius: 999px;
      background: var(--surface-2); border: 1px solid var(--rule);
      font-size: 10px; letter-spacing: 0.3px; color: var(--mute);
      text-transform: uppercase; font-weight: 600;
    }
    /* P07: score pill — muted so it doesn't compete with the kolam accent. */
    .finding .score-pill {
      display: inline-flex; align-items: center; margin-left: auto;
      padding: 2px 9px; border-radius: 999px;
      background: var(--surface-2); color: var(--mute);
      border: 1px solid var(--rule);
      font-size: 11px; font-weight: 700; letter-spacing: 0.2px;
    }
    .finding .title { font-size: 17px; font-weight: 600; margin: 8px 0 6px; line-height: 1.35; }
    .finding .title a { text-decoration: none; }
    .finding .title a:hover { text-decoration: underline; }
    .finding .angle { font-size: 14px; color: #44403c; }
    /* P05: seed pills — real tap targets */
    .finding .seeds {
      margin-top: 14px; display: flex; gap: 8px; font-size: 14px;
      flex-wrap: wrap;
    }
    .finding .seeds a {
      color: var(--ink); text-decoration: none;
      padding: 10px 16px; min-height: 44px; /* WCAG 2.5.8 tap target */
      display: inline-flex; align-items: center;
      border: 1px solid var(--rule); border-radius: 999px;
      background: #fff; font-size: 13px;
      transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
    }
    .finding .seeds a:hover,
    .finding .seeds a:focus { background: var(--surface); border-color: var(--ink); }
    .finding .seeds a:active { transform: translateY(1px); background: var(--rule); }
    .finding .seeds a::before { content: "↪ "; color: var(--accent-text); margin-right: 3px; }

    /* "▶ Explain this paper" — per-finding plain-language audio explainer. */
    .listen-wrap {
      margin: 14px 0 4px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center;
    }
    .paper-listen { margin: 12px 0 2px; }
    .paper-listen .listen-btn { padding: 8px 15px; min-height: 40px; font-size: 13px; }
    .listen-btn {
      display: inline-flex; align-items: center; gap: 6px;
      padding: 10px 18px; min-height: 44px;
      border: 1px solid var(--accent); border-radius: 999px;
      background: #fff; color: var(--accent-text);
      font-size: 14px; font-weight: 600; cursor: pointer;
      font-family: inherit; transition: background 120ms ease, color 120ms ease;
    }
    .listen-btn:hover, .listen-btn:focus { background: var(--accent); color: #fff; }
    .listen-btn:disabled { opacity: 0.75; cursor: progress; }
    .listen-btn.is-loading .listen-icon { animation: listen-pulse 1s ease-in-out infinite; }
    @keyframes listen-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
    @media (prefers-reduced-motion: reduce) { .listen-btn.is-loading .listen-icon { animation: none; } }
    .listen-hint {
      font-size: 11px; color: var(--mute); text-transform: uppercase; letter-spacing: 0.5px;
    }
    .listen-audio { width: 100%; max-width: 480px; margin-top: 4px; }
    .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;
    }
    .toast {
      position: fixed; bottom: 24px; right: 24px; z-index: 1000;
      padding: 12px 18px; max-width: 360px;
      background: var(--ink); color: var(--bg);
      border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.18);
      font-size: 14px; line-height: 1.4;
    }
    .toast.ok { background: var(--ok); }
    .toast.err { background: var(--warn); }

    .rank-delta.up { color: var(--ok); font-weight: 600; }
    .rank-delta.down { color: var(--warn); font-weight: 600; }
    .rank-delta.new { color: var(--accent-text); font-weight: 600; }

    /* Critique callout — surface + left accent (already matched palette). */
    .critique {
      margin: 20px 0; padding: 14px 16px;
      background: var(--surface); border: 1px solid var(--rule);
      border-left: 3px solid var(--accent); border-radius: 4px;
      font-size: 14px; color: var(--ink);
    }
    .critique .who {
      font-weight: 700; margin-bottom: 6px; font-size: 11px;
      letter-spacing: 0.5px; text-transform: uppercase; color: var(--accent-text);
    }

    /* Back-nav bar at top of sub-pages.
       Positive top margin so it breathes under the header border. */
    .subnav {
      margin: 20px 0 16px; font-size: 13px; color: var(--mute);
    }
    .subnav a {
      color: var(--mute); text-decoration: none;
      padding: 4px 0; border-bottom: 1px dashed transparent;
      word-break: break-word; overflow-wrap: break-word;
    }
    .subnav a:hover { color: var(--ink); border-bottom-color: var(--rule); }

    /* Debug panel — only rendered when there's nothing else to show.
       Lets the user copy the identity info so the backend mismatch can be
       reconciled. Not visible when digests list is populated. */
    .debug-panel {
      margin-top: 28px; padding: 14px 16px;
      background: var(--surface-2); border: 1px solid var(--rule);
      border-radius: 6px; font-size: 13px;
    }
    .debug-panel summary {
      cursor: pointer; font-weight: 600; color: var(--ink);
      padding: 4px 0; user-select: none;
    }
    .debug-panel .debug-body { margin-top: 12px; }
    .debug-panel .kv {
      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
      font-size: 12px; background: #fff; border: 1px solid var(--rule);
      border-radius: 4px; padding: 10px 12px;
      word-break: break-all; line-height: 1.6;
    }
    .debug-panel .kv .k { color: var(--mute); }
    .debug-panel .kv .v { color: var(--ink); }
    .debug-panel .copy-btn {
      display: inline-flex; align-items: center; gap: 6px;
      margin-top: 10px; padding: 6px 12px;
      background: var(--ink); color: #fff; border: 0;
      border-radius: 6px; font-size: 12px; font-family: inherit; cursor: pointer;
    }
    .debug-panel .copy-btn:hover { background: #292524; }
    .debug-panel .copy-btn.copied { background: var(--ok); }

    /* ── First-login auto-migration UI ───────────────────────────────── */
    .migration-card {
      display: flex; gap: 14px; align-items: center;
      margin: 18px 0; padding: 16px 18px;
      background: var(--surface); border: 1px solid var(--rule);
      border-left: 3px solid var(--accent); border-radius: 6px;
      font-size: 14px;
    }
    .migration-card.success { border-left-color: var(--ok); background: #f7fdf9; }
    .migration-card.warn    { border-left-color: var(--warn); background: #fef2f2; }
    .migration-title { font-weight: 600; color: var(--ink); }
    .migration-sub   { font-size: 13px; color: var(--mute); margin-top: 2px; }
    .migration-check {
      width: 28px; height: 28px; border-radius: 50%;
      background: var(--ok); color: #fff;
      display: inline-flex; align-items: center; justify-content: center;
      font-size: 16px; font-weight: 700; flex-shrink: 0;
    }
    /* Kolam-themed loader (revised 2026-04-21): four breathing cardinal
       dots, a pulsing heart dot, and two expanding accent rings — the same
       visual language as the kolam petals, extended into motion. Dots are
       real child elements (not pseudo-els) so each gets its own delay. */
    .spinner {
      position: relative; width: 32px; height: 32px; flex-shrink: 0;
      color: var(--accent-text);
    }
    .spinner .d {
      position: absolute; width: 4.5px; height: 4.5px; margin: -2.25px;
      border-radius: 50%; background: currentColor;
      animation: sp-breathe 1.4s ease-in-out infinite;
    }
    .spinner .d.n { top: 10%; left: 50%; animation-delay: 0s;    }
    .spinner .d.e { top: 50%; left: 90%; animation-delay: 0.35s; }
    .spinner .d.s { top: 90%; left: 50%; animation-delay: 0.7s;  }
    .spinner .d.w { top: 50%; left: 10%; animation-delay: 1.05s; }
    .spinner .heart {
      position: absolute; top: 50%; left: 50%;
      width: 7px; height: 7px; margin: -3.5px;
      border-radius: 50%; background: currentColor;
      animation: sp-heart 1.1s ease-in-out infinite;
    }
    .spinner .ring {
      position: absolute; top: 50%; left: 50%;
      width: 10px; height: 10px; margin: -5px;
      border-radius: 50%; border: 1.5px solid currentColor;
      opacity: 0; animation: sp-ring 1.6s ease-out infinite;
    }
    .spinner .ring.b { animation-delay: 0.8s; }
    @keyframes sp-breathe { 0%,100% { transform: scale(0.8); opacity: 0.4; } 50% { transform: scale(1.2); opacity: 1; } }
    @keyframes sp-heart   { 0%,100% { transform: scale(1);   opacity: 1;   } 50% { transform: scale(1.8); opacity: 0.35; } }
    @keyframes sp-ring    { 0%      { transform: scale(0.6); opacity: 0.7; } 80%,100% { transform: scale(3.2); opacity: 0; } }
    @media (prefers-reduced-motion: reduce) {
      .spinner .d, .spinner .heart, .spinner .ring { animation: none; }
      .spinner .ring { opacity: 0.4; }
    }

    /* Stage rows (revised 2026-04-21): carded rows with a gradient vertical
       rail behind the number circles, hover tint, and a two-line verb +
       clarifier layout. Optional trailing glyph in deep accent.
       `.stages` wraps them to render the rail; standalone `.stage` works
       without the wrapper for legacy uses (replay). */
    .stages { position: relative; padding-left: 8px; margin-top: 8px; }
    .stages::before {
      content: ""; position: absolute; left: 19px; top: 16px; bottom: 16px;
      width: 2px; opacity: 0.55;
      background: linear-gradient(var(--accent) 0 50%, #fcd9a3 50% 100%);
    }
    .stage {
      position: relative;
      display: flex; align-items: center; gap: 14px;
      padding: 12px 14px; margin: 6px 0;
      background: #fff; border: 1px solid var(--rule); border-radius: 8px;
      font-size: 14px;
      transition: background 120ms ease, border-color 120ms ease;
    }
    .stage:hover { background: #fffaf3; border-color: #fcd9a3; }
    .stage .num {
      width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
      background: var(--accent); color: #3a2416;
      display: inline-flex; align-items: center; justify-content: center;
      font-size: 12px; font-weight: 700;
      position: relative; z-index: 1;
      box-shadow: 0 0 0 3px #fff; /* notches the rail so the circle reads cleanly */
    }
    .stage .verb { font-size: 15px; font-weight: 600; color: var(--ink); letter-spacing: -0.1px; }
    .stage .clar { font-size: 12px; color: var(--mute); margin-top: 1px; }
    .stage .gly  { font-size: 18px; color: var(--accent-deep); opacity: 0.7; margin-left: auto; }

    .chat-box {
      display: flex; flex-direction: column; gap: 12px;
      max-height: 50vh; overflow-y: auto; padding: 10px 0;
    }
    .msg {
      padding: 12px 16px; border-radius: 14px; max-width: 80%; font-size: 14px;
    }
    .msg.user { background: var(--ink); color: white; align-self: flex-end; border-bottom-right-radius: 4px; }
    .msg.asst { background: var(--surface); align-self: flex-start; border-bottom-left-radius: 4px; border: 1px solid var(--rule); }
    .chat-input {
      display: flex; gap: 8px; margin-top: 16px;
      padding-top: 16px; border-top: 1px solid var(--rule);
    }
    .chat-input textarea {
      flex: 1; padding: 10px 12px; border: 1px solid var(--rule);
      border-radius: 8px; font-family: inherit; font-size: 14px;
      resize: vertical; min-height: 60px; max-height: 320px;
      overflow-y: auto;
    }
    @media (max-width: 640px) {
      .chat-input textarea { min-height: 52px; max-height: 200px; }
    }
    /* On iPhone SE (375px) with soft keyboard open, cap shorter so the
       submit button stays reachable above the keyboard. */
    @media (max-width: 375px) {
      .chat-input textarea { max-height: 140px; }
    }

    .error { color: var(--warn); font-size: 14px; margin: 20px 0; }
    .loading { color: var(--mute); font-size: 14px; margin: 20px 0; }

    footer {
      margin-top: 60px; padding-top: 20px; border-top: 1px solid var(--rule);
      font-size: 12px; color: var(--mute); text-align: center;
    }
    footer .footer-ta { font-family: "Noto Sans Tamil", sans-serif; }

    /* Reduced-motion: strip all non-essential animations/transitions. */
    @media (prefers-reduced-motion: reduce) {
      *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
      }
    }
  </style>
</head>
<body>
  <div class="wrap">
    <header>
      <h1 class="logo">
        <a href="#/" style="text-decoration:none;color:inherit;display:inline-flex;align-items:baseline;gap:4px;">
          <span class="brand-en">Thedi</span>
          <span class="brand-ta" lang="ta">தேடி</span>
        </a>
      </h1>
      <div class="auth-slot" id="auth-slot"></div>
    </header>

    <main id="root"><div class="loading">Loading…</div></main>

    <footer>
      <span class="footer-ta" lang="ta">தேடி</span> — Tamil for “seek”.
    </footer>
  </div>

  <script>
    const APP_ID = "app_36ybfio2fiy7";
    const API = `https://api.butterbase.ai/v1/${APP_ID}/fn`;
    const AUTH_BASE = `https://api.butterbase.ai/auth/${APP_ID}`;
    const root = document.getElementById("root");

    // --- Auth callback capture (Butterbase returns tokens in query string) ---
    (function captureAuth() {
      const params = new URLSearchParams(window.location.search);
      const accessToken = params.get("access_token");
      if (accessToken) {
        localStorage.setItem("thedi_access_token", accessToken);
        if (params.get("refresh_token")) localStorage.setItem("thedi_refresh_token", params.get("refresh_token"));
        if (params.get("expires_in")) localStorage.setItem("thedi_expires_at", String(Date.now() + parseInt(params.get("expires_in")) * 1000));
        // Restore the route the user was on before OAuth redirect.
        // Stashed by renderSessionExpired() before kicking off sign-in.
        let resumeHash = "";
        try { resumeHash = sessionStorage.getItem("thedi_return_to") || ""; } catch (_) {}
        try { sessionStorage.removeItem("thedi_return_to"); } catch (_) {}
        const cleanPath = window.location.pathname + (resumeHash || window.location.hash);
        history.replaceState({}, "", cleanPath);
      }
    })();

    function getAuth() { return localStorage.getItem("thedi_access_token"); }
    function clearAuth() {
      localStorage.removeItem("thedi_access_token");
      localStorage.removeItem("thedi_refresh_token");
      localStorage.removeItem("thedi_expires_at");
      localStorage.removeItem("thedi_user");
    }
    function startGoogleSignin(returnToHash) {
      // Stash where we were so the OAuth callback can take the user back.
      // Falls back to the current hash if no explicit returnTo given.
      try {
        const h = returnToHash || window.location.hash || "#/dashboard";
        sessionStorage.setItem("thedi_return_to", h);
      } catch (_) {}
      const redirectTo = `${window.location.origin}/`;
      window.location.href = `${AUTH_BASE}/oauth/google?redirect_to=${encodeURIComponent(redirectTo)}`;
    }

    // Exchange the stored refresh_token for a fresh access_token.
    // Butterbase default TTLs: access 1h, refresh 7d, with rotation — every
    // refresh returns a new refresh_token that supersedes the old. Returns
    // true on success, false otherwise. A single in-flight promise is shared
    // across callers so concurrent apiAuth() calls don't trigger N refreshes.
    let _refreshInflight = null;
    async function refreshSession() {
      if (_refreshInflight) return _refreshInflight;
      const refresh_token = localStorage.getItem("thedi_refresh_token");
      if (!refresh_token) return false;
      _refreshInflight = (async () => {
        try {
          const r = await fetch(`${AUTH_BASE}/refresh`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ refresh_token }),
            cache: "no-store",
          });
          if (!r.ok) return false;
          const data = await r.json();
          if (!data.access_token) return false;
          localStorage.setItem("thedi_access_token", data.access_token);
          if (data.refresh_token) localStorage.setItem("thedi_refresh_token", data.refresh_token);
          if (data.expires_in) localStorage.setItem("thedi_expires_at", String(Date.now() + parseInt(data.expires_in) * 1000));
          return true;
        } catch {
          return false;
        } finally {
          _refreshInflight = null;
        }
      })();
      return _refreshInflight;
    }
    async function fetchMe() {
      const tok = getAuth(); if (!tok) return null;
      try {
        const res = await fetch(`${AUTH_BASE}/me`, { headers: { Authorization: `Bearer ${tok}` } });
        if (!res.ok) { clearAuth(); return null; }
        const u = await res.json();
        localStorage.setItem("thedi_user", JSON.stringify(u));
        return u;
      } catch { return null; }
    }

    // ── Kolam SVG ──────────────────────────────────────────────────────────
    // Pulli-kolam (dot kolam) motif: a 5-dot grid (center + 4 cardinals) with
    // a single continuous looped path drawn around them. The path is
    // rotationally symmetric (4-fold) — the defining quality of a kolam.
    // Styling + draw-in animation live in CSS; this function just returns
    // the markup.
    function kolamSVG() {
      // Two-petal pulli-kolam (design handoff 2026-04-21): vertical lobe +
      // horizontal lobe crossing at center, four corner dots, a lit heart.
      // Both petals share the same accent stroke so the draw-in animation
      // reads as one continuous gesture.
      return `
      <svg viewBox="0 0 140 140" aria-hidden="true" role="img">
        <circle class="k-dot" cx="42" cy="42" r="2.4"/>
        <circle class="k-dot" cx="98" cy="42" r="2.4"/>
        <circle class="k-dot" cx="98" cy="98" r="2.4"/>
        <circle class="k-dot" cx="42" cy="98" r="2.4"/>
        <path class="k-path" d="M 70 16 C 42 42, 42 98, 70 124 C 98 98, 98 42, 70 16 Z"/>
        <path class="k-path" d="M 16 70 C 42 42, 98 42, 124 70 C 98 98, 42 98, 16 70 Z"/>
        <circle class="k-center" cx="70" cy="70" r="5"/>
      </svg>`;
    }

    // Kolam spinner markup — child elements (not pseudo-els) so each dot
    // gets its own animation-delay. Kept as a constant so every async wait
    // state reads visually identical.
    const SPINNER_HTML =
      '<div class="spinner" aria-hidden="true">' +
        '<span class="ring"></span><span class="ring b"></span>' +
        '<span class="d n"></span><span class="d e"></span>' +
        '<span class="d s"></span><span class="d w"></span>' +
        '<span class="heart"></span>' +
      '</div>';

    // Hash-based routing
    function route() {
      const hash = (window.location.hash || "#/").replace(/^#/, "") || "/";
      const mF = hash.match(/^\/f\/([A-Za-z0-9_-]+)/);
      const mC = hash.match(/^\/chat\/([A-Za-z0-9_-]+)/);
      const mR = hash.match(/^\/r\/([A-Za-z0-9_-]+)/);
      if (mF) return renderFindings(mF[1]);
      if (mC) return renderChat(mC[1]);
      if (mR) return renderReplay(mR[1]);
      if (hash === "/onboard" || hash.startsWith("/onboard")) return renderOnboard();
      if (hash === "/dashboard" || hash.startsWith("/dashboard")) return renderDashboard();
      return renderLanding();
    }
    window.addEventListener("hashchange", route);

    // ── Auth slot (persistent in header on every route) ─────────────────
    // Signed out: "Sign in" ghost button.
    // Signed in:  avatar + email → dropdown with Sign out.
    async function renderAuthSlot() {
      const slot = document.getElementById("auth-slot");
      if (!slot) return null;
      const user = getAuth() ? (JSON.parse(localStorage.getItem("thedi_user") || "null") || await fetchMe()) : null;
      if (!user) {
        slot.innerHTML = `<button class="auth-signin" id="header-signin" aria-label="Sign in with Google">Sign in</button>`;
        const btn = document.getElementById("header-signin");
        if (btn) btn.onclick = startGoogleSignin;
        slot.classList.remove("open");
        return null;
      }
      const email = user.email || user.display_name || "user";
      const initial = (email[0] || "?").toUpperCase();
      slot.innerHTML = `
        <button class="auth-profile" id="header-profile" aria-haspopup="menu" aria-expanded="false">
          <span class="auth-avatar">${escHtml(initial)}</span>
          <span class="auth-email">${escHtml(email)}</span>
          <span class="auth-caret">▾</span>
        </button>
        <div class="auth-menu" role="menu">
          <div class="menu-email" title="${escHtml(email)}">${escHtml(email)}</div>
          <a href="#/dashboard" role="menuitem">Your digests</a>
          <a href="#/onboard?mode=edit" role="menuitem">Edit your seeds</a>
          <button id="header-signout" role="menuitem">Sign out</button>
        </div>
      `;
      const btn = document.getElementById("header-profile");
      const so = document.getElementById("header-signout");
      if (btn) {
        btn.onclick = (e) => {
          e.stopPropagation();
          slot.classList.toggle("open");
          btn.setAttribute("aria-expanded", slot.classList.contains("open") ? "true" : "false");
        };
      }
      if (so) {
        so.onclick = () => {
          clearAuth();
          slot.classList.remove("open");
          route(); // re-render current view
        };
      }
      // Click-outside closes the menu.
      document.addEventListener("click", (e) => {
        if (!slot.contains(e.target)) {
          slot.classList.remove("open");
          if (btn) btn.setAttribute("aria-expanded", "false");
        }
      }, { once: true });
      return user;
    }

    async function renderLanding() {
      const user = await renderAuthSlot();
      // Signed-out users get the primary conversion CTA in the hero body.
      // Signed-in users get a primary link into their dashboard.
      const heroCTA = user
        ? `<div style="margin-top:24px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
             <a class="btn primary" href="#/dashboard">View your digests →</a>
             <span style="font-size:13px;color:var(--mute);">Signed in as ${escHtml(user.email || user.display_name || "user")}</span>
           </div>`
        : `<div style="margin-top:24px;">
             <button class="btn cocoa" id="google-signin"><span class="g"></span>Sign in with Google</button>
           </div>`;
      root.innerHTML = `
        <!-- P01: kolam-inspired hero visual.
             Animates on the first landing visit per session; renders static
             on subsequent returns to avoid gratuitous re-drawing. -->
        <div class="kolam-hero ${sessionStorage.getItem('thedi_kolam_seen') ? '' : 'animate'}">${kolamSVG()}</div>

        <div class="hero">
          <strong>Thedi reads arxiv, HN, Substack, Bluesky, Lobsters, and X every morning.</strong>
          You get 10 ranked items with why-it-matters for you, delivered to your inbox.
          Tell it what missed; tomorrow adjusts.
        </div>
        ${heroCTA}
        <h2>How it works</h2>
        <div class="stages">
          <div class="stage">
            <div class="num">1</div>
            <div>
              <div class="verb">Ingest</div>
              <div class="clar">Six sources in parallel · matched to your keywords</div>
            </div>
            <div class="gly">⇊</div>
          </div>
          <div class="stage">
            <div class="num">2</div>
            <div>
              <div class="verb">Rank · critique · refine</div>
              <div class="clar">Three-agent loop picks ten items</div>
            </div>
            <div class="gly">⟳</div>
          </div>
          <div class="stage">
            <div class="num">3</div>
            <div>
              <div class="verb">Deliver</div>
              <div class="clar">One email at your morning hour</div>
            </div>
            <div class="gly">✉</div>
          </div>
          <div class="stage">
            <div class="num">4</div>
            <div>
              <div class="verb">Learn</div>
              <div class="clar">You steer — tomorrow adjusts</div>
            </div>
            <div class="gly">↻</div>
          </div>
        </div>
      `;
      const gs = document.getElementById("google-signin");
      if (gs) gs.onclick = startGoogleSignin;
      // Mark kolam as seen so subsequent landing visits skip the animation.
      try { sessionStorage.setItem("thedi_kolam_seen", "1"); } catch (_) {}
    }

    function escHtml(s) {
      return String(s || "").replace(/[&<>\"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]));
    }

    async function api(path, body) {
      const res = await fetch(`${API}${path}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
        cache: "no-store",
      });
      return res.json();
    }

    // Authenticated API call — attaches the Butterbase access token so the
    // function receives ctx.user.id. Used by /scout-my-digests and any
    // future user-scoped endpoints.
    //
    // Token-refresh behavior:
    //  1. If we notice access_token has expired (thedi_expires_at already
    //     past), refresh silently BEFORE the call.
    //  2. If the call still 401s (server clock skew, token revoked mid-flight),
    //     try one refresh-and-retry. Only surface "session expired" if the
    //     refresh itself fails or retry still 401s.
    //  3. When we do clear auth, re-render the header so the "signed in"
    //     avatar doesn't sit next to a "session expired" message.
    async function apiAuth(path, body) {
      let tok = getAuth();
      if (!tok) return { ok: false, error: "not signed in" };

      // Proactive refresh: if we know the token expired, skip the
      // guaranteed-401 round trip and refresh first.
      const expiresAt = parseInt(localStorage.getItem("thedi_expires_at") || "0", 10);
      if (expiresAt && Date.now() >= expiresAt) {
        const ok = await refreshSession();
        if (!ok) {
          clearAuth();
          await renderAuthSlot().catch(() => {});
          return { ok: false, reason: "session_expired", error: "session expired" };
        }
        tok = getAuth();
      }

      const doFetch = () => fetch(`${API}${path}`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer ${getAuth()}`,
        },
        body: JSON.stringify(body || {}),
        cache: "no-store",
      });

      let res;
      try {
        res = await doFetch();
      } catch (e) {
        // Network-layer failure — not an auth problem. Surface distinctly
        // so callers can offer "Retry" instead of forcing re-auth.
        return { ok: false, reason: "network", error: "couldn't reach Thedi" };
      }
      if (res.status === 401) {
        // Token might have been revoked or the clock ran past TTL between
        // proactive check and server check. One retry after refresh.
        const ok = await refreshSession();
        if (ok) {
          try { res = await doFetch(); }
          catch (e) { return { ok: false, reason: "network", error: "couldn't reach Thedi" }; }
        }
        if (res.status === 401) {
          clearAuth();
          await renderAuthSlot().catch(() => {});
          return { ok: false, reason: "session_expired", error: "session expired" };
        }
      }
      return res.json();
    }

    // ── Session-expired card ───────────────────────────────────────────
    // Synthesized from 3 parallel UX agents (2026-04-21):
    //   • Silent refresh first; card only appears after silent fails.
    //   • Card offers "Try again" (manual retry) + "Continue with Google"
    //     (full re-auth). No flat error text.
    //   • Return route stashed to sessionStorage via startGoogleSignin
    //     so OAuth callback resumes where they were.
    //   • Header flips to signed-out (apiAuth already clearAuth+renderAuthSlot).
    async function renderSessionExpired(returnToHash) {
      renderAuthSlot();
      const target = returnToHash || window.location.hash || "#/dashboard";
      const hasRefreshToken = !!localStorage.getItem("thedi_refresh_token");

      // Try a silent refresh ONCE before showing the card. Covers the
      // common case (refresh token still valid). If it succeeds, re-route
      // transparently. If not, fall through to the card.
      if (hasRefreshToken) {
        root.innerHTML = `
          <div class="subnav"><a href="#/">← Home</a></div>
          <div class="migration-card" style="margin-top:24px;">
            ${SPINNER_HTML}
            <div>
              <div class="migration-title">Checking your session…</div>
              <div class="migration-sub">One moment.</div>
            </div>
          </div>`;
        const ok = await refreshSession();
        if (ok) {
          // Re-run the current route now that we have a fresh token.
          route();
          return;
        }
      }

      // Card: two framings depending on whether a refresh path exists.
      const refreshable = hasRefreshToken; // refresh is at least attemptable
      const heading = refreshable ? "The session went quiet." : "Sign in to continue.";
      const sub = refreshable
        ? "Thedi can try to pick it up where you left off."
        : "Thedi paused here. We'll take you back once you're in.";
      const primaryLabel = refreshable ? "Try again" : "Continue with Google";
      const secondary = refreshable
        ? `<button class="btn ghost" id="sx-signin">Sign in again</button>`
        : "";

      root.innerHTML = `
        <div class="subnav"><a href="#/">← Home</a></div>
        <div class="kolam-hero" aria-hidden="true" style="opacity:.55;margin:18px 0 6px;">${kolamSVG()}</div>
        <div class="migration-card" style="margin-top:12px;">
          <div>
            <div class="migration-title">${heading}</div>
            <div class="migration-sub">${sub}</div>
          </div>
          <div style="margin-left:auto;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
            ${secondary}
            <button class="btn primary" id="sx-primary">${primaryLabel}</button>
          </div>
        </div>`;

      const primary = document.getElementById("sx-primary");
      primary.onclick = async () => {
        if (refreshable) {
          primary.disabled = true;
          const origLabel = primary.textContent;
          primary.textContent = "Refreshing…";
          const ok = await refreshSession();
          if (ok) {
            route();
            return;
          }
          // Silent retry failed again — escalate to full OAuth with the
          // route stashed so the user lands back here after auth.
          primary.textContent = origLabel;
          primary.disabled = false;
          startGoogleSignin(target);
        } else {
          startGoogleSignin(target);
        }
      };
      const signin = document.getElementById("sx-signin");
      if (signin) signin.onclick = () => startGoogleSignin(target);
    }

    // Network-failure card (distinct from auth failure — don't force re-auth
    // on a connectivity blip). One retry button; no Google button.
    function renderNetworkFailure(retryFn) {
      renderAuthSlot();
      root.innerHTML = `
        <div class="subnav"><a href="#/">← Home</a></div>
        <div class="migration-card" style="margin-top:24px;">
          <div>
            <div class="migration-title">Can't reach Thedi.</div>
            <div class="migration-sub">The network dropped. Try again in a moment.</div>
          </div>
          <div style="margin-left:auto;">
            <button class="btn primary" id="nf-retry">Retry</button>
          </div>
        </div>`;
      const btn = document.getElementById("nf-retry");
      btn.onclick = () => { if (typeof retryFn === "function") retryFn(); else route(); };
    }

    // ── Auto-migrate helper (idempotent) ──────────────────────────────
    // After the first successful sign-in for a given user, their
    // ctx.user.id (Butterbase OAuth UUID) may not match the Thedi-layer
    // users.id that existed pre-OAuth. This helper reconciles the two
    // server-side. Runs once per browser session (tracked via
    // localStorage.thedi_migration_done). A fresh login from a different
    // device still auto-fixes because the server-side check is idempotent.
    async function autoMigrateIfNeeded(lastResponse) {
      // Skip if we already verified good state in this browser.
      const migKey = "thedi_migration_done";
      try { if (localStorage.getItem(migKey) === "1") return { status: "skipped-cached" }; } catch (_) {}
      // Only migrate when the last scout-my-digests response explicitly
      // flagged the mismatch OR when we couldn't find a users row at all.
      if (lastResponse && lastResponse.id_matches === true) {
        try { localStorage.setItem(migKey, "1"); } catch (_) {}
        return { status: "already-matched" };
      }
      // Pull email from the locally-cached profile (thedi_user); ctx.user
      // server-side may not include it for OAuth sessions.
      const profile = JSON.parse(localStorage.getItem("thedi_user") || "null") || {};
      const res = await apiAuth("/scout-auto-migrate-me", { email: profile.email });
      if (res.status === "migrated"
          || res.status === "already-migrated"
          || res.status === "fresh-user-created") {
        try { localStorage.setItem(migKey, "1"); } catch (_) {}
      }
      return res;
    }

    // ── Dashboard — list the signed-in user's digests ────────────────────
    async function renderDashboard() {
      renderAuthSlot();
      if (!getAuth()) {
        root.innerHTML = `
          <div class="subnav"><a href="#/">← Back to home</a></div>
          <h2 style="margin-top:10px;">Your digests</h2>
          <div class="error">You need to sign in to see your dashboard.</div>
          <div style="margin-top:16px;">
            <button class="btn primary" id="dash-signin">Sign in with Google</button>
          </div>`;
        const b = document.getElementById("dash-signin");
        if (b) b.onclick = startGoogleSignin;
        return;
      }
      // Dashboard loading — split auth-verify / fetch so the spinner's
      // message matches what's actually happening.
      root.innerHTML = `
        <div class="subnav"><a href="#/">← Home</a></div>
        <h2 style="margin-top:10px;">Your digests</h2>
        <div class="migration-card" id="dash-initial-load">
          ${SPINNER_HTML}
          <div>
            <div class="migration-title">Verifying your session…</div>
            <div class="migration-sub">Checking your login with Butterbase.</div>
          </div>
        </div>`;
      // Fetch digests + interests status in parallel; the two endpoints
      // are independent and we want the dashboard to decide about the
      // onboarding redirect without sequencing two round-trips.
      let [res, hiRes] = await Promise.all([
        apiAuth("/scout-my-digests", {}),
        apiAuth("/scout-has-interests", {}),
      ]);
      // Now flip the message to reflect the next phase — if the server
      // accepts the session, we're actually fetching digests.
      const initLoad = document.getElementById("dash-initial-load");
      if (initLoad && res.ok) {
        initLoad.innerHTML = `
          ${SPINNER_HTML}
          <div>
            <div class="migration-title">Curating your research…</div>
            <div class="migration-sub">Loading digests from the scout pipeline.</div>
          </div>`;
      }
      if (!res.ok) {
        // Auth failure → show the session-expired card (silent-refresh-first).
        // Network failure → retry card. Any other error → flat message.
        if (res.reason === "session_expired") return renderSessionExpired("#/dashboard");
        if (res.reason === "network") return renderNetworkFailure(() => renderDashboard());
        root.innerHTML = `
          <div class="subnav"><a href="#/">← Back to home</a></div>
          <h2 style="margin-top:10px;">Your digests</h2>
          <div class="error">${escHtml(res.error || "couldn't load")}</div>`;
        return;
      }

      // Surface has_interests onto res for downstream rendering decisions
      // (empty-dashboard CTA etc). Default to undefined if the call failed
      // — we don't want to surprise-redirect users on a transient error.
      if (hiRes && hiRes.ok) res.has_interests = hiRes.has_interests;

      // First-run onboarding: if the user has no interests yet, nudge them
      // to /#/onboard unless they explicitly chose to skip (?skip=1).
      // Gate on id_matches === true so identity-mismatch users finish
      // their migration first.
      const hashNow = (window.location.hash || "").toLowerCase();
      if (
        res.id_matches === true &&
        res.has_interests === false &&
        !hashNow.includes("skip=1")
      ) {
        window.location.hash = "#/onboard";
        return;
      }

      // Auto-migrate on first load if identity mismatch / no users row yet.
      if (res.id_matches !== true) {
        root.innerHTML = `
          <div class="subnav"><a href="#/">← Back to home</a></div>
          <h2 style="margin-top:10px;">Your digests</h2>
          <div class="migration-card">
            ${SPINNER_HTML}
            <div>
              <div class="migration-title">Linking your account…</div>
              <div class="migration-sub">First-time setup: matching your login to your existing digests.</div>
            </div>
          </div>`;
        const mig = await autoMigrateIfNeeded(res);
        let migBanner = "";
        if (mig.status === "migrated") {
          const t = mig.rows_touched || {};
          migBanner = `
            <div class="migration-card success">
              <div class="migration-check">✓</div>
              <div>
                <div class="migration-title">Account linked.</div>
                <div class="migration-sub">
                  Reconnected ${(t.digests || 0)} digests · ${(t.interests || 0)} interests · ${(t.audit_log || 0)} audit rows.
                </div>
              </div>
            </div>`;
        } else if (mig.status === "fresh-user-created") {
          migBanner = `
            <div class="migration-card success">
              <div class="migration-check">✓</div>
              <div>
                <div class="migration-title">Welcome.</div>
                <div class="migration-sub">Your account is set up. Once the daily dispatcher cycles, digests will appear here.</div>
              </div>
            </div>`;
        } else if (mig.status && mig.status !== "already-matched" && mig.status !== "already-migrated" && mig.status !== "skipped-cached") {
          migBanner = `
            <div class="migration-card warn">
              <div class="migration-check" style="color:var(--warn);">!</div>
              <div>
                <div class="migration-title">Couldn't auto-link your account.</div>
                <div class="migration-sub">${escHtml(mig.error || mig.message || mig.status)}</div>
              </div>
            </div>`;
        }
        // Re-fetch to pick up newly-visible rows after the migration.
        res = await apiAuth("/scout-my-digests", {});
        if (!res.ok) {
          root.innerHTML = `
            <div class="subnav"><a href="#/">← Back to home</a></div>
            <h2 style="margin-top:10px;">Your digests</h2>
            ${migBanner}
            <div class="error">${escHtml(res.error || "couldn't reload")}</div>`;
          return;
        }
        // Re-fetch has_interests — user's users.id is now live, which means
        // interests lookup (RLS-scoped to current_user_id) may return rows
        // it couldn't see pre-migrate. On a brand-new sign-in this still
        // returns has_interests=false, so the post-migrate redirect below
        // takes them into onboarding. Previously the dashboard rendered
        // the empty-state CTA instead of auto-redirecting, stranding
        // anya.primary@gmail.com on mobile Chrome 2026-04-22.
        try {
          const hiRes2 = await apiAuth("/scout-has-interests", {});
          if (hiRes2 && hiRes2.ok) res.has_interests = hiRes2.has_interests;
        } catch (_) { /* keep original hiRes value */ }
        // Post-migrate onboarding redirect. Gate on migration success
        // (either path that implies users.id is settled) + no interests
        // yet + not an explicit skip. Mirrors the pre-migrate check at
        // the top of this function.
        const migrationSettled = mig && (
          mig.status === "fresh-user-created" ||
          mig.status === "migrated"           ||
          mig.status === "already-matched"    ||
          mig.status === "already-migrated"   ||
          mig.status === "skipped-cached"
        );
        if (
          migrationSettled &&
          res.has_interests === false &&
          (res.digests || []).length === 0 &&
          !hashNow.includes("skip=1")
        ) {
          window.location.hash = "#/onboard";
          return;
        }
        // Tuck the mig banner above the digests render below by stashing it
        // on the result so the normal render path can emit it once.
        res._mig_banner = migBanner;
      }
      const digests = res.digests || [];

      // Debug panel — always shown when digests list is empty so we can
      // diagnose why (identity mismatch, no users row, etc.). Collapsible
      // by default so it stays out of the way.
      const debugKV = {
        email: res.email || "(none)",
        "users.id (Thedi app-layer)": res.user_id || "(no users row)",
        "auth_user_id (Butterbase OAuth)": res.auth_user_id || "(none in ctx.user)",
        "id_matches": res.id_matches === true ? "true" :
                       res.id_matches === false ? "false" : "(unknown)",
        "digests returned": String(res.digests_count ?? 0),
        "digests query source": res.digests_source || "(unknown)",
        "migration hint": res.migration_hint || "(none)",
      };
      const debugHTML = (digests.length === 0) ? `
        <details class="debug-panel" open>
          <summary>Debug — identity + API response (tap "Copy" and paste to Ashish)</summary>
          <div class="debug-body">
            <p style="margin:0 0 10px;color:var(--mute);">
              If the dashboard is empty but you've had digests before, this block
              has the two UUIDs needed to reconcile the identity and restore
              the list.
            </p>
            <div class="kv">
              ${Object.entries(debugKV).map(([k, v]) => `
                <div><span class="k">${escHtml(k)}:</span> <span class="v">${escHtml(String(v))}</span></div>
              `).join("")}
            </div>
            <button class="copy-btn" id="debug-copy-btn" type="button">Copy debug info</button>
          </div>
        </details>` : "";
      if (digests.length === 0) {
        const needsOnboard = res.has_interests === false;
        root.innerHTML = `
          <div class="subnav"><a href="#/">← Back to home</a></div>
          <h2 style="margin-top:0;">Your digests</h2>
          ${res._mig_banner || ""}
          <div class="hero" style="margin-top:10px;">
            ${needsOnboard
              ? "Thedi doesn't know what to seek for you yet."
              : "No digests yet. Once the daily dispatcher cycles (or a welcome digest fires), your briefs will appear here."}
          </div>
          ${needsOnboard ? `
            <div style="margin-top:16px;">
              <a href="#/onboard" class="btn primary" style="text-decoration:none;">Tell Thedi what to seek</a>
            </div>` : ""}
          ${debugHTML}`;
        const btn = document.getElementById("debug-copy-btn");
        if (btn) {
          btn.onclick = async () => {
            const text = Object.entries(debugKV).map(([k, v]) => `${k}: ${v}`).join("\n");
            try {
              await navigator.clipboard.writeText(text);
              btn.textContent = "✓ Copied — paste to Ashish";
              btn.classList.add("copied");
              setTimeout(() => {
                btn.textContent = "Copy debug info";
                btn.classList.remove("copied");
              }, 2400);
            } catch (e) {
              alert(text);
            }
          };
        }
        return;
      }
      const statusStyle = (s) => {
        if (s === "sent") return "background:#dcfce7;color:#166534;";
        if (s === "failed") return "background:#fee2e2;color:#991b1b;";
        if (s === "rendering" || s === "polishing") return "background:#fef3c7;color:#92400e;";
        return "background:var(--surface-2);color:var(--mute);";
      };
      const fmtDate = (iso) => {
        if (!iso) return "";
        try { return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); }
        catch { return iso; }
      };
      root.innerHTML = `
        <div class="subnav"><a href="#/">← Back to home</a></div>
        <h2 style="margin-top:10px;">Your digests <span style="font-size:13px;color:var(--mute);font-weight:400;margin-left:8px;">${digests.length} total</span></h2>
        ${res._mig_banner || ""}
        <div class="hero" style="margin-top:10px;font-size:14px;">
          Click any digest to see its findings. The replay shows how Thedi ranked it.
        </div>
        <div id="digest-list" style="margin-top:20px;">
        ${digests.map(d => {
          const runDate = escHtml(d.run_date || "(no date)");
          const status = escHtml(d.status || "");
          const count = d.item_count || 0;
          const sent = d.sent_at ? `delivered ${escHtml(fmtDate(d.sent_at))}` : `created ${escHtml(fmtDate(d.created_at))}`;
          const critic = d.critic_accepted === true  ? "critic accepted"
                        : d.critic_accepted === false ? "refiner re-ranked"
                        : "";
          return `
            <div class="finding" style="padding:18px;">
              <div class="meta">
                <span class="rank-chip">${runDate}</span>
                <span class="source-badge" style="${statusStyle(d.status)}">${status}</span>
                ${count ? `<span class="score-pill" style="background:var(--ink);color:#fff;margin-left:auto;">${count} items</span>` : ""}
              </div>
              <div style="font-size:12px;color:var(--mute);margin-top:8px;">
                ${sent}${critic ? " · " + critic : ""}
              </div>
              ${d.critique_text ? `<div style="font-size:13px;color:var(--ink);margin-top:8px;font-style:italic;">"${escHtml(String(d.critique_text).slice(0, 220))}${d.critique_text.length > 220 ? "…" : ""}"</div>` : ""}
              <div class="seeds" style="margin-top:14px;">
                ${d.status === "sent" || d.share_token ? `<a href="#/f/${escHtml(d.share_token)}">Open findings</a>` : ""}
                ${d.share_token ? `<a href="#/r/${escHtml(d.share_token)}">See replay</a>` : ""}
                ${d.share_token ? `<a href="#/chat/${escHtml(d.share_token)}">Give feedback</a>` : ""}
              </div>
            </div>`;
        }).join("")}
        </div>`;
    }

    // ── Onboarding — single textarea → Qwen parse → confirm → apply ────
    // Synthesized from three parallel critics (2026-04-21):
    //   • Trigger: has_interests === false from /scout-my-digests (idempotent)
    //   • Two-phase: parse preview → confirm → apply (never silent-commit)
    //   • Kolam spinner during parse (reuses shipped identity)
    //   • Soft-skip allowed via ?skip=1; dashboard surfaces return CTA
    //   • Language-agnostic (Qwen system prompt respects user's language)
    async function renderOnboard() {
      renderAuthSlot();
      if (!getAuth()) {
        root.innerHTML = `
          <div class="subnav"><a href="#/">← Home</a></div>
          <h2 style="margin-top:10px;">What should Thedi seek for you?</h2>
          <div class="error">Sign in first, then describe what you want Thedi to find.</div>
          <div style="margin-top:16px;">
            <button class="btn primary" id="onb-signin">Sign in with Google</button>
          </div>`;
        const b = document.getElementById("onb-signin");
        if (b) b.onclick = startGoogleSignin;
        return;
      }

      const isEdit = (window.location.hash || "").includes("mode=edit");
      const prior = localStorage.getItem("thedi_onboard_text") || "";
      const PLACEHOLDER =
        "e.g. I'm a research engineer working on small language models and RAG. " +
        "I care about post-training, eval benchmarks, and anything that makes " +
        "7B models punch above their weight. Less ML hype, more papers with code.";

      const renderTextareaStep = (preset) => {
        root.innerHTML = `
          <div class="subnav"><a href="#/dashboard?skip=1">← Dashboard</a></div>
          <h2 style="margin-top:10px;">What should Thedi seek for you?</h2>
          <div class="hero" style="font-size:16px;margin:12px 0 20px;">
            Describe what you want in your morning digest — a sentence or a paragraph.
            ${isEdit ? "<br><span style='color:var(--mute);font-size:14px;'>Editing will replace your current seeds when you confirm.</span>" : ""}
          </div>
          <textarea id="onb-text" rows="8" placeholder="${escHtml(PLACEHOLDER)}"
            style="width:100%;padding:14px 16px;border:1px solid var(--rule);
                   border-radius:8px;font-family:inherit;font-size:15px;
                   min-height:180px;resize:vertical;box-sizing:border-box;">${escHtml(preset || "")}</textarea>
          <div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;font-size:12px;color:var(--mute);">
            <span id="onb-counter">0 / 2000</span>
            <a href="#" id="onb-example" style="color:var(--mute);">Use an example →</a>
          </div>
          <div id="onb-error" class="error" style="display:none;margin-top:12px;"></div>
          <div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:20px;align-items:center;">
            <button class="btn primary" id="onb-submit" disabled>Begin seeking</button>
            <a href="#/dashboard?skip=1" style="font-size:13px;color:var(--mute);">Not yet.</a>
          </div>`;

        const ta = document.getElementById("onb-text");
        const counter = document.getElementById("onb-counter");
        const submit = document.getElementById("onb-submit");
        const errBox = document.getElementById("onb-error");
        const exampleLink = document.getElementById("onb-example");

        const update = () => {
          const n = ta.value.length;
          if (n > 2000) ta.value = ta.value.slice(0, 2000);
          counter.textContent = `${ta.value.length} / 2000`;
          submit.disabled = ta.value.trim().length < 20;
        };
        ta.addEventListener("input", update);
        update();

        exampleLink.onclick = (e) => {
          e.preventDefault();
          ta.value = PLACEHOLDER;
          update();
          ta.focus();
        };

        submit.onclick = async () => {
          const text = ta.value.trim();
          if (text.length < 20) return;
          localStorage.setItem("thedi_onboard_text", text);
          errBox.style.display = "none";
          await parsePhase(text);
        };
      };

      const parsePhase = async (text) => {
        root.innerHTML = `
          <div class="subnav"><a href="#/onboard">← Back</a></div>
          <h2 style="margin-top:10px;">What should Thedi seek for you?</h2>
          <div class="migration-card" style="margin-top:20px;">
            ${SPINNER_HTML}
            <div>
              <div class="migration-title">Thedi is reading.</div>
              <div class="migration-sub">Pulling seed topics from what you wrote.</div>
            </div>
          </div>`;
        let res;
        try {
          res = await apiAuth("/scout-parse-interests", {
            source_text: text,
            apply: false,
            timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
            delivery_hour_local: 7,
          });
        } catch (e) {
          res = { ok: false, error: "network", hint: "Thedi couldn't read that just now. Try again — your words are still here." };
        }
        if (!res.ok) {
          if (res.reason === "session_expired") return renderSessionExpired("#/onboard");
          const hint = res.hint || "Thedi couldn't read that just now. Try again — your words are still here.";
          renderTextareaStep(text);
          const errBox = document.getElementById("onb-error");
          errBox.textContent = hint;
          errBox.style.display = "block";
          return;
        }
        confirmPhase(text, res.topics || [], res.avoid || [], res.sources || []);
      };

      const confirmPhase = (text, topics, avoid, sources) => {
        const pill = (s) => `<span style="display:inline-flex;align-items:center;padding:6px 12px;border-radius:999px;border:1px solid var(--rule);background:#fff;font-size:13px;margin:0 6px 6px 0;">${escHtml(s)}</span>`;
        const srcLabel = {
          arxiv: "arXiv", hn: "Hacker News", x: "X/Twitter",
          bsky: "Bluesky", substack: "Substack", lobsters: "Lobsters"
        };
        root.innerHTML = `
          <div class="subnav"><a href="#/onboard">← Back</a></div>
          <h2 style="margin-top:10px;">Here's what Thedi heard.</h2>
          <div class="hero" style="font-size:14px;margin:12px 0 20px;">
            If this looks right, Thedi will seek these every morning. Otherwise, rewrite.
          </div>

          <div style="margin:16px 0;">
            <div style="font-size:12px;color:var(--mute);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px;">Topics</div>
            <div>${topics.map(pill).join("") || "<span style='color:var(--mute);font-size:14px;'>(none)</span>"}</div>
          </div>

          ${avoid.length ? `
          <div style="margin:16px 0;">
            <div style="font-size:12px;color:var(--mute);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px;">Avoid</div>
            <div>${avoid.map(pill).join("")}</div>
          </div>` : ""}

          ${sources.length ? `
          <div style="margin:16px 0;">
            <div style="font-size:12px;color:var(--mute);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px;">Sources</div>
            <div style="font-size:14px;color:var(--ink);">${sources.map(s => escHtml(srcLabel[s] || s)).join(" · ")}</div>
          </div>` : ""}

          <div id="onb-error" class="error" style="display:none;margin-top:12px;"></div>
          <div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:24px;">
            <button class="btn primary" id="onb-apply">Start</button>
            <button class="btn ghost" id="onb-rewrite">Let me rewrite</button>
          </div>`;

        document.getElementById("onb-rewrite").onclick = () => renderTextareaStep(text);
        document.getElementById("onb-apply").onclick = async () => {
          const errBox = document.getElementById("onb-error");
          const btn = document.getElementById("onb-apply");
          btn.disabled = true;
          // Interests commit is fast (~5s); the digest polish is decoupled
          // and driven from brewingPhase() below. Honest loading message.
          btn.textContent = "Planting seeds…";
          let res;
          try {
            // Pull email from thedi_user localStorage (populated by
            // fetchMe() hitting /auth/{app_id}/me). Server's ctx.user
            // doesn't carry email for OAuth sessions, so the client has
            // to tell it — this is what was missing in the version that
            // stranded new users with "Couldn't save your seeds".
            const profile = JSON.parse(localStorage.getItem("thedi_user") || "null") || {};
            res = await apiAuth("/scout-parse-interests", {
              source_text: text,
              apply: true,
              email: profile.email || "",
              timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
              delivery_hour_local: 7,
            });
          } catch (e) {
            res = { ok: false, error: "network" };
          }
          if (!res.ok) {
            if (res.reason === "session_expired") return renderSessionExpired("#/onboard");
            btn.disabled = false;
            btn.textContent = "Start";
            errBox.textContent = res.hint || "Couldn't save your seeds. Try again.";
            errBox.style.display = "block";
            return;
          }
          localStorage.removeItem("thedi_onboard_text");
          // Polish is decoupled server-side (v8): parse-interests returns
          // { brewing: true } fast, and the browser drives the polish +
          // polls for the finished digest. This replaces the old inline
          // res.welcome.share_token path that 504'd on slow polishes.
          return brewingPhase();
        };
      };

      // brewingPhase — the digest is polished OUT of the parse-interests
      // request now (decoupled to avoid the 45s-ceiling 504). The server
      // fires scout-polish-now in the background (ctx.waitUntil), so here we
      // just poll scout-my-digests until the finished digest appears. If it
      // isn't ready within ~90s, the hourly cron backstops and we show the
      // honest "brewing" success card.
      const brewingPhase = async () => {
        root.innerHTML = `
          <div class="subnav"><a href="#/dashboard">Skip to dashboard →</a></div>
          <h2 style="margin-top:10px;">Curating your first digest…</h2>
          <div class="migration-card" style="margin-top:20px;">
            ${SPINNER_HTML}
            <div>
              <div class="migration-title">Thedi is seeking.</div>
              <div class="migration-sub">Reading your sources and ranking your first brief. This usually takes about 30 seconds.</div>
            </div>
          </div>`;

        // Poll for the first FULLY-RENDERED digest. The digest row (and its
        // share_token) is created early with status='polishing', so we gate
        // on status==='sent' to avoid opening a half-rendered page.
        // ~90s budget (18 × 5s).
        let token = null;
        for (let i = 0; i < 18 && !token; i++) {
          await new Promise((r) => setTimeout(r, 5000));
          let d;
          try { d = await apiAuth("/scout-my-digests", {}); } catch (e) { d = null; }
          const ready = d && d.digests && d.digests.find((x) => x.share_token && x.status === "sent");
          if (ready) token = ready.share_token;
        }

        if (token) {
          window.location.hash = "#/f/" + token;
        } else {
          // Not ready yet → it's brewing; the daily dispatcher delivers it.
          successPhase();
        }
      };

      const successPhase = () => {
        root.innerHTML = `
          <div class="subnav"><a href="#/">← Home</a></div>
          <h2 style="margin-top:10px;">Your digests</h2>
          <div class="migration-card success" style="margin-top:16px;">
            <div class="migration-check">✓</div>
            <div>
              <div class="migration-title">Seeds planted.</div>
              <div class="migration-sub">Your first digest is brewing and will appear on your dashboard shortly — if it needs longer, the daily dispatcher delivers it at your next delivery hour. You can edit your seeds any time from the menu above.</div>
            </div>
          </div>
          <div style="margin-top:20px;">
            <a href="#/dashboard" class="btn primary" style="text-decoration:none;">Go to dashboard</a>
          </div>`;
      };

      renderTextareaStep(prior);
    }

    async function renderFindings(token) {
      renderAuthSlot();
      root.innerHTML = `<div class="loading">Loading digest…</div>`;
      const data = await api("/scout-findings", { token });
      if (data.error) return root.innerHTML = `<div class="error">${escHtml(data.error)}</div>`;
      const { digest, findings } = data;
      root.innerHTML = `
        <div class="subnav"><a href="#/dashboard">← Your digests</a></div>
        <div class="hero" style="margin-top:10px;">
          <strong>Morning brief · ${escHtml(digest.run_date)}</strong><br>
          <span style="font-size:14px;">${findings.length} items for ${escHtml((digest.keywords || []).join(", ")) || "you"}</span>
        </div>
        ${digest.critique ? `
          <div class="critique">
            <div class="who">Thedi's critic said</div>
            <div>${escHtml(digest.critique)}</div>
          </div>` : ""}
        ${findings.map((f) => `
          <div class="finding" data-finding-id="${escHtml(f.id || '')}">
            <div class="meta">
              <span class="rank-chip">#${f.rank}</span>
              <span class="source-badge">${escHtml(f.source)}</span>
              <span class="score-pill" title="relevance score (1–10)">${f.score?.toFixed(1) ?? '–'}/10</span>
            </div>
            <div class="title"><a href="${escHtml(f.url)}" target="_blank" rel="noopener">${escHtml(f.title)}</a></div>
            <div class="angle">${escHtml(f.angle)}</div>
            <div class="listen-wrap paper-listen">
              <button class="listen-btn paper-btn" type="button" data-fid="${escHtml(f.id || '')}" aria-label="Generate audio explaining this paper in plain language">
                <span class="listen-icon" aria-hidden="true">▶</span><span class="listen-label">Explain this paper</span>
              </button>
              <span class="listen-hint" aria-hidden="true">~90 sec · plain language</span>
              <audio class="listen-audio paper-audio" preload="none" controls hidden playsinline></audio>
              <span class="paper-status sr-only" role="status" aria-live="polite"></span>
            </div>
            <div class="seeds">
              <a href="#/chat/${token}?seed=more_like_${f.rank}">More like this</a>
              <a href="#/chat/${token}?seed=less_like_${f.rank}">Less like this</a>
            </div>
          </div>`).join("")}
        <div style="margin-top:30px;display:flex;gap:12px;flex-wrap:wrap;">
          <a class="btn" href="#/chat/${token}">Tell Thedi what missed →</a>
          <a class="btn ghost" href="#/r/${token}">See the replay</a>
          <a class="btn ghost" href="#/" style="margin-left:auto;">Home</a>
        </div>`;
      wirePaperButtons(token);
    }

    function base64ToBlobUrl(b64, mime) {
      const bin = atob(b64);
      const bytes = new Uint8Array(bin.length);
      for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
      return URL.createObjectURL(new Blob([bytes], { type: mime || 'audio/mpeg' }));
    }

    function showToast(msg, kind) {
      const t = document.createElement('div');
      t.className = 'toast' + (kind ? ' ' + kind : '');
      t.textContent = msg;
      document.body.appendChild(t);
      setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity 300ms'; }, 5000);
      setTimeout(() => t.remove(), 5400);
    }

    // ── Per-paper "Explain this paper" — single-finding audio explainer ──────
    // Each finding card has its own button. POST /digest_listen {token, finding_id}
    // -> { audio_b64, mime, cached }. The server writes an ADHD-friendly, plain-
    // language explanation of THAT paper (one analogy, ~90 sec) and caches it on
    // findings.audio_b64, so a second click is instant. iOS Safari blocks play()
    // after an await, so we prime the <audio> inside the click gesture and rely on
    // the native controls as the guaranteed fallback gesture. (WebKit #180522)
    function wirePaperButtons(token) {
      const btns = document.querySelectorAll('.paper-btn');
      btns.forEach((btn) => wireOnePaperButton(token, btn));
    }

    function wireOnePaperButton(token, btn) {
      const fid = btn.getAttribute('data-fid');
      const wrap = btn.closest('.paper-listen');
      const audio = wrap && wrap.querySelector('.paper-audio');
      const status = wrap && wrap.querySelector('.paper-status');
      if (!fid || !btn || !audio) return;

      const LABEL = 'Explain this paper';
      let objUrl = null;   // current Blob URL (revoked on replace)
      let busy = false;
      let started = false; // true once real playback has been initiated
      let phase = null;

      function revoke() { if (objUrl) { try { URL.revokeObjectURL(objUrl); } catch (_) {} objUrl = null; } }

      function setLoading(on) {
        btn.disabled = on;
        btn.setAttribute('aria-busy', on ? 'true' : 'false');
        btn.classList.toggle('is-loading', on);
        btn.querySelector('.listen-label').textContent = on ? 'Writing the explainer…' : LABEL;
        if (on) {
          status.textContent = 'Generating audio, this can take up to a minute';
          phase = setTimeout(() => {
            const lbl = btn.querySelector('.listen-label');
            if (lbl) lbl.textContent = 'Almost ready — narrating…';
          }, 12000);
        }
        if (!on && phase) { clearTimeout(phase); phase = null; }
      }

      // Recover to the button if generation fails OR the blob won't decode/play.
      function fail(msg) {
        setLoading(false);
        started = false;
        revoke();
        audio.removeAttribute('src');
        audio.hidden = true;
        btn.hidden = false;
        status.textContent = 'Audio failed, please try again';
        showToast(msg || "Couldn't generate audio — try again", 'err');
      }
      audio.addEventListener('error', () => { if (started) fail('Audio failed to play — try again'); });

      function playUrl(u) {
        if (objUrl && objUrl !== u) revoke();
        objUrl = u;
        started = true;
        audio.src = u;
        audio.hidden = false;
        btn.hidden = true;
        try { audio.focus(); } catch (_) {}
        status.textContent = 'Audio ready, playing now';
        audio.play().catch(() => { /* visible native controls are the fallback gesture */ });
      }

      async function generate() {
        const res = await api('/digest_listen', { token, finding_id: fid });
        if (!res || res.error || !res.audio_b64) {
          throw new Error((res && (res.message || res.error)) || 'no_audio');
        }
        return base64ToBlobUrl(res.audio_b64, res.mime);
      }

      btn.addEventListener('click', async () => {
        if (busy) return;
        // Prime the <audio> inside the gesture so a post-await play() is honored on
        // iOS. Only pause if real playback hasn't started yet (avoids a fast cache-hit
        // race where this stale .then() would pause the real stream).
        try {
          audio.muted = true;
          const p = audio.play();
          if (p && p.then) p.then(() => { if (!started) audio.pause(); audio.muted = false; }).catch(() => { audio.muted = false; });
          else audio.muted = false;
        } catch (_) { audio.muted = false; }

        busy = true;
        setLoading(true);
        try {
          const u = await generate();
          setLoading(false);
          playUrl(u);
        } catch (e) {
          fail("Couldn't generate audio — try again");
        } finally { busy = false; }
      });
    }

    async function renderReplay(token) {
      renderAuthSlot();
      root.innerHTML = `<div class="loading">Loading replay…</div>`;
      const data = await api("/scout-findings", { token });
      if (data.error) return root.innerHTML = `<div class="error">${escHtml(data.error)}</div>`;
      const { digest, findings } = data;

      const withDelta = findings.map(f => ({
        ...f,
        delta: f.initial_rank != null ? (f.initial_rank - f.rank) : null,
      }));

      root.innerHTML = `
        <div class="subnav"><a href="#/f/${token}">← Findings</a></div>
        <h2 style="margin-top:10px;">The MAKER loop — how Thedi got to this list</h2>
        <div class="stage"><div class="num">1</div><div><strong>Selector</strong> ranked 50 raw items → initial top 10.</div></div>
        ${digest.critique ? `
          <div class="critique">
            <div class="who">Step 2 — Critic reviewed the initial ranking</div>
            <div>${escHtml(digest.critique)}</div>
            <div style="margin-top:8px;font-size:12px;color:var(--accent-text);">Accepted: ${digest.critic_accepted ? "✓" : "✗ → refiner re-ranked"}</div>
          </div>` : ""}
        <div class="stage"><div class="num">3</div><div><strong>Refiner</strong> re-ranked given the critique. Rank-diff below.</div></div>
        <h2>Rank changes</h2>
        ${withDelta.map(f => `
          <div class="finding">
            <div class="meta">
              <span class="rank-chip">#${f.rank}</span>
              <span class="source-badge">${escHtml(f.source)}</span>
              ${f.delta == null
                ? `<span class="rank-delta new">NEW · pulled in by refiner</span>`
                : f.delta > 0
                  ? `<span class="rank-delta up">↑ ${f.delta} (was #${f.initial_rank})</span>`
                  : f.delta < 0
                    ? `<span class="rank-delta down">↓ ${Math.abs(f.delta)} (was #${f.initial_rank})</span>`
                    : `<span style="color:var(--mute);">unchanged</span>`}
            </div>
            <div class="title"><a href="${escHtml(f.url)}" target="_blank" rel="noopener">${escHtml(f.title)}</a></div>
            <div class="angle">${escHtml(f.angle)}</div>
          </div>`).join("")}
        <div style="margin-top:30px;display:flex;gap:12px;flex-wrap:wrap;">
          <a class="btn ghost" href="#/f/${token}">← Back to findings</a>
          <a class="btn ghost" href="#/" style="margin-left:auto;">Home</a>
        </div>`;
    }

    async function renderChat(token) {
      renderAuthSlot();
      const hash = window.location.hash || "";
      const qIdx = hash.indexOf("?");
      const seed = qIdx > 0 ? new URLSearchParams(hash.slice(qIdx + 1)).get("seed") : null;

      root.innerHTML = `<div class="loading">Loading chat…</div>`;
      const data = await api("/scout-findings", { token });
      if (data.error) return root.innerHTML = `<div class="error">${escHtml(data.error)}</div>`;
      const { findings } = data;

      const top2 = findings.slice(0, 2);
      let opener = "Hey — I picked these for today. Which of these missed the mark, or was there something you wanted that I skipped?";
      if (top2.length === 2) {
        opener = `I picked "${top2[0].title}" at #1 and "${top2[1].title}" at #2. Which of these missed, or was there something you wanted that I skipped?`;
      }

      let seedMessage = "";
      if (seed) {
        const m = seed.match(/^(more|less)_like_(\d+)$/);
        if (m) {
          const which = findings.find(f => f.rank === +m[2]);
          if (which) seedMessage = `${m[1] === "more" ? "More" : "Less"} like "${which.title.slice(0,80)}"`;
        }
      }

      root.innerHTML = `
        <div class="subnav"><a href="#/f/${token}">← Findings</a></div>
        <h2 style="margin-top:10px;">Talk to Thedi</h2>
        <div id="chatbox" class="chat-box"></div>
        <div class="chat-input">
          <textarea id="msg" placeholder="Tell Thedi what missed…">${escHtml(seedMessage)}</textarea>
        </div>
        <!-- P03: Preview tomorrow is now primary — the loop-closing differentiator.
             Submit feedback stays available as ghost. -->
        <div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;">
          <button class="btn primary" id="preview">Preview tomorrow's re-rank</button>
          <button class="btn ghost" id="send">Submit feedback</button>
          <a class="btn ghost" href="#/f/${token}" style="margin-left:auto;">Back to findings</a>
        </div>
        <div id="preview-pane"></div>
      `;

      const chatbox = document.getElementById("chatbox");
      function addMsg(role, text) {
        const d = document.createElement("div");
        d.className = `msg ${role}`;
        d.innerHTML = escHtml(text);
        chatbox.appendChild(d);
        chatbox.scrollTop = chatbox.scrollHeight;
      }
      addMsg("asst", opener);

      document.getElementById("send").onclick = async () => {
        const ta = document.getElementById("msg");
        const text = ta.value.trim();
        if (!text) return;
        addMsg("user", text);
        ta.value = "";
        const btn = document.getElementById("send");
        btn.disabled = true; btn.textContent = "Saving…";
        const res = await api("/scout-feedback-submit", { token, message: text });
        btn.disabled = false; btn.textContent = "Submit feedback";
        if (res.ok) {
          const prefs = res.extracted_prefs || {};
          const summary = (prefs.prefer?.length ? `prefer: ${prefs.prefer.join(", ")}` : "")
            + (prefs.avoid?.length ? (prefs.prefer?.length ? " · " : "") + `avoid: ${prefs.avoid.join(", ")}` : "");
          addMsg("asst", `Got it. I extracted: ${summary || "noted"}. Tomorrow's run will use this.`);
        } else {
          addMsg("asst", `Sorry — ${res.error || "something went wrong"}.`);
        }
      };

      document.getElementById("preview").onclick = async () => {
        const ta = document.getElementById("msg");
        const text = ta.value.trim() || seedMessage;
        if (!text) { alert("Type what you'd change first"); return; }
        const btn = document.getElementById("preview");
        btn.disabled = true; btn.textContent = "Re-ranking…";
        const res = await api("/scout-feedback-preview", { token, user_message: text });
        btn.disabled = false; btn.textContent = "Preview tomorrow's re-rank";
        const pane = document.getElementById("preview-pane");
        if (!res.ok) { pane.innerHTML = `<div class="error">${escHtml(res.error || "error")}</div>`; return; }
        pane.innerHTML = `
          <h2 style="margin-top:30px;">Tomorrow's preview</h2>
          <div class="critique"><div class="who">Refiner said</div><div>${escHtml(res.reasoning)}</div></div>
          ${res.preview.map(p => `
            <div class="finding">
              <div class="meta">
                <span class="rank-chip">#${p.new_rank}</span>
                <span class="source-badge">${escHtml(p.source)}</span>
                ${p.delta === 0
                  ? `<span style="color:var(--mute);">unchanged</span>`
                  : p.delta < 0
                    ? `<span class="rank-delta up">↑ ${Math.abs(p.delta)} (was #${p.current_rank})</span>`
                    : `<span class="rank-delta down">↓ ${p.delta} (was #${p.current_rank})</span>`}
              </div>
              <div class="title"><a href="${escHtml(p.url)}" target="_blank" rel="noopener">${escHtml(p.title)}</a></div>
              <div class="angle">${escHtml(p.angle)}</div>
            </div>`).join("")}
        `;
      };
    }

    route();
  </script>
</body>
</html>
