// globly.jsx — Globly consumer app
// Loaded before app.jsx; uses React.X directly (avoids re-declaring destructured globals).

// ─── Constants ────────────────────────────────────────────────────────────────

const REGIONS = ["South Asia", "Southeast Asia", "Middle East", "Africa", "Latin America", "East Asia"];

const REGION_COUNTRIES = {
  "South Asia":     ["India","Sri Lanka","Pakistan","Nepal","Bangladesh","Bhutan","Maldives","Afghanistan"],
  "Southeast Asia": ["Myanmar","Thailand","Vietnam","Cambodia","Laos","Malaysia","Singapore","Indonesia","Philippines","Brunei","East Timor"],
  "Middle East":    ["Iran","Iraq","Turkey","Syria","Lebanon","Jordan","Saudi Arabia","Yemen","Oman","United Arab Emirates","Qatar","Kuwait","Bahrain","Palestine","Israel","Cyprus"],
  "Africa":         ["Ethiopia","Kenya","Nigeria","Ghana","Tanzania","Uganda","Rwanda","Sudan","South Sudan","Egypt","Morocco","Mozambique","Zimbabwe","Somalia","Mali","Niger","Chad","Senegal","Cameroon","South Africa","Zambia","Democratic Republic of Congo","Angola","Ivory Coast","Guinea","Burkina Faso","Tunisia","Algeria","Libya"],
  "Latin America":  ["Brazil","Colombia","Mexico","Peru","Chile","Argentina","Ecuador","Bolivia","Venezuela","Haiti","Honduras","Guatemala","El Salvador","Nicaragua","Costa Rica","Panama","Paraguay","Uruguay","Trinidad and Tobago","Jamaica","Dominican Republic"],
  "East Asia":      ["China","Japan","South Korea","North Korea","Mongolia","Taiwan","Hong Kong"],
};

const TOPICS = ["Climate", "Health", "Economy", "Conflict", "Politics", "Trade", "Migration", "Governance", "Debt", "Energy", "Agriculture", "Infrastructure", "Diplomacy"];

const SIDEBAR_TOPICS = ["Conflict", "Health", "Economy", "Climate", "Politics", "Trade", "Migration", "Governance", "Debt", "Energy", "Agriculture", "Infrastructure", "Diplomacy"];

const TOPIC_SECTORS = {
  "Climate":  ["sector:climate","sector:energy","sector:agriculture"],
  "Health":   ["sector:health","sector:humanitarian"],
  "Economy":  ["sector:economy","sector:livelihoods","sector:infrastructure"],
  "Conflict": ["sector:security"],
  "Politics": ["sector:governance"],
  "Trade":    ["sector:economy","sector:diplomacy"],
};

const SECTOR_KW = {
  "sector:climate":        ["climate","environment","flood","drought","cyclone","emissions","carbon","forest","adaptation","sea level"],
  "sector:health":         ["health","hospital","medical","disease","pandemic","vaccine","nutrition","maternal"],
  "sector:economy":        ["gdp","economy","economic","growth","fiscal","budget","trade","finance","tax","debt","imf","inflation","remittance"],
  "sector:governance":     ["governance","corruption","election","parliament","minister","policy","reform","democracy"],
  "sector:security":       ["security","military","conflict","ceasefire","army","peace","insurgency","violence","war","coup"],
  "sector:agriculture":    ["agriculture","farm","food","rice","crop","farmer","irrigation","livestock","harvest"],
  "sector:energy":         ["energy","power","electricity","solar","renewable","dam","fuel","grid","hydro"],
  "sector:humanitarian":   ["humanitarian","aid","refugee","displacement","shelter","unhcr","crisis","emergency"],
  "sector:livelihoods":    ["livelihood","employment","job","poverty","income","microfinance","entrepreneur"],
  "sector:diplomacy":      ["diplomatic","diplomacy","bilateral","summit","geopolitics","foreign"],
  "sector:infrastructure": ["infrastructure","road","bridge","port","railway","transport","construction","highway"],
};

// ── Storage key migration: globly_* → globally_* ─────────────────────────────
// Runs once on load. Copies existing user data to the new key prefix without
// destroying old keys, so any cached session/setup survives the rename.
(function() {
  var pairs = [
    ['globly_setup',      'globally_setup'],
    ['globly_streak',     'globally_streak'],
    ['globly_viewed',     'globally_viewed'],
    ['globly_game',       'globally_game'],
    ['globly_seen',       'globally_seen'],
    ['globly_pref',       'globally_pref'],
    ['globly_user',       'globally_user'],
    ['globly_session',    'globally_session'],
    ['globly_signups',    'globally_signups'],
    ['globly_pref_decay', 'globally_pref_decay'],
    ['globly_quiz',       'globally_quiz'],
    ['globly_hl_best',    'globally_hl_best'],
  ];
  try {
    pairs.forEach(function(p) {
      var old = localStorage.getItem(p[0]);
      if (old !== null && localStorage.getItem(p[1]) === null) {
        localStorage.setItem(p[1], old);
      }
    });
  } catch(e) {}
})();

const LS = {
  SETUP:   "globally_setup",
  STREAK:  "globally_streak",
  VIEWED:  "globally_viewed",
  GAME:    "globally_game",
  SEEN:    "globally_seen",
  PREF:    "globally_pref",
  USER:    "globally_user",     // { email, password, createdAt }
  SESSION: "globally_session",  // { email, loggedInAt }
  SIGNUPS:    "globally_signups",  // [{ email, createdAt }, ...] — pilot email list
  MIRA:       "globally_mira",
  MIRA_USAGE: "globally_mira_usage",
  EDITION:    "globally_edition_date", // ISO date of last successful data fetch
  WORLD:      "globally_world",        // personal context for My Briefing
};

const PREF_VERSION = 1;

// ── Feature flags — set to false to disable a risky system without reverting ──
// Named GL_FEATURES to avoid collision with app.jsx which also defines GL_FEATURES.
// If a system causes a blank page, flip its flag to false and push.
var GL_FEATURES = {
  miraVoice:            true,  // MiraVoiceControl TTS via Cartesia
  charityImpactFinder:  true,  // CharityScreen / Charity tab
  games:                true,  // PlayScreen / trivia games
  personalBriefing:     true,  // My Briefing with personal context
};

// ── Supabase Auth ─────────────────────────────────────────────────────────────
// Loaded lazily from /api/config (public anon key — safe to expose).
// Falls back to localStorage mock auth if Supabase is not configured.
var _supabase = null;
var _oauthDoneCallback = null;  // registered by GloblyApp; called after OAuth redirect completes

// Returns the correct OAuth redirect URL for the current environment.
// This URL MUST be allowlisted in Supabase Dashboard → Authentication → URL Configuration.
// Supabase ignores the redirectTo param if the URL is not in the allowlist and falls back
// to the configured Site URL (which may be localhost:3000 if never updated).
//
// Required Supabase Dashboard → Authentication → URL Configuration entries:
//   Site URL:           https://globly-beryl.vercel.app
//   Redirect URLs add:  https://globly-beryl.vercel.app
//                       https://globly-beryl.vercel.app/
//                       http://localhost:8080
//                       http://localhost:8080/
function getAuthRedirectUrl() {
  // Returns the OAuth redirect target for the current environment.
  // Supabase PKCE flow appends ?code= to this URL via URL.searchParams (so the code
  // lands in window.location.search, not the hash), then redirects the browser here.
  // The app detects the code on load and exchanges it for a session.
  //
  // This URL MUST be in Supabase Dashboard → Authentication → URL Configuration → Redirect URLs.
  // If it is absent, Supabase ignores redirectTo and uses its configured Site URL instead
  // (which defaults to localhost:3000 for new projects — the most common symptom of this bug).
  //
  // Required allowlist entries:
  //   Site URL:   https://globly-beryl.vercel.app
  //   Redirect URLs:
  //     http://localhost:8080
  //     http://localhost:8080/
  //     http://localhost:8080/#/auth/callback
  //     https://globly-beryl.vercel.app
  //     https://globly-beryl.vercel.app/
  //     https://globly-beryl.vercel.app/#/auth/callback
  return window.location.origin + '/#/auth/callback';
}

// Detect OAuth callback tokens in the URL *before* any component renders so we can
// hold the splash screen instead of briefly flashing the landing/sign-up page.
// Handles both PKCE flow (?code= in search) and implicit flow (#access_token= in hash).
var _supabaseCallbackDetected = (function() {
  try {
    var h = window.location.hash || '';
    var s = window.location.search || '';
    return (
      h.indexOf('access_token=') !== -1 ||
      h.indexOf('type=signup')   !== -1 ||
      h.indexOf('type=recovery') !== -1 ||
      s.indexOf('code=')         !== -1 ||
      // Also catches the case where redirectTo had a hash and PKCE code ended up in the hash
      (h.indexOf('/auth/callback') !== -1 && h.indexOf('code=') !== -1)
    );
  } catch(e) { return false; }
})();

// Query the profiles table to decide new-user vs returning-user routing.
// On first sign-in (no profile row) → creates the row → leaves LS.SETUP alone → onboarding.
// On returning sign-in → reads onboarding_completed → if true marks setup.done in LS.SETUP.
// Falls back gracefully if the profiles table doesn't exist or the request fails.
function _checkAndApplyProfile(userId, userEmail, done) {
  if (!_supabase || !userId) { done(); return; }
  _supabase
    .from('profiles')
    .select('onboarding_completed')
    .eq('id', userId)
    .maybeSingle()
    .then(function(result) {
      if (result.error) {
        console.warn('[Globally] Profile read error:', result.error.message);
        done(); return;
      }
      if (!result.data) {
        // Brand-new user — insert profile row, leave LS.SETUP as-is → onboarding
        _supabase.from('profiles').insert({
          id:                   userId,
          email:                userEmail || null,
          onboarding_completed: false,
        }).then(function(){}).catch(function(e){
          console.warn('[Globally] Profile insert error:', e.message);
        });
        done(); return;
      }
      // Returning user — respect their stored onboarding_completed flag
      if (result.data.onboarding_completed) {
        var existingSetup = lsGet(LS.SETUP, null);
        if (!existingSetup || !existingSetup.done) {
          lsSet(LS.SETUP, Object.assign({ regions: REGIONS, topics: TOPICS }, existingSetup || {}, { done: true }));
        }
      }
      done();
    })
    .catch(function(e) {
      console.warn('[Globally] Profile check network error:', e.message);
      done();
    });
}

function initSupabase(url, anonKey, onOAuthDone) {
  if (!url || !anonKey) return null;
  if (typeof window === 'undefined' || !window.supabase) {
    console.warn('[Globally auth] Supabase CDN not loaded');
    return null;
  }
  try {
    _supabase = window.supabase.createClient(url, anonKey, {
      auth: {
        persistSession:     true,   // required: Supabase stores PKCE code_verifier in storage
        autoRefreshToken:   true,
        detectSessionInUrl: true,   // processes ?code= (PKCE) and #access_token= (implicit)
        flowType:           'pkce', // force PKCE so tokens arrive as ?code= not #access_token=,
                                    // avoiding conflicts with our #-based hash router
      },
    });

    if (onOAuthDone) _oauthDoneCallback = onOAuthDone;

    // ── Manual PKCE code exchange ─────────────────────────────────────────────
    // When redirectTo contains a hash (e.g. /#/auth/callback), some Supabase
    // server versions append ?code= directly to the redirectTo STRING rather
    // than using the URL API — producing #/auth/callback?code=XXX in the hash.
    // In that case detectSessionInUrl misses the code (it checks search only).
    // We handle it here. If code is in window.location.search, detectSessionInUrl
    // already handles it; we only act when code is in the hash fragment.
    (function _manualPkceExchange() {
      var hashStr  = window.location.hash  || '';
      var srchStr  = window.location.search || '';
      // Code in search → let detectSessionInUrl handle it automatically
      if (srchStr.indexOf('code=') !== -1) return;
      // Code in hash → exchange manually
      if (hashStr.indexOf('code=') === -1) return;
      var qs = hashStr.indexOf('?') !== -1 ? hashStr.slice(hashStr.indexOf('?') + 1) : '';
      var code = null;
      try { code = new URLSearchParams(qs).get('code'); } catch(e) {}
      if (!code) return;
      // Remove code from URL before exchanging — PKCE codes are single-use
      try { window.history.replaceState({}, '', window.location.origin + '/'); } catch(e) {}
      console.log('[Globally] Manual PKCE code exchange (code was in URL hash)');
      _supabase.auth.exchangeCodeForSession(code)
        .then(function(result) {
          if (result.error) {
            console.error('[Globally] PKCE exchange error:', result.error.message);
            if (_oauthDoneCallback) _oauthDoneCallback();
          }
          // Success: onAuthStateChange(SIGNED_IN) fires and handles routing
        })
        .catch(function(e) {
          console.error('[Globally] PKCE exchange network error:', e.message);
          if (_oauthDoneCallback) _oauthDoneCallback();
        });
    })();

    // Handle OAuth redirect returns (Google etc.).
    // Email/password sign-ins write LS.SESSION themselves — skip provider='email' here.
    _supabase.auth.onAuthStateChange(function(event, session) {
      if (event !== 'SIGNED_IN' || !session) return;
      var provider = (session.user && session.user.app_metadata && session.user.app_metadata.provider) || 'email';
      if (provider === 'email') return;

      var userEmail = (session.user && session.user.email) || '';
      var userId    = session.user.id;

      lsSet(LS.SESSION, {
        email:      userEmail,
        userId:     userId,
        loggedInAt: new Date().toISOString(),
        provider:   provider,
      });

      // Remove all OAuth artefacts from the URL so the hash router never sees them.
      // Covers: #access_token=... (implicit), ?code=... (PKCE in search),
      //         #/auth/callback... (PKCE redirect route), #/auth/callback?code=... (PKCE in hash).
      try {
        var _h = window.location.hash   || '';
        var _s = window.location.search || '';
        var needsClean = (
          _h.indexOf('access_token=')  !== -1 ||
          _h.indexOf('/auth/callback') !== -1 ||
          _s.indexOf('code=')          !== -1
        );
        if (needsClean) {
          window.history.replaceState(null, '', window.location.origin + '/');
        }
      } catch(e) {}

      // Check Supabase profiles table to decide new vs returning user routing.
      // _checkAndApplyProfile writes LS.SETUP.done only for returning users.
      // Falls back safely if the table doesn't exist yet.
      _checkAndApplyProfile(userId, userEmail, function() {
        if (_oauthDoneCallback) _oauthDoneCallback();
      });
    });

    console.log('[Globally auth config]', {
      supabaseHost:   new URL(url).host,
      hasAnonKey:     true,
      hasSupabaseUrl: true,
    });
    return _supabase;
  } catch(e) {
    console.error('[Globally] Supabase init failed:', e.message);
    return null;
  }
}

// Opens Google OAuth redirect. The redirectTo URL must be allowlisted in
// Supabase Dashboard → Authentication → URL Configuration → Redirect URLs.
// If it is not allowlisted, Supabase ignores this value and falls back to
// its configured Site URL (which may be localhost:3000 if never updated).
function handleGoogleAuth() {
  if (!_supabase) {
    console.warn('[Globally] Google sign-in clicked before Supabase was ready — please try again.');
    return false;
  }
  var redirectTo = getAuthRedirectUrl();
  console.log('[Globally] Google OAuth redirectTo:', redirectTo);
  _supabase.auth.signInWithOAuth({
    provider: 'google',
    options: { redirectTo: redirectTo },
  }).catch(function(err) {
    console.error('[Globally] Google OAuth initiation error:', err && err.message);
  });
  return true;
}
// ─────────────────────────────────────────────────────────────────────────────

const DAILY_TARGET = 5;

const CLIPS = [
  "https://res.cloudinary.com/sg8t5cgq/video/upload/v1782939571/clip1_yfp4f4.mp4",
  "https://res.cloudinary.com/sg8t5cgq/video/upload/v1782939509/clip2_ijuoi4.mp4",
  "https://res.cloudinary.com/sg8t5cgq/video/upload/v1782939510/clip3_cef9z9.mp4",
];

// ─── Lucide icon handles (loaded via UMD CDN in index.html) ──────────────────
var _LR            = window.LucideReact || {};
var Flame          = _LR.Flame;
var CloudSun       = _LR.CloudSun;
var HeartPulse     = _LR.HeartPulse;
var TrendingUp     = _LR.TrendingUp;
var ShieldAlert    = _LR.ShieldAlert;
var Landmark       = _LR.Landmark;
var Ship           = _LR.Ship;
// Content icons — topic tiles, card icons, DYK facts
var Activity       = _LR.Activity;
var AlertTriangle  = _LR.AlertTriangle;
var Baby           = _LR.Baby;
var Ban            = _LR.Ban;
var Banknote       = _LR.Banknote;
var BarChart3      = _LR.BarChart3;
var Bird           = _LR.Bird;
var Bomb           = _LR.Bomb;
var BookOpen       = _LR.BookOpen;
var Brain          = _LR.Brain;
var Briefcase      = _LR.Briefcase;
var Bug            = _LR.Bug;
var Building       = _LR.Building;
var Building2      = _LR.Building2;
var ClipboardList  = _LR.ClipboardList;
var CloudRain      = _LR.CloudRain;
var CreditCard     = _LR.CreditCard;
var Dna            = _LR.Dna;
var DollarSign     = _LR.DollarSign;
var Droplets       = _LR.Droplets;
var Factory        = _LR.Factory;
var Fish           = _LR.Fish;
var Flag           = _LR.Flag;
var Fuel           = _LR.Fuel;
var GlobeIcon      = _LR.Globe;
var HardHat        = _LR.HardHat;
var Handshake      = _LR.Handshake;
var Heart          = _LR.Heart;
var HeartCrack     = _LR.HeartCrack;
var Home           = _LR.Home;
var Key            = _LR.Key;
var Leaf           = _LR.Leaf;
var Link           = _LR.Link;
var Lock           = _LR.Lock;
var MapIcon        = _LR.Map;
var MapPin         = _LR.MapPin;
var Megaphone      = _LR.Megaphone;
var Microscope     = _LR.Microscope;
var Mountain       = _LR.Mountain;
var Newspaper      = _LR.Newspaper;
var Package        = _LR.Package;
var PersonStanding = _LR.PersonStanding;
var Pickaxe        = _LR.Pickaxe;
var Pill           = _LR.Pill;
var Receipt        = _LR.Receipt;
var Route          = _LR.Route;
var Scale          = _LR.Scale;
var Scroll         = _LR.Scroll;
var Shield         = _LR.Shield;
var Shirt          = _LR.Shirt;
var Smartphone     = _LR.Smartphone;
var Sprout         = _LR.Sprout;
var Stethoscope    = _LR.Stethoscope;
var Store          = _LR.Store;
var Sun            = _LR.Sun;
var Swords         = _LR.Swords;
var Syringe        = _LR.Syringe;
var Target         = _LR.Target;
var Tent           = _LR.Tent;
var Thermometer    = _LR.Thermometer;
var TreeDeciduous  = _LR.TreeDeciduous;
var TrendingDown   = _LR.TrendingDown;
var Truck          = _LR.Truck;
var Umbrella       = _LR.Umbrella;
var UtensilsCrossed= _LR.UtensilsCrossed;
var Users          = _LR.Users;
var User           = _LR.User;
var Vote           = _LR.Vote;
var Waves          = _LR.Waves;
var Wheat          = _LR.Wheat;
var Wrench         = _LR.Wrench;
var Zap            = _LR.Zap;
// Game + empty-state icons
var Bookmark       = _LR.Bookmark;
var Clock          = _LR.Clock;
var Compass        = _LR.Compass;
var Crosshair      = _LR.Crosshair;
var Frown          = _LR.Frown;
var PartyPopper    = _LR.PartyPopper;
var Smile          = _LR.Smile;
var Sparkles       = _LR.Sparkles;
var ThumbsUp       = _LR.ThumbsUp;
var Trophy         = _LR.Trophy;

// ─── Popup colour system ──────────────────────────────────────────────────────
// Keys must match REGION_COUNTRIES keys exactly. "Latin America" used (not "Americas");
// "Europe" omitted — European/Central Asian articles appear in Other bucket, not filtered by region.
var REGION_COLORS = {
  "East Asia":      "#E05A4D",  // soft red
  "South Asia":     "#E8923E",  // soft orange
  "Southeast Asia": "#2FA7A0",  // soft teal
  "Middle East":    "#C2843F",  // soft bronze
  "Africa":         "#4FA45C",  // soft green
  "Latin America":  "#C75C9B",  // soft magenta
};
var EXPLAINER_COLORS = {
  "remittances":     "#E8923E",  // orange (South Asia / economy)
  "sovereign-debt":  "#5E6AD2",  // indigo
  "microfinance":    "#5E6AD2",  // indigo
  "imf-worldbank":   "#8E6FC7",  // violet
  "famines":         "#B5544A",  // muted red (conflict)
  "climate-finance": "#3E9E6E",  // green
};
var NEUTRAL_COLOR = "#C9D3DE";  // slate fallback (should never appear — see resolveHex)

// ─── Page theme system — one entry per topic + special pages ─────────────────
// Used by TopicFeedScreen, FeedScreen, PlayScreen, GlobeScreen, ProfileScreen.
// `glow` and `glow2` are rgba strings for radial gradient layers.
var PAGE_THEMES = {
  "Conflict":       { primary:"#ff4d4d", sec:"#7f1d1d", glow:"rgba(255,77,77,0.58)",    glow2:"rgba(127,29,29,0.26)",  subtitle:"Wars, security, displacement and humanitarian access." },
  "Health":         { primary:"#ff4fa3", sec:"#831843", glow:"rgba(255,79,163,0.54)",   glow2:"rgba(131,24,67,0.26)",  subtitle:"Disease, nutrition, health systems and humanitarian response." },
  "Economy":        { primary:"#f5a524", sec:"#78350f", glow:"rgba(245,165,36,0.52)",   glow2:"rgba(37,99,235,0.20)",  subtitle:"Debt, trade, jobs, inflation and development finance." },
  "Climate":        { primary:"#22c55e", sec:"#14532d", glow:"rgba(34,197,94,0.50)",    glow2:"rgba(6,182,212,0.18)",  subtitle:"Climate risk, adaptation, food systems and environmental change." },
  "Politics":       { primary:"#a855f7", sec:"#4c1d95", glow:"rgba(168,85,247,0.54)",   glow2:"rgba(99,102,241,0.22)", subtitle:"Elections, governance, policy reform and public institutions." },
  "Governance":     { primary:"#a855f7", sec:"#4c1d95", glow:"rgba(168,85,247,0.54)",   glow2:"rgba(99,102,241,0.22)", subtitle:"Accountability, institutions, reform and state capacity." },
  "Trade":          { primary:"#f97316", sec:"#7c2d12", glow:"rgba(249,115,22,0.54)",   glow2:"rgba(234,179,8,0.18)",  subtitle:"Supply chains, exports, commodities and the movement of goods." },
  "Migration":      { primary:"#06b6d4", sec:"#164e63", glow:"rgba(6,182,212,0.54)",    glow2:"rgba(56,189,248,0.20)", subtitle:"Displacement, remittances, refugees and movement across borders." },
  "Debt":           { primary:"#6366f1", sec:"#312e81", glow:"rgba(99,102,241,0.54)",   glow2:"rgba(139,92,246,0.22)", subtitle:"Debt pressure, fiscal risk, IMF programmes and public finance." },
  "Energy":         { primary:"#eab308", sec:"#713f12", glow:"rgba(234,179,8,0.54)",    glow2:"rgba(245,165,36,0.20)", subtitle:"Oil, power, renewables and access to energy." },
  "Agriculture":    { primary:"#65a30d", sec:"#1a2e05", glow:"rgba(101,163,13,0.52)",   glow2:"rgba(34,197,94,0.18)",  subtitle:"Food production, land, irrigation, crops and rural livelihoods." },
  "Infrastructure": { primary:"#38bdf8", sec:"#0c4a6e", glow:"rgba(56,189,248,0.50)",   glow2:"rgba(6,182,212,0.20)",  subtitle:"Roads, ports, bridges, transport and connectivity." },
  "Diplomacy":      { primary:"#c084fc", sec:"#581c87", glow:"rgba(192,132,252,0.52)",  glow2:"rgba(244,114,182,0.20)",subtitle:"International relations, summits, peace talks and foreign policy." },
};

// Precise per-topic keyword lists — used for tight first-pass filtering on topic pages.
// Each keyword is checked against article.title + article.summary (lowercase).
var TOPIC_PRIMARY_KW = {
  // Conflict: specific combat/military terms only. Removed "weapon","attack","rocket","offensive"
  // (too broad → tech/law-enforcement false positives), "explosion" (matches industrial accidents),
  // "killed in" (matches accidents/disasters). Added multi-word precise phrases.
  "Conflict":   ["war","conflict","violence","military","armed","ceasefire","fighting","troops","bomb","security forces","siege","coup","insurgency","militant","rebel","peacekeeping","airstrike","artillery","frontline","hostilities","gunfire","mortar","armed attack","weapons test","weapons tests","nuclear test","missile test","missile launch","ballistic missile","armed conflict","military operation","war zone","combat","civilian deaths","war crimes","drone strike"],
  // Health: unchanged — strong distinctive vocabulary
  "Health":     ["health","hospital","medical","disease","pandemic","vaccine","vaccination","nutrition","maternal","clinic","cholera","ebola","malaria","epidemic","hiv","aids","healthcare","public health","medicine","patient","outbreak","neonatal","tuberculosis","measles","mpox","polio","dengue","health system","mental health","maternal health","child mortality","malnutrition"],
  // Economy: removed generic "growth","budget","finance","investment","revenue","surplus"
  // to stop political/housing/other articles scoring Economy via body-only weak matches.
  // Added "wage","minimum wage","labour market","cost of living".
  "Economy":    ["gdp","economy","economic","inflation","remittance","employment","unemployment","poverty","interest rate","currency","fiscal deficit","trade deficit","trade balance","economic growth","economic crisis","central bank","monetary policy","minimum wage","wages","wage","labour market","cost of living","foreign exchange","financial crisis","economic reform","imf","world bank","fiscal","tax revenue"],
  // Climate: added "heatwave" (one word) — the two-word "heat wave" never matched
  // single-word "heatwave" headlines. Added typhoon/monsoon/flooding variants.
  "Climate":    ["climate","drought","flood","cyclone","emissions","carbon","deforestation","adaptation","sea level","heatwave","heat wave","rainfall","biodiversity","renewable energy","clean energy","glacier","climate change","global warming","extreme weather","wildfire","monsoon","typhoon","flash flood","water scarcity","climate finance","greenhouse","flooding"],
  // Politics: removed "government","policy","reform","legislation","ruling" — too generic.
  // These caused housing/civil-service articles to score as Politics.
  "Politics":   ["election","parliament","prime minister","president","political party","opposition","cabinet","government formation","protest","vote","constitution","political crisis","democracy","political","lawmakers","general election","parliamentary","ruling party","political reform","political violence","by-election","voter"],
  // Governance: distinct from Politics — corruption/accountability focus
  "Governance": ["corruption","accountability","rule of law","transparency","civil society","state capacity","public administration","anti-corruption","oversight","bribery","embezzlement","public finance","graft","money laundering","judicial","government accountability","public sector reform"],
  // Trade: kept "port"/"commodity" (legitimate trade terms, word-boundary safe)
  "Trade":      ["export","import","tariff","supply chain","commodity","port","shipping","trade agreement","trade deal","market access","logistics","freight","trade war","trade policy","commodity price","textile","customs duty","free trade","trade sanction","trade surplus"],
  // Migration: removed "border","remittance" (too broad/shared); added "asylum seeker"
  "Migration":  ["refugee","asylum","displaced","migration","migrant","labour migration","internal displacement","unhcr","human trafficking","irregular migration","returnee","deportation","diaspora","asylum seeker","forced migration","stateless","resettlement"],
  // Debt: unchanged — already very specific multi-word phrases
  "Debt":       ["sovereign debt","debt distress","debt relief","imf programme","imf deal","fiscal pressure","public debt","debt service","debt restructuring","creditor","debt default","debt crisis","debt burden","debt sustainability"],
  // Energy: replaced bare "oil"/"gas" with specific phrases to stop "edible oil",
  // "gas station opening", etc. scoring as Energy. Kept distinctive power/grid terms.
  "Energy":     ["oil price","crude oil","oil production","oil sector","gas price","natural gas","electricity","power grid","fuel prices","energy access","solar power","wind power","hydroelectric","energy crisis","pipeline","lng","energy transition","power outage","energy sector","fuel shortage","power plant","fossil fuel"],
  // Agriculture: added "food crisis","food insecurity","crop failure","food prices"
  "Agriculture":["farm","farmer","crop","harvest","irrigation","livestock","rice","wheat","maize","food production","agriculture","agricultural","fishing","food security","food crisis","food insecurity","fertilizer","smallholder","famine","crop failure","food prices","agri"],
  // Infrastructure: added "housing","housing project" — key omission causing misclassification
  "Infrastructure":["road","bridge","railway","transport","construction","highway","airport","water supply","sanitation","electricity grid","infrastructure","telecommunications","broadband","housing","housing project","infrastructure project","urban development","water system"],
  // Diplomacy: added "sanctions","united nations" for clearer diplomatic signal
  "Diplomacy":  ["diplomatic","diplomacy","bilateral","summit","treaty","peace agreement","foreign minister","ambassador","embassy","peace talks","ceasefire talks","mediation","regional cooperation","geopolitics","negotiation","international agreement","foreign policy","united nations","multilateral","sanctions","diplomatic relations"],
};

// ─── Topic classifier ─────────────────────────────────────────────────────────
// Pass-1 confidence thresholds (primary keywords only).
var TOPIC_SCORE_MIN    = 4;
var TOPIC_SCORE_MARGIN = 3;

// Broad fallback keywords — used in pass-3 when no primary keyword fires.
// Single-word / short phrases, intentionally more permissive.
var TOPIC_BROAD_KW = {
  "Conflict":       ["war","attack","killed","military","troops","bomb","missile","fighting","violence","army","armed","shooting","explosion","offensive","soldiers","casualties","airstrike","rebel","militant","siege"],
  "Health":         ["health","hospital","patient","disease","vaccine","drug","medicine","virus","infection","treatment","healthcare","surgery","nurse","doctor","clinic","covid","flu","malaria","hiv"],
  "Economy":        ["economy","economic","gdp","growth","budget","finance","investment","revenue","bank","inflation","tax","money","poverty","wage","unemployment","income","price","market","deficit","expenditure"],
  "Climate":        ["climate","weather","flood","drought","storm","environment","carbon","temperature","heat","wildfire","fire","dry","wet","river","rainfall","sea","ecosystem","deforestation","biodiversity"],
  "Politics":       ["government","minister","president","parliament","election","party","vote","protest","policy","law","reform","ruling","prime minister","cabinet","opposition","political","legislation","coup","authoritarian","senator","lawmaker"],
  "Trade":          ["trade","export","import","market","goods","supply","commerce","shipping","cargo","customs","tariff","commodity","supply chain","logistics","port"],
  "Migration":      ["refugee","migrant","border","asylum","displaced","trafficking","flee","diaspora","immigration","resettlement","detention","deportation"],
  "Governance":     ["corruption","accountability","transparency","judiciary","oversight","bribery","public service","administration","rule of law","institution","civil society"],
  "Debt":           ["debt","loan","imf","credit","bond","interest","borrowing","lending","deficit","fiscal","surplus","restructuring"],
  "Energy":         ["energy","oil","gas","electricity","fuel","power","solar","coal","petroleum","grid","renewables","hydropower","pipeline"],
  "Agriculture":    ["food","farm","crop","harvest","agriculture","wheat","rice","farmer","livestock","fishing","fertilizer","irrigation","seed","famine","hunger","nutrition"],
  "Infrastructure": ["road","transport","construction","bridge","infrastructure","housing","railway","airport","port","building","urban","water supply","sanitation","broadband"],
  "Diplomacy":      ["diplomat","diplomacy","foreign","international","agreement","summit","bilateral","treaty","relations","united nations","sanctions","alliance","negotiation","envoy","foreign minister","foreign policy","geopolit"],
};

var _TOPIC_LIST = ['Conflict','Health','Economy','Climate','Politics','Trade',
                   'Migration','Governance','Debt','Energy','Agriculture','Infrastructure','Diplomacy'];

function _topicPad(s) {
  return (' ' + s.replace(/[^a-z0-9]/g, ' ') + ' ').replace(/ {2,}/g, ' ');
}

// 3-pass classifier — always returns a non-null topic so every story is counted.
// Pass 1: primary keywords, strict TOPIC_SCORE_MIN + TOPIC_SCORE_MARGIN check.
// Pass 2: primary keywords, no threshold — accepts the highest scorer if score > 0.
// Pass 3: broad/common keywords, accepts highest scorer if score > 0.
// Fallback: 'Diplomacy' (international-affairs catch-all).
function classifyArticleTopic(article) {
  var titlePad  = _topicPad((article.title || '').toLowerCase());
  var bodyPad   = _topicPad([
    article.summary        || '',
    article.deeper_summary || '',
  ].join(' ').toLowerCase());

  // ── Pass 1 + 2: primary keywords ──
  var best = null; var bestScore = 0; var secondScore = 0;
  for (var ti = 0; ti < _TOPIC_LIST.length; ti++) {
    var topic = _TOPIC_LIST[ti];
    var kws   = TOPIC_PRIMARY_KW[topic] || [];
    var score = 0;
    for (var ki = 0; ki < kws.length; ki++) {
      var kw    = kws[ki];
      var kwPad = _topicPad(kw);
      var wt    = kw.split(' ').length;
      if      (titlePad.indexOf(kwPad) !== -1) score += 4 * wt;
      else if (bodyPad.indexOf(kwPad)  !== -1) score += 1 * wt;
    }
    if (score > bestScore) { secondScore = bestScore; bestScore = score; best = topic; }
    else if (score > secondScore) { secondScore = score; }
  }
  // Pass 1: strict confidence
  if (best && bestScore >= TOPIC_SCORE_MIN && (bestScore - secondScore) >= TOPIC_SCORE_MARGIN) return best;
  // Pass 2: relax thresholds — accept any positive primary signal
  if (best && bestScore > 0) return best;

  // ── Pass 3: broad keyword scan ──
  var bBest = null; var bBestScore = 0;
  for (var ti2 = 0; ti2 < _TOPIC_LIST.length; ti2++) {
    var t2   = _TOPIC_LIST[ti2];
    var bkws = TOPIC_BROAD_KW[t2] || [];
    var bs   = 0;
    for (var bki = 0; bki < bkws.length; bki++) {
      var bkwPad = _topicPad(bkws[bki]);
      if      (titlePad.indexOf(bkwPad) !== -1) bs += 3;
      else if (bodyPad.indexOf(bkwPad)  !== -1) bs += 1;
    }
    if (bs > bBestScore) { bBestScore = bs; bBest = t2; }
  }
  if (bBest && bBestScore > 0) return bBest;

  // Fallback: every uncategorised world-affairs story lands in Diplomacy
  return 'Diplomacy';
}

// ─── Topic-based colour palette ───────────────────────────────────────────────
var TOPIC_COLORS = {
  "Conflict":       "#ff4d4d",
  "Health":         "#ff4fa3",
  "Economy":        "#f5a524",
  "Climate":        "#22c55e",
  "Politics":       "#a855f7",
  "Trade":          "#f97316",
  "Migration":      "#06b6d4",
  "Governance":     "#a855f7",
  "Debt":           "#6366f1",
  "Energy":         "#eab308",
  "Agriculture":    "#65a30d",
  "Infrastructure": "#38bdf8",
  "Diplomacy":      "#7c3aed",
};

// Sector-based filtering for all 13 sidebar topics
var TOPIC_FILTER_SECTORS = {
  "Conflict":       ["sector:security"],
  "Health":         ["sector:health", "sector:humanitarian"],
  "Economy":        ["sector:economy", "sector:livelihoods"],
  "Climate":        ["sector:climate"],
  "Politics":       ["sector:governance"],
  "Trade":          ["sector:economy", "sector:diplomacy"],
  "Migration":      ["sector:humanitarian"],
  "Governance":     ["sector:governance"],
  "Debt":           ["sector:economy"],
  "Energy":         ["sector:energy"],
  "Agriculture":    ["sector:agriculture"],
  "Infrastructure": ["sector:infrastructure"],
  "Diplomacy":      ["sector:diplomacy"],
};

// Topics requiring muted, desaturated colour treatment — tone gate for grave content.
// Background tint, topic pills, and accent colours must be sober on these pages.
var GRAVE_TOPIC_NAMES = { "Conflict": true };

// Sector tag → canonical topic (priority: first match in getArticleTopic wins)
var SECTOR_TO_TOPIC = {
  "sector:climate":        "Climate",
  "sector:energy":         "Energy",
  "sector:agriculture":    "Agriculture",
  "sector:health":         "Health",
  "sector:humanitarian":   "Migration",
  "sector:security":       "Conflict",
  "sector:diplomacy":      "Diplomacy",
  "sector:governance":     "Governance",
  "sector:economy":        "Economy",
  "sector:livelihoods":    "Economy",
  "sector:infrastructure": "Infrastructure",
};

// Explainer id → topic
var EXPLAINER_TOPIC = {
  // Original 6
  "remittances":          "Economy",
  "sovereign-debt":       "Economy",
  "microfinance":         "Economy",
  "imf-worldbank":        "Economy",
  "famines":              "Conflict",
  "climate-finance":      "Climate",
  // Economy (8 new)
  "aid-effectiveness":    "Economy",
  "inflation-developing": "Economy",
  "informal-economy":     "Economy",
  "cash-transfers":       "Economy",
  "land-rights":          "Economy",
  "corruption-development":"Economy",
  "debt-relief":          "Economy",
  "tax-justice":          "Economy",
  // Climate (9 new)
  "sea-level-rise":       "Climate",
  "climate-migration":    "Climate",
  "deforestation":        "Climate",
  "water-crisis":         "Climate",
  "clean-energy":         "Climate",
  "disaster-risk":        "Climate",
  "climate-adaptation":   "Climate",
  "food-climate":         "Climate",
  "carbon-markets":       "Climate",
  // Health (12 new)
  "maternal-health":      "Health",
  "malaria":              "Health",
  "vaccine-equity":       "Health",
  "mental-health":        "Health",
  "child-malnutrition":   "Health",
  "hiv-aids":             "Health",
  "uhc":                  "Health",
  "tuberculosis":         "Health",
  "cholera":              "Health",
  "health-workers":       "Health",
  "sanitation":           "Health",
  "pandemic-prep":        "Health",
  // Conflict (9 new)
  "refugees":             "Conflict",
  "internal-displacement":"Conflict",
  "peacebuilding":        "Conflict",
  "sanctions":            "Politics",
  "arms-trade":           "Conflict",
  "child-soldiers":       "Conflict",
  "humanitarian-law":     "Conflict",
  "post-conflict":        "Conflict",
  "conflict-resources":   "Conflict",
  // Politics (10 new)
  "democracy-backsliding":"Politics",
  "civil-society":        "Politics",
  "press-freedom":        "Politics",
  "elections-fragile":    "Politics",
  "un-system":            "Politics",
  "human-rights-law":     "Politics",
  "transitional-justice": "Politics",
  "governance":           "Politics",
  "corruption-politics":  "Politics",
  "federalism":           "Politics",
  // Trade (6 new)
  "commodity-dependence": "Trade",
  "supply-chains":        "Trade",
  "agricultural-subsidies":"Trade",
  "wto-dev":              "Trade",
  "currency-crisis":      "Economy",
  "trade-corridors":      "Trade",
};

// DYK fact topics[] string → canonical topic
var DYK_TOPIC_MAP = {
  "economy":      "Economy",
  "trade":        "Trade",
  "climate":      "Climate",
  "health":       "Health",
  "conflict":     "Conflict",
  "politics":     "Politics",
  "poverty":      "Economy",
  "development":  "Economy",
  "food":         "Conflict",
  "humanitarian": "Health",
};

// Radial glow — coloured radial over charcoal so every hue glows clearly on dark.
// Same gradient geometry for FACT, READ, and Brief sheets.
function makeWash(hex) {
  var r = parseInt(hex.slice(1,3), 16);
  var g = parseInt(hex.slice(3,5), 16);
  var b = parseInt(hex.slice(5,7), 16);
  var c = r+","+g+","+b;
  return "radial-gradient(ellipse 130% 110% at 50% 28%, " +
    "rgba("+c+",0.82) 0%, rgba("+c+",0.46) 28%, rgba("+c+",0.18) 58%, rgba("+c+",0) 100%), #1C1E24";
}

// ─── Story routing helpers ────────────────────────────────────────────────────
function slugify(str) {
  return (str || '').toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 64);
}
function articleSlug(article) { return slugify(article.title || ''); }

// ─── Preference profile ───────────────────────────────────────────────────────

function defaultPref() {
  var t = {}, r = {};
  TOPICS.forEach(function(x)  { t[x] = 1.0; });
  REGIONS.forEach(function(x) { r[x] = 1.0; });
  return {
    v:             PREF_VERSION,
    topicWeights:  t,
    regionWeights: r,
    formatBias:    { brief: 1.0, explainer: 1.0, quiz: 1.0 },
    updatedAt:     null,
  };
}

// Seed initial weights from onboarding choices — stated preferences become priors
function seedPrefFromSetup(setup) {
  var pref = defaultPref();
  var selR = setup.regions || REGIONS;
  var selT = setup.topics  || TOPICS;
  REGIONS.forEach(function(r) { pref.regionWeights[r] = selR.indexOf(r) !== -1 ? 1.5 : 0.75; });
  TOPICS.forEach(function(t)  { pref.topicWeights[t]  = selT.indexOf(t)  !== -1 ? 1.5 : 0.75; });
  pref.updatedAt = null;
  return pref;
}

// Load stored pref or initialise from setup; migrates stale schema versions
function loadPref(setup) {
  var stored = lsGet(LS.PREF, null);
  if (stored && stored.v === PREF_VERSION) return stored;
  var pref = setup ? seedPrefFromSetup(setup) : defaultPref();
  lsSet(LS.PREF, pref);
  return pref;
}

function savePref(pref) {
  lsSet(LS.PREF, pref);
}

// ─── Storage helpers ──────────────────────────────────────────────────────────

function lsGet(key, fallback) {
  try { var v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; }
  catch(e) { return fallback; }
}
function lsSet(key, val) {
  try { localStorage.setItem(key, JSON.stringify(val)); } catch(e) {}
}

function todayKey() { return new Date().toDateString(); }

// ISO-format daily key ("2026-06-29") — canonical edition identifier used for auto-refresh
function getTodayKey() { return new Date().toISOString().slice(0, 10); }

// ─── Seeded utilities ─────────────────────────────────────────────────────────

function seededShuffle(arr, seed) {
  var a = arr.slice(), s = Math.abs(seed) || 1;
  for (var i = a.length - 1; i > 0; i--) {
    s = (s * 16807 + 0) % 2147483647;
    var j = s % (i + 1);
    var tmp = a[i]; a[i] = a[j]; a[j] = tmp;
  }
  return a;
}

// Numeric seed from today's date — changes each day, stable within the day
function dailySeed() {
  var d = new Date();
  return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
}

// True if article was published or scraped in the last 48 hours
function isFresh(article) {
  var ts = article.published || article.scraped_at || 0;
  return (Date.now() - new Date(ts).getTime()) < 48 * 3600 * 1000;
}

// ─── Seen-article tracking ────────────────────────────────────────────────────

function loadSeen() {
  return lsGet(LS.SEEN, {});
}

function markSeen(url) {
  if (!url) return;
  var seen = loadSeen();
  seen[url] = Date.now();
  var cutoff = Date.now() - 7 * 86400000;
  Object.keys(seen).forEach(function(k) { if (seen[k] < cutoff) delete seen[k]; });
  lsSet(LS.SEEN, seen);
}

// ─── Signal capture ───────────────────────────────────────────────────────────
// EMA-style weight update: w += alpha * (target - w), clamped [0.2, 3.0]
// Weekly decay: once per day each weight drifts 5% back toward 1.0

var SIG_ALPHA  = { open: 0.07, finish: 0.12, save: 0.15, like: 0.12, skip: 0.10 };
var SIG_TARGET = { open: 3.0,  finish: 3.0,  save: 3.0,  like: 3.0,  skip: 0.2  };
var PREF_DECAY_KEY = "globally_pref_decay";

function clampWeight(w) { return Math.max(0.2, Math.min(3.0, w)); }

function maybeDecayPref(pref) {
  var today = new Date().toDateString();
  if (lsGet(PREF_DECAY_KEY, null) === today) return pref;
  TOPICS.forEach(function(t) {
    var w = pref.topicWeights[t] || 1.0;
    pref.topicWeights[t] = clampWeight(w + 0.05 * (1.0 - w));
  });
  REGIONS.forEach(function(r) {
    var w = pref.regionWeights[r] || 1.0;
    pref.regionWeights[r] = clampWeight(w + 0.05 * (1.0 - w));
  });
  lsSet(PREF_DECAY_KEY, today);
  return pref;
}

function recordSignal(article, signalType) {
  var pref = loadPref(lsGet(LS.SETUP, null));
  maybeDecayPref(pref);
  var alpha  = SIG_ALPHA[signalType]  || 0.07;
  var target = SIG_TARGET[signalType] || 3.0;
  var topic  = article && getArticleTopic(article);
  var region = article && getArticleRegion(article);
  if (topic && pref.topicWeights[topic] !== undefined) {
    pref.topicWeights[topic] = clampWeight(pref.topicWeights[topic] + alpha * (target - pref.topicWeights[topic]));
  }
  if (region && pref.regionWeights[region] !== undefined) {
    pref.regionWeights[region] = clampWeight(pref.regionWeights[region] + alpha * (target - pref.regionWeights[region]));
  }
  savePref(pref);
}

// ─── Topic interleaving ───────────────────────────────────────────────────────

function topicInterleave(articles) {
  if (articles.length < 3) return articles;
  var byTopic = {};
  articles.forEach(function(a) {
    var t = getArticleTopic(a) || '_other';
    if (!byTopic[t]) byTopic[t] = [];
    byTopic[t].push(a);
  });
  var topicKeys = Object.keys(byTopic);
  var out = [], lastTopic = null;
  var guard = articles.length * 3;
  while (out.length < articles.length && guard-- > 0) {
    var available = topicKeys.filter(function(t) {
      return byTopic[t].length > 0 && t !== lastTopic;
    });
    if (!available.length) {
      topicKeys.forEach(function(t) {
        while (byTopic[t].length) out.push(byTopic[t].shift());
      });
      break;
    }
    available.sort(function(a, b) { return byTopic[b].length - byTopic[a].length; });
    var pick = available[0];
    out.push(byTopic[pick].shift());
    lastTopic = pick;
  }
  return out;
}

// Pick one hero article from fresh articles only — no stale fallback.
// Within fresh candidates, sort newest-first then pick by daily seed for stability.
function pickDailyHero(pool, seed) {
  if (!pool.length) return null;
  // Prefer fresh (< 48h) articles; fall back to full pool if none qualify
  var fresh = pool.filter(function(a) { return isFresh(a); });
  var base  = fresh.length ? fresh : pool;
  var candidates = base.filter(function(a) { return (a.significance || 1) >= 3; });
  if (!candidates.length) candidates = base;
  candidates = candidates.slice().sort(function(a, b) {
    return new Date(b.published || 0) - new Date(a.published || 0);
  });
  var top = candidates.slice(0, Math.min(5, candidates.length));
  return top[Math.abs(seed) % top.length];
}

// ─── Streak ───────────────────────────────────────────────────────────────────

function loadStreak() {
  var s = lsGet(LS.STREAK, { current: 0, lastDate: null, longest: 0 });
  var today = todayKey();
  var yesterday = new Date(Date.now() - 86400000).toDateString();
  if (s.lastDate === today) { return s; }
  if (s.lastDate === yesterday) {
    s = { current: s.current + 1, lastDate: today, longest: Math.max(s.longest, s.current + 1) };
  } else {
    s = { current: 1, lastDate: today, longest: Math.max(s.longest || 0, 1) };
  }
  lsSet(LS.STREAK, s);
  return s;
}


// ─── Mira memory, usage limits & answer engine ────────────────────────────────

var MIRA_FREE_DAILY_LIMIT = 5;

function getMiraMemory() {
  return lsGet(LS.MIRA, { savedStories: [], miraQueries: [], updatedAt: '' });
}
function saveMiraMemory(m) {
  lsSet(LS.MIRA, Object.assign({}, m, { updatedAt: new Date().toISOString() }));
}
function getMiraUsageToday() {
  var u = lsGet(LS.MIRA_USAGE, { date: '', count: 0 });
  var today = new Date().toISOString().slice(0, 10);
  return (u.date === today) ? (u.count || 0) : 0;
}
function incrementMiraUsage() {
  var today = new Date().toISOString().slice(0, 10);
  var u = lsGet(LS.MIRA_USAGE, { date: '', count: 0 });
  lsSet(LS.MIRA_USAGE, { date: today, count: (u.date === today ? (u.count || 0) + 1 : 1) });
}
function canUseMira() { return true; } // unlimited free — gating ready when paid plan launches
function recordMiraQuery(question, articleTitle) {
  var m = getMiraMemory();
  var queries = [{ q: question, article: articleTitle, at: new Date().toISOString() }]
    .concat((m.miraQueries || []).slice(0, 19));
  saveMiraMemory(Object.assign({}, m, { miraQueries: queries }));
}

function articleOverlapScore(a, b) {
  if (!a || !b) return 0;
  var score = 0;
  var tA = getArticleTopic(a), tB = getArticleTopic(b);
  if (tA && tA === tB) score += 3;
  if (a.country && a.country === b.country) score += 2;
  var textA = ((a.title || '') + ' ' + (a.summary || '')).toLowerCase();
  var textB = ((b.title || '') + ' ' + (b.summary || '')).toLowerCase();
  var kws = SECTOR_KW['sector:' + ((tA || '').toLowerCase())] || [];
  kws.forEach(function(kw) { if (textA.includes(kw) && textB.includes(kw)) score++; });
  return score;
}

function findSeenRelated(article, allArticles, seen) {
  if (!seen || Object.keys(seen).length === 0) return [];
  return allArticles
    .filter(function(a) { return a.url !== article.url && !!seen[a.url]; })
    .map(function(a) { return { a: a, score: articleOverlapScore(article, a) }; })
    .filter(function(x) { return x.score >= 2; })
    .sort(function(x, y) { return y.score - x.score; })
    .slice(0, 3).map(function(x) { return x.a; });
}

function findCorpusRelated(article, allArticles) {
  return allArticles
    .filter(function(a) { return a.url !== article.url && !a._clusterHidden; })
    .map(function(a) { return { a: a, score: articleOverlapScore(article, a) }; })
    .filter(function(x) { return x.score >= 2; })
    .sort(function(x, y) { return y.score - x.score; })
    .slice(0, 5).map(function(x) { return x.a; });
}

function isMiraQuestion(q) {
  var t = q.trim().toLowerCase();
  return /^(what|who|why|how|which|when|where|explain|tell me|is there|are there|describe)\b/.test(t) || t.endsWith('?');
}

// Rejects fragments like "how will", "what is", "why does" — needs real substance.
function isMiraQuerySubstantive(q) {
  var words = q.trim().split(/\s+/).filter(Boolean);
  if (words.length < 3) return false;
  var FILLER = ['how', 'what', 'why', 'who', 'where', 'when', 'which', 'is', 'are', 'will', 'does', 'do', 'can', 'tell', 'me', 'explain', 'describe', 'the', 'a', 'an', 'this', 'that', 'it', 'of', 'in', 'to', 'be', 'would', 'could', 'should', 'please', 'you', 'going', 'happen', 'about'];
  var lower = words.map(function(w){ return w.toLowerCase(); });
  var substantive = lower.filter(function(w){ return FILLER.indexOf(w) === -1; });
  return substantive.length >= 2;
}

function classifyMiraQuestion(q) {
  var t = q.toLowerCase();
  if (/\bwhy\b|matter|important|significance/.test(t))         return 'matters';
  if (/\bwho\b|affected|impact|people|population|civilian/.test(t)) return 'affected';
  if (/next|watch|upcoming|what.{0,20}happen/.test(t))          return 'next';
  if (/connect|link|similar|related|previous|before/.test(t))   return 'connect';
  if (/30.?sec|quick brief|short version/.test(t))              return '30sec';
  if (/3.?min|in depth|full version|more detail/.test(t))       return '3min';
  return 'background';
}

function briefingSectionIntro(articles) {
  if (!articles || articles.length === 0) return null;
  var a = articles[0];
  var s = (a.summary || '').replace(/<[^>]*>/g, '').trim();
  var sentences = s.split(/\.\s+(?=[A-Z"'])/).map(function(x){ return x.trim(); }).filter(function(x){ return x.length > 20; });
  if (sentences.length === 0) return null;
  return sentences.slice(0, 2).join(' ');
}

function buildGroundedMiraAnswer(questionType, article, relatedStories) {
  var topic     = getArticleTopic(article) || 'this issue';
  var country   = article.country || '';
  var summary   = (article.summary || '').replace(/<[^>]*>/g, '').trim();
  var sentences = summary.split(/\.\s+(?=[A-Z"'])/).map(function(s){ return s.trim(); }).filter(function(s){ return s.length > 20; });
  var why       = (article.storyModes && article.storyModes.matters)  || null;
  var context   = (article.storyModes && article.storyModes.explain)  || null;
  var facts     = (article.storyModes && article.storyModes.facts)    || [];
  var sources   = [article].concat(relatedStories || []).slice(0, 5);

  if (!summary && sentences.length === 0) {
    return { text: "I couldn't find enough sourced content in Globally to answer that reliably for this story.", sources: [] };
  }

  var text = '';

  if (questionType === 'background') {
    text = context || (sentences.slice(0, 4).join('. ') + '.');
  } else if (questionType === 'matters') {
    text = why || (sentences.slice(0, 3).map(function(s){ return s + '.'; }).join(' '));
  } else if (questionType === 'affected') {
    var parts = country ? ['people in ' + country] : [];
    if (facts.length > 0) {
      text = 'Based on Globally\'s sourced reporting: ' + facts.slice(0, 3).join('. ') + '.';
      if (parts.length) text += ' Reporting focuses on ' + parts.join(' and ') + '.';
    } else {
      text = sentences.slice(0, 3).join('. ') + '.';
      if (parts.length) text += ' The reporting focuses on ' + parts.join(' and ') + '.';
    }
  } else if (questionType === 'connect') {
    if (!relatedStories || relatedStories.length === 0) {
      return {
        text: 'I couldn\'t find closely related stories in your reading history. Read more ' + topic + ' stories to build your knowledge trail.',
        sources: []
      };
    }
    var connParts = relatedStories.slice(0, 2).map(function(s) {
      var shared = [];
      if (getArticleTopic(s) === topic) shared.push(topic);
      if (s.country && s.country === country) shared.push(s.country);
      return '"' + displayTitle(s) + '" — both stories report on ' + (shared.join(' and ') || topic);
    });
    text = 'Based on stories you\'ve previously read:\n\n' + connParts.join('\n\n');
    sources = relatedStories.slice(0, 3);
  } else if (questionType === 'next') {
    var nextParts = facts.length > 2 ? facts.slice(-2) : sentences.slice(-2);
    if (nextParts.length === 0) {
      return { text: "I couldn't find enough sourced content about what's next for this story in Globally.", sources: [] };
    }
    text = 'What sources report — not a prediction:\n\n' + nextParts.map(function(p){ return '• ' + p + (p.endsWith('.') ? '' : '.'); }).join('\n');
  } else if (questionType === '30sec') {
    var brief = sentences.slice(0, 3);
    if (brief.length === 0) { return { text: "I couldn't find enough sourced content for a brief.", sources: [] }; }
    text = brief.map(function(s, i){ return (i + 1) + '. ' + s + (s.endsWith('.') ? '' : '.'); }).join('\n');
  } else if (questionType === '3min') {
    var long = article.deeper_summary
      ? article.deeper_summary.split('\n').filter(function(l){ return l.trim().length > 0; }).map(function(l){ return l.replace(/^•\s*/, ''); })
      : sentences;
    if (long.length === 0) { return { text: "I couldn't find enough sourced content for an in-depth version.", sources: [] }; }
    text = long.slice(0, 8).map(function(s, i){ return (i + 1) + '. ' + s + (s.endsWith('.') ? '' : '.'); }).join('\n');
  }

  if (!text) { return { text: "I couldn't find enough sourced content in Globally to answer that reliably.", sources: [] }; }
  return { text: text, sources: sources };
}

// ═══════════════════════════════════════════════════════════════════════════════
// MIRA ANSWER PIPELINE v3
// Deterministic synthesis from Globally corpus only. No invented facts.
// Hard country/entity filter prevents cross-country contamination.
// ═══════════════════════════════════════════════════════════════════════════════

var MIRA_PLAN = { FREE: 'free', PLUS: 'plus' };

// ── Text helpers ──────────────────────────────────────────────────────────────
function miraCleanText(html) {
  return (html || '').replace(/<[^>]*>/g, '').trim();
}
function miraSentenceSplit(text) {
  return (text || '')
    .split(/\.\s+(?=[A-Z"'])/)
    .map(function(s){ return s.trim(); })
    .filter(function(s){ return s.length > 20; });
}
function miraBullets(text) {
  return (text || '').split('\n')
    .map(function(l){ return l.replace(/^[•\-\*]\s*/, '').trim(); })
    .filter(function(l){ return l.length > 10; });
}

// ── Fuzzy matching for spell correction ──────────────────────────────────────
function levenshteinDistance(a, b) {
  var m = a.length, n = b.length;
  var dp = [];
  for (var i = 0; i <= m; i++) {
    dp[i] = new Array(n + 1);
    dp[i][0] = i;
  }
  for (var j = 0; j <= n; j++) dp[0][j] = j;
  for (var i = 1; i <= m; i++) {
    for (var j = 1; j <= n; j++) {
      if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1];
      else dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
    }
  }
  return dp[m][n];
}

function fuzzyMatchBest(input, options, maxDistance) {
  maxDistance = (maxDistance === undefined) ? 2 : maxDistance;
  var il = input.toLowerCase();
  var best = null, bestDist = Infinity;
  for (var i = 0; i < options.length; i++) {
    var opt  = options[i];
    var optL = opt.toLowerCase();
    if (optL === il) return opt;
    // Allow partial prefix match with small edit distance
    var candidate = optL.slice(0, Math.min(il.length + 2, optL.length));
    var d = levenshteinDistance(il, candidate);
    if (d < bestDist) { bestDist = d; best = opt; }
    // Also try full string for short words
    if (optL.length <= 8) {
      var df = levenshteinDistance(il, optL);
      if (df < bestDist) { bestDist = df; best = opt; }
    }
  }
  return (bestDist <= maxDistance && bestDist < il.length) ? best : null;
}

// ── Spelling correction ───────────────────────────────────────────────────────
var KNOWN_MISSPELLINGS = {
  // countries
  'venezuala':'Venezuela','venezuella':'Venezuela','venezula':'Venezuela',
  'venezeula':'Venezuela','venuzuela':'Venezuela',
  'pakistn':'Pakistan','pakstan':'Pakistan','pakiston':'Pakistan',
  'srilanka':'Sri Lanka','sri-lanka':'Sri Lanka','srilanka':'Sri Lanka',
  'bangaldesh':'Bangladesh','bangledesh':'Bangladesh','bangladsh':'Bangladesh',
  'ethopia':'Ethiopia','ethoipia':'Ethiopia','ethiopa':'Ethiopia',
  'nigerria':'Nigeria','nigeira':'Nigeria',
  'palistine':'Palestine','palestin':'Palestine','palastine':'Palestine',
  'afganistan':'Afghanistan','afgahnistan':'Afghanistan',
  'myanamar':'Myanmar','myannar':'Myanmar','burma':'Myanmar',
  'hondurus':'Honduras','hondures':'Honduras',
  'guatamala':'Guatemala','gautemala':'Guatemala',
  'nicargua':'Nicaragua','nicaraga':'Nicaragua',
  'colomba':'Colombia','columbia':'Colombia',
  'argantina':'Argentina','argentinia':'Argentina',
  'philipines':'Philippines','phillipines':'Philippines','philippines':'Philippines',
  'indonasia':'Indonesia','indonsia':'Indonesia',
  'camboida':'Cambodia','camboda':'Cambodia',
  'moroco':'Morocco','morrocco':'Morocco',
  'tanznia':'Tanzania','tanzaina':'Tanzania',
  'ugaanda':'Uganda','ugandda':'Uganda',
  'zimbabuwe':'Zimbabwe','zimbambwe':'Zimbabwe',
  'mozambiqe':'Mozambique','mozambiqe':'Mozambique',
  'siera leone':'Sierra Leone','seirra leone':'Sierra Leone',
  'ivory coas':'Ivory Coast',
  'south corea':'South Korea','south korea':'South Korea',
  'nort korea':'North Korea','north corea':'North Korea',
  'saudia arabia':'Saudi Arabia','saudi araiba':'Saudi Arabia',
  'united arab emirats':'United Arab Emirates',
  // topics
  'goverment':'Governance','goverments':'Governance',
  'infrastucture':'Infrastructure','infrasture':'Infrastructure',
  'agricultre':'Agriculture','agirculture':'Agriculture',
  'migraiton':'Migration','migartaion':'Migration',
  'ecnomy':'Economy','econmy':'Economy','eocnomy':'Economy',
  'conflcit':'Conflict','confict':'Conflict',
  'climte':'Climate','cilmate':'Climate',
  'politcs':'Politics','politcis':'Politics',
  'depolmacy':'Diplomacy','deplomacy':'Diplomacy',
  'govrnance':'Governance',
  'infastructure':'Infrastructure',
};

function correctMiraSpelling(query) {
  var words = query.trim().split(/\s+/);
  var corrections = [];
  var correctedWords = words.map(function(w) { return w; });

  // First pass: known misspellings — whole-word matching only to avoid
  // matching "palestin" inside correctly-spelled "palestine", etc.
  var joined = words.join(' ').toLowerCase();
  var correctedWords2 = words.map(function(w){ return w; });
  Object.keys(KNOWN_MISSPELLINGS).sort(function(a, b){ return b.length - a.length; }).forEach(function(bad) {
    // Match whole words only — use word-boundary regex
    var re = new RegExp('(?:^|\\s)' + bad.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:\\s|$)');
    if (re.test(joined)) {
      var good = KNOWN_MISSPELLINGS[bad];
      corrections.push({ original: bad, corrected: good, type: 'known' });
      correctedWords2 = correctedWords2.map(function(w) {
        return w.toLowerCase() === bad ? good : w;
      });
    }
  });

  var correctedQuery = correctedWords2.join(' ');

  // If nothing changed via known map, try fuzzy on each word against country/topic list
  if (corrections.length === 0) {
    var allCountries = [];
    Object.values(REGION_COUNTRIES).forEach(function(cs){ allCountries = allCountries.concat(cs); });
    var allOptions = allCountries.concat(TOPICS);

    var anyFuzz = false;
    correctedWords = words.map(function(w) {
      if (w.length < 4) return w;
      var wl = w.toLowerCase();
      // Skip if already matches
      if (allOptions.some(function(o){ return o.toLowerCase() === wl; })) return w;
      var fuzz = fuzzyMatchBest(wl, allOptions, Math.floor(wl.length / 4));
      if (fuzz) {
        corrections.push({ original: w, corrected: fuzz, type: 'fuzzy' });
        anyFuzz = true;
        return fuzz;
      }
      return w;
    });
    if (anyFuzz) correctedQuery = correctedWords.join(' ');
    else correctedQuery = query;
  }

  var wasCorrected = corrections.length > 0 && correctedQuery.toLowerCase() !== query.toLowerCase();
  return { query: wasCorrected ? correctedQuery : query, wasCorrected: wasCorrected, corrections: corrections };
}

// ── Intent detection ──────────────────────────────────────────────────────────
var MIRA_INTENT = {
  WHAT_IS_HAPPENING:   'what_is_happening',
  EXPLAIN_BACKGROUND:  'explain_background',
  WHY_IT_MATTERS:      'why_it_matters',
  WHO_IS_AFFECTED:     'who_is_affected',
  CONNECT_STORIES:     'connect_stories',
  SOURCES_REPORT_NEXT: 'sources_report_next',
  SUMMARISE_30S:       'summarise_30s',
  SUMMARISE_3MIN:      'summarise_3min',
  COMPARE:             'compare',
};

// understandMiraQuestion — enhanced with spelling correction, countries[] array,
// and a confidence score. Backwards-compatible: still returns .country for single-country queries.
// Returns true when a vague query on an article/cluster page should be anchored
// to the current article/event context rather than treated as a global search.
function shouldUseCurrentArticleContext(query, ctx) {
  var q = (query || '').toLowerCase().trim();
  var isArticlePage = !!(ctx && (ctx.article || ctx.currentStory || ctx.currentCluster));
  if (!isArticlePage) return false;
  var phrases = [
    'this','this story','this article','this event','this disaster',
    'the effects','effects of this','effect of this',
    'why does this','who is affected',
    'how bad','what does this mean','what happens next',
    'explain this','summarise this','summarize this',
    'natural disaster','economic effects','humanitarian effects',
    'political effects','development effects','consequences of this',
    'impacts of this','what is the impact','how will this',
    'casualties','death toll','missing people',
    // Preset button labels — always anchored to the current article
    '3-minute version','30-second version','explain the background',
    'why does this matter','connect to previous','what sources report',
  ];
  return phrases.some(function(p) { return q.indexOf(p) !== -1; });
}

function understandMiraQuestion(question, ctx) {
  ctx = ctx || {};
  var q  = (question || '').trim();
  var ql = q.toLowerCase();

  // Spell-correct first
  var corrected     = correctMiraSpelling(q);
  var correctedQ    = corrected.query;
  var correctedQL   = correctedQ.toLowerCase();
  var wasCorrected  = corrected.wasCorrected;
  var corrections   = corrected.corrections;

  // Use corrected query for entity detection
  var detect = correctedQL;

  var intent = MIRA_INTENT.WHAT_IS_HAPPENING;
  if (/30.?sec|quick|short.{0,10}vers|one.?liner/.test(detect))
    intent = MIRA_INTENT.SUMMARISE_30S;
  else if (/3.?min|in.?depth|full.{0,10}vers|detail|comprehensive/.test(detect))
    intent = MIRA_INTENT.SUMMARISE_3MIN;
  else if (/\bwhy.{0,20}matter|why.{0,20}import|significance|why should/.test(detect))
    intent = MIRA_INTENT.WHY_IT_MATTERS;
  else if (/\bwho.{0,20}affect|affected|impact on.{0,15}people|civilian|population/.test(detect))
    intent = MIRA_INTENT.WHO_IS_AFFECTED;
  else if (/what.{0,25}next|sources?.{0,20}report.{0,10}next|what to watch|upcoming|what.{0,25}expect/.test(detect))
    intent = MIRA_INTENT.SOURCES_REPORT_NEXT;
  else if (/\bconnect|link.{0,10}stor|similar|related|previous|pattern/.test(detect))
    intent = MIRA_INTENT.CONNECT_STORIES;
  // Effect/impact questions ("what are the effects on the economy", "how does X affect Y")
  // must stay as WHAT_IS_HAPPENING so the question-keyword steering in synthesis applies.
  // EXPLAIN_BACKGROUND ignores qkws, which produces generic story summaries instead of
  // answering the specific angle the user asked about.
  else if (/\bwhat.{0,25}(effect|impact|consequence|affect)\b|how.{0,20}(affect|impact)\b/.test(detect))
    intent = MIRA_INTENT.WHAT_IS_HAPPENING;
  else if (/\bexplain|background|context|what is\b|what are\b|describe|tell me/.test(detect))
    intent = MIRA_INTENT.EXPLAIN_BACKGROUND;
  else if (/\bcompar|vs\b|versus|difference|differ/.test(detect))
    intent = MIRA_INTENT.COMPARE;

  // ── Country detection — returns ARRAY now ────────────────────────────────
  var allCountries = [];
  Object.values(REGION_COUNTRIES).forEach(function(cs){ allCountries = allCountries.concat(cs); });
  if (ctx.article && ctx.article.country) allCountries.unshift(ctx.article.country);

  var detectedCountries = [];
  allCountries.forEach(function(c) {
    var cl = c.toLowerCase();
    // Whole-word match to avoid "in" matching "India", etc.
    var re = new RegExp('\\b' + cl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
    if (re.test(detect) && detectedCountries.indexOf(c) === -1) {
      detectedCountries.push(c);
    }
  });
  var detectedCountry = detectedCountries[0] || null;

  // ── Region detection ─────────────────────────────────────────────────────
  var detectedRegion = null;
  var rnames = Object.keys(REGION_COUNTRIES);
  for (var ri = 0; ri < rnames.length; ri++) {
    var rn = rnames[ri];
    var re2 = new RegExp('\\b' + rn.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
    if (re2.test(detect)) { detectedRegion = rn; break; }
  }

  // ── Topic detection ──────────────────────────────────────────────────────
  var detectedTopic = null;
  var tnames = Object.keys(TOPIC_PRIMARY_KW || {});
  for (var tn = 0; tn < tnames.length; tn++) {
    var re3 = new RegExp('\\b' + tnames[tn].toLowerCase() + '\\b');
    if (re3.test(detect)) { detectedTopic = tnames[tn]; break; }
  }
  if (!detectedTopic) {
    var bestT = null, bestTS = 0;
    tnames.forEach(function(t) {
      var kws = (TOPIC_PRIMARY_KW[t] || []).concat((TOPIC_BROAD_KW && TOPIC_BROAD_KW[t]) || []);
      var ts = 0;
      kws.forEach(function(kw){ if (detect.indexOf(kw) !== -1) ts += kw.split(' ').length; });
      if (ts > bestTS) { bestTS = ts; bestT = t; }
    });
    if (bestTS >= 2) detectedTopic = bestT;
  }

  // ── Keywords ─────────────────────────────────────────────────────────────
  var SW = new Set(['what','is','are','the','a','an','of','in','on','at','for','to','and','or','how','why','who','where','when','does','do','did','will','can','tell','me','us','about','this','that','these','those','with','from','by','it','its','was','has','have','be','been','some','any','all','more','most','other','than','so','if','but','not','no','as','up','out','there','their','they','them','he','she','we','you','his','her','our','your','my','which','into','through','during','before','after','also','only','over','under','then','just','very','its','happening','situation','latest','recent','today','news','story','stories','current','update','updates']);
  var keywords = detect.replace(/[^a-z\s]/g, ' ').split(/\s+/).filter(function(w){ return w.length > 2 && !SW.has(w); });

  // ── Article/cluster page context anchoring ────────────────────────────────
  // If the user is on an article page and asks a vague "this" question with no
  // explicit country, anchor the query to the current article's country so
  // retrieveMiraSources finds the right stories rather than doing a global search.
  if (!detectedCountry && shouldUseCurrentArticleContext(q, ctx)) {
    var artCtx = ctx.article || null;
    if (artCtx && artCtx.country) {
      detectedCountry  = artCtx.country;
      detectedCountries = [artCtx.country];
    }
    if (!detectedTopic && artCtx && getArticleTopic && artCtx.topic) {
      detectedTopic = artCtx.topic;
    }
  }

  // ── Confidence ───────────────────────────────────────────────────────────
  var confidence = 'high';
  if (detectedCountries.length > 0 || detectedTopic) {
    confidence = 'high';
  } else if (keywords.length >= 2) {
    confidence = 'medium';
  } else {
    confidence = 'low';
  }

  return {
    original:       q,
    normalizedQuery: ql,
    correctedQuery:  correctedQ,
    wasCorrected:    wasCorrected,
    corrections:     corrections,
    intent:          intent,
    country:         detectedCountry,
    countries:       detectedCountries,
    region:          detectedRegion,
    topic:           detectedTopic,
    keywords:        keywords,
    confidence:      confidence,
  };
}

// ── Autocomplete / predictive completion ──────────────────────────────────────
function buildMiraAutocomplete(partial, articles) {
  var p  = (partial || '').trim();
  if (p.length < 2) return [];
  var pl = p.toLowerCase();
  var results = [];

  // All countries flat
  var allCountries = [];
  Object.values(REGION_COUNTRIES).forEach(function(cs){ allCountries = allCountries.concat(cs); });

  // Country story counts for ranking
  var countryCounts = {};
  (articles || []).forEach(function(a) {
    if (a.country && !a._clusterHidden) countryCounts[a.country] = (countryCounts[a.country] || 0) + 1;
  });

  // ── Country prefix / fuzzy matches ────────────────────────────────────────
  var matchedCountries = allCountries.filter(function(c) {
    var cl = c.toLowerCase();
    if (cl.startsWith(pl)) return true;
    if (cl.indexOf(pl) !== -1) return true;
    // Fuzzy: allow 1 edit per 4 chars of input
    if (pl.length >= 4 && levenshteinDistance(pl, cl.slice(0, Math.min(pl.length + 1, cl.length))) <= Math.floor(pl.length / 4)) return true;
    return false;
  }).sort(function(a, b) {
    var ac = countryCounts[a] || 0, bc = countryCounts[b] || 0;
    // Exact prefix first
    if (a.toLowerCase().startsWith(pl) !== b.toLowerCase().startsWith(pl))
      return a.toLowerCase().startsWith(pl) ? -1 : 1;
    return bc - ac;
  }).slice(0, 3);

  matchedCountries.forEach(function(c) {
    results.push({ type: 'country', label: c, description: 'Country', query: c });
    if (countryCounts[c]) {
      results.push({ type: 'question', label: 'What is happening in ' + c + '?', description: 'Ask Mira', query: 'What is happening in ' + c + '?' });
    }
  });

  if (results.length >= 7) return results.slice(0, 7);

  // ── "What is happening in..." completion ─────────────────────────────────
  var happeningRe = /^what(\s+is)?(\s+happening)?(\s+in)?(\s+\w+)?$/i;
  var hapIn = /^what\s+is\s+happening\s+in\s*/i.test(pl);
  var justWhat = /^what\s+(is\s+)?happening\s*$/i.test(pl);
  if (hapIn || justWhat) {
    var topCountries = Object.keys(countryCounts).sort(function(a, b){ return countryCounts[b] - countryCounts[a]; }).slice(0, 5);
    topCountries.forEach(function(c) {
      results.push({ type: 'question', label: 'What is happening in ' + c + '?', description: 'Ask Mira', query: 'What is happening in ' + c + '?' });
    });
    if (results.length >= 5) return results.slice(0, 7);
  }

  // ── Topic prefix / fuzzy matches ──────────────────────────────────────────
  var matchedTopics = TOPICS.filter(function(t) {
    var tl = t.toLowerCase();
    return tl.startsWith(pl) || tl.indexOf(pl) !== -1 || (pl.length >= 4 && levenshteinDistance(pl, tl.slice(0, Math.min(pl.length + 1, tl.length))) <= 1);
  }).slice(0, 2);

  matchedTopics.forEach(function(t) {
    results.push({ type: 'topic', label: t, description: 'Topic', query: t });
    results.push({ type: 'question', label: 'What changed in ' + t.toLowerCase() + ' today?', description: 'Ask Mira', query: 'What changed in ' + t.toLowerCase() + ' today?' });
  });

  if (results.length >= 7) return results.slice(0, 7);

  // ── Story title matches ───────────────────────────────────────────────────
  if (results.length < 4) {
    var storyMatches = (articles || [])
      .filter(function(a) { return !a._clusterHidden && (a.title || '').toLowerCase().indexOf(pl) !== -1; })
      .sort(function(a, b){ return (b.significance || 1) - (a.significance || 1); })
      .slice(0, 3);
    storyMatches.forEach(function(a) {
      results.push({ type: 'story', label: a.title || '', description: [a.country, a.topic].filter(Boolean).join(' · '), query: a.title || '' });
    });
  }

  return results.slice(0, 7);
}

// ── Source retrieval ──────────────────────────────────────────────────────────
// Minimum score for a story to be included when NO country/region filter is active.
var MIRA_SCORE_MIN = 8;

function scoreMiraSource(story, understood) {
  if (!story || !understood) return 0;
  var score   = 0;
  var title   = (story.title   || '').toLowerCase();
  var summary = miraCleanText(story.summary || '').toLowerCase();
  var country = (story.country || '').toLowerCase();
  var sTopic  = getArticleTopic(story);

  // ── HARD COUNTRY FILTER ───────────────────────────────────────────────────
  // If the user asked about specific countries, any story that doesn't
  // mention those countries in its country field, title, or summary gets -999.
  // This prevents Hong Kong/Iran stories answering Venezuela queries.
  if (understood.countries && understood.countries.length > 0) {
    var matchesACountry = understood.countries.some(function(c) {
      var cl = c.toLowerCase();
      // Whole-word test in text fields
      var re = new RegExp('\\b' + cl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
      return country === cl || re.test(title) || re.test(summary);
    });
    if (!matchesACountry) return -999;
  }

  // Country score
  if (understood.countries && understood.countries.length > 0) {
    understood.countries.forEach(function(c) {
      if (country === c.toLowerCase()) score += 30;
      else if (title.indexOf(c.toLowerCase()) !== -1) score += 20;
      else if (summary.indexOf(c.toLowerCase()) !== -1) score += 10;
    });
  } else if (understood.country) {
    if (country === understood.country.toLowerCase()) score += 30;
  }

  // Region score (only when no country detected)
  if (understood.region && (!understood.countries || understood.countries.length === 0)) {
    var rcs = (REGION_COUNTRIES[understood.region] || []).map(function(c){ return c.toLowerCase(); });
    if (rcs.indexOf(country) !== -1) score += 12;
  }

  // Topic score
  if (understood.topic && sTopic === understood.topic) score += 20;

  // Keyword scores — non-stopword terms from the user's question.
  // Each keyword hit in title/summary boosts the story.
  // Longer keywords (≥7 chars, e.g. "economy", "humanitarian") get extra weight
  // because they are more specific — a story that mentions "economy" is much more
  // likely to be what the user wants than one that merely mentions "trade".
  var kwHits = 0;
  understood.keywords.forEach(function(kw) {
    var inTitle   = title.indexOf(kw)   !== -1;
    var inSummary = summary.indexOf(kw) !== -1;
    var boost     = kw.length >= 7 ? 10 : (kw.length >= 5 ? 6 : 4);
    if (inTitle)   { score += boost * 1.5; kwHits++; }
    if (inSummary) { score += boost;       kwHits++; }
  });
  // When the question has substantive specific keywords (≥2 with length ≥5)
  // and this story matches none of them, apply a mild relevance penalty so that
  // high-significance but off-topic stories don't crowd out on-topic ones.
  // (Only when no country filter — country filter already handles story selectivity.)
  var specificKws = (understood.keywords || []).filter(function(kw){ return kw.length >= 5; });
  var _noCountryFilter = !understood.countries || understood.countries.length === 0;
  if (specificKws.length >= 2 && kwHits === 0 && _noCountryFilter) {
    score -= 8;
  }

  // Recency
  var pub = new Date(story.published || story.scraped_at || 0).getTime();
  var age = Date.now() - pub;
  if      (age < 24  * 3600000) score += 5;
  else if (age < 72  * 3600000) score += 2;
  else if (age < 168 * 3600000) score += 1;

  // Significance
  score += ((story.significance || 1) - 1) * 2;
  if (story._clusterHidden) score -= 100;

  return score;
}

function retrieveMiraSources(understood, allArticles, ctx) {
  ctx = ctx || {};
  var contextArticle = ctx.article || null;
  var hasCountryFilter = understood.countries && understood.countries.length > 0;

  // Context article is only treated as primary when it is relevant to the question.
  // If the user asks "what is happening in Sri Lanka?" on a Pakistan article page,
  // the Pakistan article must NOT bypass the country filter — it would cause source
  // drift and the answer would draw from the wrong story.
  var contextIsRelevant = !hasCountryFilter || !contextArticle || understood.countries.some(function(c) {
    var cl = c.toLowerCase();
    var re = new RegExp('\\b' + cl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i');
    return (contextArticle.country || '').toLowerCase() === cl ||
           re.test(contextArticle.title || '') ||
           re.test(contextArticle.summary || '');
  });

  var scored = allArticles
    .filter(function(a){ return !a._clusterHidden; })
    .map(function(a) {
      var s = scoreMiraSource(a, understood);
      // Context boost only when the article is relevant to the question country
      if (contextIsRelevant && contextArticle && a.url === contextArticle.url) s += 200;
      return { a: a, score: s };
    })
    .filter(function(x) {
      // Hard exclude anything scored -999 (failed country filter)
      if (x.score <= -900) return false;
      // Context article bypass only when it is relevant to the question
      if (contextIsRelevant && contextArticle && x.a.url === contextArticle.url) return true;
      // With country filter: any positive score passes
      if (hasCountryFilter) return x.score > 0;
      // Without country filter: use MIRA_SCORE_MIN
      return x.score >= MIRA_SCORE_MIN;
    })
    .sort(function(x, y){ return y.score - x.score; })
    .slice(0, 8)
    .map(function(x){ return x.a; });

  // Only force-include context article when it is relevant to the question
  if (contextIsRelevant && contextArticle && !scored.some(function(a){ return a.url === contextArticle.url; })) {
    scored = [contextArticle].concat(scored.slice(0, 4));
  }

  // Dev debug log — localhost only
  if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
    console.log('[Mira query understanding]', understood);
    console.log('[Mira source ranking — top 5]', allArticles
      .filter(function(a){ return !a._clusterHidden; })
      .map(function(a) {
        var s = scoreMiraSource(a, understood);
        return { title: a.title, country: a.country, topic: getArticleTopic(a), score: s };
      })
      .sort(function(x, y){ return y.score - x.score; })
      .slice(0, 5));
  }

  return scored;
}

function rankMiraSources(stories, understood) {
  return stories.slice().sort(function(a, b){ return scoreMiraSource(b, understood) - scoreMiraSource(a, understood); });
}

// ── Source bundle ─────────────────────────────────────────────────────────────
// buildMiraSourceBundle enriches Globally articles with bundle metadata fields and
// merges any external sources that were fetched asynchronously (via ctx.externalSources).
// The returned objects are backward-compatible: all original article fields are
// preserved so synthesiseMiraAnswer can read .summary, .storyModes, etc. unchanged.
//
// Priority on article pages: current story → cluster source stories → related stories
// Priority on search: exact country/topic matches → clusters → related stories
// External sources (if any) are appended after Globally sources.
function buildMiraSourceBundle(understood, allArticles, ctx) {
  ctx = ctx || {};
  var contextArticle   = ctx.article    || null;
  var contextClusterId = contextArticle ? (contextArticle.eventClusterId || null) : null;

  // 1. Retrieve and order Globally sources (existing scoring + country filter)
  var globallyRaw = retrieveMiraSources(understood, allArticles, ctx);

  var bundle = globallyRaw.map(function(a) {
    var rs = scoreMiraSource(a, understood);
    // Article-page priority boosts (mirrors retrieveMiraSources boosts)
    if (contextArticle && a.url === contextArticle.url) rs += 200;
    if (contextClusterId && a.eventClusterId === contextClusterId) rs += 30;

    var domain = '';
    try { domain = new URL(a.url).hostname.replace(/^www\./, ''); } catch(e) {}

    var reason = (contextArticle && a.url === contextArticle.url)
      ? 'current article'
      : (contextClusterId && a.eventClusterId === contextClusterId)
        ? 'cluster source'
        : rs >= 40 ? 'high relevance' : 'related';

    return Object.assign({}, a, {
      id:               a.url,
      sourceName:       a.sourceName || domain,
      publishedAt:      a.published  || a.scraped_at || null,
      excerpt:          (a.summary || '').slice(0, 280),
      imageUrl:         a.image_url  || null,
      isGloballySource: true,
      isExternalSource: false,
      relevanceScore:   rs,
      relevanceReason:  reason,
    });
  });

  // 2. Merge external sources (async-fetched, passed via ctx.externalSources)
  var external = ctx.externalSources || [];
  external.forEach(function(ext) {
    // Skip duplicates by URL
    if (!bundle.some(function(b) { return b.url === ext.url; })) {
      bundle.push(Object.assign({ isGloballySource: false, isExternalSource: true }, ext));
    }
  });

  return bundle;
}

// searchExternalSourcesForMira — async, called only when Globally sources are thin.
// Calls the /api/mira-search Vercel serverless route (key is safe in env vars).
// Returns a Promise that resolves to a source array; resolves to [] on any error so
// the caller can always proceed with Globally-only sources as a fallback.
//
// DO NOT put any search API key here — keys belong in Vercel env vars, not client JS.
function searchExternalSourcesForMira(params) {
  var q    = (params.question           || '').trim();
  var un   = params.understoodQuestion  || {};
  var subj = params.primarySubject      || null;

  if (!q) return Promise.resolve([]);

  // Build a focused search query
  var searchQuery = q;
  if (subj && subj.value)  searchQuery = subj.value + ' ' + q;
  else if (un.country)     searchQuery = un.country  + ' ' + q;

  return fetch('/api/mira-search', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({
      question:    q,
      query:       searchQuery.slice(0, 200),
      intent:      un.intent  || null,
      country:     un.country || null,
      subject:     subj ? subj.value : null,
    }),
  })
  .then(function(r) {
    if (!r.ok) return [];
    return r.json().then(function(d) { return d.sources || []; });
  })
  .catch(function() { return []; });
}

// Decide whether external search would improve the answer.
// Only triggers when Globally sources are thin AND the question warrants richer coverage.
function needsExternalSearch(understood, ranked, synthesis) {
  var globallyCount = ranked.filter(function(s) { return !s.isExternalSource; }).length;

  // Already plenty of Globally sources
  if (globallyCount >= 4) return false;

  // Intents that don't benefit from external web results
  var skipIntents = [
    MIRA_INTENT.SUMMARISE_30S,
    MIRA_INTENT.SUMMARISE_3MIN,
    MIRA_INTENT.CONNECT_STORIES,
    MIRA_INTENT.SOURCES_REPORT_NEXT,
  ];
  if (skipIntents.indexOf(understood.intent) !== -1) return false;

  // No clear subject/country/topic — web search unlikely to return relevant results
  if (!understood.country && (!understood.countries || !understood.countries.length) && !understood.topic) {
    return false;
  }

  // Coverage gap in synthesis or very thin sources → try external
  var gapDetected = (synthesis.limitations || []).some(function(l) {
    return /don.t yet cover|don.t include specific|specific reporting/.test(l);
  });

  return globallyCount < 2 || gapDetected;
}

// ── Reasoning layer — derives analytical framing from source content ──────────
// This function infers development implications from what sources actually say.
// It never invents specific facts, numbers, or claims. It reasons from reported
// content to recognised development patterns — the same way an analyst would.
function buildMiraReasoningLayer(topic, country, allSourceText, srcCount) {
  var lower = (allSourceText || '').toLowerCase();
  var parts = [];

  function has(re) { return re.test(lower); }

  // Natural disaster
  if (has(/\b(earthquake|quake|flood|storm|cyclone|hurricane|tsunami|volcano)\b/)) {
    var concerns = [];
    if (has(/\b(dead|killed|death|casualt|missing|survivor|buried|trapped)\b/))
      concerns.push('the search for survivors and the scale of casualties');
    if (has(/\b(displace|shelter|homeless|evacuate|camp)\b/))
      concerns.push('displaced populations and emergency shelter needs');
    if (has(/\b(infrastructure|road|bridge|hospital|building|collapse|damage)\b/))
      concerns.push('damaged infrastructure and access for rescue teams');
    if (concerns.length > 0) {
      parts.push(
        'The immediate development concern centres on ' +
        (concerns.length === 1 ? concerns[0] :
         concerns.slice(0, -1).join(', ') + ', and ' + concerns[concerns.length - 1]) + '.'
      );
    }
    if (has(/\b(aid|relief|international|pledge|million|billion|donate|rescue team)\b/)) {
      parts.push('International assistance is mobilising — typical when a disaster of this scale stretches local response capacity.');
    }
    if (has(/\b(econom|fiscal|debt|crisis|poor|poverty|already|existing|compound)\b/)) {
      parts.push('Recovery in contexts with pre-existing economic pressure tends to be slower and more dependent on sustained external support.');
    }
  }

  // Armed conflict (but not if it is a disaster context)
  else if (has(/\b(conflict|war|militia|rebel|ceasefire|airstrike|strikes?|bombing|military attack|offensive)\b/)) {
    var conflictConcerns = [];
    if (has(/\b(civilian|population|children|family|families)\b/))
      conflictConcerns.push('civilian populations caught in active fighting zones');
    if (has(/\b(displace|refugee|flee|internally displaced|idp)\b/))
      conflictConcerns.push('people displaced from their homes');
    if (has(/\b(humanitarian|aid|access|delivery|block|obstruct)\b/))
      conflictConcerns.push('humanitarian access and aid delivery');
    if (conflictConcerns.length > 0) {
      parts.push(
        'The human cost falls especially on ' +
        (conflictConcerns.length === 1 ? conflictConcerns[0] :
         conflictConcerns.slice(0, -1).join(', ') + ', and ' + conflictConcerns[conflictConcerns.length - 1]) + '.'
      );
    }
    if (has(/\b(hospital|water|power|electricity|infrastructure)\b/)) {
      parts.push('Damage to critical infrastructure — hospitals, water, and power — can extend the humanitarian impact far beyond active fighting.');
    }
    if (has(/\b(ceasefire|talks|negotiat|deal|agreement|halt)\b/)) {
      parts.push('Ceasefire or negotiation developments matter significantly here: they shape whether aid reaches affected communities and whether displacement slows.');
    }
  }

  // Food / humanitarian crisis
  if (has(/\b(famine|hunger|food.{0,12}(insecurity|crisis)|malnutrit|wfp|food supply|ipc phase [34])\b/)) {
    var foodConcerns = [];
    if (has(/\b(child|children|malnutrit)\b/))
      foodConcerns.push('children facing acute malnutrition');
    if (has(/\b(million|emergency|phase [34])\b/))
      foodConcerns.push('large numbers of people in emergency food insecurity');
    if (foodConcerns.length > 0) {
      parts.push('Food crises have compounding effects — ' + foodConcerns.join(' and ') + ' point to a situation where development gains built over years can reverse rapidly.');
    }
    if (has(/\b(funding|donor|cut|shortfall|underfund|gap)\b/)) {
      parts.push('Funding shortfalls force aid organisations into painful decisions about who receives support when resources are insufficient for all who need them.');
    }
  }

  // Economic / IMF / debt
  if (has(/\b(imf|world bank|debt|fiscal|economy|economic crisis|gdp|inflation|budget|austerity)\b/) &&
      !has(/\b(earthquake|quake|flood)\b/)) {
    if (has(/\b(million|billion|disburs|loan|programme|grant|tranche)\b/)) {
      parts.push('External financing decisions of this type typically carry reform conditions that shape what governments can spend on health, education, and social protection.');
    }
    if (has(/\b(inflation|currency|exchange|devaluat|depreciat)\b/)) {
      parts.push('Currency and inflation pressures hit low-income households hardest — they spend the largest share of income on food and basic goods, leaving little buffer.');
    }
  }

  // Governance / repression
  if (has(/\b(election|democracy|parliament|corruption|accountability)\b/) &&
      !has(/\b(earthquake|quake)\b/)) {
    if (has(/\b(protest|crackdown|arrest|detain|opposition|journalist|media|press|censor)\b/)) {
      parts.push('Restrictions on political activity, press freedom, or civil society have cascading effects — they shape what information reaches the public and whether affected communities can advocate for their rights.');
    } else if (has(/\b(election|vote|voted|ballot|fraud)\b/)) {
      parts.push('Electoral developments are closely watched by regional and international observers when governance conditions are contested, since outcomes affect policy direction and institutional legitimacy.');
    }
  }

  // Migration / displacement
  if (has(/\b(migrant|refugee|asylum|irregular migration|border crossing|deportat|expel|xenophob)\b/)) {
    parts.push('Migration situations are shaped by both push factors — conflict, poverty, climate — and the policies receiving countries apply, which determine whether people can reach safety and legal protection.');
  }

  // Climate (standalone, not disaster)
  if (has(/\b(climate change|emission|carbon|sea.level|adaptation|loss and damage)\b/) &&
      !has(/\b(earthquake|quake|flood|storm)\b/)) {
    if (has(/\b(vulnerable|small island|developing|south|africa|asia|pacific|least developed)\b/)) {
      parts.push('The communities most exposed to climate impacts are typically those that have contributed least to global emissions — making this both an environmental and a development justice issue.');
    }
  }

  // Sanctions / trade
  if (has(/\b(sanction|embargo|trade restriction|tariff|economic pressure)\b/)) {
    parts.push('Trade restrictions and sanctions tend to affect ordinary people through prices, shortages, and job losses — even when the policy target is a government or specific sector.');
  }

  return parts.join(' ');
}

// Produces a subject-centred opening sentence when no source sentence leads with the subject.
// Derived purely from keywords in the source text — never invents specific numbers or facts.
function buildMiraDirectOpening(subjectName, allSourceText) {
  var lower = (allSourceText || '').toLowerCase();
  function has(re) { return re.test(lower); }

  if (has(/\b(earthquake|quake)\b/)) {
    var parts = [];
    if (has(/\b(dead|killed|casualt|death)\b/)) parts.push('casualties reported');
    if (has(/\b(missing|trapped|buried)\b/))     parts.push('people missing or trapped');
    if (has(/\b(displace|evacuate|shelter)\b/))  parts.push('widespread displacement');
    if (has(/\b(rescue|search.and.rescue|emergency team)\b/)) parts.push('rescue operations under way');
    if (has(/\b(aid|relief|international)\b/))   parts.push('international relief mobilising');
    var opener = subjectName + ' is dealing with the aftermath of a major earthquake';
    if (parts.length > 0) opener += ', with ' + joinMiraList(parts);
    return opener + '.';
  }
  if (has(/\b(flood(ing)?|flash flood)\b/)) {
    return subjectName + ' is facing severe flooding, with rescue and relief operations under way.';
  }
  if (has(/\b(cyclone|hurricane|typhoon)\b/)) {
    return subjectName + ' has been struck by a major cyclone, with emergency response efforts ongoing.';
  }
  if (has(/\b(conflict|war|fighting|airstrike|military offensive)\b/)) {
    var cparts = [];
    if (has(/\b(civilian|population)\b/))        cparts.push('civilians affected');
    if (has(/\b(displace|flee|refugee)\b/))      cparts.push('displacement ongoing');
    if (has(/\b(humanitarian|aid access)\b/))    cparts.push('humanitarian access under pressure');
    var co = subjectName + ' is experiencing active conflict';
    if (cparts.length > 0) co += ', with ' + joinMiraList(cparts);
    return co + '.';
  }
  if (has(/\b(famine|food insecurity|hunger crisis|malnutrition)\b/)) {
    return subjectName + ' is facing a serious food security crisis, with humanitarian organisations warning of acute need.';
  }
  if (has(/\b(imf|debt crisis|economic crisis|fiscal crisis|recession)\b/)) {
    return subjectName + ' is navigating significant economic pressure, with international financial institutions involved.';
  }
  if (has(/\b(protest|demonstration|unrest|civil unrest)\b/)) {
    return subjectName + ' is experiencing civil unrest, with protests reported in recent days.';
  }
  return null;
}

function joinMiraList(items) {
  if (!items || items.length === 0) return '';
  if (items.length === 1) return items[0];
  if (items.length === 2) return items[0] + ' and ' + items[1];
  return items.slice(0, -1).join(', ') + ', and ' + items[items.length - 1];
}

// Returns the primary subject of the user's question so answers stay centred on it.
// Without this, a "China sends aid to Venezuela" story can become the lead when the
// user asked about Venezuela — the actor becomes the subject.
function getPrimaryAnswerSubject(understood, currentStory) {
  if (understood && understood.countries && understood.countries.length > 0) {
    return { type: 'country', value: understood.countries[0] };
  }
  if (understood && understood.entities && understood.entities.length > 0) {
    return { type: 'entity', value: understood.entities[0] };
  }
  if (currentStory && currentStory.country) {
    return { type: 'country', value: currentStory.country };
  }
  return null;
}

// ── Answer mode detection ─────────────────────────────────────────────────────
// Returns 'fact_short' | 'explain_medium' | 'analysis_long'
function detectMiraAnswerMode(question, intent) {
  var q = (question || '').toLowerCase().trim();
  // fact_short: questions that ask for a specific number, count, or amount
  if (/^how many\b|^how much\b/.test(q) ||
      /\bhow many\s+(people|were|are|have|has|died?|killed|injur|missing|hurt)\b/i.test(q) ||
      /\bhow much.{0,30}(donat|pledg|contribut|send|sent|gave?|money|fund|aid|grant)\b/i.test(q) ||
      /\b(death toll|injury.{0,5}(count|toll|figure)|how many.{0,20}(died?|killed|injur|missing))\b/i.test(q) ||
      /what (is the )?(number|amount|figure|toll|count)\b/i.test(q))
    return 'fact_short';
  if (/\b(analys|in.?depth|long version|compar|causes?|consequences?|opportunity cost|wider implications?|development impact|structural|systemic)\b/i.test(q))
    return 'analysis_long';
  return 'explain_medium';
}

// Returns 'injured'|'deaths'|'missing'|'donation'|'count'|'general'
function detectFactType(question) {
  var q = (question || '').toLowerCase();
  if (/\b(injur|wound|hospitalis|hurt)\b/.test(q)) return 'injured';
  if (/\b(died?\b|dead\b|killed?\b|death.{0,5}toll)\b/.test(q)) return 'deaths';
  if (/\b(missing|unaccounted|trapped)\b/.test(q)) return 'missing';
  if (/\b(donat|pledg|contribut|how much|amount\b|fund|aid|grant|send|sent|gave?|money)\b/.test(q)) return 'donation';
  if (/\b(number|count|total|how many)\b/.test(q)) return 'count';
  return 'general';
}

// Searches source sentences for the specific metric + an actual number.
// Returns { sentence, source, hasNumber } or null.
var _FACT_PATTERNS = {
  injured: {
    withNum: /\b\d[\d,]*\+?\s*(?:people|persons?)?\s*(?:were|are|have been)?\s*(?:injured|wounded|hospitalised|hospitalized|hurt)\b|\b(?:injured|wounded|hospitalised|hospitalized|hurt)\b[^.]{0,100}\b\d[\d,]+/i,
    anyMention: /\b(?:injur|wound|hospitalis|hurt)\b/i,
  },
  deaths: {
    withNum: /\b\d[\d,]*\+?\s*(?:people|persons?)?\s*(?:were|have been|are)?\s*(?:killed|dead|died)\b|\b(?:dead|killed|deaths?|fatalities?)\b[^.]{0,100}\b\d[\d,]+|\bdeath.{0,10}toll\b[^.]{0,60}\b\d[\d,]+/i,
    anyMention: /\b(?:killed|dead|died|deaths?|fatal)\b/i,
  },
  missing: {
    withNum: /\b\d[\d,]*\+?\s*(?:people|persons?)?\s*(?:are|remain|still)?\s*(?:missing|unaccounted)\b|\b(?:missing|unaccounted)\b[^.]{0,100}\b\d[\d,]+/i,
    anyMention: /\b(?:missing|unaccounted)\b/i,
  },
  donation: {
    withNum: /\b(?:yuan|rmb|cny|\$|usd|euros?|million|billion|crore|rupees?)\b[^.]{0,80}(?:donat|pledg|contribut|aid|fund|commit|sent|allocat|giv)|(?:donat|pledg|contribut|commit|sent|giv)[^.]{0,80}\b(?:yuan|rmb|cny|\$|usd|euros?|million|billion|crore)\b/i,
    anyMention: /\b(?:donat|pledg|contribut|fund|aid|grant)\b/i,
  },
};

function extractFactFromSources(sources, factType) {
  var patterns = _FACT_PATTERNS[factType];
  if (!patterns) return null;
  var fallback = null;
  for (var _fi = 0; _fi < sources.length; _fi++) {
    var _fs = sources[_fi];
    var _blocks = [
      _fs.summary || '',
      ((_fs.storyModes && _fs.storyModes.facts) || []).join(' '),
      _fs.deeper_summary || '',
      (_fs.storyModes && _fs.storyModes.explain) || '',
    ];
    for (var _fj = 0; _fj < _blocks.length; _fj++) {
      var _sents = miraSentenceSplit(miraCleanText(_blocks[_fj]));
      for (var _fk = 0; _fk < _sents.length; _fk++) {
        var _sent = _sents[_fk];
        if (patterns.withNum.test(_sent)) return { sentence: _sent, source: _fs, hasNumber: true };
        if (!fallback && patterns.anyMention.test(_sent)) fallback = { sentence: _sent, source: _fs, hasNumber: false };
      }
    }
  }
  return fallback;
}

// ── buildMiraReasoningPlan ────────────────────────────────────────────────────
// Produces a plan for intent-specific questions so synthesiseMiraAnswer can
// separate source facts from general reasoning.  Source facts support the answer;
// they are not repeated AS the answer.
//
// Returns:
//   answerType            'hybrid' | 'gap_plus_general'
//   sourceFactsToUse      [{ text, source, quality }]
//   sourcesCoverAngle     bool  — true when sources mention the specific angle
//   generalReasoningNeeded bool
//   generalReasoningFocus  string  — the intent key
//   forbiddenClaims       string[]
//   answerStructure       string
function buildMiraReasoningPlan(opts) {
  var intent  = opts.intent;
  var sources = opts.sources || [];

  var signals      = MIRA_COVERAGE_SIGNALS[intent] || null;
  var sourceFactsToUse = [];

  if (signals) {
    sources.forEach(function(s) {
      var candidates = [];
      ((s.storyModes && s.storyModes.facts) || []).forEach(function(f) {
        if (f && signals.test(f)) candidates.push({ text: f, source: s, quality: 'fact' });
      });
      (s.deeper_summary ? miraBullets(s.deeper_summary) : []).forEach(function(b) {
        if (b && signals.test(b)) candidates.push({ text: b, source: s, quality: 'deep' });
      });
      miraSentenceSplit(miraCleanText(s.summary || '')).forEach(function(sent) {
        if (sent && signals.test(sent)) candidates.push({ text: sent, source: s, quality: 'summary' });
      });
      if (s.storyModes && s.storyModes.matters && signals.test(s.storyModes.matters)) {
        candidates.push({ text: s.storyModes.matters, source: s, quality: 'matters' });
      }
      candidates.forEach(function(c) {
        var slug = c.text.slice(0, 40).toLowerCase();
        if (!sourceFactsToUse.some(function(x) { return x.text.slice(0, 40).toLowerCase() === slug; })) {
          sourceFactsToUse.push(c);
        }
      });
    });
  }

  var sourcesCoverAngle = sourceFactsToUse.length > 0;

  return {
    answerType:             sourcesCoverAngle ? 'hybrid' : 'gap_plus_general',
    directAnswerNeeded:     true,
    sourceFactsToUse:       sourceFactsToUse.slice(0, 6),
    sourcesCoverAngle:      sourcesCoverAngle,
    generalReasoningNeeded: true,
    generalReasoningFocus:  intent,
    forbiddenClaims:        ['specific_numbers_not_in_sources', 'event_specific_damage_not_in_sources'],
    answerStructure:        sourcesCoverAngle
      ? 'source_summary + general_reasoning'
      : 'gap_note + general_reasoning',
  };
}

// ── Synthesis — analytical answer builder ─────────────────────────────────────
function synthesiseMiraAnswer(question, ranked, understood, ctx) {
  ctx = ctx || {};
  var hasCountryFilter = understood.countries && understood.countries.length > 0;
  var targetName = hasCountryFilter ? understood.countries.join(' / ') : (understood.region || understood.topic || null);

  if (!ranked || ranked.length === 0) {
    var noSrcMsg = hasCountryFilter
      ? "Mira can't find enough recent stories about " + targetName + " in Globally's current sources to give a reliable answer."
      : "Mira can't find enough relevant stories to answer that reliably right now.";
    return { text: noSrcMsg, paragraphs: [], sourcesUsed: [], limitations: ["No relevant stories found in today's corpus."] };
  }

  var primary = ranked[0];
  var support = ranked.slice(1, 6);
  var intent  = understood.intent;
  var country = (understood.countries && understood.countries[0]) || primary.country || '';
  var topic   = understood.topic || getArticleTopic(primary);

  // ── Question-keyword set ────────────────────────────────────────────────────
  // These are the SPECIFIC angle keywords from the user's question (e.g. "economy",
  // "health", "refugee") that must steer which sentences/bullets get pulled.
  // We strip out country/region names since those are handled by the country filter.
  var _countryWords = (understood.countries || []).map(function(c){ return c.toLowerCase(); });
  var qkws = (understood.keywords || []).filter(function(w) {
    return w.length >= 4 && _countryWords.indexOf(w.toLowerCase()) === -1;
  });

  // Score a single text passage by how many question keywords it contains.
  // Returns 0–N; higher = more directly answers the question asked.
  function scorePassage(text) {
    if (!text || !qkws.length) return 0;
    var lower = text.toLowerCase();
    return qkws.reduce(function(n, kw) { return n + (lower.indexOf(kw) !== -1 ? (kw.length >= 7 ? 3 : 2) : 0); }, 0);
  }

  // From an array of passages, return the one most relevant to the question.
  // Falls back to first item when scores are all 0.
  function topPassage(passages) {
    if (!passages || !passages.length) return null;
    if (!qkws.length) return passages[0] || null;
    var best = passages[0]; var bestSc = scorePassage(best);
    for (var _i = 1; _i < passages.length; _i++) {
      var sc = scorePassage(passages[_i]);
      if (sc > bestSc) { bestSc = sc; best = passages[_i]; }
    }
    return best;
  }

  // ── Coverage gap detection ──────────────────────────────────────────────────
  // Before synthesising, check whether ANY ranked source actually contains the
  // question's specific keywords. If none do, report the gap honestly instead
  // of manufacturing a generic summary that doesn't answer the question.
  var coverageScore = 0;
  if (qkws.length >= 2) {
    ranked.forEach(function(s) {
      var fullText = [
        s.summary || '',
        s.deeper_summary || '',
        ((s.storyModes && s.storyModes.facts) || []).join(' '),
        (s.storyModes && s.storyModes.matters) || '',
      ].join(' ').toLowerCase();
      qkws.forEach(function(kw) { if (fullText.indexOf(kw) !== -1) coverageScore++; });
    });
  }

  // Coverage gap threshold: require at least 2 keyword-hits across all sources.
  // A single passing mention (coverageScore = 1) is not enough — it likely means
  // "economy" appears once in an earthquake rescue story, not that sources actually
  // cover the economic angle the user asked about.
  var hasCoverage = (qkws.length < 2) || (coverageScore >= 2);

  // Extract content from primary source
  var primSum   = miraSentenceSplit(miraCleanText(primary.summary || ''));
  var primFacts = (primary.storyModes && primary.storyModes.facts)   || [];
  var primWhy   = (primary.storyModes && primary.storyModes.matters) || null;
  var primCtx   = (primary.storyModes && primary.storyModes.explain) || null;
  var primDeep  = primary.deeper_summary ? miraBullets(primary.deeper_summary) : [];

  // Gather cross-source evidence — pick the most question-relevant passage per
  // source rather than always the first one.
  var sourcesUsed   = [primary];
  var crossEvidence = [];
  support.forEach(function(s) {
    var ss    = miraSentenceSplit(miraCleanText(s.summary || ''));
    var facts = (s.storyModes && s.storyModes.facts) || [];
    var deep  = s.deeper_summary ? miraBullets(s.deeper_summary) : [];
    // Rank all candidates from this source by question relevance, then pick the best
    var candidates = facts.concat(deep, ss).filter(function(p){ return p && p.length > 20; });
    var best = topPassage(candidates) || '';
    if (best && best.length > 20) {
      crossEvidence.push({ text: best, source: s });
      sourcesUsed.push(s);
    }
  });

  // Combined text used by the reasoning layer
  var allSourceText = [
    primary.summary || '', primary.deeper_summary || '',
    primWhy || '', primCtx || '',
  ].concat(support.map(function(s){ return (s.summary || '') + ' ' + (s.deeper_summary || ''); })).join(' ');

  var paragraphs  = [];
  var limitations = [];

  function addPara(text, src) {
    var t = (text || '').trim();
    if (t.length > 10) paragraphs.push({ text: t, source: src || primary });
  }

  // ── Coverage gap handling ─────────────────────────────────────────────────────
  // The gap check is BYPASSED for:
  //   (a) Structured-intent questions (3-minute version, why does this matter, etc.)
  //       — those have dedicated synthesis paths that ignore keyword coverage.
  //   (b) Specific development-intent questions (economic_impact, humanitarian, etc.)
  //       — buildMiraReasoningPlan handles those with inline general reasoning.
  // For remaining open questions with thin coverage, Mira provides the best available
  // story context plus a note — it never flat-refuses to answer.
  var _BYPASS_GAP_INTENTS = [
    MIRA_INTENT.SUMMARISE_30S, MIRA_INTENT.SUMMARISE_3MIN,
    MIRA_INTENT.WHY_IT_MATTERS, MIRA_INTENT.WHO_IS_AFFECTED,
    MIRA_INTENT.EXPLAIN_BACKGROUND, MIRA_INTENT.SOURCES_REPORT_NEXT,
    MIRA_INTENT.CONNECT_STORIES,
  ];
  var _specificGapIntent = detectMiraQuestionIntent(question);
  var _gapBypassed = _BYPASS_GAP_INTENTS.indexOf(intent) !== -1 ||
    ['economic_impact','opportunity_cost','tourism_impact','humanitarian_impact',
     'infrastructure_impact','state_capacity','effects_impact'].indexOf(_specificGapIntent) !== -1;

  if (!hasCoverage && !_gapBypassed) {
    // Provide story context and a development note rather than refusing.
    var _ctxSnippet = (primFacts[0] || primSum[0] || '').trim();
    limitations.push("Limited source coverage for that specific angle — showing the best available reporting.");
    if (_ctxSnippet) {
      addPara("Globally's sources confirm the story context: " + _ctxSnippet +
        "\n\nMira doesn't have a confirmed source for that specific detail in Globally's current coverage, " +
        "but can explain the likely development implications in general terms.", primary);
    } else {
      addPara("Mira doesn't have a confirmed source for that specific detail in Globally's current coverage. " +
        "Here is what the current reporting covers:", primary);
    }
    // Set hasCoverage so the default WHAT_IS_HAPPENING path runs and adds source content.
    hasCoverage = true;
  }

  // ── Fact-short mode ──────────────────────────────────────────────────────────
  // For direct factual questions (how many, how much, who, when) return a
  // 1-3 sentence answer and skip bullets and the reasoning layer entirely.
  var _answerMode   = detectMiraAnswerMode(question, intent);
  // Specific intent for routing economic/humanitarian/effects questions to the
  // reasoning-plan path instead of the generic WHAT_IS_HAPPENING path.
  var _specificIntent = detectMiraQuestionIntent(question);
  if (_answerMode === 'fact_short') {
    var _factType  = detectFactType(question);
    var _factFound = extractFactFromSources(ranked, _factType);

    if (_factFound && _factFound.hasNumber) {
      var _factSent = _factFound.sentence.trim();
      if (_factSent.length > 0 && !/^[A-Z]/.test(_factSent))
        _factSent = _factSent[0].toUpperCase() + _factSent.slice(1);
      addPara(_factSent, _factFound.source);
      return {
        text:        _factSent,
        paragraphs:  paragraphs,
        sourcesUsed: [_factFound.source],
        limitations: [],
      };
    } else {
      var _FACT_LABELS = {
        injured:  'a confirmed injury figure',
        deaths:   'a confirmed death toll',
        missing:  'the number of people missing',
        donation: 'the donation amount',
        count:    'that specific count',
        general:  'that specific figure',
      };
      var _factLabel = _FACT_LABELS[_factType] || 'that specific figure';
      var _shortGap  = 'The current sources do not report ' + _factLabel + '.';

      // Surface the closest related fact so the answer is still useful
      var _related = null;
      if (_factType === 'injured') {
        _related = extractFactFromSources(ranked, 'deaths') ||
                   extractFactFromSources(ranked, 'missing');
      } else if (_factType === 'deaths') {
        _related = extractFactFromSources(ranked, 'missing') ||
                   extractFactFromSources(ranked, 'injured');
      }
      if (_related && _related.sentence) {
        _shortGap += ' They report: ' + _related.sentence.trim();
      } else {
        var _nearestFact = (primFacts[0] || primDeep[0] || primSum[0] || '').trim();
        if (_nearestFact.length > 15) _shortGap += ' ' + _nearestFact;
      }
      _shortGap += ' Mira will not estimate a number that is not in the sources.';

      limitations.push('Specific ' + _factLabel + ' not found in current sources.');
      return {
        text:        _shortGap,
        paragraphs:  [{ text: _shortGap, source: primary }],
        sourcesUsed: sourcesUsed,
        limitations: limitations,
      };
    }
  }

  // ── Answer by intent ─────────────────────────────────────────────────────────

  if (intent === MIRA_INTENT.SUMMARISE_30S) {
    var brief = primSum.slice(0, 2).join(' ') || primDeep[0] || primFacts[0] || '';
    addPara(brief, primary);

  } else if (intent === MIRA_INTENT.SUMMARISE_3MIN) {
    // Para 1: 2-sentence opening — what happened and where
    var _3mOpen = primSum.slice(0, 2).join(' ').trim();
    if (_3mOpen) addPara(_3mOpen, primary);

    // Para 2: Key facts / numbered breakdown
    var _3mBullets = primDeep.length > 0 ? primDeep : (primFacts.length > 0 ? primFacts : primSum.slice(2));
    if (_3mBullets.length > 0) {
      addPara(_3mBullets.slice(0, 5).map(function(b, i){ return (i+1) + '. ' + b + (b.endsWith('.') ? '' : '.'); }).join('\n'), primary);
    }

    // Para 3: Why it matters (from storyModes.matters or reasoning layer)
    if (primWhy) {
      addPara(primWhy, primary);
    } else {
      var _3mReason = buildMiraReasoningLayer(topic, country, allSourceText, ranked.length);
      if (_3mReason) addPara(_3mReason, primary);
    }

    // Para 4: Context / background if present
    if (primCtx && paragraphs.length < 4) addPara(primCtx, primary);

    // Para 5: Cross-source reporting
    crossEvidence.slice(0, 2).forEach(function(ev){ addPara(ev.text, ev.source); });

  } else if (intent === MIRA_INTENT.WHY_IT_MATTERS) {
    if (primWhy) {
      addPara(primWhy, primary);
    } else {
      var reasoning = buildMiraReasoningLayer(topic, country, allSourceText, ranked.length);
      addPara(primSum.slice(0, 2).join(' '), primary);
      if (reasoning) addPara(reasoning, primary);
      else limitations.push("The primary source doesn't include explicit 'why it matters' analysis — this is drawn from the source summary instead.");
    }
    crossEvidence.slice(0, 1).forEach(function(ev){ addPara(ev.text, ev.source); });

  } else if (intent === MIRA_INTENT.WHO_IS_AFFECTED) {
    var affContent = primFacts.length > 0 ? primFacts : (primDeep.length > 0 ? primDeep : null);
    if (affContent && affContent.length >= 2) {
      addPara(affContent.slice(0, 3).map(function(f){ return '• ' + f + (f.endsWith('.') ? '' : '.'); }).join('\n'), primary);
    } else if (affContent && affContent.length === 1) {
      // Single fact — use it plus the first two summary sentences for context
      addPara('• ' + affContent[0] + (affContent[0].endsWith('.') ? '' : '.'), primary);
      var ctxSentences = primSum.slice(0, 2).join('. ').trim();
      if (ctxSentences) addPara(ctxSentences + (ctxSentences.endsWith('.') ? '' : '.'), primary);
    } else {
      // No structured facts — use summary sentences that mention people or scale
      var peopleSentences = primSum.filter(function(s) {
        return /people|thousand|million|population|victim|survivor|displaced|children|household|civilian|community/.test(s.toLowerCase());
      });
      var affText = (peopleSentences.length > 0 ? peopleSentences : primSum).slice(0, 2).join('. ').trim();
      if (affText) addPara(affText + (affText.endsWith('.') ? '' : '.'), primary);
    }
    crossEvidence.slice(0, 2).forEach(function(ev){ addPara(ev.text, ev.source); });

  } else if (intent === MIRA_INTENT.EXPLAIN_BACKGROUND) {
    // Use topPassage to find the most question-relevant context/summary sentence.
    // Without this, EXPLAIN_BACKGROUND always takes the first sentences regardless of
    // what the user actually asked — "explain the economic effects" would return the
    // same generic story opener as "explain what happened."
    var bgCandidates = [primCtx].concat(primSum, primDeep, primFacts).filter(Boolean);
    var bgLead = (qkws.length > 0 ? topPassage(bgCandidates) : null) || primCtx || primSum.slice(0, 2).join(' ');
    addPara(bgLead, primary);

    if (primDeep.length >= 2 || primFacts.length >= 2) {
      var bgBullets = (primDeep.length > 0 ? primDeep : primFacts).slice(0, 6);
      // Sort bullets by question relevance so economy/health/conflict bullets rise
      // above whatever happens to be listed first in the source.
      if (qkws.length > 0) {
        bgBullets = bgBullets.slice().sort(function(a, b) { return scorePassage(b) - scorePassage(a); });
      }
      addPara(bgBullets.slice(0, 3).map(function(b){ return '• ' + b + (b.endsWith('.') ? '' : '.'); }).join('\n'), primary);
    }
    crossEvidence.slice(0, 1).forEach(function(ev){ addPara(ev.text, ev.source); });

  } else if (intent === MIRA_INTENT.SOURCES_REPORT_NEXT) {
    var nextItems = primFacts.length >= 2 ? primFacts.slice(-2) :
                    primDeep.length  >= 2 ? primDeep.slice(-2)  :
                    primSum.slice(-2);
    if (nextItems.length === 0) {
      return { text: "The available sources don't contain forward-looking reporting on this topic yet.", paragraphs: [], sourcesUsed: [primary], limitations: ["No forward-looking content found."] };
    }
    addPara('What sources report — not a prediction:\n\n' + nextItems.map(function(p){ return '• ' + p + (p.endsWith('.') ? '' : '.'); }).join('\n'), primary);
    crossEvidence.slice(0, 1).forEach(function(ev){ addPara(ev.text, ev.source); });

  } else if (intent === MIRA_INTENT.CONNECT_STORIES) {
    if (!support || support.length === 0) {
      return { text: "Mira couldn't find closely related stories to connect here. Read more stories on this topic to build your knowledge trail.", paragraphs: [], sourcesUsed: [], limitations: [] };
    }
    var connParts = support.slice(0, 3).map(function(s) {
      var sharedT = (getArticleTopic(s) === topic) ? topic : null;
      var sharedC = (s.country && s.country === country) ? s.country : null;
      var shared  = [sharedT, sharedC].filter(Boolean).join(' and ');
      return '"' + displayTitle(s) + '"' + (shared ? ' — both cover ' + shared : '');
    });
    addPara(connParts.join('\n\n'), primary);

  } else if (['economic_impact','opportunity_cost','tourism_impact','humanitarian_impact',
              'infrastructure_impact','state_capacity','effects_impact'].indexOf(_specificIntent) !== -1) {
    // ── Specific-intent reasoning path ─────────────────────────────────────────
    // For "how will this affect the local economy" and similar questions, the default
    // WHAT_IS_HAPPENING path pulls earthquake snippets (casualties, rescue teams) that
    // don't address the economic/humanitarian/etc. angle asked.
    //
    // This path:
    //   1. Uses buildMiraReasoningPlan to extract only the source facts relevant to
    //      the specific angle (economic signals, humanitarian signals, etc.)
    //   2. Shows a short source note, or a gap note if sources don't cover the angle
    //   3. Adds inline general reasoning through the correct channels
    //      ("In general terms, ..." — in the main answer body, not a separate box)
    //
    // Source facts support the answer. They are never repeated AS the answer.
    var _intentSubject = inferMiraQuestionSubject(question, ctx.article || ctx.currentCluster || null, ctx);
    var _plan = buildMiraReasoningPlan({
      question:       question,
      intent:         _specificIntent,
      subject:        _intentSubject,
      currentStory:   ctx.article        || null,
      currentCluster: ctx.currentCluster || null,
      sources:        ranked,
      sourceCoverage: coverageScore,
    });

    // Para 1 (conditional): brief source facts if sources cover this angle
    if (_plan.sourcesCoverAngle && _plan.sourceFactsToUse.length > 0) {
      var _srcNote = _plan.sourceFactsToUse.slice(0, 3)
        .map(function(f) { return '• ' + f.text.trim() + (f.text.trim().endsWith('.') ? '' : '.'); })
        .join('\n');
      addPara('What the current sources report:\n\n' + _srcNote, _plan.sourceFactsToUse[0].source);
      _plan.sourceFactsToUse.slice(0, 3).forEach(function(f) {
        if (sourcesUsed.indexOf(f.source) === -1) sourcesUsed.push(f.source);
      });
    }

    // Para 2: intent-specific general reasoning — inline in the main answer body
    // buildQuestionAwareGeneralContext already provides well-structured per-intent text,
    // prefixed with "In general terms, …" (bolded in the renderer).
    var _intentCtx = buildQuestionAwareGeneralContext({
      question:                  question,
      intent:                    _specificIntent,
      subject:                   _intentSubject,
      storyOrCluster:            ctx.article || ctx.currentCluster || null,
      sourcesCoverExactQuestion: _plan.sourcesCoverAngle,
    });
    if (_intentCtx && _intentCtx.text) {
      addPara(_intentCtx.text, primary);
      limitations.push('General context only — Mira cannot confirm event-specific figures without direct source reporting.');
    } else {
      // buildQuestionAwareGeneralContext returned nothing — provide story context instead
      var _fallbackSnippet = (primFacts[0] || primSum[0] || '').trim();
      addPara(
        "Globally's sources confirm the story context" +
        (_fallbackSnippet ? ': ' + _fallbackSnippet : '') +
        ".\n\nMira can explain the likely development implications in general terms — " +
        "try asking about the economic effects, humanitarian impact, or what usually happens in situations like this.",
        primary
      );
    }

    var _specificText = paragraphs.map(function(p) { return p.text; }).join('\n\n');
    return {
      text:                 _specificText,
      paragraphs:           paragraphs,
      sourcesUsed:          sourcesUsed,
      limitations:          limitations,
      _inlineReasoningUsed: true,
    };

  } else {
    // WHAT_IS_HAPPENING (default) — direct answer + evidence + reasoning
    //
    // Strategy:
    // 1. First search for a sentence that scores on BOTH subject AND question keywords.
    // 2. Fall back to subject-only sentence if nothing matches both.
    // 3. Pull bullets filtered by question relevance, not just the first ones.

    var subject = getPrimaryAnswerSubject(understood, ctx.article);
    var subjectName    = subject ? subject.value : null;
    var subjectCountry = subject && subject.type === 'country' ? subject.value.toLowerCase() : null;

    // ── Para 1: question-directed opening ─────────────────────────────────────
    // For each sentence in all ranked sources compute a combined score:
    //   subjectScore  — does it name the subject country near the start?
    //   questionScore — does it contain any question-angle keywords (e.g. "economy")?
    // We pick the highest combined scorer so Mira leads with an angle-relevant sentence.
    var _scRe = subjectName
      ? new RegExp('\\b' + subjectName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i')
      : null;

    var leadSentence = '';
    var leadSentenceSource = primary;

    var _candidates = [];
    ranked.slice(0, 6).forEach(function(s) {
      miraSentenceSplit(miraCleanText(s.summary || '')).forEach(function(sent) {
        var words = sent.split(/\s+/);
        var subjectSc = 0;
        if (_scRe) {
          if (_scRe.test(words.slice(0, 2).join(' ')))      subjectSc = 10;
          else if (_scRe.test(words.slice(0, 5).join(' '))) subjectSc = 6;
          else if (_scRe.test(words.slice(0, 9).join(' '))) subjectSc = 3;
          else if (_scRe.test(sent))                        subjectSc = 1;
        } else {
          subjectSc = 2; // no subject filter — any sentence is a candidate
        }
        var questionSc = scorePassage(sent) * 5; // weight question angle heavily
        var total = subjectSc + questionSc;
        if (total > 0) _candidates.push({ text: sent, source: s, score: total, questionSc: questionSc });
      });
    });
    // Sort by position score + question-keyword relevance so a sentence about
    // the question's specific angle (e.g. economy) beats a generic country opener.
    _candidates.sort(function(a, b) {
      var qa = scorePassage(a.text), qb = scorePassage(b.text);
      if (qa !== qb) return (b.score + qb * 3) - (a.score + qa * 3);
      return b.score - a.score;
    });

    if (_candidates.length > 0 && _candidates[0].score >= 3) {
      leadSentence = _candidates[0].text;
      leadSentenceSource = _candidates[0].source;
    }

    // Template fallback: if no source sentence leads with the subject country,
    // build a subject-centred opening from detected event keywords in the corpus.
    if (!leadSentence && subjectName) {
      var _tpl = buildMiraDirectOpening(subjectName, allSourceText);
      if (_tpl) { leadSentence = _tpl; leadSentenceSource = primary; }
    }

    // Best story whose .country matches the subject (for pulling bullets/facts)
    var leadStory = primary;
    if (subjectCountry && primary.country && primary.country.toLowerCase() !== subjectCountry) {
      var _matched = ranked.find(function(s) {
        return s.country && s.country.toLowerCase() === subjectCountry;
      });
      if (_matched) leadStory = _matched;
    }

    // Track sources
    if (leadSentenceSource !== primary && sourcesUsed.indexOf(leadSentenceSource) === -1) {
      sourcesUsed.unshift(leadSentenceSource);
    }
    if (leadStory !== primary && sourcesUsed.indexOf(leadStory) === -1) {
      sourcesUsed.unshift(leadStory);
    }

    var leadSum   = miraSentenceSplit(miraCleanText(leadStory.summary || ''));
    var leadDeep  = leadStory.deeper_summary ? miraBullets(leadStory.deeper_summary) : [];
    var leadFacts = (leadStory.storyModes && leadStory.storyModes.facts) || [];

    // Para 1
    var direct = leadSentence || leadSum.slice(0, 2).join(' ');
    if (!direct && leadDeep.length > 0) direct = topPassage(leadDeep);
    if (!direct && leadFacts.length > 0) direct = topPassage(leadFacts);
    if (direct) addPara(direct, leadSentenceSource);

    // ── Para 2: question-filtered evidence bullets ─────────────────────────────
    // Sort all available bullets from the lead story by question relevance.
    // This ensures "economy" questions lead with economy-related bullets rather
    // than casualty or rescue bullets that happen to be first in the list.
    var evidenceParts = [];
    var rawBullets = (leadDeep.length > 0 ? leadDeep : leadFacts);

    var keyBullets;
    if (qkws.length > 0) {
      // Sort by question relevance descending; bullets with score 0 go last
      keyBullets = rawBullets.slice().sort(function(a, b) {
        return scorePassage(b) - scorePassage(a);
      }).slice(0, 4);
    } else {
      keyBullets = rawBullets.slice(0, 4);
    }

    // Drop the first bullet if it heavily overlaps with the lead sentence
    if (keyBullets.length > 0 && direct) {
      var _wb0 = keyBullets[0].toLowerCase().split(' ').filter(function(w){ return w.length > 4; });
      var _ws0 = direct.toLowerCase().split(' ').filter(function(w){ return w.length > 4; });
      var _ov  = _wb0.filter(function(w){ return _ws0.indexOf(w) !== -1; }).length;
      if (_ov >= 4) keyBullets = keyBullets.slice(1);
    }

    if (keyBullets.length > 0) {
      evidenceParts.push(keyBullets.slice(0, 3).map(function(b){ return '• ' + b + (b.endsWith('.') ? '' : '.'); }).join('\n'));
    }

    // Add primary (when it's different from leadStory) + support stories as evidence.
    // For each source, pick the passage most relevant to the question (not just the first).
    var evidenceSources = (leadStory === primary ? support : [primary].concat(support)).slice(0, 3);
    evidenceSources.forEach(function(s) {
      var ss    = miraSentenceSplit(miraCleanText(s.summary || ''));
      var facts = (s.storyModes && s.storyModes.facts) || [];
      var deep  = s.deeper_summary ? miraBullets(s.deeper_summary) : [];
      var candidates = facts.concat(deep, ss).filter(function(p){ return p && p.length > 20; });
      var best = topPassage(candidates) || '';
      if (!best || best.length <= 20) return;
      var _evL = best.toLowerCase().slice(0, 80);
      var _dup = evidenceParts.some(function(ep){ return ep.toLowerCase().indexOf(_evL.slice(0, 50)) !== -1; });
      if (!_dup) {
        evidenceParts.push(best);
        if (sourcesUsed.indexOf(s) === -1) sourcesUsed.push(s);
      }
    });

    if (evidenceParts.length > 0) addPara(evidenceParts.join('\n\n'), leadStory);

    // ── Para 3: analytical reasoning ─────────────────────────────────────────
    var reasoning = buildMiraReasoningLayer(topic, country, allSourceText, ranked.length);
    if (reasoning) addPara(reasoning, primary);
  }

  if (ranked.length === 1) limitations.push("Globally's current sources include only one directly relevant story.");

  // Build final answer text
  var fullText = paragraphs.map(function(p){ return p.text; }).join('\n\n');
  if (!fullText.trim()) {
    fullText = hasCountryFilter
      ? "Globally's current coverage doesn't include enough recent stories about " + targetName + " for a full answer. Mira can explain the general development context if you'd like."
      : "Globally's current sources don't have enough detail on that right now. Try a broader question, or ask about a specific country or topic.";
    limitations.push("Insufficient source detail.");
    return { text: fullText, paragraphs: paragraphs, sourcesUsed: sourcesUsed, limitations: limitations };
  }

  return { text: fullText, paragraphs: paragraphs, sourcesUsed: sourcesUsed, limitations: limitations };
}

// ── Anti-fabrication gate ─────────────────────────────────────────────────────
function verifyMiraAnswer(synthesis, sources) {
  var text        = synthesis.text || '';
  var limitations = (synthesis.limitations || []).slice();

  [
    { p: /Rs\.\s*($|\n)/m,  m: 'A currency value (Rs.) appears incomplete.' },
    { p: /\$\s*($|\n)/m,    m: 'A currency value ($) appears incomplete.' },
    { p: /USD\s*($|\n)/m,   m: 'A currency value (USD) appears incomplete.' },
    { p: /\.\.\.\s*$/m,     m: 'Answer text appears cut off.' },
    { p: /,\s*$/m,          m: 'Answer text appears cut off.' },
  ].forEach(function(chk){ if (chk.p.test(text)) limitations.push(chk.m); });

  var trimmed = text.trim();
  if (trimmed.length > 0 && !/[.!?"»)]/.test(trimmed.slice(-1))) {
    var lastEnd = Math.max(trimmed.lastIndexOf('. '), trimmed.lastIndexOf('.\n'), trimmed.lastIndexOf('!'), trimmed.lastIndexOf('?'));
    if (lastEnd > trimmed.length * 0.5) {
      text = trimmed.slice(0, lastEnd + 1).trim();
      limitations.push("Part of the answer was trimmed because the source content was incomplete.");
    }
  }

  text = text
    .replace(/\bwill likely\b/gi, 'may')
    .replace(/\bis expected to\b/gi, 'sources mention')
    .replace(/\bpredicts?\b/gi,  'reports')
    .replace(/\bforecast(s|ed)?\b/gi, 'the reporting indicates')
    .replace(/\bprobably will\b/gi, 'may');

  var n = (sources || []).length;
  // Confidence based on source count; validateMiraAnswerQuality may upgrade/downgrade.
  var conf;
  if (n === 0)                                 conf = 'needs_more_detail';
  else if (n < 2)                              conf = 'limited_sources';
  else if (n < 4)                              conf = 'grounded';
  else                                         conf = 'high_confidence';
  // Downgrade to grounded when limitations exist even if source count is high
  if (n >= 4 && limitations.length > 0)        conf = 'grounded';

  return {
    answer:      text,
    paragraphs:  synthesis.paragraphs || [],
    sources:     sources || [],
    confidence:  conf,
    limitations: limitations,
  };
}

// ── Answer quality validation ────────────────────────────────────────────────
// validateMiraAnswerQuality checks that the generated answer actually answers
// the question asked. It detects intent mismatch, subject drift, and uncovered
// specific figures. Issues are surfaced as limitations; confidence is adjusted.
//
// Rules (from spec):
//  - opportunity_cost intent → answer must mention opportunity cost / trade-off
//  - tourism intent → answer must mention tourism/travel/visitors
//  - effects intent → must mention ≥2 impact categories (human/infrastructure/recovery)
//  - why_it_matters → must explain significance
//  - coverage gap → must include note that reporting doesn't cover that exact angle
//  - precise numbers not in sources → flag
//  - full event summary for analytical question → flag (but don't rewrite here)
//  - subject drift → flag when all Globally sources are about a different country
function validateMiraAnswerQuality(text, question, understood, sources) {
  var q   = (question || '').toLowerCase();
  var ans = (text     || '').toLowerCase();
  var issues = [];

  // ── Intent alignment ────────────────────────────────────────────────────────
  if (/\bopportunity.{0,5}cost\b|trade.?off\b|foregone/.test(q)) {
    if (!/opportunity.{0,5}cost|trade.?off|foregone|alternative.{0,10}use/.test(ans)) {
      issues.push('Answer doesn\'t address the opportunity cost or trade-off angle asked.');
    }
  }

  if (/\btouris[mt]\b|travel.{0,8}(sector|industry|revenue)\b|visitors?\b/.test(q)) {
    if (!/\btouris[mt]\b|travel.{0,8}(sector|industry)\b|visitors?\b/.test(ans)) {
      issues.push('Tourism question not addressed in answer — tourism/travel should be mentioned.');
    }
  }

  // Effects question: answer should mention multiple impact categories
  if (/\beffects?\b|\bimpact\b|\bconsequences?\b/.test(q)) {
    var effectCategories = [
      /human|civilian|casualt|injur|death|killed|displace|survivor/,
      /infrastructure|road|bridge|building|power|hospital|school|water/,
      /recover|rebuild|reconstruct|aid|relief|emergency|response/,
      /econom|market|trade|fiscal|gdp|budget|livelihoods/,
    ];
    var hits = effectCategories.filter(function(re) { return re.test(ans); }).length;
    if (hits < 2) {
      issues.push('Effects question should cover multiple impact categories (human, infrastructure, recovery).');
    }
  }

  if (/\bwhy.{0,20}matter|significance|important\b/.test(q)) {
    if (!/matter|significan|important|concern|because|therefore/.test(ans)) {
      issues.push('Why-it-matters question answered without explaining significance.');
    }
  }

  // ── Precise-number guard ────────────────────────────────────────────────────
  // Detect specific statistics in the answer and check they appear in source text.
  var numPattern = /\b(\d[\d,]*\.?\d*)\s*(million|billion|thousand|%|percent|people|dead|killed|injured|displaced)\b/g;
  var sourceText = sources.map(function(s) {
    return (s.summary || '') + ' ' + (s.excerpt || '') + ' ' +
      ((s.storyModes && s.storyModes.facts) || []).join(' ');
  }).join(' ').toLowerCase();
  var numMatch;
  while ((numMatch = numPattern.exec(ans)) !== null) {
    var rawNum = numMatch[1].replace(/,/g, '');
    // Only flag if the number doesn't appear anywhere in source text
    if (sourceText.indexOf(rawNum) === -1 && sourceText.indexOf(numMatch[0].split(' ')[0]) === -1) {
      issues.push('Specific figure "' + numMatch[0] + '" not found in sources — may be unsupported.');
    }
  }

  // ── Subject drift ───────────────────────────────────────────────────────────
  if (understood && understood.countries && understood.countries.length > 0) {
    var askedCountry = understood.countries[0].toLowerCase();
    var gsources = sources.filter(function(s) { return !s.isExternalSource; });
    if (gsources.length > 0) {
      var allDrifted = gsources.every(function(s) {
        var c = (s.country || '').toLowerCase();
        return c && c !== askedCountry;
      });
      if (allDrifted) {
        issues.push('Source drift: all Globally sources are about ' + gsources[0].country +
          ', not ' + understood.countries[0] + '. Answer may not reflect the right country.');
      }
    }
  }

  // ── Confidence ──────────────────────────────────────────────────────────────
  var gsCount = sources.filter(function(s) { return !s.isExternalSource; }).length;
  var conf;
  if (!understood || (!understood.country && !(understood.countries && understood.countries.length) && !understood.topic)) {
    conf = 'needs_more_detail';
  } else if (gsCount === 0) {
    conf = 'needs_more_detail';
  } else if (gsCount < 2 || issues.length > 0) {
    conf = 'limited_sources';
  } else if (gsCount >= 4 && issues.length === 0) {
    conf = 'high_confidence';
  } else {
    conf = 'grounded';
  }

  return { isValid: issues.length === 0, confidence: conf, issues: issues };
}

// ── Question-aware general context ───────────────────────────────────────────
// Mira generates background text from the USER'S ACTUAL QUESTION INTENT —
// not from a fixed event-type template. The text is always clearly distinct
// from grounded reporting and never invents event-specific figures.
//
// HARD RULES (enforced by design):
//  1. No invented specific figures, death tolls, or current-event statistics.
//  2. General context always says "in general terms" — never claims to describe
//     the measured impact of THIS specific event.
//  3. Only shown when current sources don't directly answer the exact angle asked.

// ── Intent detection (question-specific) ─────────────────────────────────────
function detectMiraQuestionIntent(question) {
  var q = (question || '').toLowerCase().trim();

  // Quantity/count questions — expect a short factual answer, not a reasoning essay.
  // Checked FIRST so "how many people were injured" → factual_count, not humanitarian_impact.
  if (/\bhow many\b|\bwhat (is|'?s) the (number|count|total|figure|death toll|casualt)/i.test(q))
    return 'factual_count';

  if (/\bopportunity.{0,5}cost\b|trade.{0,5}off\b|what.{0,20}(give\s+up|gave\s+up|sacrifice|foregone|foregoing|divert)|what.{0,20}else.{0,20}(use|spend|do)\b/i.test(q))
    return 'opportunity_cost';
  if (/\btouris(m|t|ts)\b|\bvisitor\b|travel.{0,10}(warning|advisory|alert)|hotel.{0,10}(damage|close|cancel)|booking.{0,10}cancel/i.test(q))
    return 'tourism_impact';
  if (/\binfrastructure\b|\broads?\b.{0,15}(damage|destroy|block)|hospital|school.{0,10}(damage|destroy)|power.{0,10}(grid|supply|outage)|water.{0,10}(supply|system|pipe)|bridge.{0,10}(damage|destroy|collapse)/i.test(q))
    return 'infrastructure_impact';
  if (/\bhumanitarian\b|\bshelter\b|food.{0,10}(supply|shortage|aid)|medical.{0,15}(need|care|supply)|displace\w*\b|refugee|sanitati/i.test(q))
    return 'humanitarian_impact';
  // "local economy" and broad economic/livelihood questions — before generic effects_impact
  if (/\blocal economy\b|local.{0,12}economic|\beconom\w*\b|\bjobs?\b|\bincome\b|\bprice\w*\b|\breconstruct\w*\b|\brebuild\w*\b|\bmarket\b|\btrade\b|\bfiscal\b|\bfinanc\w*\b|\bgdp\b|\blivelihoods?\b/i.test(q))
    return 'economic_impact';
  if (/\bgovernment\b|\bstate.{0,10}(capacit|function|respons)|\bpublic.{0,10}service|\binstitution|\bcoordinat/i.test(q))
    return 'state_capacity';
  if (/\bwhy.{0,20}matter|why.{0,20}import|significance|why should/i.test(q))
    return 'why_it_matters';
  if (/30.?sec|quick\s|short.{0,10}vers|one.?liner/i.test(q))
    return 'summarise_30_seconds';
  if (/3.?min|in.?depth|comprehensive|long.{0,10}(version|explain)|full.{0,10}version/i.test(q))
    return 'long_explainer';
  if (/what.{0,25}next|what to watch|what.{0,25}expect|sources?.{0,20}report.{0,10}next/i.test(q))
    return 'what_sources_report_next';
  // Broad effects / impact / "what does this mean for" — after economic, before generic
  if (/\beffect\w*\b|\bimpact\w*\b|\bconsequence\w*\b|\baffect\b|\bmean for\b|\bmeans for\b|\bpeople affected\b/i.test(q))
    return 'effects_impact';
  if (/\bexplain\b|background|context|what is\b|what are\b|define\b|describe\b|tell me/i.test(q))
    return 'general_explanation';
  return 'what_is_happening';
}

// ── Subject / actor extraction ────────────────────────────────────────────────
var MIRA_KNOWN_ACTORS = [
  'China','United States','United Kingdom','Russia','Ukraine','France','Germany',
  'Venezuela','Iran','Israel','Lebanon','Hezbollah','Saudi Arabia','Turkey',
  'India','Pakistan','Bangladesh','Nepal','Myanmar','Sri Lanka',
  'DRC','Democratic Republic of Congo','Palestine','Gaza','Afghanistan',
  'IMF','World Bank','United Nations','WFP','WHO','UNICEF','UNHCR',
];

function inferMiraQuestionSubject(question, storyOrCluster, pageContext) {
  var q = question || '';
  var ql = q.toLowerCase();

  // Named actor match (check longer names first to avoid "Iran" matching inside "Ukraine")
  var sorted = MIRA_KNOWN_ACTORS.slice().sort(function(a, b) { return b.length - a.length; });
  for (var i = 0; i < sorted.length; i++) {
    var actor = sorted[i];
    var re = new RegExp('\\b' + actor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i');
    if (re.test(q)) return { name: actor, type: 'actor' };
  }

  // "this country" / "the country" → use article country
  if (/\b(this country|the country|this region|the region)\b/i.test(q)) {
    var c = storyOrCluster && (storyOrCluster.country || storyOrCluster.region);
    if (c) return { name: c, type: 'country' };
  }

  // "this / this story / this event / natural disaster" → event
  if (/\b(this|the event|this story|this disaster|natural disaster|the disaster)\b/i.test(q)) {
    var eventName = storyOrCluster
      ? (storyOrCluster.clusterTitle || storyOrCluster.title || 'this event')
      : 'this event';
    return { name: eventName, type: 'event' };
  }

  // Fallback: article/cluster country
  var fc = storyOrCluster && (storyOrCluster.country || storyOrCluster.region);
  if (fc) return { name: fc, type: 'country' };
  return null;
}

// ── Source coverage check ─────────────────────────────────────────────────────
var MIRA_COVERAGE_SIGNALS = {
  opportunity_cost: /opportunity.{0,5}cost|trade.{0,5}off|budget.{0,15}(cut|redirect|divert|fund)|reallocat|diverted?|gave.{0,10}up|sacrifice|alternative.{0,10}use|funding.{0,10}source/i,
  tourism_impact:  /touris|visitor|travel.{0,10}(warning|advisory)|hotel|booking|cancell/i,
  economic_impact: /econom|gdp|jobs?|income|prices?|reconstruction|rebuild|market|financ/i,
  humanitarian_impact: /displaced|missing|injur|casualt|shelter|relief|humanitarian|survivor|rescue/i,
  infrastructure_impact: /road|bridge|hospital|school|power.{0,10}(grid|outage)|water.{0,10}supply|infrastructure/i,
  state_capacity: /government|institution|public.{0,10}service|capacit|coordinat|response/i,
  effects_impact: /affected|casualt|displaced|damage|destroy|impact|consequence/i,
};

function sourcesAnswerSpecificQuestion(question, sources, intent) {
  if (!sources || sources.length === 0) return false;
  var signals = MIRA_COVERAGE_SIGNALS[intent];
  if (!signals) return true;
  return sources.some(function(s) {
    var text = [
      s.title || '', s.summary || '',
      (s.storyModes && (s.storyModes.explain || s.storyModes.matters || '')) || '',
      s.deeper_summary || '',
    ].join(' ').toLowerCase();
    return signals.test(text);
  });
}

// ── Question-aware general context builder ────────────────────────────────────
// Generates text FROM the user's actual intent — not a fixed event-type template.
// Returns { text, disclaimer } or null if the intent has no applicable background.
function buildQuestionAwareGeneralContext(opts) {
  var question        = opts.question || '';
  var intent          = opts.intent   || '';
  var subject         = opts.subject  || null;
  var story           = opts.storyOrCluster || null;
  var sourcesCover    = opts.sourcesCoverExactQuestion;

  // Detect event type from article/cluster to contextualise phrasing
  var eventType = 'event';
  if (story) {
    var t = ((story.title || '') + ' ' + (story.summary || '') + ' ' + (story.clusterTitle || '')).toLowerCase();
    if (/earthquake|seismic|tremor|quake/.test(t))        eventType = 'earthquake';
    else if (/flood|cyclone|hurricane|typhoon|storm.{0,10}surge/.test(t)) eventType = 'flood';
    else if (/conflict|fighting|\bwar\b|bombing|airstrike/.test(t)) eventType = 'conflict';
    else if (/drought/.test(t))                            eventType = 'drought';
    else if (/disaster|emergency/.test(t))                 eventType = 'disaster';
  }

  var actorName = subject ? subject.name : null;
  var prefix = sourcesCover ? '' : "Globally's sources confirm the story context, but don't include specific reporting on that exact angle.\n\n";
  var disclaimer = 'This is general background. Mira cannot and will not invent specific figures, confirmed impacts, or budget details for this event.';
  var lines;

  if (intent === 'opportunity_cost') {
    var actor = actorName || 'the donating country';
    lines = [
      prefix + 'In general terms, when ' + actor + ' commits funds to disaster relief abroad, the opportunity cost is what those same resources would otherwise have been used for.',
      '• Fiscal trade-offs — money pledged abroad is unavailable for domestic spending: infrastructure, healthcare, debt servicing, or other bilateral partnerships. Governments typically draw on contingency reserves or foreign ministry budgets. Which domestic programmes are reduced is rarely announced publicly.',
      '• Diplomatic attention and staff time — a large aid commitment requires sustained senior engagement, coordination, and follow-through. That capacity is diverted from other foreign policy priorities for the duration.',
      '• Soft-power returns — large visible pledges are not purely costly. They generate bilateral goodwill, international standing, and often open or deepen commercial and diplomatic relationships. These are real returns, even if they cannot be priced exactly.',
      '• Strategic signalling — the scale of a commitment signals relative priority. A large pledge to one partner implies comparatively less bandwidth for others.',
      'Without source reporting on what ' + actor + ' specifically gave up or redirected, Mira cannot identify a particular programme or budget line that was cut.',
    ];
  } else if (intent === 'tourism_impact') {
    lines = [
      prefix + 'In general terms, major disasters affect tourism through several interconnected channels:',
      '• Visitor confidence — images and reports of destruction suppress bookings even in areas unaffected by the disaster.',
      '• Travel warnings — foreign governments typically issue travel advisories for the affected country or region, which trigger travel insurance exclusions and cause tour operators to cancel group departures.',
      '• Transport disruption — damage to airports, roads, and fuel supply makes travel logistically difficult even for willing visitors.',
      '• Accommodation and attraction damage — hotels and tourist sites directly affected become unavailable, reducing capacity.',
      '• Booking lag — cancellations and revenue figures for tourism typically take weeks or months to emerge. Unless the story directly covers the tourism sector, sources at this stage are unlikely to report specific visitor numbers or revenue impacts.',
    ];
  } else if (intent === 'economic_impact') {
    var evtW = eventType === 'earthquake' ? 'earthquakes' : eventType === 'flood' ? 'major floods and cyclones' : eventType === 'conflict' ? 'active conflict' : 'events like this';
    lines = [
      prefix + 'In general terms, ' + evtW + ' disrupt local economies through several mechanisms:',
      '• Direct damage — to businesses, premises, inventory, equipment, and commercial infrastructure.',
      '• Household income loss — displacement and business closures reduce income for workers and self-employed people; recovery of livelihoods typically lags physical reconstruction.',
      '• Supply-chain disruption — damage to roads, ports, and logistics networks means inputs cannot reach businesses and products cannot reach markets.',
      '• Public budget strain — emergency spending rises at the same moment tax revenues fall, squeezing the fiscal position.',
      '• Reconstruction offset — international assistance and rebuilding activity inject money and can stimulate some sectors, but full economic recovery typically takes years.',
    ];
  } else if (intent === 'humanitarian_impact') {
    lines = [
      prefix + 'In general terms, the immediate humanitarian effects of events like this include:',
      '• Casualties and displacement — people are killed, injured, or forced to flee their homes. Displacement compounds vulnerability to secondary health risks.',
      '• Shelter — loss of housing pushes people into emergency centres or makeshift shelters, increasing exposure to weather and disease.',
      '• Food and water access — supply chains are disrupted, water systems damaged, and food stocks destroyed. Safe water access is typically the most acute early problem.',
      '• Medical pressure — injury treatment competes with ongoing healthcare; facilities may be damaged or overwhelmed.',
      '• Longer-term recovery — households that lose assets, income, or breadwinners face multi-year recovery challenges even after physical infrastructure is rebuilt.',
    ];
  } else if (intent === 'infrastructure_impact') {
    lines = [
      prefix + 'In general terms, the infrastructure effects of events like this typically include:',
      '• Transport networks — roads, bridges, and rail links are damaged, blocking rescue, relief, and economic activity.',
      '• Utilities — power grids, water systems, and communications infrastructure are disrupted, with cascading effects on hospitals, businesses, and households.',
      '• Health and education facilities — hospitals and schools are damaged or commandeered as emergency shelters.',
      '• Reconstruction timeline — restoring infrastructure is typically measured in years. Critical systems (power, water, roads) are prioritised; social infrastructure often takes longer and depends on sustained donor support.',
    ];
  } else if (intent === 'state_capacity') {
    lines = [
      prefix + 'In general terms, events like this test state capacity across several dimensions:',
      '• Emergency response — coordinating evacuation, rescue, and initial relief requires functioning inter-agency communication and clear command.',
      '• Resource mobilisation — deploying personnel, equipment, and funds quickly while maintaining routine government operations.',
      '• Public communication — managing accurate information flow, correcting dangerous rumours, and coordinating with international responders.',
      '• Long-term reconstruction governance — rebuilding involves multiple ministries, international donors, and local governments simultaneously, requiring sustained institutional capacity.',
      'Countries with weaker institutions before a disaster typically have slower, less equitable recovery — the event amplifies pre-existing capacity gaps.',
    ];
  } else if (intent === 'effects_impact') {
    var evtW2 = eventType === 'earthquake' ? 'an earthquake' : eventType === 'flood' ? 'a major flood' : eventType === 'conflict' ? 'active conflict' : 'this type of event';
    lines = [
      prefix + 'In general terms, the effects of ' + evtW2 + ' operate across several dimensions:',
      '• Human impact — casualties, injuries, displacement, and psychological trauma in affected communities.',
      '• Infrastructure — damage to housing, transport, utilities, hospitals, and schools disrupts daily life and economic activity.',
      '• Economic disruption — household income falls, businesses close, and public budgets are strained by emergency spending.',
      '• Institutional capacity — government and civil society response systems are tested; weaker institutions produce slower and less equitable recovery.',
      '• Development trajectory — major events set back years of development progress in health, education, and poverty reduction. Recovery depends heavily on the quality and speed of the reconstruction process.',
    ];
  } else {
    return null;
  }

  return { text: lines.join('\n'), disclaimer: disclaimer, intent: intent };
}


// ── Main pipeline ─────────────────────────────────────────────────────────────
function answerWithMira(question, allArticles, ctx) {
  ctx = ctx || {};
  var understood = understandMiraQuestion(question, ctx);

  // Use buildMiraSourceBundle (instead of retrieveMiraSources directly) so that:
  // (a) each source gets bundle metadata (isGloballySource, isExternalSource, etc.)
  // (b) any external sources in ctx.externalSources are merged in
  var bundle = buildMiraSourceBundle(understood, allArticles, ctx);
  var ranked = rankMiraSources(bundle, understood);

  var synthesis = synthesiseMiraAnswer(question, ranked, understood, ctx);
  var verified  = verifyMiraAnswer(synthesis, ranked);

  // Validate answer quality — checks intent alignment, subject drift, fabricated numbers
  var validation = validateMiraAnswerQuality(verified.answer, question, understood, ranked);

  // Merge confidence: validation result takes precedence over source-count estimate
  var finalConf   = validation.confidence || verified.confidence;
  var finalLimits = verified.limitations.concat(
    validation.issues.filter(function(i) { return verified.limitations.indexOf(i) === -1; })
  );

  // Flag when external search would materially improve the answer
  var _needsExt = needsExternalSearch(understood, ranked, synthesis);

  // Question-aware general context — only generated when synthesis did NOT already
  // handle reasoning inline (i.e., when intent-specific routing was NOT used).
  // When _inlineReasoningUsed is true, the general reasoning is already in answer.answer
  // and showing generalBackground would duplicate it.
  var _qIntent  = detectMiraQuestionIntent(question);
  var _qSubject = inferMiraQuestionSubject(question, ctx.article || ctx.currentCluster || null, ctx);
  var generalBackground = null;
  if (!synthesis._inlineReasoningUsed) {
    var _INTENTS_WITH_CONTEXT = [
      'opportunity_cost', 'tourism_impact', 'economic_impact',
      'humanitarian_impact', 'infrastructure_impact', 'state_capacity', 'effects_impact',
    ];
    if (_INTENTS_WITH_CONTEXT.indexOf(_qIntent) !== -1) {
      var _srcCovers = sourcesAnswerSpecificQuestion(question, verified.sources, _qIntent);
      if (!_srcCovers) {
        generalBackground = buildQuestionAwareGeneralContext({
          question:                  question,
          intent:                    _qIntent,
          subject:                   _qSubject,
          storyOrCluster:            ctx.article || ctx.currentCluster || null,
          sourcesCoverExactQuestion: false,
        });
      }
    }
  }

  return {
    understood:           understood,
    answer:               verified.answer,
    paragraphs:           verified.paragraphs,
    sources:              verified.sources,
    confidence:           finalConf,
    limitations:          finalLimits,
    generalBackground:    generalBackground,
    needsExternalSearch:  _needsExt,
    validationIssues:     validation.issues,
    hasExternalSources:   ranked.some(function(s) { return s.isExternalSource; }),
    _inlineReasoningUsed: !!synthesis._inlineReasoningUsed,
  };
}


function logMiraQuery(entry) {
  var log = getMiraQueryLog();
  var id  = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  var item = Object.assign({
    id: id, timestamp: new Date().toISOString(),
    question: '', page: 'unknown', intent: null,
    sourceIds: [], hadEnoughSources: false, confidence: 'low', limitations: [],
    feedback: null, sourceClicked: false, followUpAsked: false,
  }, entry, { id: id, timestamp: new Date().toISOString() });
  try { localStorage.setItem(MIRA_QUERY_LOG_KEY, JSON.stringify([item].concat(log).slice(0, 100))); } catch(e) {}
  return id;
}

function getMiraQueryLog() {
  try { return JSON.parse(localStorage.getItem(MIRA_QUERY_LOG_KEY) || '[]'); } catch(e) { return []; }
}

function clearMiraQueryLog() {
  try { localStorage.removeItem(MIRA_QUERY_LOG_KEY); } catch(e) {}
}

function updateMiraQueryLog(id, updates) {
  var log = getMiraQueryLog();
  try { localStorage.setItem(MIRA_QUERY_LOG_KEY, JSON.stringify(log.map(function(item){ return item.id === id ? Object.assign({}, item, updates) : item; }))); } catch(e) {}
}

// Briefing section synthesis — deterministic, no invention
function buildBriefingSectionSynth(articles, sectionType) {
  if (!articles || articles.length === 0) return null;
  var a     = articles[0];
  var sents = miraSentenceSplit(miraCleanText(a.summary || ''));
  var facts = (a.storyModes && a.storyModes.facts) || [];
  var n     = articles.length;
  var countries = [];
  articles.forEach(function(x){ if (x.country && countries.indexOf(x.country) === -1) countries.push(x.country); });

  if (sectionType === 'what_changed') {
    var lead = 'The available Globally reporting covers ' + n + ' development' + (n > 1 ? 's' : '') + ' matching your interests.';
    if (sents.length > 0) lead += ' ' + sents[0];
    return lead;
  }
  if (sectionType === 'who_affected') {
    var loc = countries.slice(0, 3).join(', ') || 'multiple countries';
    var s = 'Reporting from ' + loc + ' describes impacts on affected populations.';
    if (facts.length > 0) s += ' ' + facts[0]; else if (sents.length > 0) s += ' ' + sents[0];
    return s;
  }
  if (sectionType === 'how_connects') {
    return n + ' ' + (n > 1 ? 'stories share' : 'story shares') + ' thematic connections across sources.' + (sents.length > 0 ? ' ' + sents[0] : '');
  }
  if (sectionType === 'sources_next') {
    var nxt = (facts.length > 1 ? facts.slice(-1)[0] : null) || (sents.length > 1 ? sents.slice(-1)[0] : null);
    return nxt ? 'What sources report — not a prediction: ' + nxt : null;
  }
  if (sectionType === 'today') {
    return n + ' ' + (n > 1 ? 'stories are' : 'story is') + ' worth reading based on your interests today.' + (sents.length > 0 ? ' ' + sents[0] : '');
  }
  return null;
}

function buildBriefingItems(allArticles, setup, seen) {
  var pref = loadPref(setup);
  return allArticles
    .filter(function(a) { return !a._clusterHidden; })
    .map(function(a) {
      var t = getArticleTopic(a), r = getArticleRegion(a);
      var tW = (t && pref.topicWeights[t])  || 1.0;
      var rW = (r && pref.regionWeights[r]) || 1.0;
      var freshBoost = (Date.now() - new Date(a.published || a.scraped_at || 0).getTime()) < 72 * 3600000 ? 2 : 1;
      return { a: a, score: tW * rW * freshBoost * ((a.significance || 1) / 3.0) };
    })
    .sort(function(x, y) { return y.score - x.score; })
    .slice(0, 30).map(function(x) { return x.a; });
}

// ─── Personal context briefing ────────────────────────────────────────────────

// The 14 real-world effect categories a user can flag as personally relevant.
var WORLD_EFFECTS = [
  'Cost of living', 'Fuel / energy prices', 'Travel disruption',
  'Migration / visas', 'Jobs and careers', 'Student life',
  'Family abroad', 'Remittances', 'Currency changes', 'Food prices',
  'Conflict and security', 'Climate risk', 'Business / markets',
  'Development and inequality',
];

// Place → canonical search terms (lowercase).  A comma-separated string of
// watch places is split at runtime; terms here also serve the dynamic user
// input (they type "UAE" → we expand to cover dubai/abu-dhabi etc.).
var _WORLD_PLACE_TERMS = {
  'Sri Lanka':       ['sri lanka', 'colombo', 'kandy', 'jaffna'],
  'UAE':             ['united arab emirates', 'uae', 'dubai', 'abu dhabi', 'sharjah', 'ajman'],
  'United Kingdom':  ['united kingdom', 'uk', 'britain', 'england', 'scotland', 'wales', 'london'],
  'India':           ['india', 'new delhi', 'mumbai', 'bangalore', 'delhi', 'chennai'],
  'Pakistan':        ['pakistan', 'islamabad', 'karachi', 'lahore'],
  'Bangladesh':      ['bangladesh', 'dhaka', 'chittagong'],
  'Nepal':           ['nepal', 'kathmandu'],
  'Afghanistan':     ['afghanistan', 'kabul', 'kandahar'],
  'South Asia':      ['sri lanka', 'india', 'pakistan', 'bangladesh', 'nepal', 'bhutan', 'maldives', 'afghanistan', 'south asia', 'south asian'],
  'Middle East':     ['iran', 'iraq', 'israel', 'jordan', 'lebanon', 'saudi arabia', 'syria', 'yemen', 'palestine', 'oman', 'middle east'],
  'Gulf region':     ['gulf', 'bahrain', 'kuwait', 'oman', 'qatar', 'saudi arabia', 'uae', 'dubai', 'abu dhabi'],
  'East Africa':     ['kenya', 'ethiopia', 'somalia', 'tanzania', 'uganda', 'rwanda', 'eritrea', 'east africa'],
  'West Africa':     ['nigeria', 'ghana', 'senegal', 'mali', 'guinea', 'burkina faso', "côte d'ivoire", 'west africa'],
  'Southeast Asia':  ['myanmar', 'thailand', 'vietnam', 'cambodia', 'indonesia', 'philippines', 'malaysia', 'southeast asia'],
  'Europe':          ['europe', 'european union', 'germany', 'france', 'italy', 'spain', 'netherlands', 'poland', 'ukraine'],
  'North America':   ['united states', 'usa', 'canada', 'north america'],
  'Sub-Saharan Africa': ['sub-saharan', 'africa south', 'zimbabwe', 'zambia', 'mozambique', 'angola', 'sudan', 'south sudan'],
};

var _WORLD_EFFECT_TERMS = {
  'Cost of living':          ['inflation', 'cost of living', 'price rise', 'prices', 'poverty', 'income', 'wage', 'consumer prices', 'purchasing power'],
  'Fuel / energy prices':    ['oil', 'energy', 'fuel', 'gas price', 'petrol', 'electricity', 'opec', 'crude', 'barrel', 'petroleum', 'energy prices'],
  'Travel disruption':       ['travel', 'flight', 'airport', 'shipping', 'port', 'route', 'supply chain', 'border crossing', 'airspace'],
  'Migration / visas':       ['migration', 'migrant', 'visa', 'asylum', 'refugee', 'displacement', 'border', 'emigration', 'immigration'],
  'Jobs and careers':        ['employment', 'jobs', 'labour', 'unemployment', 'workforce', 'workers', 'salary', 'layoff', 'redundan'],
  'Student life':            ['education', 'university', 'students', 'school', 'scholarship', 'tuition', 'graduate', 'campus'],
  'Family abroad':           ['remittance', 'diaspora', 'families', 'overseas', 'abroad', 'migrant worker', 'foreign worker'],
  'Remittances':             ['remittance', 'transfer', 'diaspora', 'overseas worker', 'money transfer', 'foreign exchange'],
  'Currency changes':        ['currency', 'exchange rate', 'dollar', 'pound', 'rupee', 'depreciation', 'devaluation', 'forex', 'reserve'],
  'Food prices':             ['food', 'famine', 'hunger', 'wheat', 'grain', 'crop', 'food price', 'harvest', 'rice price', 'staple'],
  'Conflict and security':   ['conflict', 'war', 'attack', 'security', 'military', 'instability', 'armed', 'fighting', 'ceasefire'],
  'Climate risk':            ['climate', 'flood', 'drought', 'cyclone', 'typhoon', 'hurricane', 'extreme weather', 'sea level', 'disaster'],
  'Business / markets':      ['trade', 'market', 'investment', 'gdp', 'economy', 'business', 'tariff', 'sanction', 'export', 'import'],
  'Development and inequality': ['development', 'poverty', 'inequality', 'aid', 'health system', 'public service', 'infrastructure', 'human development'],
};

// Returns a short hedged explanation (string) of why an article may matter to
// the user based on their saved world context, or null if no relevant link.
// Deliberately uses "may", "could", "one pathway" — never predicts outcomes.
function buildPersonalRelevance(article, worldCtx) {
  if (!worldCtx) return null;
  if (worldCtx.level === 'light') return null;

  // Parse watchPlaces: stored as comma-separated string or array
  var watchPlaces = [];
  if (worldCtx.watchPlaces) {
    watchPlaces = Array.isArray(worldCtx.watchPlaces)
      ? worldCtx.watchPlaces
      : worldCtx.watchPlaces.split(',').map(function(p){ return p.trim(); }).filter(Boolean);
  }
  var effects = Array.isArray(worldCtx.effects) ? worldCtx.effects : [];
  if (watchPlaces.length === 0 && effects.length === 0) return null;

  var text     = ((article.title || '') + ' ' + (article.summary || '')).toLowerCase();
  var country  = (article.country || '').toLowerCase();
  var fromLow  = (worldCtx.from    || '').toLowerCase();
  var grewLow  = (worldCtx.grewUp  || '').toLowerCase();
  var liveLow  = (worldCtx.livesNow || '').toLowerCase();

  function placeTerms(place) {
    return _WORLD_PLACE_TERMS[place] || [place.toLowerCase()];
  }
  function articleMentions(terms) {
    return terms.some(function(t){ return text.indexOf(t) !== -1 || country.indexOf(t) !== -1; });
  }
  function profileMentions(terms, profileStr) {
    return terms.some(function(t){ return profileStr.indexOf(t) !== -1; });
  }

  // Match watch places to article
  var matchedPlaces = watchPlaces.filter(function(p) {
    return articleMentions(placeTerms(p));
  });

  // Match effect categories to article text
  var matchedEffects = effects.filter(function(e) {
    var terms = _WORLD_EFFECT_TERMS[e] || [];
    return terms.some(function(t){ return text.indexOf(t) !== -1; });
  });

  if (matchedPlaces.length === 0 && matchedEffects.length === 0) return null;

  // Classify relationship: origin / grew up / current home / just watching
  var parts = [];

  if (matchedPlaces.length > 0) {
    var p = matchedPlaces[0];
    var terms = placeTerms(p);
    var isOrigin  = profileMentions(terms, fromLow);
    var isHome    = profileMentions(terms, liveLow);
    var isGrewUp  = profileMentions(terms, grewLow);

    if (isOrigin && isHome) {
      parts.push('This story concerns ' + p + ', where you are from and currently live.');
    } else if (isOrigin) {
      parts.push('This story concerns ' + p + ', where you are from.');
    } else if (isHome) {
      parts.push('This story involves ' + p + ', where you currently live.');
    } else if (isGrewUp) {
      parts.push('This story involves ' + p + ', where you grew up.');
    } else if (matchedPlaces.length === 1) {
      parts.push('This story involves ' + p + ', which you are watching closely.');
    } else {
      parts.push('This story involves ' + matchedPlaces.slice(0, 2).join(' and ') + ', both regions you are watching.');
    }
  }

  if (matchedEffects.length > 0) {
    var eff0 = matchedEffects[0];
    if (eff0 === 'Fuel / energy prices') {
      parts.push('Energy prices could be one pathway to watch if developments affect supply routes — though Globally\'s sources do not yet confirm a direct price impact.');
    } else if (eff0 === 'Cost of living') {
      parts.push('Inflation and cost-of-living effects are possible downstream pathways, though the current sources do not confirm a specific impact on you.');
    } else if (eff0 === 'Travel disruption') {
      parts.push('Travel or shipping disruption may be one pathway if the situation affects regional routes — sources do not yet confirm this.');
    } else if (eff0 === 'Remittances' || eff0 === 'Family abroad') {
      parts.push('If the situation affects cross-border money flows or travel for diaspora families, this could matter to you — sources do not yet confirm the specific impact.');
    } else if (eff0 === 'Currency changes') {
      parts.push('Currency and exchange-rate effects are one possible downstream pathway — sources do not yet confirm movement that would directly affect you.');
    } else {
      var effLabel = eff0.toLowerCase();
      parts.push('This may connect to ' + effLabel + ' concerns you are watching — though the current sources do not confirm a direct personal impact.');
    }
  }

  if (parts.length === 0) return null;

  if (worldCtx.level === 'strong') {
    parts.push('These are possible pathways based on your world context, not confirmed outcomes. Globally\'s sources cover the headline situation only.');
  }

  return parts.join(' ');
}

// ─── Article tagging ──────────────────────────────────────────────────────────

function tagArticle(article) {
  var text = ((article.title || "") + " " + (article.summary || "")).toLowerCase();
  var tags = [];
  Object.keys(SECTOR_KW).forEach(function(sector) {
    if (SECTOR_KW[sector].some(function(kw){ return text.indexOf(kw) !== -1; })) {
      tags.push(sector);
    }
  });
  return tags;
}

function getArticleRegion(article) {
  var country = article.country || "";
  var found = null;
  Object.keys(REGION_COUNTRIES).forEach(function(r) {
    if (REGION_COUNTRIES[r].indexOf(country) !== -1) found = r;
  });
  return found;
}

// ─── Tone gate: grave/sober story detection ───────────────────────────────────
var GRAVE_KEYWORDS = [
  'killed','dead','death','deaths','casualt','famine','displaced','displacement',
  'atrocity','bombing','attack','massacre','genocide','murdered','executed',
  'starvation','starving','hostage','artillery','airstrike','airstrikes',
  'civilian deaths','war crime','ethnic cleansing'
];

// Returns true when an image URL looks like a document/PDF scan rather than a photo.
// These images are unreadable at card size so we fall back to the gradient.
function isDocumentImage(url) {
  if (!url) return false;
  var u = url.toLowerCase();
  // Generic document/report patterns
  if (u.includes('pdf')) return true;
  if (u.includes('/thumbnail') || u.includes('/thumb/')) return true;
  if (u.includes('cover_page') || u.includes('_cover.') || u.includes('page-1.')) return true;
  // SVGs are almost always logos/icons, not photos
  if (u.endsWith('.svg') || u.includes('.svg?')) return true;
  // ReliefWeb: document previews use /previews/ path; actual article images don't
  if (u.includes('reliefweb.int') && u.includes('/previews/')) return true;
  if (u.includes('reliefweb.int') && (u.includes('/bulletin') || u.includes('_bulletin') || u.includes('/report') || u.includes('_report'))) return true;
  // OCHA document infrastructure
  if (u.includes('unocha.org') && (u.includes('/document') || u.includes('/report') || u.includes('/publication') || u.includes('ocha-'))) return true;
  if (u.includes('ochaunite') || u.includes('humanitarianresponse.info')) return true;
  // UN agency document/publication images (these are nearly always report covers)
  if (u.includes('unhabitat.org')) return true;
  if (u.includes('unhcr.org') && (u.includes('/docs/') || u.includes('/publication') || u.includes('/resource'))) return true;
  if (u.includes('wfp.org') && (u.includes('/document') || u.includes('/publication') || u.includes('/previews/'))) return true;
  if (u.includes('fao.org') && (u.includes('/document') || u.includes('/publication'))) return true;
  if (u.includes('who.int/docs')) return true;
  if (u.includes('/files/resources')) return true;
  return false;
}

// Returns the best image URL for an article:
// 1. Article's own image if valid
// 2. First good image from a cluster member (same story — same copyright context)
// 3. null (card renders with gradient/colour fallback)
function resolveArticleImage(article) {
  if (!article) return null;
  var own = article.image_url;
  if (own && !isDocumentImage(own)) return own;
  var members = article._clusterMembers;
  if (members && members.length) {
    for (var i = 0; i < members.length; i++) {
      var mu = members[i].image_url;
      if (mu && !isDocumentImage(mu)) return mu;
    }
  }
  return null;
}

function isGraveStory(article) {
  if ((article.significance || 0) >= 4) return true;
  var text = ((article.title || '') + ' ' + (article.summary || '')).toLowerCase();
  return GRAVE_KEYWORDS.some(function(kw) { return text.indexOf(kw) !== -1; });
}

// Decode HTML entities using the browser's native parser (textarea trick).
// Handles both &amp; → & and double-encoded &#x26;amp; → & in one call.
function _htmlDecode(str) {
  var ta = document.createElement('textarea');
  ta.innerHTML = str;
  return ta.value;
}

// Returns a clean, readable headline from an article.
// For machine-generated report titles (bulletin, flash update, agrometeorological etc.)
// strips markers, dates, and bulletin numbers. For real news articles, only fixes
// ALL-CAPS country prefixes. Never invents content — all output comes from the raw title.
function displayTitle(article) {
  var t = _htmlDecode((article.title || '').trim());
  if (!t) return '';

  var isReport = /bulletin|flash update|flash appeal|situation report|sitrep|agrometeorological|early warning\s+information|monitoring report|response plan|humanitarian overview|informe de situac|rapport de situation|update\s*#\d/i.test(t);

  if (!isReport) {
    // Normal news title — only normalise ALL-CAPS country prefix
    return t.replace(/^([A-Z]{2,}(?:[\s/&]+[A-Z]{2,})*)(\s*[:–—-]\s*)/, function(m, country, sep) {
      return country.charAt(0).toUpperCase() + country.slice(1).toLowerCase() + sep;
    });
  }

  // Strip date clauses and bulletin markers
  t = t
    .replace(/,?\s*\(as of[^)]*\)/gi, '')
    .replace(/,?\s*as of\s+\d{1,2}\s+\w+\s+20\d\d/gi, '')
    .replace(/,?\s*(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*[-–]\w+\s+20\d\d/gi, '')
    .replace(/,?\s+(20\d\d)\b/g, '')
    .replace(/Flash (?:Update|Appeal)\s*#\d+\s*[-–—]\s*/gi, '')
    .replace(/Flash (?:Update|Appeal)\s*#\d+/gi, 'Update')
    .replace(/Situation Report\s*#\d+/gi, 'Update')
    .replace(/\bSitrep\s*#?\d*/gi, 'Update')
    .replace(/\bBulletin\b/gi, 'Update')
    .replace(/Agrometeorological\s+Update/gi, 'Food security update')
    .replace(/Early Warning Information System/gi, 'Food security update')
    .replace(/\s*\|\s*Datos[^\n]*/i, '')
    .replace(/\s{2,}/g, ' ')
    .trim();

  // Strip non-English report openers (French / Spanish) before the first colon or pipe
  t = t.replace(/^(?:Informe|Rapport|Feuille|Bilan|Boletín|Reporte)[^:|]+[:|]\s*/i, '');

  // Fix ALL-CAPS country prefix
  t = t.replace(/^([A-Z]{2,}(?:[\s/&]+[A-Z]{2,})*)(\s*[:–—-]\s*)/, function(m, country, sep) {
    return country.charAt(0).toUpperCase() + country.slice(1).toLowerCase() + sep;
  });

  if (t.length > 90) t = t.slice(0, 88).trim() + '…';
  return t || article.title;
}

// ─── Relative time helper ─────────────────────────────────────────────────────
function relTime(iso) {
  if (!iso) return '';
  var ms = Date.now() - new Date(iso).getTime();
  var h  = Math.floor(ms / 3600000);
  var m  = Math.floor(ms / 60000);
  if (m < 1)  return 'just now';
  if (h < 1)  return m + 'm ago';
  if (h < 24) return h + 'h ago';
  var d = Math.floor(h / 24);
  return d + 'd ago';
}

// ── Data freshness helpers ────────────────────────────────────────────────────

// Returns the ISO timestamp of the most recent data ingest.
// Prefers the server-level `last_updated` field from map-data.json top-level
// (which reflects when the scraper finished writing the file). Falls back to
// max `scraped_at` across all articles, then max `published`.
function getLatestDataUpdatedAt(articles, meta) {
  meta = meta || {};
  if (meta.last_updated)             return meta.last_updated;
  if (meta.lastFetchedAt)            return meta.lastFetchedAt;
  if (meta.latestScrapeCompletedAt)  return meta.latestScrapeCompletedAt;
  if (meta.generatedAt)              return meta.generatedAt;

  var ts = (articles || [])
    .map(function(a) { return a.scraped_at || a.ingestedAt || a.fetchedAt || a.updatedAt; })
    .filter(Boolean)
    .map(function(t) { return new Date(t).getTime(); })
    .filter(isFinite);

  if (!ts.length) {
    // Last resort: newest published date (less reliable — reflects news time, not ingest time)
    var pub = (articles || [])
      .map(function(a) { return a.published; })
      .filter(Boolean)
      .map(function(t) { return new Date(t).getTime(); })
      .filter(isFinite);
    if (!pub.length) return null;
    return new Date(Math.max.apply(null, pub)).toISOString();
  }

  return new Date(Math.max.apply(null, ts)).toISOString();
}

// Human-readable "Updated Xm ago" label derived from a real ingest timestamp.
function formatUpdatedLabel(updatedAt) {
  if (!updatedAt) return 'Update time unavailable';
  var diffMs  = Date.now() - new Date(updatedAt).getTime();
  var diffMin = Math.floor(diffMs / 60000);
  var diffH   = Math.floor(diffMin / 60);
  var diffD   = Math.floor(diffH / 24);
  if (diffMin < 1)  return 'Updated just now';
  if (diffMin < 60) return 'Updated ' + diffMin + 'm ago';
  if (diffH < 24)   return 'Updated ' + diffH + 'h ago';
  return 'Updated ' + diffD + 'd ago';
}

// Returns true if the ingest timestamp is older than maxAgeMinutes (default 90m).
// The scraper runs daily so > 90m is expected overnight; this is mainly used to
// surface a console warning and to gate the auto-refresh on tab focus.
function isStoryDataStale(updatedAt, maxAgeMinutes) {
  if (!updatedAt) return true;
  maxAgeMinutes = maxAgeMinutes === undefined ? 90 : maxAgeMinutes;
  return Date.now() - new Date(updatedAt).getTime() > maxAgeMinutes * 60000;
}

// ─── Shared article ranking ───────────────────────────────────────────────────
// Single source of truth used by every story list in the app.
//
// score = topicWeight × regionWeight × recency × (sig/3) × seenPenalty
//
// Recency tuning knobs — apply everywhere:
var RECENCY_WEIGHT    = 3.0;  // multiplier at age 0h (1.0 = disable recency boost)
var RECENCY_HALF_LIFE = 36;   // hours until boost is halfway between 1 and RECENCY_WEIGHT

// Continuous exponential decay: RECENCY_WEIGHT at 0h → 1.0 at ∞
function freshnessDecay(article) {
  var ts   = article.published || article.scraped_at || 0;
  var ageH = Math.max(0, (Date.now() - new Date(ts).getTime()) / 3600000);
  return 1.0 + (RECENCY_WEIGHT - 1.0) * Math.exp(-ageH * 0.693147 / RECENCY_HALF_LIFE);
}

// scoreAndRank(articles, pref, seen, floor)
// Returns [{article, score, recency, personScore, isExplore}, …] descending.
// Bottom `floor` fraction (default 0.25) is marked isExplore and re-sorted
// newest-first so non-personalisation slots are still timely.
function scoreAndRank(articles, pref, seen, floor) {
  var floorFrac = (floor === undefined) ? 0.25 : floor;
  var seenMap   = seen || {};

  var scored = articles.map(function(a) {
    var topic       = getArticleTopic(a)  || null;
    var region      = getArticleRegion(a) || null;
    var tW          = (topic  && pref.topicWeights[topic])   || 1.0;
    var rW          = (region && pref.regionWeights[region]) || 1.0;
    var recency     = freshnessDecay(a);
    var sig         = (a.significance || 1) / 3.0;
    var seenMult    = seenMap[a.url] ? 0.3 : 1.0;
    var personScore = tW * rW;
    return {
      article:     a,
      score:       personScore * recency * sig * seenMult,
      recency:     recency,
      personScore: personScore,
      isExplore:   false,
    };
  });

  // Seeded shuffle breaks score ties differently each day
  scored = seededShuffle(scored, dailySeed());
  scored.sort(function(a, b) { return b.score - a.score; });

  if (floorFrac <= 0) return scored;

  // Exploration floor: bottom fraction re-sorted newest-first
  var curatedN = Math.ceil(scored.length * (1 - floorFrac));
  var curated  = scored.slice(0, curatedN);
  var explore  = scored.slice(curatedN)
                   .sort(function(a, b) {
                     return new Date(b.article.published || 0) - new Date(a.article.published || 0);
                   });
  explore.forEach(function(x) { x.isExplore = true; });
  return curated.concat(explore);
}

// Take exactly `limit` items from a scoreAndRank result while honouring the 25%
// exploration floor in the *displayed* count (not just the full pool).
// Math.floor on cLimit guarantees explore gets at least ceil(limit * 0.25) slots.
// Returns plain article objects.
function sliceRanked(ranked, limit) {
  var cLimit = Math.floor(limit * 0.75);  // floor → explore always ≥ 25%
  var eLimit = limit - cLimit;
  var curated = [], explore = [];
  for (var i = 0; i < ranked.length; i++) {
    if (!ranked[i].isExplore && curated.length < cLimit)  { curated.push(ranked[i].article); continue; }
    if ( ranked[i].isExplore && explore.length  < eLimit) { explore.push(ranked[i].article);            }
  }
  return curated.concat(explore);
}

function buildDailyFeed(articles, setup) {
  var seen = loadSeen();
  var pref = loadPref(setup);

  var ranked = scoreAndRank(
    articles.filter(function(a) { return !a._clusterHidden; }),
    pref, seen, 0.25
  );

  // Cap: 60 personalisation-driven + 20 exploration = 80, then topic-interleave
  var curated = ranked.filter(function(x) { return !x.isExplore; }).slice(0, 60).map(function(x) { return x.article; });
  var explore = ranked.filter(function(x) { return  x.isExplore; }).slice(0, 20).map(function(x) { return x.article; });
  return topicInterleave(curated.concat(explore)).slice(0, 80);
}

// ─── Daily game helpers ───────────────────────────────────────────────────────

function getDailyGameQuestions() {
  var questions = (window.GLOBLY_DATA || {}).questions || [];
  if (!questions.length) return [];
  var key = todayKey();
  var hash = 0;
  for (var i = 0; i < key.length; i++) hash = (hash * 31 + key.charCodeAt(i)) | 0;
  var start = Math.abs(hash) % questions.length;
  var result = [];
  for (var j = 0; j < 5; j++) {
    result.push(questions[(start + j) % questions.length]);
  }
  return result;
}

// ─── Onboarding — shared data & sub-components ───────────────────────────────

var TICKER_TAGS = [
  "Climate", "Governance", "Health", "Education", "Trade",
  "Energy", "Migration", "Debt", "Security", "Agriculture",
  "Infrastructure", "Diplomacy",
];

var PREVIEW_HEADLINES = [
  { region: "South Asia",  topic: "Economy",     text: "IMF warns Pakistan's debt trajectory remains unsustainable despite reform progress" },
  { region: "East Africa", topic: "Climate",     text: "Kenya's Rift Valley braces for second consecutive failed rainy season" },
  { region: "South Asia",  topic: "Labour",      text: "Bangladesh garment exporters face cancellations as EU sustainability rules tighten" },
  { region: "West Africa", topic: "Governance",  text: "Ghana's new administration inherits $20bn debt overhang from previous government" },
  { region: "South Asia",  topic: "Health",      text: "India's child malnutrition rate remains highest in South Asia despite decade of growth" },
];

// Minimal globe SVG — thin strokes, no fill, no colour
function GlobeSVG() {
  return (
    <svg className="g-globe-svg" width="22" height="22" viewBox="0 0 22 22" fill="none"
      xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <circle cx="11" cy="11" r="9.5" stroke="currentColor" strokeWidth="1.1"/>
      <line x1="1.5" y1="11" x2="20.5" y2="11" stroke="currentColor" strokeWidth="1.1"/>
      <path d="M11 1.5 C7.5 5 7.5 17 11 20.5" stroke="currentColor" strokeWidth="1.1" fill="none"/>
      <path d="M11 1.5 C14.5 5 14.5 17 11 20.5" stroke="currentColor" strokeWidth="1.1" fill="none"/>
      <path d="M3.5 7 Q11 5.2 18.5 7" stroke="currentColor" strokeWidth="0.9" fill="none"/>
      <path d="M3.5 15 Q11 16.8 18.5 15" stroke="currentColor" strokeWidth="0.9" fill="none"/>
    </svg>
  );
}

// Rotating headline preview card
function RotatingCard(props) {
  var onNext = props.onNext;
  var _i = React.useState(0);
  var idx = _i[0], setIdx = _i[1];

  var _vis = React.useState(true);
  var visible = _vis[0], setVisible = _vis[1];

  React.useEffect(function() {
    var t = setInterval(function() {
      setVisible(false);
      setTimeout(function() {
        setIdx(function(i) { return (i + 1) % PREVIEW_HEADLINES.length; });
        setVisible(true);
      }, 380);
    }, 4000);
    return function() { clearInterval(t); };
  }, []);

  var h = PREVIEW_HEADLINES[idx];

  return (
    <div className="g-preview-wrap">
      <div className="g-preview-card">
        <div className={"g-preview-inner" + (visible ? " g-preview-in" : " g-preview-out")}>
          <div className="g-preview-tags">
            <span className="g-preview-tag">{h.region}</span>
            <span className="g-preview-dot" />
            <span className="g-preview-tag g-preview-tag--topic">{h.topic}</span>
          </div>
          <p className="g-preview-text">{h.text}</p>
        </div>
      </div>
      <div className="g-preview-dots">
        {PREVIEW_HEADLINES.map(function(_, i) {
          return <span key={i} className={"g-pdot" + (i === idx ? " g-pdot--active" : "")} />;
        })}
      </div>
    </div>
  );
}

// ─── Onboarding wallpaper ─────────────────────────────────────────────────────

// Per-slide wallpaper phrase sets.
// Positions are % of ob-page height; kept to safe gaps:
//   Zone A = above the headline block (top 4-16%)
//   Zone C = below last option, above footer (top 70-85%)
// 4-option slides get 6 phrases (more vertical room); 5/6-option slides get 5.

var OB_WP = {
  // Step 4 — "What pulls you in?" (4 options, content ~24–68%)
  s4: [
    { t: '/stories-that-matter',  top:  4, left:  5, size: 27 },
    { t: '/beyond-the-headlines', top:  9, left: 54, size: 24 },
    { t: '/your-daily-brief',     top: 16, left:  7, size: 25 },
    { t: '/no-noise',             top: 70, left: 52, size: 28 },
    { t: '/stay-curious',         top: 78, left:  5, size: 24 },
    { t: '/start-informed',       top: 85, left: 48, size: 22 },
  ],
  // Step 6 — "How old are you?" (5 options, content ~18–72%)
  s6: [
    { t: '/stay-curious',         top:  4, left:  7, size: 26 },
    { t: '/one-world-daily',      top: 10, left: 51, size: 24 },
    { t: '/every-region',         top: 74, left:  7, size: 27 },
    { t: '/in-30-seconds',        top: 80, left: 50, size: 23 },
    { t: '/start-informed',       top: 85, left:  5, size: 22 },
  ],
  // Step 7 — "What do you want from Globly?" (5 options, content ~18–72%)
  s7: [
    { t: '/beyond-the-headlines', top:  4, left:  5, size: 25 },
    { t: '/no-noise',             top:  9, left: 55, size: 27 },
    { t: '/the-whole-world',      top: 75, left:  6, size: 24 },
    { t: '/your-daily-brief',     top: 81, left: 49, size: 26 },
    { t: '/stories-that-matter',  top: 85, left:  5, size: 22 },
  ],
  // Step 8 — "Where in the world?" (6 options, content ~15–76%)
  s8: [
    { t: '/every-region',         top:  4, left:  6, size: 28 },
    { t: '/the-whole-world',      top: 10, left: 50, size: 24 },
    { t: '/beyond-the-headlines', top: 78, left:  5, size: 26 },
    { t: '/stories-that-matter',  top: 82, left: 49, size: 23 },
    { t: '/one-world-daily',      top: 85, left:  6, size: 22 },
  ],
  // Step 9 — "What matters to you?" (6 options, content ~15–76%)
  s9: [
    { t: '/no-noise',             top:  4, left:  5, size: 27 },
    { t: '/your-daily-brief',     top: 10, left: 51, size: 24 },
    { t: '/in-30-seconds',        top: 78, left:  7, size: 26 },
    { t: '/stay-curious',         top: 82, left: 49, size: 23 },
    { t: '/start-informed',       top: 85, left:  5, size: 22 },
  ],
  // Step 12 — "Commit to staying informed." (4 options, content ~24–68%)
  s12: [
    { t: '/one-world-daily',      top:  4, left:  6, size: 26 },
    { t: '/your-daily-brief',     top:  9, left: 53, size: 23 },
    { t: '/start-informed',       top: 16, left:  7, size: 25 },
    { t: '/stay-curious',         top: 70, left: 51, size: 28 },
    { t: '/beyond-the-headlines', top: 78, left:  5, size: 24 },
    { t: '/stories-that-matter',  top: 85, left: 47, size: 22 },
  ],
};

// ONBOARDING ONLY — never mount ObWallpaper or any slash-tag layer in signed-in app screens.
function ObWallpaper(props) {
  var phrases = props.phrases;
  if (!phrases || !phrases.length) return null;
  return (
    <div className="ob-wallpaper" aria-hidden="true">
      {phrases.map(function(tag, i) {
        return (
          <span key={i} className="ob-wp-tag" style={{
            top:      tag.top  + '%',
            left:     tag.left + '%',
            fontSize: tag.size + 'px',
          }}>{tag.t}</span>
        );
      })}
    </div>
  );
}

// ─── MiraMark — animated brand face ──────────────────────────────────────────

function MiraMark(props) {
  var size = props.size !== undefined ? props.size : 48;
  var h = Math.round(size * 150 / 680);
  var noMotion = typeof window !== 'undefined' &&
    window.matchMedia &&
    window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  return (
    <svg viewBox="0 0 680 150" width={size} height={h}
      role="img" aria-label="Globally"
      fill="none" stroke="currentColor" strokeWidth="7"
      strokeLinecap="round" strokeLinejoin="round">
      <g>
        <path d="M291,60 Q305,60 319,60">
          {!noMotion && (
            <animate attributeName="d" dur="6s" repeatCount="indefinite"
              keyTimes="0;0.88;0.91;0.95;1"
              values="M291,60 Q305,60 319,60;M291,60 Q305,60 319,60;M291,57 Q305,73 319,57;M291,60 Q305,60 319,60;M291,60 Q305,60 319,60"
              calcMode="spline" keySplines="0 0 1 1;0.4 0 1 1;0 0 0.6 1;0 0 1 1"/>
          )}
        </path>
        <path d="M361,60 Q375,60 389,60">
          {!noMotion && (
            <animate attributeName="d" dur="6s" repeatCount="indefinite"
              keyTimes="0;0.88;0.91;0.95;1"
              values="M361,60 Q375,60 389,60;M361,60 Q375,60 389,60;M361,57 Q375,73 389,57;M361,60 Q375,60 389,60;M361,60 Q375,60 389,60"
              calcMode="spline" keySplines="0 0 1 1;0.4 0 1 1;0 0 0.6 1;0 0 1 1"/>
          )}
        </path>
        <path d="M298,90 Q340,120 382,90"/>
      </g>
    </svg>
  );
}

// ─── MiraBlockMark — geometric animated brand mark ───────────────────────────
// Four diagonal rounded squares arranged as a diamond cluster.
// Top square is hollow; left, right, bottom are solid.
// state: "idle" | "thinking" | "searching" | "answering" | "talking"
// tone:  "dark" (black) | "light" (cream/white)
// size:  number in px

function MiraBlockMark(props) {
  var state     = props.state     || 'idle';
  var tone      = props.tone      || 'dark';
  var size      = props.size      !== undefined ? props.size : 120;
  var className = props.className || '';

  var noMotion =
    typeof window !== 'undefined' &&
    window.matchMedia &&
    window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // size <= 40 always forces outline squares + no animation regardless of caller props
  var isSmall = size <= 40;

  // Normalize state aliases (only matters for large instances)
  var s = state;
  if (s === 'loading')    s = 'searching';
  if (s === 'generating') s = 'answering';
  if (s === 'speaking')   s = 'talking';
  var activeState = (isSmall || noMotion) ? 'static' : s;

  var cls = [
    'mira-block-mark',
    isSmall ? 'mira-block-mark--small' : null,
    'mira-block-mark--' + activeState,
    isSmall ? 'mira-block-mark--outline' : null,
    'mira-block-mark--' + tone,
    className,
  ].filter(Boolean).join(' ');

  return (
    <div
      className={cls}
      style={{ '--mira-size': size + 'px' }}
      role="img"
      aria-label="Mira"
    >
      <span className="mira-block mira-block--top"    aria-hidden="true" />
      <span className="mira-block mira-block--left"   aria-hidden="true" />
      <span className="mira-block mira-block--right"  aria-hidden="true" />
      <span className="mira-block mira-block--bottom" aria-hidden="true" />
    </div>
  );
}

// ─── Auth screens ─────────────────────────────────────────────────────────────

function SignupScreen(props) {
  var onDone          = props.onDone;
  var onBack          = props.onBack;
  var onSwitchToLogin = props.onSwitchToLogin;

  var _email      = React.useState(''); var email    = _email[0],    setEmail    = _email[1];
  var _password   = React.useState(''); var password = _password[0], setPassword = _password[1];
  var _error      = React.useState(null); var error  = _error[0],    setError    = _error[1];
  var _loading    = React.useState(false); var loading = _loading[0], setLoading = _loading[1];
  var _confirming    = React.useState(false); var confirming    = _confirming[0],    setConfirming    = _confirming[1];
  var _googleLoading = React.useState(false); var googleLoading = _googleLoading[0], setGoogleLoading = _googleLoading[1];

  // Shared post-signup side-effects: subscribe + welcome email (fire-and-forget)
  function triggerPostSignup(norm) {
    fetch('/api/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: norm }),
    }).then(function(r){ return r.json(); })
      .then(function(d){
        if (!d.ok) console.warn('[Globally signup] Contact add failed:', d.reason, d.message || '');
        else console.log('[Globally signup] Contact added to audience:', d.id);
      })
      .catch(function(err){ console.warn('[Globally signup] Contact add error:', err.message); });

    fetch('/api/welcome-email', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: norm }),
    }).then(function(r){ return r.json(); })
      .then(function(d){
        if (!d.ok && !d.skipped) console.warn('[Globally signup] Welcome email failed:', d.reason, d.error || '');
        else if (d.skipped) console.log('[Globally signup] Welcome email already sent — skipped');
        else console.log('[Globally signup] Welcome email sent — id:', d.id);
      })
      .catch(function(err){ console.warn('[Globally signup] Welcome email error:', err.message); });
  }

  function handleSubmit(e) {
    if (e) e.preventDefault();
    var trimmed = email.trim();
    if (!trimmed) { setError('Enter your email address.'); return; }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) { setError('Enter a valid email address.'); return; }
    if (!password) { setError('Enter a password.'); return; }
    if (password.length < 6) { setError('Password must be at least 6 characters.'); return; }

    var norm = trimmed.toLowerCase();

    // ── Supabase path (when configured) ──────────────────────────────────────
    if (_supabase) {
      setLoading(true);
      setError(null);
      _supabase.auth.signUp({ email: norm, password: password })
        .then(function(result) {
          var data = result.data, err = result.error;
          setLoading(false);
          if (err) {
            if (err.message && err.message.toLowerCase().indexOf('already registered') !== -1) {
              setError('An account with this email already exists. Log in instead.');
            } else {
              setError('Could not create your account. Please try again.');
              console.error('[Globally signup] Supabase error:', err.message);
            }
            return;
          }

          // Email confirmation required — user exists but has no session yet
          if (data.user && !data.session) {
            triggerPostSignup(norm);
            setConfirming(true);
            return;
          }

          // Signed up and immediately logged in (confirmation disabled)
          if (data.session) {
            lsSet(LS.SESSION, {
              email: norm,
              userId: data.user.id,
              loggedInAt: new Date().toISOString(),
              provider: 'supabase',
            });
            lsSet(LS.SETUP, null);
            triggerPostSignup(norm);
            onDone();
          }
        })
        .catch(function(err) {
          setLoading(false);
          setError('Signup is temporarily unavailable. Please try again shortly.');
          console.error('[Globally signup] Network error:', err.message);
        });
      return;
    }

    // ── localStorage fallback (development / Supabase not yet configured) ────
    var users = lsGet(LS.USER, {});
    if (users[norm]) {
      setError('An account with this email already exists. Log in instead.');
      return;
    }
    var now = new Date().toISOString();
    users[norm] = { email: norm, password: password, createdAt: now };
    lsSet(LS.USER, users);

    var signups = lsGet(LS.SIGNUPS, []);
    signups.push({ email: norm, createdAt: now });
    lsSet(LS.SIGNUPS, signups);

    lsSet(LS.SESSION, { email: norm, loggedInAt: now, provider: 'local' });
    lsSet(LS.SETUP, null);
    triggerPostSignup(norm);
    onDone();
  }

  // ── Confirmation waiting screen ───────────────────────────────────────────
  if (confirming) {
    return (
      <div className="g-authscreen">
        <span className="g-authscreen-wordmark">Globally</span>
        <div className="g-authscreen-card">
          <h1 className="g-authscreen-title">Check your email</h1>
          <p className="g-authscreen-sub" style={{marginBottom:'16px'}}>
            We sent a confirmation link to <strong>{email.trim().toLowerCase()}</strong>.
          </p>
          <p className="g-authscreen-sub">
            Click the link in the email to confirm your account, then come back here to log in.
          </p>
          <button
            className="g-authscreen-btn-primary"
            style={{marginTop:'20px'}}
            type="button"
            onClick={onSwitchToLogin}
          >
            Go to login
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="g-authscreen">
      <span className="g-authscreen-wordmark">Globally</span>
      <div className="g-authscreen-card">
        <button className="g-authscreen-back" type="button" onClick={onBack}>← Back</button>
        <h1 className="g-authscreen-title">Create your account</h1>
        <p className="g-authscreen-sub">Free. No ads. Just what matters.</p>
        {_supabase && (
          <React.Fragment>
            <button
              type="button"
              className="g-authscreen-google-btn"
              disabled={googleLoading || loading}
              onClick={function() {
                setGoogleLoading(true);
                handleGoogleAuth();
              }}>
              <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
                <path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908C16.658 13.652 17.64 11.345 17.64 9.2z" fill="#4285F4"/>
                <path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z" fill="#34A853"/>
                <path d="M3.964 10.71c-.18-.54-.282-1.117-.282-1.71s.102-1.17.282-1.71V4.958H.957C.347 6.173 0 7.548 0 9s.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
                <path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
              </svg>
              {googleLoading ? 'Redirecting…' : 'Continue with Google'}
            </button>
            <div className="g-authscreen-divider"><span>or</span></div>
          </React.Fragment>
        )}
        <form className="g-authscreen-form" onSubmit={handleSubmit} noValidate>
          {error && <div className="g-authscreen-error" role="alert">{error}</div>}
          <label className="g-authscreen-label" htmlFor="ga-su-email">Email</label>
          <input
            id="ga-su-email"
            className="g-authscreen-input"
            type="email"
            placeholder="you@example.com"
            value={email}
            onChange={function(e){ setEmail(e.target.value); setError(null); }}
            autoComplete="email"
            disabled={loading}
          />
          <label className="g-authscreen-label" htmlFor="ga-su-pw">Password</label>
          <input
            id="ga-su-pw"
            className="g-authscreen-input"
            type="password"
            placeholder="Min. 6 characters"
            value={password}
            onChange={function(e){ setPassword(e.target.value); setError(null); }}
            autoComplete="new-password"
            disabled={loading}
          />
          <button className="g-authscreen-btn-primary" type="submit" disabled={loading}>
            {loading ? 'Creating account…' : 'Create free account'}
          </button>
        </form>
        <p className="g-authscreen-switch">
          Already have an account?{' '}
          <button className="g-authscreen-switch-btn" type="button" onClick={onSwitchToLogin}>Log in</button>
        </p>
      </div>
    </div>
  );
}

function LoginScreen(props) {
  var onDone           = props.onDone;
  var onBack           = props.onBack;
  var onSwitchToSignup = props.onSwitchToSignup;

  var _email    = React.useState(''); var email    = _email[0],    setEmail    = _email[1];
  var _password = React.useState(''); var password = _password[0], setPassword = _password[1];
  var _error    = React.useState(null); var error  = _error[0],    setError    = _error[1];
  var _loading       = React.useState(false); var loading       = _loading[0],       setLoading       = _loading[1];
  var _googleLoading = React.useState(false); var googleLoading = _googleLoading[0], setGoogleLoading = _googleLoading[1];

  function handleSubmit(e) {
    if (e) e.preventDefault();
    var trimmed = email.trim();
    if (!trimmed) { setError('Enter your email address.'); return; }
    if (!password) { setError('Enter your password.'); return; }

    var norm = trimmed.toLowerCase();

    // ── Supabase path (when configured) ──────────────────────────────────────
    if (_supabase) {
      setLoading(true);
      setError(null);
      _supabase.auth.signInWithPassword({ email: norm, password: password })
        .then(function(result) {
          var data = result.data, err = result.error;
          setLoading(false);
          if (err) {
            var msg = err.message || '';
            if (msg.toLowerCase().indexOf('email not confirmed') !== -1) {
              setError('Please confirm your email before logging in. Check your inbox for a confirmation link.');
            } else if (
              msg.toLowerCase().indexOf('invalid login credentials') !== -1 ||
              msg.toLowerCase().indexOf('invalid credentials') !== -1 ||
              msg.toLowerCase().indexOf('wrong password') !== -1
            ) {
              setError('That email or password is incorrect.');
            } else if (msg.toLowerCase().indexOf('user not found') !== -1) {
              setError('No account found for this email. Try signing up instead.');
            } else {
              setError('Login is temporarily unavailable. Please try again shortly.');
              console.error('[Globally login] Supabase error:', msg);
            }
            return;
          }
          if (!data.session) {
            setError('Login failed — please try again.');
            return;
          }
          lsSet(LS.SESSION, {
            email: norm,
            userId: data.user.id,
            loggedInAt: new Date().toISOString(),
            provider: 'supabase',
          });
          var existingSetup = lsGet(LS.SETUP, null);
          if (!existingSetup || !existingSetup.done) {
            lsSet(LS.SETUP, Object.assign({ regions: REGIONS, topics: TOPICS }, existingSetup || {}, { done: true }));
          }
          onDone();
        })
        .catch(function(err) {
          setLoading(false);
          setError('Login is temporarily unavailable. Please try again shortly.');
          console.error('[Globally login] Network error:', err.message);
        });
      return;
    }

    // ── localStorage fallback (development / Supabase not yet configured) ────
    var users = lsGet(LS.USER, {});

    // Migrate old single-user format { email, password, createdAt } → { [email]: {...} }
    if (users && typeof users.email === 'string' && typeof users.password === 'string') {
      var m = {};
      var mk = users.email.toLowerCase().trim();
      m[mk] = { email: mk, password: users.password, createdAt: users.createdAt || '' };
      users = m;
      lsSet(LS.USER, users);
    }

    // Migrate any dict keys that weren't lowercased
    if (Object.keys(users).some(function(k) { return k !== k.toLowerCase().trim(); })) {
      var migrated = {};
      Object.keys(users).forEach(function(k) {
        var kn = k.toLowerCase().trim();
        migrated[kn] = Object.assign({}, users[k], { email: kn });
      });
      users = migrated;
      lsSet(LS.USER, users);
    }

    var user = users[norm];
    if (!user) {
      setError('No account found for this email. Try signing up instead.');
      return;
    }
    if (user.password !== password) {
      setError('That email or password is incorrect.');
      return;
    }

    lsSet(LS.SESSION, { email: norm, loggedInAt: new Date().toISOString(), provider: 'local' });
    var existingSetup = lsGet(LS.SETUP, null);
    if (!existingSetup || !existingSetup.done) {
      lsSet(LS.SETUP, Object.assign({ regions: REGIONS, topics: TOPICS }, existingSetup || {}, { done: true }));
    }
    onDone();
  }

  return (
    <div className="g-authscreen">
      <span className="g-authscreen-wordmark">Globally</span>
      <div className="g-authscreen-card">
        <button className="g-authscreen-back" type="button" onClick={onBack}>← Back</button>
        <h1 className="g-authscreen-title">Welcome back</h1>
        <p className="g-authscreen-sub">Log in to your Globally account.</p>
        {_supabase && (
          <React.Fragment>
            <button
              type="button"
              className="g-authscreen-google-btn"
              disabled={googleLoading || loading}
              onClick={function() {
                setGoogleLoading(true);
                handleGoogleAuth();
              }}>
              <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
                <path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908C16.658 13.652 17.64 11.345 17.64 9.2z" fill="#4285F4"/>
                <path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z" fill="#34A853"/>
                <path d="M3.964 10.71c-.18-.54-.282-1.117-.282-1.71s.102-1.17.282-1.71V4.958H.957C.347 6.173 0 7.548 0 9s.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
                <path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
              </svg>
              {googleLoading ? 'Redirecting…' : 'Continue with Google'}
            </button>
            <div className="g-authscreen-divider"><span>or</span></div>
          </React.Fragment>
        )}
        <form className="g-authscreen-form" onSubmit={handleSubmit} noValidate>
          {error && <div className="g-authscreen-error" role="alert">{error}</div>}
          <label className="g-authscreen-label" htmlFor="ga-li-email">Email</label>
          <input
            id="ga-li-email"
            className="g-authscreen-input"
            type="email"
            placeholder="you@example.com"
            value={email}
            onChange={function(e){ setEmail(e.target.value); setError(null); }}
            autoComplete="email"
            disabled={loading}
          />
          <label className="g-authscreen-label" htmlFor="ga-li-pw">Password</label>
          <input
            id="ga-li-pw"
            className="g-authscreen-input"
            type="password"
            placeholder="Your password"
            value={password}
            onChange={function(e){ setPassword(e.target.value); setError(null); }}
            autoComplete="current-password"
            disabled={loading}
          />
          <button className="g-authscreen-btn-primary" type="submit" disabled={loading}>
            {loading ? 'Logging in…' : 'Log in'}
          </button>
        </form>
        <p className="g-authscreen-switch">
          No account?{' '}
          <button className="g-authscreen-switch-btn" type="button" onClick={onSwitchToSignup}>Create one free</button>
        </p>
      </div>
    </div>
  );
}

function MiraIntro() {
  var FULL = "Hi, I'm Mira. Let me show you the world.";
  var TYPING_SPEED_MS = 120;

  var _typed = React.useState('');
  var typed = _typed[0], setTyped = _typed[1];

  var isDone = typed.length >= FULL.length;

  React.useEffect(function() {
    if (isDone) return;
    var t = setTimeout(function() {
      setTyped(FULL.slice(0, typed.length + 1));
    }, TYPING_SPEED_MS);
    return function() { clearTimeout(t); };
  }, [typed]);

  return (
    <div className="g-mira-intro">
      <MiraBlockMark size={88} state={isDone ? 'idle' : 'talking'} />
      <p className="g-mira-intro-text">
        {typed}<span className="g-mira-cursor" aria-hidden="true">|</span>
      </p>
    </div>
  );
}

function AuthFlow(props) {
  var onDone = props.onDone;

  var _mode          = React.useState('landing');
  var mode           = _mode[0],          setMode          = _mode[1];
  var _googleLoading = React.useState(false);
  var googleLoading  = _googleLoading[0], setGoogleLoading = _googleLoading[1];

  if (mode === 'signup') {
    return (
      <SignupScreen
        onDone={onDone}
        onBack={function(){ setMode('landing'); }}
        onSwitchToLogin={function(){ setMode('login'); }}
      />
    );
  }

  if (mode === 'login') {
    return (
      <LoginScreen
        onDone={onDone}
        onBack={function(){ setMode('landing'); }}
        onSwitchToSignup={function(){ setMode('signup'); }}
      />
    );
  }

  // ── Landing screen ────────────────────────────────────────────
  var tickerItems = TICKER_TAGS.concat(TICKER_TAGS);
  return (
    <div className="g-lp">
      <div className="g-lp-grain" aria-hidden="true" />

      <div className="g-lp-hero-col">
        <div className="g-lp-header">
          <div className="g-lp-logo">
            <span className="gl-landing-brand" aria-label="Globally">Globally</span>
          </div>
        </div>

        <div className="g-ticker" aria-hidden="true">
          <div className="g-ticker-track">
            {tickerItems.map(function(tag, i) {
              return (
                <span key={i} className="g-ticker-item">
                  <span className="g-ticker-tag">{tag}</span>
                  <span className="g-ticker-sep">·</span>
                </span>
              );
            })}
          </div>
        </div>

        <div className="gs-video-banner g-lp-video">
          <video autoPlay muted={true} loop playsInline preload="metadata"
            src={CLIPS[0]}
            onError={function(e){ e.target.style.display = 'none'; }} />
        </div>

        <div className="g-lp-text-group">
          <h1 className="g-lp-headline">The world, in 30 seconds.</h1>
          <p className="g-lp-subtitle">Global development news, distilled daily. Free. No ads. Just what matters.</p>
        </div>

        <div className="g-lp-card-zone">
          <RotatingCard />
        </div>

        <div className="g-lp-spacer" />
      </div>

      <div className="g-lp-bot">
        <MiraIntro />
        <div className="g-lp-ctas">
          <button className="g-lp-btn-primary" onClick={function(){ setMode('signup'); }}>
            Create free account
          </button>
          <button className="g-lp-btn-ghost" onClick={function(){ setMode('login'); }}>
            Log in
          </button>
          {_supabase && (
            <React.Fragment>
              <div className="g-lp-divider"><span>or</span></div>
              <button
                type="button"
                className="g-lp-btn-google"
                disabled={googleLoading}
                onClick={function() {
                  setGoogleLoading(true);
                  handleGoogleAuth();
                }}>
                <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
                  <path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908C16.658 13.652 17.64 11.345 17.64 9.2z" fill="#4285F4"/>
                  <path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z" fill="#34A853"/>
                  <path d="M3.964 10.71c-.18-.54-.282-1.117-.282-1.71s.102-1.17.282-1.71V4.958H.957C.347 6.173 0 7.548 0 9s.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
                  <path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
                </svg>
                {googleLoading ? 'Redirecting…' : 'Continue with Google'}
              </button>
            </React.Fragment>
          )}
        </div>
        <div className="g-lp-footer">
          Covering the whole world, one edition a day.
        </div>
      </div>
    </div>
  );
}

// ─── GloballyTimePicker — custom scroll-snap hour/minute selector ─────────────

function GloballyTimePicker(props) {
  var value    = props.value || '08:00';
  var onChange = props.onChange;

  function pad2(n) { return n < 10 ? '0' + n : '' + n; }

  var parts = value.split(':');
  var initH = parseInt(parts[0] || '8', 10);
  var rawM  = parseInt(parts[1] || '0', 10);
  var initM = Math.min(55, Math.round(rawM / 5) * 5);

  var _h = React.useState(initH); var selH = _h[0], setSelH = _h[1];
  var _m = React.useState(initM); var selM = _m[0], setSelM = _m[1];

  var hours = [];
  for (var hi = 0; hi < 24; hi++) { hours.push(hi); }
  var minutes = [];
  for (var mi = 0; mi < 60; mi += 5) { minutes.push(mi); }

  var hourColRef = React.useRef(null);
  var minColRef  = React.useRef(null);

  React.useEffect(function() {
    if (!hourColRef.current) return;
    var sel = hourColRef.current.querySelector('.gl-time-opt--sel');
    if (sel) sel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }, [selH]);

  React.useEffect(function() {
    if (!minColRef.current) return;
    var sel = minColRef.current.querySelector('.gl-time-opt--sel');
    if (sel) sel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  }, [selM]);

  function pickH(h) {
    setSelH(h);
    if (onChange) onChange(pad2(h) + ':' + pad2(selM));
  }
  function pickM(m) {
    setSelM(m);
    if (onChange) onChange(pad2(selH) + ':' + pad2(m));
  }

  return (
    <div className="gl-time-picker">
      <div className="gl-time-col" ref={hourColRef}>
        {hours.map(function(h) {
          return (
            <button
              key={h}
              type="button"
              className={'gl-time-opt' + (h === selH ? ' gl-time-opt--sel' : '')}
              onClick={function() { pickH(h); }}
            >{pad2(h)}</button>
          );
        })}
      </div>
      <div className="gl-time-sep">:</div>
      <div className="gl-time-col" ref={minColRef}>
        {minutes.map(function(m) {
          return (
            <button
              key={m}
              type="button"
              className={'gl-time-opt' + (m === selM ? ' gl-time-opt--sel' : '')}
              onClick={function() { pickM(m); }}
            >{pad2(m)}</button>
          );
        })}
      </div>
    </div>
  );
}

// ─── Onboarding flow ──────────────────────────────────────────────────────────

function OnboardingFlow(props) {
  var onDone = props.onDone;
  var TOTAL_STEPS = 15;

  var _s = React.useState(0);
  var step = _s[0], setStep = _s[1];

  // Collected data
  var _r   = React.useState([]); var regions      = _r[0],   setRegions      = _r[1];
  var _t   = React.useState([]); var topics        = _t[0],   setTopics        = _t[1];
  var _ti  = React.useState("08:00"); var reminderTime = _ti[0], setReminderTime = _ti[1];
  var _ec  = React.useState(null);   var emailConsent = _ec[0],  setEmailConsent  = _ec[1];
  var _ecE = React.useState(false);  var consentError = _ecE[0], setConsentError  = _ecE[1];
  var _age = React.useState(null);  var age          = _age[0], setAge          = _age[1];
  var _g   = React.useState([]);   var goals         = _g[0],   setGoals         = _g[1];
  var _v   = React.useState(null);  var valueAnswer  = _v[0],   setValueAnswer  = _v[1];
  var _ls  = React.useState(null);  var learningStyle = _ls[0],  setLearningStyle = _ls[1];
  var _sg  = React.useState(null);  var streakGoal   = _sg[0],  setStreakGoal   = _sg[1];

  function next() { setStep(function(s){ return typeof s === "number" ? s + 1 : 0; }); }

  function toggleRegion(r) {
    setRegions(function(prev) { return prev.indexOf(r) !== -1 ? prev.filter(function(x){ return x!==r; }) : prev.concat([r]); });
  }
  function toggleTopic(t) {
    setTopics(function(prev) { return prev.indexOf(t) !== -1 ? prev.filter(function(x){ return x!==t; }) : prev.concat([t]); });
  }
  function toggleGoal(g) {
    setGoals(function(prev) {
      if (prev.indexOf(g) !== -1) return prev.filter(function(x){ return x!==g; });
      if (prev.length >= 3) return prev;
      return prev.concat([g]);
    });
  }

  function finish() {
    var setup = {
      done: true,
      regions:               regions.length ? regions : REGIONS,
      topics:                topics.length  ? topics  : TOPICS,
      reminderTime:          reminderTime,
      emailReminderConsent:  emailConsent,
      reminderTimezone:      Intl.DateTimeFormat().resolvedOptions().timeZone,
      reminderUpdatedAt:     new Date().toISOString(),
      age:                   age,
      goals:                 goals,
      valueAnswer:           valueAnswer,
      learningStyle:         learningStyle,
      streakGoal:            streakGoal,
    };
    lsSet(LS.SETUP, setup);
    lsSet(LS.PREF, seedPrefFromSetup(setup));
    lsSet(LS.MIRA, { savedStories: [], miraQueries: [], updatedAt: new Date().toISOString() });

    // Persist email preferences to backend (Vercel function → Resend audience)
    var session = lsGet(LS.SESSION, {});
    if (session.email) {
      fetch('/api/save-prefs', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          email:                  session.email,
          emailBriefingsEnabled:  !!setup.emailReminderConsent,
          dailyBriefingTime:      setup.reminderTime || '08:00',
          timezone:               setup.reminderTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
        }),
      }).then(function(r){ return r.json(); })
        .then(function(d){
          if (d.ok) console.log('[Globally] Email prefs saved (onboarding)', d.persisted ? 'server' : 'local-only');
          else console.warn('[Globally] Email prefs save failed:', d.reason);
        })
        .catch(function(err){ console.warn('[Globally] Email prefs error:', err.message); });
    }

    onDone(setup);
  }

  // Affirmation copy keyed by valueAnswer
  var AFFIRMATIONS = {
    "Understanding the bigger picture": "Seeing the bigger picture is how you actually understand the world.",
    "Knowing what others miss":         "Knowing what others miss is how you actually understand the world.",
    "Following regions I care about":   "Following what you care about is how you actually understand the world.",
    "Staying curious about the world":  "Staying curious is how you actually understand the world.",
  };

  // ── Reusable option card ──────────────────────────────────────────────────
  function ObOpt(p) {
    return (
      <button className={"ob-option" + (p.active ? " ob-option--on" : "")} onClick={p.onClick}>
        <span className="ob-option-label">{p.label}</span>
        {p.sub && <span className="ob-option-sub">{p.sub}</span>}
        {p.active && <span className="ob-option-check">✓</span>}
      </button>
    );
  }

  // ── Shared screen shell ───────────────────────────────────────────────────
  function ObScreen(p) {
    var pct = ((typeof step === "number" ? step + 1 : 0) / TOTAL_STEPS * 100).toFixed(1);
    return (
      <div className="ob-page">
        {p.wallpaper && <ObWallpaper phrases={p.wallpaper} />}
        <div className="ob-bar"><div className="ob-bar-fill" style={{ width: pct + "%" }} /></div>
        <div className="ob-content-zone">
          {(p.eyebrow || p.headline || p.sub) && (
            <div className="ob-head">
              {p.eyebrow && <p className="ob-eyebrow">{p.eyebrow}</p>}
              {p.headline && <h2 className="ob-headline">{p.headline}</h2>}
              {p.sub && <p className="ob-hint">{p.sub}</p>}
            </div>
          )}
          <div className="ob-body">{p.children}</div>
        </div>
        <div className="ob-foot">
          <button className="ob-btn" onClick={p.onContinue} disabled={!!p.disabled}>
            {p.btnLabel || "Continue"}
          </button>
        </div>
      </div>
    );
  }

  // ── 15-step flow — ObWallpaper persists across all steps at position 0 ──────

  var stepContent = null;

  // 0 — Quote screen
  if (step === 0) {
    stepContent = (
      <ObScreen headline={""} onContinue={next}>
        <div className="ob-quote-block">
          <div className="ob-quote-mark">&ldquo;</div>
          <p className="ob-quote-text">Overcoming poverty is not a gesture of charity. It is the protection of a fundamental human right, the right to dignity and a decent life.</p>
          <p className="ob-quote-attr">— Nelson Mandela, Make Poverty History speech, 2005</p>
          <p className="ob-quote-globly">Globally keeps the world's overlooked stories in front of you. 30 seconds a day.</p>
        </div>
      </ObScreen>
    );
  }

  // 1 — Fact screen
  else if (step === 1) {
    stepContent = (
      <ObScreen
        headline={"1 in 10 people live in extreme poverty."}
        onContinue={next}
      >
        <div className="ob-fact-block">
          <p className="ob-fact-sub">Most of the world's biggest stories never reach your feed. Globally brings them to you — in 30 seconds a day.</p>
          <p className="ob-fact-source">Source: World Bank, 2025</p>
        </div>
      </ObScreen>
    );
  }

  // 2 — Comparison
  else if (step === 2) {
    stepContent = (
      <ObScreen
        headline={"Not all news is worth your time."}
        onContinue={next}
      >
        <div className="ob-vs">
          <div className="ob-vs-hd ob-vs-hd--left">Doomscrolling</div>
          <div className="ob-vs-hd ob-vs-hd--right">Globally</div>
          <div className="ob-vs-cell ob-vs-cell--left">Hours of scrolling</div>
          <div className="ob-vs-cell ob-vs-cell--right">30 seconds a day</div>
          <div className="ob-vs-cell ob-vs-cell--left">Noise and outrage</div>
          <div className="ob-vs-cell ob-vs-cell--right">Just what matters</div>
          <div className="ob-vs-cell ob-vs-cell--left">Leaves you anxious</div>
          <div className="ob-vs-cell ob-vs-cell--right">Leaves you informed</div>
          <div className="ob-vs-cell ob-vs-cell--left">Forgotten by lunch</div>
          <div className="ob-vs-cell ob-vs-cell--right">Actually sticks</div>
        </div>
      </ObScreen>
    );
  }

  // 3 — Stat
  else if (step === 3) {
    stepContent = (
      <ObScreen
        headline={"Join people who want to understand the world."}
        onContinue={next}
      >
        <div className="ob-big-stat">
          <div className="ob-big-stat-num">30s</div>
          <div className="ob-big-stat-line">Average time to read your daily brief.</div>
          <div className="ob-big-stat-source">Globally, 2026.</div>
        </div>
      </ObScreen>
    );
  }

  // 4 — What pulls you in (single-select)
  else if (step === 4) {
    var valueOpts = [
      "Understanding the bigger picture",
      "Knowing what others miss",
      "Following regions I care about",
      "Staying curious about the world",
    ];
    stepContent = (
      <ObScreen
        headline={"What pulls you in?"}
        sub={"Pick the one that fits best."}
        onContinue={next}
        disabled={!valueAnswer}
        wallpaper={OB_WP.s4}
      >
        {valueOpts.map(function(v) {
          return (
            <ObOpt key={v} label={v} active={valueAnswer === v}
              onClick={function(){ setValueAnswer(v); }} />
          );
        })}
      </ObScreen>
    );
  }

  // 5 — Affirmation
  else if (step === 5) {
    stepContent = (
      <ObScreen
        headline={"Good choice. That's exactly what Globally is for."}
        sub={"We'll tune your daily brief around it."}
        onContinue={next}
      >
        <div className="ob-affirm-pill">{valueAnswer || "Understanding the bigger picture"}</div>
      </ObScreen>
    );
  }

  // 6 — Age
  else if (step === 6) {
    var ageOpts = ["18–24", "25–34", "35–44", "45–54", "55+"];
    stepContent = (
      <ObScreen
        headline={"How old are you?"}
        onContinue={next}
        disabled={!age}
        wallpaper={OB_WP.s6}
      >
        {ageOpts.map(function(a) {
          return <ObOpt key={a} label={a} active={age === a} onClick={function(){ setAge(a); }} />;
        })}
      </ObScreen>
    );
  }

  // 7 — Goals (multi-select up to 3)
  else if (step === 7) {
    var goalOpts = [
      "Understand global issues",
      "Sound sharper in conversations",
      "Follow a region closely",
      "Build a daily habit",
      "See past the headlines",
    ];
    stepContent = (
      <ObScreen
        headline={"What do you want from Globally?"}
        sub={"Pick up to 3."}
        onContinue={next}
        wallpaper={OB_WP.s7}
      >
        {goalOpts.map(function(g) {
          return (
            <ObOpt key={g} label={g} active={goals.indexOf(g) !== -1}
              onClick={function(){ toggleGoal(g); }} />
          );
        })}
      </ObScreen>
    );
  }

  // 8 — Regions
  else if (step === 8) {
    stepContent = (
      <ObScreen
        headline={"Where in the world?"}
        sub={"Pick the regions you want to follow. Skip for everything."}
        onContinue={next}
        wallpaper={OB_WP.s8}
      >
        {REGIONS.map(function(r) {
          return <ObOpt key={r} label={r} active={regions.indexOf(r) !== -1} onClick={function(){ toggleRegion(r); }} />;
        })}
      </ObScreen>
    );
  }

  // 9 — Topics
  else if (step === 9) {
    var topicOpts = [
      { key: "Climate",  label: "Climate"  },
      { key: "Health",   label: "Health"   },
      { key: "Economy",  label: "Economy"  },
      { key: "Conflict", label: "Conflict" },
      { key: "Politics", label: "Politics" },
      { key: "Trade",    label: "Trade"    },
    ];
    stepContent = (
      <ObScreen
        headline={"What matters to you?"}
        sub={"Choose your topics. Skip for everything."}
        onContinue={next}
        wallpaper={OB_WP.s9}
      >
        {topicOpts.map(function(t) {
          return <ObOpt key={t.key} label={t.label} active={topics.indexOf(t.key) !== -1} onClick={function(){ toggleTopic(t.key); }} />;
        })}
      </ObScreen>
    );
  }

  // 10 — Learning style
  else if (step === 10) {
    var styleOpts = ["Quick reads", "Deep dives", "A daily quiz", "Maps & visuals"];
    stepContent = (
      <ObScreen
        headline={"How do you like to take it in?"}
        onContinue={next}
        disabled={!learningStyle}
      >
        {styleOpts.map(function(s) {
          return <ObOpt key={s} label={s} active={learningStyle === s} onClick={function(){ setLearningStyle(s); }} />;
        })}
      </ObScreen>
    );
  }

  // 11 — Value recap
  else if (step === 11) {
    var features = [
      { text: "A daily brief in 30 seconds" },
      { text: "Plain-English, no jargon" },
      { text: "A daily game to test yourself" },
      { text: "Streaks to keep the habit" },
    ];
    stepContent = (
      <ObScreen
        headline={"Globally helps you stay informed — efficiently."}
        onContinue={next}
      >
        <div className="ob-features">
          {features.map(function(f, i) {
            return (
              <div key={i} className="ob-feature-row">
                <span className="ob-feature-icon">◆</span>
                <span className="ob-feature-text">{f.text}</span>
              </div>
            );
          })}
        </div>
      </ObScreen>
    );
  }

  // 12 — Streak commitment
  else if (step === 12) {
    var streakOpts = [
      { key: "7",  label: "7-day streak",  sub: "Promising"   },
      { key: "14", label: "14-day",        sub: "Determined"  },
      { key: "30", label: "30-day",        sub: "Consistent"  },
      { key: "50", label: "50-day",        sub: "Unstoppable" },
    ];
    stepContent = (
      <ObScreen
        headline={"Commit to staying informed."}
        onContinue={next}
        disabled={!streakGoal}
        wallpaper={OB_WP.s12}
      >
        {streakOpts.map(function(s) {
          return <ObOpt key={s.key} label={s.label} sub={s.sub} active={streakGoal === s.key} onClick={function(){ setStreakGoal(s.key); }} />;
        })}
      </ObScreen>
    );
  }

  // 13 — Reminder + email consent (inlined: plain divs survive reconciliation; ObScreen remounts on every render)
  else if (step === 13) {
    var pct13 = (14 / TOTAL_STEPS * 100).toFixed(1);
    function handleReminderContinue() {
      if (emailConsent === null) {
        setConsentError(true);
        return;
      }
      setConsentError(false);
      next();
    }
    stepContent = (
      <div className="ob-page">
        <div className="ob-bar"><div className="ob-bar-fill" style={{ width: pct13 + "%" }} /></div>
        <div className="ob-content-zone">
          <div className="ob-head">
            <h2 className="ob-headline">Stay consistent.</h2>
            <p className="ob-hint">Pick a time for your daily brief, then tell us how you'd like to be reminded.</p>
          </div>
          <div className="ob-body">
            <div className="ob-reminder-wrap">
              <GloballyTimePicker
                value={reminderTime}
                onChange={function(v){ setReminderTime(v); }}
              />
            </div>
            <div className="ob-consent-group">
              <button
                type="button"
                className={"ob-consent-btn" + (emailConsent === true ? " ob-consent-btn--on" : "")}
                onClick={function(){ setEmailConsent(true); setConsentError(false); }}
              >
                <span className="ob-consent-label">I agree to receive daily email reminders</span>
                <span className="ob-consent-copy">I agree to receive daily email reminders from Globally at my selected time. I understand I can change this preference or unsubscribe at any time.</span>
              </button>
              <button
                type="button"
                className={"ob-consent-btn" + (emailConsent === false ? " ob-consent-btn--on" : "")}
                onClick={function(){ setEmailConsent(false); setConsentError(false); }}
              >
                <span className="ob-consent-label">I do not want daily email reminders</span>
                <span className="ob-consent-copy">I do not want daily email reminders. I can turn them on later from my profile.</span>
              </button>
            </div>
            {consentError && (
              <p className="ob-consent-error">Please choose whether you want email reminders before continuing.</p>
            )}
          </div>
        </div>
        <div className="ob-foot">
          <button
            className={"ob-btn" + (emailConsent === null ? " ob-btn--unselected" : "")}
            onClick={handleReminderContinue}
          >Continue</button>
        </div>
      </div>
    );
  }

  // 14 — Final
  else if (step === 14) {
    stepContent = (
      <div className="ob-page">
        <div className="ob-bar"><div className="ob-bar-fill" style={{ width: "100%" }} /></div>
        <div className="ob-final">
          <div className="ob-final-globe"><GlobeSVG /></div>
          <h2 className="ob-final-headline">It only takes<br /><em>30 seconds a day.</em></h2>
          <p className="ob-final-sub">Start your first brief and begin your streak.</p>
        </div>
        <div className="ob-foot">
          <button className="ob-btn" onClick={finish}>Start reading</button>
        </div>
      </div>
    );
  }

  function goBack() {
    if (step === 0) {
      if (props.onBack) props.onBack();
    } else {
      setStep(function(s) { return s - 1; });
    }
  }
  return (
    <div className="ob-flow-wrap has-back">
      <button className="ob-back-btn" type="button" onClick={goBack}>← Back</button>
      {stepContent}
    </div>
  );
}

// ─── Today screen — helpers ────────────────────────────────────────────────────

function filterByTopics(articles, sectorKeys, limit) {
  var out = [];
  for (var i = 0; i < articles.length; i++) {
    var tags = tagArticle(articles[i]);
    if (sectorKeys.some(function(s){ return tags.indexOf(s) !== -1; })) out.push(articles[i]);
  }
  var pref = loadPref(lsGet(LS.SETUP, null));
  return scoreAndRank(out, pref, {}, 0)
    .slice(0, limit || 4)
    .map(function(x) { return x.article; });
}

function MissionBar(props) {
  var streak = props.streak;
  return (
    <div className="gt-mission-bar">
      <div className="gt-mission-left">
        <span className="gt-mission-flame">{Flame ? <Flame size={14} strokeWidth={2} /> : null}</span>
        <span className="gt-mission-count">{streak.current}-day streak</span>
      </div>
      <p className="gt-mission-label">Read a brief today to keep it going.</p>
    </div>
  );
}

// ─── Mira UI components ───────────────────────────────────────────────────────

function MiraSourceCard(props) {
  var a       = props.article;
  var compact = props.compact;
  if (!a) return null;
  var topic  = getArticleTopic(a);
  var hex    = topic ? (TOPIC_COLORS[topic] || '#7A8A9A') : '#7A8A9A';
  var domain = '';
  try { domain = new URL(a.url).hostname.replace(/^www\./, ''); } catch(e) {}
  var srcName  = a.sourceName || domain;
  // Source-type badge: "Globally · SCMP" or "External · UN OCHA".
  // isExternalSource is set by buildMiraSourceBundle; falls back to Globally for
  // legacy article objects that predate the bundle system.
  var isExt    = !!a.isExternalSource;
  var typeTag  = isExt ? 'External' : 'Globally';
  return (
    <a href={a.url} target="_blank" rel="noopener noreferrer"
       className={"mira-source-card" + (compact ? " mira-source-card--compact" : "") + (isExt ? " mira-source-card--external" : "")}>
      <div className="mira-source-card-head">
        <span className={"mira-source-type-tag" + (isExt ? " mira-source-type-tag--ext" : "")}>{typeTag}</span>
        <img src={"/favicons/" + domain + ".png"} alt="" width="13" height="13"
             style={{borderRadius:2,flexShrink:0}}
             onError={function(e){ e.currentTarget.style.display='none'; }} />
        <span className="mira-source-name">{srcName}</span>
        {topic   && <span className="mira-source-topic" style={{color:hex}}>{topic}</span>}
        {a.country && <span className="mira-source-country">{a.country}</span>}
        {(a.published || a.publishedAt) && <span className="mira-source-time">{relTime(a.published || a.publishedAt)}</span>}
      </div>
      <p className="mira-source-title">{a.title || displayTitle(a)}</p>
    </a>
  );
}

var MIRA_QUESTIONS = [
  { id: 'background', label: 'Explain the background' },
  { id: 'matters',    label: 'Why does this matter?' },
  { id: 'affected',   label: 'Who is affected?' },
  { id: 'connect',    label: 'Connect to previous stories' },
  { id: 'next',       label: "What sources report about what's next" },
  { id: '30sec',      label: '30-second version' },
  { id: '3min',       label: '3-minute version' },
];

// ─── MiraVoiceControl ─────────────────────────────────────────────────────────
// Play/waveform button used exclusively on Mira-generated text.
// Props: text (string to speak), label (aria-label string), className (extra class)
function MiraVoiceControl(props) {
  var text  = (props.text  || '').trim();
  var label = props.label  || 'Play Mira answer';
  var cls   = props.className || '';
  var voice = useVoice();
  var s     = voice.state;
  if (!text) return null;

  var isPlaying = s === 'playing';
  var isLoading = s === 'loading';
  var isPaused  = s === 'paused';
  var isErr     = s === 'error' || s === 'not_configured';

  var ariaLabel = isPlaying ? 'Pause Mira voice' : (isPaused ? 'Resume Mira voice' : label);

  return (
    <React.Fragment>
      <button
        className={'mira-vc' + (isPlaying ? ' mira-vc--playing' : '') + (isLoading ? ' mira-vc--loading' : '') + (isErr ? ' mira-vc--err' : '') + (cls ? ' ' + cls : '')}
        onClick={function(e) { e.stopPropagation(); voice.play(text); }}
        disabled={isLoading}
        aria-label={ariaLabel}
        title={isErr ? 'Mira voice is unavailable right now' : ''}>
        {isLoading && (
          <span className="mira-vc-dots" aria-hidden="true">
            <span /><span /><span />
          </span>
        )}
        {isPlaying && (
          <span className="mira-vc-wave" aria-hidden="true">
            <span className="mira-vc-bar mira-vc-bar--1" />
            <span className="mira-vc-bar mira-vc-bar--2" />
            <span className="mira-vc-bar mira-vc-bar--3" />
            <span className="mira-vc-bar mira-vc-bar--4" />
            <span className="mira-vc-bar mira-vc-bar--5" />
          </span>
        )}
        {!isLoading && !isPlaying && (
          <svg className="mira-vc-play" width="10" height="11" viewBox="0 0 10 11" fill="none" aria-hidden="true">
            <path d="M1.5 1.5l7 4-7 4V1.5Z" fill="currentColor"/>
          </svg>
        )}
      </button>
      {isErr && (
        <span className="mira-vc-unavail">Mira voice is unavailable right now.</span>
      )}
    </React.Fragment>
  );
}

function AskMiraPanel(props) {
  var article  = props.article;
  var articles = props.articles || [];
  var setup    = props.setup    || {};
  var onOpenStory = props.onOpenStory;

  var _activeQ   = React.useState(null);  var activeQ   = _activeQ[0],   setActiveQ   = _activeQ[1];
  var _answer    = React.useState(null);  var answer    = _answer[0],    setAnswer    = _answer[1];
  var _loading   = React.useState(false); var loading   = _loading[0],   setLoading   = _loading[1];
  var _inputVal  = React.useState('');    var inputVal  = _inputVal[0],  setInputVal  = _inputVal[1];
  var _showChips = React.useState(true);  var showChips = _showChips[0], setShowChips = _showChips[1];
  var _logId     = React.useRef(null);
  var _feedback  = React.useState(null);  var feedback  = _feedback[0],  setFeedback  = _feedback[1];
  var inputRef   = React.useRef(null);

  var seen = loadSeen();
  var seenRelated = React.useMemo(function() {
    return findSeenRelated(article, articles, seen);
  }, [article.url, articles.length]);

  // Chips shown conditionally based on available article data
  var chips = React.useMemo(function() {
    var list = [
      { id: 'background', label: 'Explain the background' },
      { id: 'matters',    label: 'Why does this matter?' },
      { id: 'affected',   label: 'Who is affected?' },
    ];
    if (seenRelated.length > 0)
      list.push({ id: 'connect', label: 'Connect to previous stories' });
    var hasNextData = article.storyModes && (
      (article.storyModes.facts && article.storyModes.facts.length >= 2) ||
      article.storyModes.matters
    );
    if (hasNextData)
      list.push({ id: 'next', label: "What sources report about what's next" });
    list.push({ id: '30sec', label: '30-second version' });
    list.push({ id: '3min',  label: '3-minute version' });
    return list;
  }, [article.url, seenRelated.length]);

  function _finaliseAnswer(result, question) {
    setAnswer(result);
    setLoading(false);
    var qid = logMiraQuery({
      question: question, page: 'article',
      intent:           result.understood  ? result.understood.intent : null,
      sourceIds:        (result.sources    || []).map(function(s){ return s.url; }),
      hadEnoughSources: (result.sources    || []).length >= 2,
      confidence:       result.confidence,
      limitations:      result.limitations || [],
    });
    _logId.current = qid;
    recordMiraQuery(question, displayTitle(article));
  }

  function runAnswerFor(question) {
    setActiveQ(question);
    setAnswer(null);
    setFeedback(null);
    setLoading(true);
    setTimeout(function() {
      // Phase 1: synchronous Globally-only answer
      var phase1 = answerWithMira(question, articles, { article: article });

      if (!phase1.needsExternalSearch) {
        // Globally sources are sufficient — no external search needed
        _finaliseAnswer(phase1, question);
        return;
      }

      // Show phase-1 result immediately while external search runs in background.
      // The _searching flag lets the UI show a subtle "checking external sources" note.
      setAnswer(Object.assign({}, phase1, { _searching: true }));

      // Phase 2: augment with external web sources (async, safe via /api/mira-search)
      searchExternalSourcesForMira({
        question:           question,
        understoodQuestion: phase1.understood,
        primarySubject:     getPrimaryAnswerSubject(phase1.understood, article),
        currentStory:       article,
      }).then(function(extSources) {
        if (extSources && extSources.length > 0) {
          var phase2 = answerWithMira(question, articles, {
            article:         article,
            externalSources: extSources,
          });
          _finaliseAnswer(phase2, question);
        } else {
          // External search returned nothing — finalise phase1
          _finaliseAnswer(phase1, question);
        }
      }).catch(function() {
        _finaliseAnswer(phase1, question);
      });
    }, 300);
  }


  function handleChip(c) { setInputVal(''); runAnswerFor(c.label); }

  function handleSend() {
    var q = inputVal.trim();
    if (!q) return;
    setInputVal('');
    runAnswerFor(q);
  }

  function handleKeyDown(e) { if (e.key === 'Enter') handleSend(); }

  function handleFeedback(val) {
    setFeedback(val);
    if (_logId.current) updateMiraQueryLog(_logId.current, { feedback: val });
  }

  var LOADING_LINES = [
    'Mira is reading the sources…',
    'Mira is checking the stories…',
    'Mira is connecting the reporting…',
  ];
  var _loadMsg = React.useMemo(function() {
    return LOADING_LINES[Math.floor(Math.random() * LOADING_LINES.length)];
  }, [loading]);

  return (
    <section className="mira-ask-panel mira-ask-panel--v2">
      <div className="mira-ask-hd">
        <MiraBlockMark size={18} />
        <span className="mira-ask-hd-label">Ask Mira</span>
        <span className="mira-ask-hd-sub">Story context + Mira's development knowledge</span>
      </div>

      {/* Chips — conditional, always visible before answer */}
      {showChips && !loading && !answer && (
        <div className="mira-ask-chips">
          {chips.map(function(c) {
            return (
              <button key={c.id}
                className={"mira-ask-chip" + (activeQ === c.label ? " is-active" : "")}
                onClick={function(){ handleChip(c); }}>
                {c.label}
              </button>
            );
          })}
        </div>
      )}

      {/* Free-type input */}
      {!loading && !answer && (
        <div className="mira-ask-input-row">
          <input
            ref={inputRef}
            className="mira-ask-input"
            type="text"
            placeholder="Or type your own question…"
            value={inputVal}
            onChange={function(e){ setInputVal(e.target.value); }}
            onKeyDown={handleKeyDown}
            autoComplete="off"
          />
          <button className={"mira-ask-send" + (inputVal.trim() ? ' is-active' : '')}
            onClick={handleSend} aria-label="Ask Mira">
            <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor"
                 strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <line x1="1" y1="13" x2="13" y2="1"/>
              <polyline points="5,1 13,1 13,9"/>
            </svg>
          </button>
        </div>
      )}

      {loading && (
        <div className="mira-loading">
          <MiraBlockMark size={15} />
          <span className="mira-loading-text">{_loadMsg}</span>
        </div>
      )}

      {answer && !loading && (
        <div className="mira-answer">
          <p className="mira-answer-question"><em>{activeQ}</em></p>

          <div className="mira-answer-text">
            {answer.answer.split('\n').map(function(line, i) {
              var trimmed = line.trim();
              if (!trimmed) return <div key={i} style={{height:6}} />;
              // Bold "In general terms," and "What the current sources report:" in the
              // main answer body (used by the specific-intent reasoning path).
              var boldMatch = /^(In general terms,|What the current sources report:?)\s*(.*)/.exec(trimmed);
              if (boldMatch) {
                return (
                  <p key={i} className="mira-answer-line">
                    <strong>{boldMatch[1]}</strong>{boldMatch[2] ? ' ' + boldMatch[2] : ''}
                  </p>
                );
              }
              var isBullet = /^[•\-]/.test(trimmed);
              return <p key={i} className={'mira-answer-line' + (isBullet ? ' mira-answer-line--bullet' : '')}>{trimmed}</p>;
            })}
          </div>

          {answer.limitations && answer.limitations.length > 0 && (
            <p className="mira-answer-limitation">{answer.limitations[0]}</p>
          )}

          {/* Question-aware general context — inline text, not a boxed callout */}
          {answer.generalBackground && (
            <div className="mira-answer-text mira-answer-general-ctx">
              {answer.generalBackground.text.split('\n').map(function(line, i) {
                if (!line.trim()) return <div key={i} style={{height:4}} />;
                var isBullet = /^•/.test(line.trim());
                var boldRe = /^(In general terms,)\s*(.*)/;
                var bm = boldRe.exec(line);
                if (bm) {
                  return (
                    <p key={i} className="mira-answer-line">
                      <strong>{bm[1]}</strong>{bm[2] ? ' ' + bm[2] : ''}
                    </p>
                  );
                }
                return (
                  <p key={i} className={'mira-answer-line' + (isBullet ? ' mira-answer-line--bullet' : '')}>
                    {line}
                  </p>
                );
              })}
              <p className="mira-answer-limitation">{answer.generalBackground.disclaimer}</p>
            </div>
          )}

          {answer.sources && answer.sources.length > 0 ? (
            <div className="mira-answer-sources">
              <p className="mira-answer-src-label">
                {answer.hasExternalSources ? 'Sources Mira used' : (answer._inlineReasoningUsed ? 'Using this story + Mira\'s development context' : 'Globally sources')}
                {answer.confidence === 'high_confidence' && ' · High confidence'}
                {answer.confidence === 'limited_sources' && ' · Limited sources'}
                {answer.confidence === 'needs_more_detail' && ' · Needs more context'}
                {answer._searching && ' · checking more sources…'}
              </p>
              {answer.sources.slice(0, 5).map(function(s, i) {
                return (
                  <button key={i} className="mira-answer-src-btn"
                    onClick={function(){
                      if (_logId.current) updateMiraQueryLog(_logId.current, { sourceClicked: true });
                      if (s.url !== article.url) {
                        if (onOpenStory) onOpenStory(s);
                        else window.open(s.url, '_blank', 'noopener');
                      }
                    }}>
                    <MiraSourceCard article={s} compact={true} />
                  </button>
                );
              })}
            </div>
          ) : (
            <p className="mira-answer-no-src">
              {answer.generalBackground
                ? "Globally's sources confirm the story context — Mira is answering from general development knowledge."
                : "Mira doesn't have a confirmed source for this in Globally's current coverage. Try a more specific question."}
            </p>
          )}

          <div className="mira-answer-footer">
            <div className="mira-answer-feedback">
              {!feedback ? (
                <React.Fragment>
                  <span className="mira-feedback-label">Was this useful?</span>
                  <button className="mira-feedback-btn" onClick={function(){ handleFeedback('useful'); }}>Yes</button>
                  <button className="mira-feedback-btn" onClick={function(){ handleFeedback('not_useful'); }}>Not really</button>
                </React.Fragment>
              ) : (
                <span className="mira-feedback-thanks">
                  {feedback === 'useful' ? 'Thanks — helps Mira improve.' : 'Noted. Mira will do better.'}
                </span>
              )}
            </div>
            {answer.answer && (
              <div className="mira-answer-voice-wrap">
                <MiraVoiceControl text={answer.answer} label="Play Mira answer" />
                <span className="mira-answer-voice-label">Mira voice</span>
              </div>
            )}
            <button className="mira-ask-another"
              onClick={function(){ setAnswer(null); setActiveQ(null); setFeedback(null); setShowChips(true); }}>
              Ask another
            </button>
          </div>
        </div>
      )}
    </section>
  );
}

function MiraConnectionsPanel(props) {
  var article  = props.article;
  var articles = props.articles || [];
  var seen     = loadSeen();
  var related  = React.useMemo(function() {
    return findSeenRelated(article, articles, seen);
  }, [article.url, articles.length]);

  if (related.length === 0) return null;
  var topic = getArticleTopic(article);

  return (
    <section className="gbd2-ep-section mira-connections-section">
      <h2 className="gbd2-ep-section-h">
        <span style={{display:'inline-flex',alignItems:'center',gap:6}}>
          <MiraBlockMark size={16} />
          Mira connects this
        </span>
      </h2>
      <p className="mira-connections-note">
        These stories you've read appear connected through reported patterns:
      </p>
      <div className="mira-connections-list">
        {related.map(function(r, i) {
          var rTopic = getArticleTopic(r);
          var shared = [];
          if (rTopic === topic && topic) shared.push(topic);
          if (r.country && r.country === article.country) shared.push(r.country);
          return (
            <div key={i} className="mira-connection-item">
              <span className="mira-connection-through">
                appears connected through {shared.length > 0 ? shared.join(' · ') : 'related reporting'}
              </span>
              <MiraSourceCard article={r} compact={true} />
            </div>
          );
        })}
      </div>
    </section>
  );
}

// ─── Brief detail screen ──────────────────────────────────────────────────────

// Colorize numbers in a headline — returns array of React elements
function renderColorTitle(title, hex) {
  var parts = [];
  var re = /\b(\d[\d,.]*(?:\s*(?:bn|mn|billion|million|thousand|%))?)\b/gi;
  var last = 0;
  var m;
  while ((m = re.exec(title)) !== null) {
    if (m.index > last) parts.push(title.slice(last, m.index));
    parts.push(React.createElement('span', { key: 'n' + m.index, className: 'gbd2-color-word' }, m[0]));
    last = m.index + m[0].length;
  }
  if (last < title.length) parts.push(title.slice(last));
  return parts.length > 1 ? parts : title;
}

// Build quick-fact chips — SHORT tags only; sentences live in Key Facts bullets
function buildArticleFacts(article, coverageCount, topic) {
  var facts = [];
  if (coverageCount > 1) facts.push(coverageCount + ' sources');
  if (article.country) facts.push(article.country);
  if (topic) facts.push(topic);
  return facts.filter(Boolean);
}

// Highlight numbers AND country name inside a title/summary string
function renderEntityTitle(title, country, hex) {
  if (!title) return title;
  var escC = country ? country.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : null;
  var reStr = '(\\d[\\d,.]*(?:\\s*(?:bn|mn|billion|million|thousand|%))?)'
            + (escC ? '|(' + escC + ')' : '');
  var re = new RegExp('\\b' + reStr + '\\b', 'gi');
  var parts = [], last = 0, m, idx = 0;
  while ((m = re.exec(title)) !== null) {
    if (m.index > last) parts.push(title.slice(last, m.index));
    parts.push(React.createElement('span', {
      key: 'e' + (idx++), className: 'gbd2-sp-entity', style: { color: hex }
    }, m[0]));
    last = m.index + m[0].length;
  }
  if (last < title.length) parts.push(title.slice(last));
  return parts.length > 1 ? parts : title;
}

// Topic-specific headline terms to highlight (matched case-insensitively in headline)
var HEADLINE_TOPIC_KW = {
  'Conflict':      ['ceasefire', 'airstrike', 'war crimes', 'peace deal', 'troops', 'siege', 'offensive', 'insurgency', 'bombardment', 'conflict'],
  'Health':        ['nutrition crisis', 'malnutrition', 'cholera outbreak', 'outbreak', 'epidemic', 'pandemic', 'vaccine shortage', 'health crisis'],
  'Economy':       ['debt crisis', 'inflation', 'fiscal pressure', 'economic collapse', 'currency crisis', 'poverty', 'budget cuts', 'austerity'],
  'Climate':       ['El Niño', 'La Niña', 'drought', 'flooding', 'cyclone', 'climate crisis', 'heatwave', 'food insecurity', 'deforestation'],
  'Politics':      ['election', 'coup', 'protests', 'constitutional crisis', 'political crisis', 'opposition', 'parliament', 'referendum'],
  'Governance':    ['corruption', 'accountability', 'reform', 'rule of law', 'civil society'],
  'Trade':         ['sanctions', 'tariffs', 'trade war', 'supply chain', 'export ban', 'trade deal', 'embargo'],
  'Migration':     ['refugee crisis', 'mass displacement', 'displacement', 'forced displacement', 'asylum', 'exodus'],
  'Debt':          ['debt default', 'debt crisis', 'sovereign debt', 'debt distress', 'debt restructuring', 'fiscal crisis'],
  'Energy':        ['power outage', 'fuel crisis', 'blackout', 'oil prices', 'gas prices', 'energy crisis'],
  'Agriculture':   ['food crisis', 'harvest failure', 'famine', 'food prices', 'crop failure', 'food insecurity'],
  'Infrastructure':['infrastructure gap', 'connectivity', 'roads', 'ports'],
  'Diplomacy':     ['peace talks', 'peace deal', 'summit', 'diplomatic crisis', 'bilateral talks', 'foreign policy'],
};

// Highlight country, region, key topic terms, and numbers in a headline.
// Returns an array of React elements or the plain string if nothing matched.
function highlightHeadlineTerms(title, topic, country, region, hex) {
  if (!title) return title;
  var titleLC = title.toLowerCase();
  function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }

  // Build candidate list: country → region → topic phrases
  var candidates = [];

  if (country && country.length > 2 && titleLC.includes(country.toLowerCase())) {
    candidates.push(country);
  }
  if (region && region.length > 2 && region !== country && titleLC.includes(region.toLowerCase())) {
    candidates.push(region);
  }
  var phrases = HEADLINE_TOPIC_KW[topic] || [];
  for (var i = 0; i < phrases.length; i++) {
    if (candidates.length >= 3) break;
    if (titleLC.includes(phrases[i].toLowerCase())) {
      candidates.push(phrases[i]);
    }
  }

  // Sort longest-first so multi-word phrases aren't shadowed by sub-terms
  candidates.sort(function(a, b) { return b.length - a.length; });

  // Dedup: skip a term if it is a substring of an already-selected term
  var selected = [];
  for (var j = 0; j < candidates.length && selected.length < 3; j++) {
    var c = candidates[j].toLowerCase();
    var overlap = false;
    for (var k = 0; k < selected.length; k++) {
      var s = selected[k].toLowerCase();
      if (s.includes(c) || c.includes(s)) { overlap = true; break; }
    }
    if (!overlap) selected.push(candidates[j]);
  }

  // Numbers pattern (always included)
  var numPat = '\\d[\\d,.]*(?:\\s*(?:bn|mn|billion|million|thousand|%|m|k))?';

  var termPats = selected.map(function(t) { return '(?:' + escRe(t) + ')'; });
  var re = new RegExp('(?:' + termPats.concat(['(?:'+numPat+')']).join('|') + ')', 'gi');

  var parts = [], last = 0, m, idx = 0;
  while ((m = re.exec(title)) !== null) {
    if (m.index > last) parts.push(title.slice(last, m.index));
    parts.push(React.createElement('span', {
      key: 'hl' + (idx++), className: 'gbd2-sp-entity', style: { color: hex }
    }, m[0]));
    last = m.index + m[0].length;
  }
  if (last < title.length) parts.push(title.slice(last));
  return parts.length > 1 ? parts : title;
}

// Renders text with numbers, key stats, and country names highlighted in
// the topic accent colour. Safe: only wraps existing text, never invents content.
function formatBriefParagraph(text, accentColor) {
  if (!text) return null;
  // Match: numbers with commas/decimals, optionally followed by units
  var numRe = /(\b\d[\d,]*(?:\.\d+)?(?:\s*(?:million|billion|thousand|percent|%))?(?:\s*(?:people|deaths?|killed|missing|injured|displaced|unaccounted))?)/gi;
  var parts = text.split(numRe);
  return React.createElement(React.Fragment, null, parts.map(function(part, i) {
    if (/^\d/.test(part) && part.trim().length > 0) {
      return React.createElement('strong', {
        key: i,
        style: { fontWeight: 700, color: accentColor },
      }, part);
    }
    return part;
  }));
}

// ── ArticleBriefModule — shared premium reading module ───────────────────────
// Used by both BriefDetailScreen (stories) and ClusterDetailScreen (events).
// Replaces the plain gbd2-ep-section brief/overview blocks with a designed
// dotted-background Mira reading card.
function ArticleBriefModule(props) {
  var type          = props.type         || 'story'; // 'story' | 'event'
  var overviewText  = props.overviewText || null;    // paragraph for cluster-synth stories
  var thirtySecText = props.thirtySecText|| null;    // paragraph for events (30s brief)
  var quickBullets  = props.quickBullets || [];      // story: brief bullets (quick tab)
  var deeperLines   = props.deeperLines  || [];      // story: deeper_summary lines
  var sections      = props.sections     || [];      // event: miraSections (long analysis)
  var keyFacts      = props.keyFacts     || [];
  var coverageCount = props.coverageCount|| 1;
  var accentColor   = props.accentColor  || 'rgba(17,17,15,0.55)';
  var sober         = props.sober        || false;
  var hasDepth      = props.hasDepth     || false;   // story Quick/In depth toggle

  // For events: '30s' (default) vs 'long' toggle
  var _eventMode = React.useState('30s');
  var eventMode = _eventMode[0], setEventMode = _eventMode[1];

  // For stories: 'quick' vs 'deeper'
  var _tab = React.useState('quick');
  var tab = _tab[0], setTab = _tab[1];

  var accentSt = sober ? {} : { color: accentColor };

  var hasEventToggle = type === 'event' && thirtySecText && sections.length > 0;
  var storyLabel = overviewText ? 'Synthesis' : '30-second brief';

  // Per-mode texts computed once (stable when data arrives) so both tabs can be prefetched.
  var _quickVT = React.useMemo(function() {
    if (type !== 'story') return '';
    if (overviewText) return overviewText;
    return quickBullets.length > 0 ? quickBullets.join('. ') + '.' : '';
  }, [type, overviewText, quickBullets.length]);

  var _deepVT = React.useMemo(function() {
    if (type !== 'story' || !hasDepth || deeperLines.length === 0) return '';
    return deeperLines.map(function(l){ return l.replace(/^[•\-]\s*/, ''); }).join('. ') + '.';
  }, [type, hasDepth, deeperLines.length]);

  var _eventShortVT = React.useMemo(function() {
    return type === 'event' ? (thirtySecText || '') : '';
  }, [type, thirtySecText]);

  var _eventLongVT = React.useMemo(function() {
    if (type !== 'event' || sections.length === 0) return '';
    return sections.map(function(sec){ return sec.heading + '. ' + sec.body; }).join(' ');
  }, [type, sections.length]);

  // Active-tab text (what the play button reads)
  var voiceText = React.useMemo(function() {
    if (type === 'event') return eventMode === '30s' ? _eventShortVT : _eventLongVT;
    return tab === 'quick' ? _quickVT : _deepVT;
  }, [type, tab, eventMode, _quickVT, _deepVT, _eventShortVT, _eventLongVT]);

  // Inactive-tab text (prefetched in background so switching feels instant)
  var _otherVoiceText = React.useMemo(function() {
    if (type === 'event') return eventMode === '30s' ? _eventLongVT : _eventShortVT;
    return tab === 'quick' ? _deepVT : _quickVT;
  }, [type, tab, eventMode, _quickVT, _deepVT, _eventShortVT, _eventLongVT]);

  // Prefetch active tab at 500ms, inactive tab at 2500ms — both non-blocking
  React.useEffect(function() {
    if (!voiceText) return;
    var t1 = setTimeout(function() { _ttsPrefetch(voiceText); }, 500);
    var t2 = _otherVoiceText ? setTimeout(function() { _ttsPrefetch(_otherVoiceText); }, 2500) : null;
    return function() { clearTimeout(t1); if (t2) clearTimeout(t2); };
  }, [voiceText, _otherVoiceText]);

  return (
    <div className="abm-module">

      {/* ── HEADER BAR ─────────────────────────────────────────────────────── */}
      <div className="abm-header">
        {/* Mira pill */}
        <div className="abm-mira-pill">
          <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
            <path d="M5 0.5v9M0.5 5h9M1.96 1.96l6.08 6.08M8.04 1.96L1.96 8.04"
                  stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/>
          </svg>
          <span>Mira</span>
        </div>

        {/* Source count pill */}
        {coverageCount > 1 && (
          <span className="abm-src-count">{coverageCount} sources</span>
        )}

        {/* Spacer */}
        <div style={{flex:1}} />

        {/* Event: 30 seconds / Long toggle */}
        {hasEventToggle && (
          <div className="abm-depth-toggle">
            <button className={'abm-depth-btn'+(eventMode==='30s'?' abm-depth-btn--on':'')}
                    onClick={function(){setEventMode('30s');}}>30 seconds</button>
            <button className={'abm-depth-btn'+(eventMode==='long'?' abm-depth-btn--on':'')}
                    onClick={function(){setEventMode('long');}}>Long</button>
          </div>
        )}

        {/* Story: Quick / In depth toggle */}
        {hasDepth && !hasEventToggle && (
          <div className="abm-depth-toggle">
            <button className={'abm-depth-btn'+(tab==='quick'?' abm-depth-btn--on':'')}
                    onClick={function(){setTab('quick');}}>Quick</button>
            <button className={'abm-depth-btn'+(tab==='deeper'?' abm-depth-btn--on':'')}
                    onClick={function(){setTab('deeper');}}>In depth</button>
          </div>
        )}

        {/* Story label (when no toggle) */}
        {type === 'story' && !hasDepth && (
          <span className="abm-header-title" style={accentSt}>{storyLabel}</span>
        )}

        {/* Mira voice — plays whichever view is currently active */}
        {voiceText && (
          <div className="abm-voice-wrap">
            <MiraVoiceControl text={voiceText} label="Play Mira answer" />
            <span className="abm-voice-label">Mira voice</span>
          </div>
        )}
      </div>

      {/* ── BODY ───────────────────────────────────────────────────────────── */}
      <div className="abm-body">

        {/* Cluster-synthesis paragraph (for multi-source stories) */}
        {overviewText && (
          <p className="abm-overview-text">{overviewText}</p>
        )}

        {/* Event 30-second brief — shown in '30s' mode or when no sections */}
        {type === 'event' && thirtySecText && (eventMode === '30s' || sections.length === 0) && (
          <p className="abm-thirty-text">
            {formatBriefParagraph(thirtySecText, sober ? 'rgba(17,17,15,0.80)' : accentColor)}
          </p>
        )}

        {/* Event long analysis sections — shown in 'long' mode */}
        {type === 'event' && eventMode === 'long' && sections.length > 0 && (
          <div className="abm-sections">
            {sections.map(function(sec, i) {
              return (
                <div key={i} className="abm-section">
                  <h3 className="abm-section-h" style={accentSt}>{sec.heading}</h3>
                  <div className="abm-section-body">
                    {typeof renderMiraAnswerBlocks === 'function'
                      ? renderMiraAnswerBlocks(sec.body)
                      : sec.body}
                  </div>
                </div>
              );
            })}
          </div>
        )}

        {/* Story quick bullets */}
        {type === 'story' && !overviewText && tab === 'quick' && quickBullets.length > 0 && (
          <ul className="abm-bullets">
            {quickBullets.map(function(b, i) {
              return (
                <li key={i} className="abm-bullet"
                    style={{'--abm-bc': accentColor}}>{b}.</li>
              );
            })}
          </ul>
        )}

        {/* Story in-depth lines */}
        {type === 'story' && !overviewText && tab === 'deeper' && deeperLines.length > 0 && (
          <ul className="abm-bullets abm-bullets--deeper">
            {deeperLines.map(function(l, i) {
              return (
                <li key={i} className="abm-bullet"
                    style={{'--abm-bc': accentColor}}>{l.replace(/^•\s*/, '')}</li>
              );
            })}
          </ul>
        )}

        {/* Key facts — shown for synth stories and events */}
        {keyFacts.length > 0 && (
          <div className="abm-facts-block">
            <div className="abm-facts-label" style={accentSt}>
              <svg width="9" height="9" viewBox="0 0 10 10" fill="none" aria-hidden="true">
                <circle cx="5" cy="5" r="4" stroke="currentColor" strokeWidth="1.5"/>
                <path d="M5 4v3M5 3h.01" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
              </svg>
              Key facts
            </div>
            <ul className="abm-facts-list">
              {keyFacts.slice(0, 6).map(function(f, i) {
                return (
                  <li key={i} className="abm-fact-item" style={{'--abm-bc': accentColor}}>{f}</li>
                );
              })}
            </ul>
          </div>
        )}
      </div>
    </div>
  );
}

// ── ClusterImageGallery — messy image collage with lightbox ──────────────────
// Only rendered on event cluster pages when source articles have images.
function ClusterImageGallery(props) {
  var images      = props.images      || []; // [{url,domain,sourceName}]
  var accentColor = props.accentColor || null;
  var sober       = props.sober       || false;

  var _lbIdx = React.useState(null);
  var lbIdx = _lbIdx[0], setLbIdx = _lbIdx[1];

  if (images.length === 0) return null;

  function prevImg(e) {
    e.stopPropagation();
    setLbIdx(function(i){ return (i - 1 + images.length) % images.length; });
  }
  function nextImg(e) {
    e.stopPropagation();
    setLbIdx(function(i){ return (i + 1) % images.length; });
  }
  function onKeyDown(e) {
    if (e.key === 'ArrowLeft')  prevImg(e);
    if (e.key === 'ArrowRight') nextImg(e);
    if (e.key === 'Escape')     setLbIdx(null);
  }

  return (
    <React.Fragment>
      <section className="gbd2-ep-section">
        <h2 className="gbd2-ep-section-h">
          Coverage images
          <span className="abm-gallery-count"> · {images.length}</span>
        </h2>
        <div className="abm-gallery">
          {images.map(function(img, i) {
            /* Slight offset for "messy collage" feel — every 2nd tile dips, 3rd lifts */
            var offset = i % 3 === 1 ? 'abm-gallery-tile--dip'
                       : i % 3 === 2 ? 'abm-gallery-tile--lift'
                       : '';
            return (
              <button key={i}
                      className={'abm-gallery-tile ' + offset}
                      onClick={function(){ setLbIdx(i); }}
                      aria-label={'View image from ' + (img.sourceName || img.domain)}>
                <div className="abm-gallery-img"
                     style={{backgroundImage:"url('"+img.url.replace(/'/g,'%27')+"')"}} />
                <div className="abm-gallery-foot">
                  <img src={'/favicons/'+img.domain+'.png'} alt="" width="10" height="10"
                       style={{borderRadius:2,flexShrink:0,opacity:0.85}}
                       onError={function(e){e.currentTarget.style.display='none';}} />
                  <span className="abm-gallery-src">{img.sourceName || img.domain}</span>
                </div>
              </button>
            );
          })}
        </div>
      </section>

      {/* ── LIGHTBOX ── */}
      {lbIdx !== null && (
        <div className="abm-lb-overlay" onClick={function(){setLbIdx(null);}}
             onKeyDown={onKeyDown} tabIndex={-1}>
          <div className="abm-lb-dialog" onClick={function(e){e.stopPropagation();}}>
            {/* Close */}
            <button className="abm-lb-close" onClick={function(){setLbIdx(null);}} aria-label="Close">
              <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
                <path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
              </svg>
            </button>

            {/* Image */}
            <img src={images[lbIdx].url} alt="" className="abm-lb-img"
                 onError={function(e){ e.currentTarget.style.opacity='0.2'; }} />

            {/* Caption */}
            <div className="abm-lb-caption">
              <img src={'/favicons/'+images[lbIdx].domain+'.png'} alt="" width="13" height="13"
                   style={{borderRadius:2,flexShrink:0,opacity:0.75}}
                   onError={function(e){e.currentTarget.style.display='none';}} />
              <span>{images[lbIdx].sourceName || images[lbIdx].domain}</span>
              <span className="abm-lb-counter">{lbIdx+1} / {images.length}</span>
            </div>

            {/* Prev / Next */}
            {images.length > 1 && (
              <React.Fragment>
                <button className="abm-lb-prev" onClick={prevImg} aria-label="Previous image">
                  <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
                    <path d="M10 3L5 8l5 5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
                  </svg>
                </button>
                <button className="abm-lb-next" onClick={nextImg} aria-label="Next image">
                  <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
                    <path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
                  </svg>
                </button>
              </React.Fragment>
            )}
          </div>
        </div>
      )}
    </React.Fragment>
  );
}

function BriefDetailScreen(props) {
  var article      = props.article;
  var articles     = props.articles || [];
  var sourceLabels = props.sourceLabels || {};
  var orgAppeals   = props.orgAppeals   || {};
  var onClose      = props.onClose;
  var onOpenStory  = props.onOpenStory;
  var setup        = props.setup || {};

  var region   = getArticleRegion(article);
  var topic    = getArticleTopic(article);
  var topicHex = topic ? (TOPIC_COLORS[topic] || '#7A8A9A') : '#7A8A9A';
  var topicRgb = hexRgb(topicHex);
  var sober      = isGraveStory(article);
  var timeAgo    = relTime(article.published);
  var orgEntries = (article.country && orgAppeals[article.country]) ? orgAppeals[article.country] : [];

  var _sidebarCharities = React.useState([]);
  var sidebarCharities = _sidebarCharities[0], setSidebarCharities = _sidebarCharities[1];
  React.useEffect(function() {
    _loadCharitiesOnce(function(data){ setSidebarCharities(data); });
  }, []);

  var relatedOrgs = React.useMemo(function() {
    return findRelatedCharities(article, sidebarCharities);
  }, [article.url, sidebarCharities.length]);

  function viewOrg(c) {
    try { sessionStorage.setItem('globally_focus_charity', c.id); } catch(e) {}
    window.location.hash = '#/charity';
  }

  function getDomain(url) {
    try { return new URL(url).hostname.replace(/^www\./, ''); } catch(e) { return ''; }
  }

  function fmtDate(iso) {
    try {
      return new Date(iso).toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' });
    } catch(e) { return ''; }
  }

  function handleShare() {
    var storyUrl = window.location.origin + window.location.pathname
      + '#/story/' + encodeURIComponent(articleSlug(article));
    if (navigator.share) {
      navigator.share({ title: article.title, url: storyUrl }).catch(function(){});
    } else if (navigator.clipboard) {
      navigator.clipboard.writeText(storyUrl).catch(function(){});
    }
  }

  var cleanSummary = (article.summary || '').replace(/<[^>]*>/g, '').trim();

  var allSentences = cleanSummary
    .split(/\.\s+(?=[A-Z"'])/)
    .map(function(s){ return s.trim().replace(/\.$/, ''); })
    .filter(function(s){ return s.length > 20; });

  var overviewBullets = allSentences.slice(0, 6);
  var briefBullets    = allSentences.slice(0, 3);

  var clusterSynth  = article._clusterSynthesis || null;
  var clusterMbrs   = article._clusterMembers   || null;
  var sources       = clusterMbrs
    ? null
    : ((article.sources && article.sources.length > 0) ? article.sources : null);
  var coverageCount = clusterMbrs
    ? (article._clusterSize || clusterMbrs.length)
    : (sources ? sources.length + 1 : 1);

  var timelineItems = React.useMemo(function() {
    if (!sources) return [];
    var items = [{
      published: article.published, title: article.title,
      url: article.url, isCanonical: true,
    }].concat(sources.map(function(s) {
      return { published: s.published, title: s.title, url: s.url, isCanonical: false };
    }));
    items.sort(function(a, b) { return new Date(a.published||0) - new Date(b.published||0); });
    var dates = items.map(function(x){ return (x.published||'').slice(0,10); });
    var uniqueDates = dates.filter(function(d,i){ return dates.indexOf(d)===i; });
    return uniqueDates.length > 1 ? items : [];
  }, [article.url, sources]);

  var relatedStories = React.useMemo(function() {
    if (!articles.length) return [];
    return articles
      .filter(function(a) { return !a._clusterHidden && a.url !== article.url && getArticleTopic(a) === topic; })
      .slice(0, 4);
  }, [articles.length, article.url, topic]);

  /* Build unified source card list */
  var allSourceCards = [];
  if (clusterMbrs && clusterMbrs.length >= 2) {
    clusterMbrs.forEach(function(m, i) {
      var d = getDomain(m.url);
      allSourceCards.push({
        domain: d,
        name:   m.sourceName || d,
        label:  sourceLabels[d] || null,
        title:  m.title,
        url:    m.url,
        image:  (i === 0 && m.image_url && !isDocumentImage(m.image_url)) ? m.image_url : null,
        time:   m.published ? relTime(m.published) : '',
        isCanonical: i === 0,
      });
    });
  } else {
    var primaryDomain = getDomain(article.url);
    allSourceCards.push({
      domain: primaryDomain,
      name:   article.sourceName || primaryDomain,
      label:  sourceLabels[primaryDomain] || null,
      title:  article.title,
      url:    article.url,
      image:  (article.image_url && !isDocumentImage(article.image_url)) ? article.image_url : null,
      time:   timeAgo,
      isCanonical: true,
    });
    if (sources) {
      sources.forEach(function(s) {
        var d = getDomain(s.url);
        allSourceCards.push({
          domain: d, name: s.name || d,
          label:  sourceLabels[d] || null,
          title: s.title, url: s.url,
          image: null,
          time: s.published ? relTime(s.published) : '',
          isCanonical: false,
        });
      });
    }
  }

  /* Fact cards — real data only */
  var factCards = [];
  if (coverageCount > 1) factCards.push({ label:'Sources',  value: coverageCount + ' outlets' });
  if (region)            factCards.push({ label:'Region',   value: region });
  if (article.country)   factCards.push({ label:'Country',  value: article.country });
  if (topic)             factCards.push({ label:'Topic',    value: topic });
  if (timeAgo)           factCards.push({ label:'Published',value: timeAgo });
  if (allSourceCards.length > 0) factCards.push({ label:'Source', value: allSourceCards[0].domain });

  var whyText     = (article.storyModes && article.storyModes.matters) ? article.storyModes.matters : null;
  var contextText = (article.storyModes && article.storyModes.explain) ? article.storyModes.explain : null;
  var keyFacts    = (article.storyModes && article.storyModes.facts && article.storyModes.facts.length > 0) ? article.storyModes.facts : null;

  var mainImage = (article.image_url && !isDocumentImage(article.image_url)) ? article.image_url : null;

  /* Hero radial glow */
  var heroBg = sober
    ? '#EBE7D8'
    : ('radial-gradient(ellipse 85% 70% at 12% 40%, rgba(' + topicRgb + ',0.12) 0%, transparent 62%),' +
       'radial-gradient(ellipse 55% 50% at 88% 10%, rgba(' + topicRgb + ',0.06) 0%, transparent 55%),' +
       '#EBE7D8');

  /* Highlighted headline */
  var entityHex   = sober ? 'rgba(17,17,15,0.70)' : topicHex;
  var entityTitle = highlightHeadlineTerms(displayTitle(article), topic, article.country, region, entityHex);

  /* Deck: first 1–2 clean sentences */
  var deckText = allSentences.length > 0
    ? (allSentences.slice(0, 2).join('. ') + '.')
    : '';

  var accentStyle = sober ? {} : { color: topicHex };
  var bulletStyle = sober ? 'rgba(17,17,15,0.30)' : topicHex;

  return (
    <div className={"gbd2-ep-page" + (sober ? " gbd2-ep-page--sober" : "")}
         style={{ background: heroBg }}>

      {/* ── STICKY TOPBAR ── */}
      <div className="gbd2-ep-topbar">
        <button className="gbd2-ep-back" onClick={onClose} aria-label="Back">
          <svg width="15" height="15" viewBox="0 0 16 16" fill="none" style={{verticalAlign:'-2px'}}>
            <path d="M10 3L5 8l5 5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
          Back
        </button>
        <span className="gbd2-ep-brand">Globally</span>
        <button className="gbd2-ep-share" onClick={handleShare} aria-label="Share">
          <svg width="16" height="16" viewBox="0 0 18 18" fill="none">
            <path d="M13 2l3 3-3 3M16 5H8a5 5 0 000 10h1" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
        </button>
      </div>

      {/* ── SCROLL ROOT ── */}
      <div className="gbd2-ep-scroll">

        {/* ════════════ HERO ════════════ */}
        <div className="gbd2-ep-hero">

          {/* LEFT: meta + headline + deck + source chips */}
          <div className="gbd2-ep-hero-left">

            {/* Metadata strip */}
            <div className="gbd2-ep-meta">
              {topic && (
                <span className="gbd2-ep-meta-topic" style={accentStyle}>{topic.toUpperCase()}</span>
              )}
              {region && (
                <React.Fragment>
                  <span className="gbd2-ep-meta-sep">·</span>
                  <span className="gbd2-ep-meta-loc">{region.toUpperCase()}</span>
                </React.Fragment>
              )}
              {article.country && article.country !== region && (
                <React.Fragment>
                  <span className="gbd2-ep-meta-sep">·</span>
                  <span className="gbd2-ep-meta-loc">{article.country.toUpperCase()}</span>
                </React.Fragment>
              )}
              {coverageCount > 1 && (
                <React.Fragment>
                  <span className="gbd2-ep-meta-sep">·</span>
                  <span className="gbd2-ep-meta-srcs">{coverageCount} SOURCES</span>
                </React.Fragment>
              )}
              {timeAgo && (
                <React.Fragment>
                  <span className="gbd2-ep-meta-sep">·</span>
                  <span className="gbd2-ep-meta-time">{timeAgo}</span>
                </React.Fragment>
              )}
            </div>

            {/* Headline */}
            <h1 className="gbd2-ep-headline">{entityTitle}</h1>

            {/* Deck */}
            {deckText && <p className="gbd2-ep-deck">{deckText}</p>}

            {/* Source favicon chips */}
            {allSourceCards.length > 0 && (
              <div className="gbd2-ep-src-chips">
                {allSourceCards.slice(0, 5).map(function(s, i) {
                  return (
                    <div key={i} className={"gbd2-ep-src-chip" + (s.isCanonical ? " gbd2-ep-src-chip--lead" : "")}
                         style={s.isCanonical && !sober ? { borderColor:'rgba('+topicRgb+',0.50)' } : {}}>
                      <img src={'/favicons/'+s.domain+'.png'} alt="" width="13" height="13"
                           style={{borderRadius:2,flexShrink:0}}
                           onError={function(e){e.currentTarget.style.display='none';}} />
                      <span>{s.domain}</span>
                    </div>
                  );
                })}
                {allSourceCards.length > 5 && (
                  <span className="gbd2-ep-src-more">+{allSourceCards.length - 5}</span>
                )}
              </div>
            )}

          </div>

          {/* RIGHT: image collage */}
          <div className="gbd2-ep-hero-right">
            <div className="gbd2-ep-collage">

              {/* ── Main image card (or premium gradient fallback) ── */}
              {mainImage ? (
                <div className="gbd2-ep-collage-main"
                     style={{backgroundImage:"url('"+mainImage.replace(/'/g,'%27')+"')"}}>
                  {/* Dark scrim for depth */}
                  <div className="gbd2-ep-collage-scrim" />
                  {/* Topic colour tint at bottom */}
                  {!sober && (
                    <div className="gbd2-ep-collage-tint"
                         style={{background:'linear-gradient(to top,rgba('+topicRgb+',0.38) 0%,transparent 50%)'}} />
                  )}
                  {/* Bottom bar: source badge + coverage count */}
                  <div className="gbd2-ep-collage-foot">
                    <div className="gbd2-ep-collage-badge">
                      <img src={'/favicons/'+getDomain(article.url)+'.png'} alt="" width="11" height="11"
                           style={{borderRadius:2,flexShrink:0}}
                           onError={function(e){e.currentTarget.style.display='none';}} />
                      <span>{article.sourceName || getDomain(article.url)}</span>
                    </div>
                    {coverageCount > 1 && (
                      <div className="gbd2-ep-collage-coverage"
                           style={{color: sober ? 'rgba(255,255,255,0.72)' : topicHex}}>
                        {coverageCount} sources
                      </div>
                    )}
                  </div>
                </div>
              ) : (
                /* Premium gradient fallback — only shown when truly no image */
                <div className="gbd2-ep-collage-main gbd2-ep-collage-main--gradient"
                     style={sober
                       ? {background:'linear-gradient(155deg,#1c1c26 0%,#0e0e16 100%)'}
                       : {background:'radial-gradient(ellipse 90% 80% at 25% 25%,rgba('+topicRgb+',0.75) 0%,rgba('+topicRgb+',0.30) 45%,#060608 80%)'}
                     }>
                  <div className="gbd2-ep-collage-grad-inner">
                    <span className="gbd2-ep-collage-grad-topic" style={accentStyle}>{topic || 'World'}</span>
                    {allSourceCards.length > 0 && (
                      <div className="gbd2-ep-collage-grad-srcs">
                        {allSourceCards.slice(0, 3).map(function(s, i) {
                          return (
                            <div key={i} className="gbd2-ep-collage-grad-src">
                              <img src={'/favicons/'+s.domain+'.png'} alt="" width="12" height="12"
                                   style={{borderRadius:2,flexShrink:0,opacity:0.75}}
                                   onError={function(e){e.currentTarget.style.display='none';}} />
                              <span>{s.domain}</span>
                            </div>
                          );
                        })}
                      </div>
                    )}
                    <span className="gbd2-ep-collage-grad-brand">Globally</span>
                  </div>
                </div>
              )}

              {/* ── Secondary source cards (always shown when multi-source) ── */}
              {(clusterMbrs ? clusterMbrs.slice(1, 4) : (sources ? sources.slice(0, 3) : [])).map(function(s, i) {
                var d   = getDomain(s.url);
                var nm  = clusterMbrs ? (s.sourceName || d) : (s.name || d);
                var pub = clusterMbrs ? s.published : s.published;
                return (
                  <div key={i} className="gbd2-ep-collage-mini">
                    <div className="gbd2-ep-collage-mini-src-row">
                      <img src={'/favicons/'+d+'.png'} alt="" width="14" height="14"
                           style={{borderRadius:2,flexShrink:0}}
                           onError={function(e){e.currentTarget.style.display='none';}} />
                      <span className="gbd2-ep-collage-mini-src">{nm}</span>
                      {pub && <span className="gbd2-ep-collage-mini-time">{relTime(pub)}</span>}
                    </div>
                    {s.title && (
                      <p className="gbd2-ep-collage-mini-title">
                        {s.title.length > 80 ? s.title.slice(0,80)+'…' : s.title}
                      </p>
                    )}
                  </div>
                );
              })}

            </div>
          </div>
        </div>{/* /gbd2-ep-hero */}

        {/* ════════════ BODY (2-col on desktop) ════════════ */}
        <div className="gbd2-ep-body">

          {/* ── MAIN CONTENT ── */}
          <div className="gbd2-ep-main">

            {/* ── Premium Mira reading module (replaces plain brief sections) ── */}
            <ArticleBriefModule
              type="story"
              overviewText={clusterSynth ? clusterSynth.overview : null}
              quickBullets={clusterSynth ? [] : (article.deeper_summary ? briefBullets : overviewBullets)}
              deeperLines={article.deeper_summary
                ? article.deeper_summary.split('\n').filter(function(l){return l.trim().length>0;})
                : []}
              keyFacts={clusterSynth && clusterSynth.points ? clusterSynth.points : []}
              coverageCount={coverageCount}
              accentColor={sober ? 'rgba(17,17,15,0.45)' : topicHex}
              sober={sober}
              hasDepth={!clusterSynth && !!article.deeper_summary}
            />

            {/* Mira — Ask questions grounded in this story's sources */}
            <AskMiraPanel article={article} articles={articles} setup={setup} onOpenStory={onOpenStory} />

            {/* Why it matters */}
            {whyText && (
              <section className="gbd2-ep-section">
                <h2 className="gbd2-ep-section-h">Why it matters</h2>
                <p className="gbd2-ep-body-text">{whyText}</p>
              </section>
            )}

            {/* Key context */}
            {contextText && (
              <section className="gbd2-ep-section">
                <h2 className="gbd2-ep-section-h">Key context</h2>
                <p className="gbd2-ep-body-text">{contextText}</p>
              </section>
            )}

            {/* Key facts from storyModes */}
            {keyFacts && (
              <section className="gbd2-ep-section">
                <h2 className="gbd2-ep-section-h">Key facts</h2>
                <ul className="gbd2-ep-bullets">
                  {keyFacts.map(function(f,i){
                    return (
                      <li key={i} className="gbd2-ep-bullet" style={{'--bc':bulletStyle}}>{f}</li>
                    );
                  })}
                </ul>
              </section>
            )}

            {/* Facts grid */}
            {factCards.length > 0 && (
              <section className="gbd2-ep-section">
                <h2 className="gbd2-ep-section-h">Facts</h2>
                <div className="gbd2-ep-facts-grid">
                  {factCards.map(function(fc, i) {
                    return (
                      <div key={i} className="gbd2-ep-fact-card"
                           style={{borderColor: sober ? 'rgba(255,255,255,0.07)' : 'rgba('+topicRgb+',0.22)'}}>
                        <div className="gbd2-ep-fact-label">{fc.label}</div>
                        <div className="gbd2-ep-fact-value" style={accentStyle}>{fc.value}</div>
                      </div>
                    );
                  })}
                </div>
              </section>
            )}

            {/* Developments timeline */}
            {timelineItems.length > 1 && (
              <section className="gbd2-ep-section">
                <h2 className="gbd2-ep-section-h">Developments</h2>
                <div className="gbd2-timeline">
                  {timelineItems.map(function(item, i) {
                    return (
                      <a key={i} href={item.url} target="_blank" rel="noopener noreferrer"
                         className={'gbd2-tl-item'+(item.isCanonical?' gbd2-tl-item--main':'')}>
                        <span className="gbd2-tl-date">{fmtDate(item.published)}</span>
                        <div className="gbd2-tl-line">
                          <div className="gbd2-tl-dot"
                               style={item.isCanonical ? {borderColor:topicHex,background:'rgba('+topicRgb+',0.25)'} : {}} />
                        </div>
                        <span className="gbd2-tl-title">{item.title}</span>
                      </a>
                    );
                  })}
                </div>
              </section>
            )}

            {/* Mira connections from reading history */}
            <MiraConnectionsPanel article={article} articles={articles} />

            {/* Source list — shown on mobile (hidden on desktop where rail shows) */}
            <div className="gbd2-ep-mobile-sources">
              <section className="gbd2-ep-section">
                <h2 className="gbd2-ep-section-h">
                  Articles <span style={accentStyle}>{coverageCount}</span>
                </h2>
                <div className="gbd2-ep-src-list">
                  {allSourceCards.map(function(s, i) {
                    return (
                      <a key={i} href={s.url} target="_blank" rel="noopener noreferrer"
                         className={'gbd2-ep-src-card'+(s.isCanonical?' gbd2-ep-src-card--lead':'')}
                         style={s.isCanonical&&!sober ? {borderLeftColor:topicHex} : {}}>
                        {s.image && (
                          <div className="gbd2-ep-src-card-thumb"
                               style={{backgroundImage:"url('"+s.image+"')"}} />
                        )}
                        <div className="gbd2-ep-src-card-body">
                          <div className="gbd2-ep-src-card-head">
                            <img src={'/favicons/'+s.domain+'.png'} alt="" width="14" height="14"
                                 style={{borderRadius:2,flexShrink:0}}
                                 onError={function(e){e.currentTarget.style.display='none';}} />
                            <span className="gbd2-ep-src-card-name"
                                  style={s.isCanonical&&!sober ? accentStyle : {}}>{s.name||s.domain}</span>
                            {s.label && <span className="gbd2-src-label">{s.label}</span>}
                            {s.time && <span className="gbd2-ep-src-card-time">{s.time}</span>}
                          </div>
                          {s.title && <p className="gbd2-ep-src-card-title">{s.title}</p>}
                        </div>
                      </a>
                    );
                  })}
                </div>
              </section>
            </div>

            {/* Related stories */}
            {relatedStories.length > 0 && (
              <section className="gbd2-ep-section gbd2-ep-section--related">
                <h2 className="gbd2-ep-section-h">Related stories</h2>
                <div className="gbd2-ep-related-grid">
                  {relatedStories.map(function(s, i) {
                    var sImg = s.image_url && !isDocumentImage(s.image_url) ? s.image_url : null;
                    var sTopic = getArticleTopic(s);
                    var sHex = sTopic ? (TOPIC_COLORS[sTopic]||topicHex) : topicHex;
                    return (
                      <button key={i} className="gbd2-ep-related-card"
                              onClick={function(){if(onOpenStory) onOpenStory(s); else onClose();}}>
                        {sImg && (
                          <div className="gbd2-ep-related-img"
                               style={{backgroundImage:"url('"+sImg.replace(/'/g,'%27')+"')"}} />
                        )}
                        <div className="gbd2-ep-related-body">
                          {sTopic && (
                            <span className="gbd2-ep-related-topic" style={{color:sHex}}>
                              {sTopic}
                            </span>
                          )}
                          <p className="gbd2-ep-related-title">{displayTitle(s)}</p>
                          <span className="gbd2-ep-related-meta">
                            {getDomain(s.url)}{s.published ? ' · '+relTime(s.published) : ''}
                          </span>
                        </div>
                      </button>
                    );
                  })}
                </div>
              </section>
            )}

            {orgEntries.length > 0 && (
              <div className="gbd2-ep-orgs-section">
                <p className="gbd2-ep-orgs-heading">Organisations responding</p>
                <ul className="gbd2-ep-orgs-list">
                  {orgEntries.map(function(e, i) {
                    return (
                      <li key={i}>
                        <a href={e.url} target="_blank" rel="noopener noreferrer"
                           className="gbd2-ep-orgs-link">
                          {e.name}
                          <span className="gbd2-ep-orgs-arrow" aria-hidden="true">↗</span>
                        </a>
                      </li>
                    );
                  })}
                </ul>
              </div>
            )}

            <p className="gbd2-copyright" style={{padding:'0 0 0 0',marginTop:32}}>
              Summary by Globally · full article on {getDomain(article.url)||'source'}
            </p>
            <div style={{height:80}} />
          </div>{/* /gbd2-ep-main */}

          {/* ── RIGHT RAIL (desktop only) ── */}
          <aside className="gbd2-ep-rail">
            <div className="gbd2-ep-rail-sticky">
              <div className="gbd2-ep-rail-head">
                <span className="gbd2-ep-rail-head-label">Articles</span>
                <span className="gbd2-ep-rail-head-count" style={accentStyle}>{coverageCount}</span>
              </div>
              <div className="gbd2-ep-rail-items">
                {allSourceCards.map(function(s, i) {
                  return (
                    <a key={i} href={s.url} target="_blank" rel="noopener noreferrer"
                       className={'gbd2-ep-rail-item'+(s.isCanonical?' gbd2-ep-rail-item--lead':'')}
                       style={s.isCanonical&&!sober ? {borderLeftColor:topicHex} : {}}>
                      {s.image && (
                        <div className="gbd2-ep-rail-thumb"
                             style={{backgroundImage:"url('"+s.image+"')"}} />
                      )}
                      <div className="gbd2-ep-rail-body">
                        <div className="gbd2-ep-rail-src-row">
                          <img src={'/favicons/'+s.domain+'.png'} alt="" width="11" height="11"
                               style={{borderRadius:2,flexShrink:0}}
                               onError={function(e){e.currentTarget.style.display='none';}} />
                          <span className="gbd2-ep-rail-src-name"
                                style={s.isCanonical&&!sober ? accentStyle : {}}>{s.name||s.domain}</span>
                          {s.label && <span className="gbd2-src-label">{s.label}</span>}
                          {s.time && <span className="gbd2-ep-rail-time">{s.time}</span>}
                        </div>
                        {s.title && <p className="gbd2-ep-rail-title">{s.title}</p>}
                      </div>
                    </a>
                  );
                })}
              </div>
              <div className="gbd2-ep-rail-foot">
                <a href={article.url} target="_blank" rel="noopener noreferrer"
                   className="gbd2-ep-rail-cta"
                   style={sober ? {} : {borderColor:'rgba('+topicRgb+',0.45)',color:topicHex}}>
                  Read original source ↗
                </a>
              </div>

              {relatedOrgs.length > 0 && (
                <div className="gbd2-rail-orgs">
                  <div className="gbd2-rail-orgs-head">
                    <span className="gbd2-rail-orgs-title">Related charities</span>
                  </div>
                  <p className="gbd2-rail-orgs-sub">Charities working on similar issues or regions.</p>
                  <div className="gbd2-rail-orgs-list">
                    {relatedOrgs.map(function(c) {
                      var topicChip = null;
                      if (c.topics && c.topics[0]) {
                        var tl = TAK_TOPICS.find(function(x){ return x.id === c.topics[0]; });
                        topicChip = tl ? tl.label : c.topics[0];
                      }
                      var placeChip = (c.countries && c.countries[0]) ||
                        ((c.regions || []).filter(function(r){ return r !== 'Global'; })[0]) || null;
                      return (
                        <div key={c.id} className="gbd2-rail-org-card">
                          <div className="gbd2-rail-org-name">{c.name}</div>
                          <p className="gbd2-rail-org-rel">{orgRelevanceLine(c)}</p>
                          {(topicChip || placeChip) && (
                            <div className="gbd2-rail-org-chips">
                              {topicChip && <span className="gbd2-rail-org-chip">{topicChip}</span>}
                              {placeChip && <span className="gbd2-rail-org-chip">{placeChip}</span>}
                            </div>
                          )}
                          <div className="gbd2-rail-org-actions">
                            <button className="gbd2-rail-org-btn"
                              onClick={function(){ viewOrg(c); }}>
                              View charity
                            </button>
                            {c.websiteUrl && /^https?:\/\/.+/.test(c.websiteUrl) && (
                              <a href={c.websiteUrl} target="_blank" rel="noopener noreferrer"
                                 className="gbd2-rail-org-btn gbd2-rail-org-btn--ext">
                                Official site ↗
                              </a>
                            )}
                          </div>
                        </div>
                      );
                    })}
                  </div>
                  <p className="gbd2-rail-orgs-note">Globally signposts to public charity information only.</p>
                </div>
              )}
            </div>
          </aside>

        </div>{/* /gbd2-ep-body */}
      </div>{/* /gbd2-ep-scroll */}

      {/* Mobile sticky CTA */}
      <div className="gbd2-ep-mobile-cta">
        <a href={article.url} target="_blank" rel="noopener noreferrer"
           className="gbd2-ep-mobile-cta-btn"
           style={sober
             ? {background:'rgba(255,255,255,0.09)',color:'rgba(255,255,255,0.80)'}
             : {background:topicHex,color:'#fff'}}>
          Read full story ↗
        </a>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────

// Convert hex colour to "r,g,b" string for rgba() usage
function hexRgb(hex) {
  return parseInt(hex.slice(1,3),16)+','+parseInt(hex.slice(3,5),16)+','+parseInt(hex.slice(5,7),16);
}

// Returns [H (0-360), S (0-100), L (0-100)]
function hexToHsl(hex) {
  var r = parseInt(hex.slice(1,3),16)/255;
  var g = parseInt(hex.slice(3,5),16)/255;
  var b = parseInt(hex.slice(5,7),16)/255;
  var max = Math.max(r,g,b), min = Math.min(r,g,b);
  var l = (max+min)/2, h = 0, s = 0;
  if (max !== min) {
    var d = max - min;
    s = l > 0.5 ? d/(2-max-min) : d/(max+min);
    if (max === r) h = ((g-b)/d + (g<b?6:0))/6;
    else if (max === g) h = ((b-r)/d+2)/6;
    else h = ((r-g)/d+4)/6;
  }
  return [Math.round(h*360), Math.round(s*100), Math.round(l*100)];
}

// Converts H (0-360), S (0-100), L (0-100) to "R,G,B" string — companion to hexToHsl.
// Used for building muted/desaturated rgba() values in the tone gate.
function hslToRgbStr(h, s, l) {
  s /= 100; l /= 100;
  var c = (1 - Math.abs(2*l - 1)) * s;
  var x = c * (1 - Math.abs((h/60) % 2 - 1));
  var m = l - c/2;
  var r = 0, g = 0, b = 0;
  if      (h <  60) { r=c; g=x; b=0; }
  else if (h < 120) { r=x; g=c; b=0; }
  else if (h < 180) { r=0; g=c; b=x; }
  else if (h < 240) { r=0; g=x; b=c; }
  else if (h < 300) { r=x; g=0; b=c; }
  else              { r=c; g=0; b=x; }
  return Math.round((r+m)*255)+','+Math.round((g+m)*255)+','+Math.round((b+m)*255);
}

function getArticleTopic(article) {
  // Use cached topic if backfill has already run (set in data-load useEffect)
  if (Object.prototype.hasOwnProperty.call(article, '_topic')) return article._topic || null;
  return classifyArticleTopic(article);
}

// Return topic string for a DYK fact card
function getFactTopic(fact) {
  var topics = fact.topics || [];
  for (var i = 0; i < topics.length; i++) {
    var t = DYK_TOPIC_MAP[topics[i]];
    if (t) return t;
  }
  return null;
}

// Resolve hex for a topic; warn in console when a card can't resolve
function resolveHex(topic, label) {
  var hex = topic ? TOPIC_COLORS[topic] : null;
  if (!hex) console.warn("[Globly] unresolved topic colour — " + (label || "?") + " topic=" + topic);
  return hex || "#7A8A9A";
}

function MixedCard(props) {
  var item = props.item;
  var onReadOpen  = props.onReadOpen;
  var onFactOpen  = props.onFactOpen;
  var onBriefOpen = props.onBriefOpen;
  var type = item.type, data = item.data;

  if (type === "brief") {
    var bTopic = getArticleTopic(data);
    var bHex = resolveHex(bTopic, data.title ? data.title.slice(0,50) : "brief");
    var bC   = hexRgb(bHex);
    return (
      <button className="gt-mixed-card gt-mixed-card--brief"
        onClick={function(){ onBriefOpen(data); }}>
        <span className="gt-card-badge gt-card-badge--brief"
          style={{ background: 'rgba('+bC+',0.32)', color: bHex }}>Brief</span>
        <div className="gt-mixed-img"
          style={{ backgroundImage: data.image_url ? "url('"+data.image_url+"')" : "none" }} />
        <div className="gt-mixed-body"
          style={{ background: 'linear-gradient(to top, rgba('+bC+',0.65) 0%, rgba('+bC+',0.28) 100%), #1C1E24' }}>
          {bTopic && <span className="gt-mixed-region" style={{ color: bHex }}>{bTopic}</span>}
          <p className="gt-mixed-title">{data.title}</p>
        </div>
      </button>
    );
  }

  if (type === "read") {
    var rTopic = EXPLAINER_TOPIC[data.id] || null;
    var rHex = resolveHex(rTopic, "read:"+data.id);
    var rC   = hexRgb(rHex);
    return (
      <button className="gt-mixed-card gt-mixed-card--read"
        style={{ background: 'linear-gradient(145deg, rgba('+rC+',0.50) 0%, rgba('+rC+',0.22) 100%), #1C1E24' }}
        onClick={function(){ onReadOpen(data); }}>
        <span className="gt-card-badge gt-card-badge--read"
          style={{ background: 'rgba('+rC+',0.32)', color: rHex }}>Read</span>
        <ContentIcon iconName={data.icon} size={28} color={rHex} bg={'rgba('+rC+',0.18)'} />
        <p className="gt-mixed-title">{data.title}</p>
        <span className="gt-mixed-meta">{data.steps.length} concepts</span>
      </button>
    );
  }

  if (type === "fact") {
    var fTopic = getFactTopic(data);
    var fHex = resolveHex(fTopic, "fact:"+data.stat);
    var fC = hexRgb(fHex);
    return (
      <button className="gt-mixed-card gt-mixed-card--fact"
        style={{ background: 'linear-gradient(145deg, rgba('+fC+',0.55) 0%, rgba('+fC+',0.22) 100%), #1C1E24' }}
        onClick={function(){ onFactOpen(data); }}>
        <span className="gt-card-badge gt-card-badge--fact"
          style={{ background: 'rgba('+fC+',0.30)', color: fHex }}>Fact</span>
        <div className="gt-mixed-stat">{data.stat}</div>
        <p className="gt-mixed-title">{data.text}</p>
      </button>
    );
  }

  return null;
}

function FactOverlay(props) {
  var fact = props.fact, onClose = props.onClose;
  var fHex = resolveHex(getFactTopic(fact), "fact-overlay:"+fact.stat);
  return (
    <div className="gbd-backdrop" onClick={onClose}>
      <div className="gbd-screen" style={{ background: makeWash(fHex) }} onClick={function(e){ e.stopPropagation(); }}>
        <div className="gbd-topbar gbd-topbar--fact">
          <button className="gbd-icon-btn" onClick={onClose} aria-label="Close">
            <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
              <path d="M14 4L4 14M4 4l10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
            </svg>
          </button>
          <span className="gbd-topbar-label">Fact</span>
          <div style={{ width: 36 }} />
        </div>
        <div className="gbd-scroll gbd-fact-scroll">
          <div className="gbd-fact-inner">
            <ContentIcon iconName={fact.icon || "BarChart3"} size={32} color={fHex} bg={'rgba(255,255,255,0.12)'} />
            <div className="gt-fact-modal-stat">{fact.stat}</div>
            <p className="gbd-fact-text">{fact.text}</p>
          </div>
        </div>
        <div className="gbd-foot gbd-foot--fact">
          <button className="gbd-read-btn" onClick={onClose}>Got it</button>
        </div>
      </div>
    </div>
  );
}

function ExplainerOverlay(props) {
  var ex = props.explainer;
  var onClose = props.onClose;
  var _s = React.useState(0);
  var step = _s[0], setStep = _s[1];
  var total = ex.steps.length;
  var cur = ex.steps[step];
  var isLast = step === total - 1;
  var washBg = makeWash(resolveHex(EXPLAINER_TOPIC[ex.id] || null, "read-overlay:"+ex.id));

  return (
    <div className="gbd-backdrop" onClick={onClose}>
      <div className="gbd-screen" style={{ background: washBg }}
        onClick={function(e){ e.stopPropagation(); }}>
        {/* Top bar */}
        <div className="gbd-topbar gbd-topbar--clear">
          <button className="gbd-icon-btn" onClick={onClose} aria-label="Close">
            <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
              <path d="M14 4L4 14M4 4l10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
            </svg>
          </button>
          <span className="gbd-topbar-label">Read</span>
          <div style={{ width: 36 }} />
        </div>
        {/* Scrollable content */}
        <div className="gbd-scroll gbd-read-scroll">
          <div className="gbd-read-pips">
            {ex.steps.map(function(_, i){
              return <div key={i} className={"gbd-read-pip" + (i <= step ? " gbd-read-pip--on" : "")} />;
            })}
          </div>
          <ContentIcon iconName={ex.icon} size={32} color={resolveHex(EXPLAINER_TOPIC[ex.id] || null, "read-icon:"+ex.id)} bg={'rgba(255,255,255,0.12)'} />
          <h3 className="gbd-read-heading">{cur.heading}</h3>
          <p className="gbd-read-body">{cur.body}</p>
          {isLast && ex.whyItMatters && (
            <div className="gbd-read-why">
              <div className="gbd-read-why-label">Why it matters</div>
              <p className="gbd-read-why-text">{ex.whyItMatters}</p>
            </div>
          )}
        </div>
        {/* Bottom button */}
        <div className="gbd-foot gbd-foot--clear">
          <button className="gbd-read-btn" onClick={function(){
            if (isLast) { setStep(0); onClose(); }
            else { setStep(function(s){ return s + 1; }); }
          }}>
            {isLast ? "Done" : "Next →"}
          </button>
        </div>
      </div>
    </div>
  );
}

// ─── Today-screen card components (td- prefix) ────────────────────────────────

function HeroCard(props) {
  var article = props.article;
  var onOpen  = props.onOpen;
  if (!article) return null;
  var topic    = getArticleTopic(article);
  var hex      = resolveHex(topic, article.title ? article.title.slice(0,50) : "hero");
  var c        = hexRgb(hex);
  var artStyle = article.image_url
    ? { backgroundImage: "url('"+article.image_url+"')" }
    : { background: 'linear-gradient(145deg,rgba('+c+',0.85) 0%,rgba('+c+',0.45) 100%)' };
  return (
    <div className="td-hero-wrap">
      <button className="td-hero-card" style={artStyle} onClick={function(){ onOpen(article); }}>
        <div className="td-hero-scrim" />
        <div className="td-hero-body">
          {topic && <span className="td-hero-kicker" style={{ color: hex }}>{topic}</span>}
          <h2 className="td-hero-title">{article.title}</h2>
          <span className="td-hero-cta">Open brief →</span>
        </div>
      </button>
    </div>
  );
}

function PortraitCard(props) {
  var item        = props.item;
  var onBriefOpen = props.onBriefOpen;
  var onReadOpen  = props.onReadOpen;
  var onFactOpen  = props.onFactOpen;
  var type = item.type, data = item.data;
  if (type === "brief") {
    var bTopic = getArticleTopic(data);
    var bHex   = resolveHex(bTopic, data.title ? data.title.slice(0,50) : "portrait");
    var bC     = hexRgb(bHex);
    var artStyle = data.image_url
      ? { backgroundImage: "url('"+data.image_url+"')" }
      : { background: 'linear-gradient(145deg,rgba('+bC+',0.85) 0%,rgba('+bC+',0.45) 100%)' };
    return (
      <button className="td-portrait-card" onClick={function(){ onBriefOpen(data); }}>
        <div className="td-portrait-art" style={artStyle} />
        <div className="td-portrait-body">
          {bTopic && <span className="td-portrait-kicker" style={{ color: bHex }}>{bTopic}</span>}
          <p className="td-portrait-title">{data.title}</p>
        </div>
      </button>
    );
  }
  if (type === "read") {
    var rTopic = EXPLAINER_TOPIC[data.id] || null;
    var rHex   = resolveHex(rTopic, "portrait-read:"+data.id);
    var rC     = hexRgb(rHex);
    return (
      <button className="td-portrait-card" onClick={function(){ onReadOpen(data); }}>
        <div className="td-portrait-art" style={{ background: 'linear-gradient(145deg,rgba('+rC+',0.85) 0%,rgba('+rC+',0.45) 100%)' }}>
          <ContentIcon iconName={data.icon} size={28} color={rHex} bg={'rgba(255,255,255,0.15)'} />
        </div>
        <div className="td-portrait-body">
          {rTopic && <span className="td-portrait-kicker" style={{ color: rHex }}>{rTopic}</span>}
          <p className="td-portrait-title">{data.title}</p>
        </div>
      </button>
    );
  }
  if (type === "fact") {
    var fTopic = getFactTopic(data);
    var fHex   = resolveHex(fTopic, "portrait-fact:"+data.stat);
    var fC     = hexRgb(fHex);
    return (
      <button className="td-portrait-card" onClick={function(){ onFactOpen(data); }}>
        <div className="td-portrait-art" style={{ background: 'linear-gradient(145deg,rgba('+fC+',0.85) 0%,rgba('+fC+',0.45) 100%)' }}>
          <span className="td-portrait-stat">{data.stat}</span>
        </div>
        <div className="td-portrait-body">
          {fTopic && <span className="td-portrait-kicker" style={{ color: fHex }}>{fTopic}</span>}
          <p className="td-portrait-title">{data.text}</p>
        </div>
      </button>
    );
  }
  return null;
}

function SplitCard(props) {
  var article = props.article;
  var onOpen  = props.onOpen;
  if (!article) return null;
  var topic    = getArticleTopic(article);
  var hex      = resolveHex(topic, article.title ? article.title.slice(0,50) : "split");
  var c        = hexRgb(hex);
  var artStyle = article.image_url
    ? { backgroundImage: "url('"+article.image_url+"')" }
    : { background: 'linear-gradient(145deg,rgba('+c+',0.85) 0%,rgba('+c+',0.45) 100%)' };
  var summary = article.summary || "";
  return (
    <div className="td-split-wrap">
      <button className="td-split-card" onClick={function(){ onOpen(article); }}>
        <div className="td-split-left" style={artStyle} />
        <div className="td-split-right">
          {topic && <span className="td-split-kicker" style={{ color: hex }}>{topic}</span>}
          <p className="td-split-title">{article.title}</p>
          {summary && <p className="td-split-summary">{summary.slice(0,90)}{summary.length > 90 ? '…' : ''}</p>}
        </div>
      </button>
    </div>
  );
}

// ─── Shared icon system ───────────────────────────────────────────────────────

var TOPIC_ICON_MAP = {
  "Climate":  CloudSun,
  "Health":   HeartPulse,
  "Economy":  TrendingUp,
  "Conflict": ShieldAlert,
  "Politics": Landmark,
  "Trade":    Ship,
};

function TopicIcon(props) {
  var topic = props.topic;
  var size  = props.size || 18;
  var color = props.color || (TOPIC_COLORS[topic] || "#7A8A9A");
  var Icon  = TOPIC_ICON_MAP[topic];
  if (!Icon) return null;
  return <Icon size={size} color={color} strokeWidth={2} />;
}

function ContentIcon(props) {
  var iconName = props.iconName;
  var size     = props.size || 20;
  var color    = props.color || "#9AAABB";
  var bg       = props.bg;
  var Icon     = _LR[iconName] || null;
  if (!Icon) return null;
  return (
    <span className="ci-wrap" style={bg ? { background: bg } : undefined}>
      <Icon size={size} color={color} strokeWidth={1.5} />
    </span>
  );
}

function TopicChip(props) {
  var topic   = props.topic;
  var onPress = props.onPress;
  var hex     = TOPIC_COLORS[topic] || "#7A8A9A";
  var c       = hexRgb(hex);
  return (
    <button className="td-chip"
      style={{ background: 'rgba('+c+',0.14)', borderColor: 'rgba('+c+',0.30)' }}
      onClick={onPress}>
      <span className="td-chip-icon"><TopicIcon topic={topic} size={14} /></span>
      <span className="td-chip-label" style={{ color: hex }}>{topic}</span>
    </button>
  );
}

function SquareCard(props) {
  var item        = props.item;
  var onBriefOpen = props.onBriefOpen;
  var onReadOpen  = props.onReadOpen;
  var onFactOpen  = props.onFactOpen;
  var type = item.type, data = item.data;
  if (type === "brief") {
    var bTopic = getArticleTopic(data);
    var bHex   = resolveHex(bTopic, data.title ? data.title.slice(0,50) : "sq");
    var bC     = hexRgb(bHex);
    var artStyle = data.image_url
      ? { backgroundImage: "url('"+data.image_url+"')" }
      : { background: 'linear-gradient(145deg,rgba('+bC+',0.85) 0%,rgba('+bC+',0.45) 100%)' };
    return (
      <button className="td-square-card" onClick={function(){ onBriefOpen(data); }}>
        <div className="td-square-art" style={artStyle} />
        <div className="td-square-body">
          {bTopic && <span className="td-square-kicker" style={{ color: bHex }}>{bTopic}</span>}
          <p className="td-square-title">{data.title}</p>
        </div>
      </button>
    );
  }
  if (type === "read") {
    var rTopic = EXPLAINER_TOPIC[data.id] || null;
    var rHex   = resolveHex(rTopic, "sq-read:"+data.id);
    var rC     = hexRgb(rHex);
    return (
      <button className="td-square-card" onClick={function(){ onReadOpen(data); }}>
        <div className="td-square-art" style={{ background: 'linear-gradient(145deg,rgba('+rC+',0.85) 0%,rgba('+rC+',0.45) 100%)' }}>
          <ContentIcon iconName={data.icon} size={24} color={rHex} bg={'rgba(255,255,255,0.15)'} />
        </div>
        <div className="td-square-body">
          {rTopic && <span className="td-square-kicker" style={{ color: rHex }}>{rTopic}</span>}
          <p className="td-square-title">{data.title}</p>
        </div>
      </button>
    );
  }
  if (type === "fact") {
    var fTopic = getFactTopic(data);
    var fHex   = resolveHex(fTopic, "sq-fact:"+data.stat);
    var fC     = hexRgb(fHex);
    return (
      <button className="td-square-card" onClick={function(){ onFactOpen(data); }}>
        <div className="td-square-art" style={{ background: 'linear-gradient(145deg,rgba('+fC+',0.85) 0%,rgba('+fC+',0.45) 100%)' }}>
          <span className="td-square-stat">{data.stat}</span>
        </div>
        <div className="td-square-body">
          {fTopic && <span className="td-square-kicker" style={{ color: fHex }}>{fTopic}</span>}
          <p className="td-square-title">{data.text}</p>
        </div>
      </button>
    );
  }
  return null;
}

function ListRow(props) {
  var item        = props.item;
  var onBriefOpen = props.onBriefOpen;
  var onReadOpen  = props.onReadOpen;
  var onFactOpen  = props.onFactOpen;
  var type = item.type, data = item.data;
  if (type === "brief") {
    var bTopic = getArticleTopic(data);
    var bHex   = resolveHex(bTopic, data.title ? data.title.slice(0,50) : "lr");
    var bC     = hexRgb(bHex);
    var artStyle = data.image_url
      ? { backgroundImage: "url('"+data.image_url+"')" }
      : { background: 'linear-gradient(145deg,rgba('+bC+',0.80) 0%,rgba('+bC+',0.50) 100%)' };
    return (
      <button className="td-list-row" onClick={function(){ onBriefOpen(data); }}>
        <div className="td-list-thumb" style={artStyle} />
        <div className="td-list-body">
          {bTopic && <span className="td-list-kicker" style={{ color: bHex }}>{bTopic}</span>}
          <p className="td-list-title">{data.title}</p>
        </div>
      </button>
    );
  }
  if (type === "read") {
    var rTopic = EXPLAINER_TOPIC[data.id] || null;
    var rHex   = resolveHex(rTopic, "lr-read:"+data.id);
    var rC     = hexRgb(rHex);
    return (
      <button className="td-list-row" onClick={function(){ onReadOpen(data); }}>
        <div className="td-list-thumb"
          style={{ background: 'linear-gradient(145deg,rgba('+rC+',0.80) 0%,rgba('+rC+',0.50) 100%)',
                   display:'flex', alignItems:'center', justifyContent:'center' }}>
          <ContentIcon iconName={data.icon} size={20} color={rHex} bg={'rgba(255,255,255,0.15)'} />
        </div>
        <div className="td-list-body">
          {rTopic && <span className="td-list-kicker" style={{ color: rHex }}>{rTopic}</span>}
          <p className="td-list-title">{data.title}</p>
        </div>
      </button>
    );
  }
  if (type === "fact") {
    var fTopic = getFactTopic(data);
    var fHex   = resolveHex(fTopic, "lr-fact:"+data.stat);
    var fC     = hexRgb(fHex);
    return (
      <button className="td-list-row" onClick={function(){ onFactOpen(data); }}>
        <div className="td-list-thumb"
          style={{ background: 'linear-gradient(145deg,rgba('+fC+',0.80) 0%,rgba('+fC+',0.50) 100%)',
                   display:'flex', alignItems:'center', justifyContent:'center' }}>
          <span style={{ fontSize: 14, fontWeight: 800, color: '#fff' }}>{data.stat}</span>
        </div>
        <div className="td-list-body">
          {fTopic && <span className="td-list-kicker" style={{ color: fHex }}>{fTopic}</span>}
          <p className="td-list-title">{data.text}</p>
        </div>
      </button>
    );
  }
  return null;
}

function QuizCard(props) {
  var onNav = props.onNav;
  return (
    <button className="td-quiz-card" onClick={function(){ onNav("play"); }}>
      <div className="td-quiz-left">
        <span className="td-quiz-icon">{Crosshair ? <Crosshair size={20} strokeWidth={1.8} /> : null}</span>
        <div className="td-quiz-text">
          <span className="td-quiz-label">Today's Quiz</span>
          <span className="td-quiz-meta">5 questions · ~2 min</span>
        </div>
      </div>
      <span className="td-quiz-cta">Play now →</span>
    </button>
  );
}

var STAT_TILE_TOPICS = ["Economy","Climate","Health","Conflict","Politics","Trade"];

function StatTile(props) {
  var fact   = props.fact;
  var index  = props.index;
  var onOpen = props.onOpen;
  var topic  = STAT_TILE_TOPICS[index % STAT_TILE_TOPICS.length];
  var hex    = TOPIC_COLORS[topic];
  var c      = hexRgb(hex);
  return (
    <button className="td-stat-tile"
      style={{ background: 'linear-gradient(145deg,rgba('+c+',0.80) 0%,rgba('+c+',0.55) 100%)' }}
      onClick={function(){ onOpen(fact); }}>
      <span className="td-stat-number">{fact.stat}</span>
      <p className="td-stat-caption">{fact.text}</p>
    </button>
  );
}

// ─── Topic feed screen ────────────────────────────────────────────────────────

function TopicFeedScreen(props) {
  var articles    = props.articles;
  var topic       = props.topic;
  var onBack      = props.onBack;
  var onOpenStory = props.onOpenStory;

  var hex   = TOPIC_COLORS[topic] || "#7A8A9A";
  var rgb   = hexRgb(hex);
  var theme = PAGE_THEMES[topic] || {};

  // Tight primary filter: must contain topic-specific keywords
  var primaryStories = React.useMemo(function() {
    var pref    = loadPref(lsGet(LS.SETUP, null));
    var seenMap = loadSeen();
    var kws = TOPIC_PRIMARY_KW[topic] || [];
    var filtered = articles.filter(function(a) {
      if (a._clusterHidden) return false;
      var text = ((a.title || '') + ' ' + (a.summary || '')).toLowerCase();
      return kws.some(function(kw) { return text.indexOf(kw) !== -1; });
    });
    return sliceRanked(scoreAndRank(filtered, pref, seenMap, 0.25), 40);
  }, [articles.length, topic]);

  var relatedStories = React.useMemo(function() {
    var pref    = loadPref(lsGet(LS.SETUP, null));
    var seenMap = loadSeen();
    var primarySet = {};
    primaryStories.forEach(function(a) { primarySet[a.url] = true; });
    var filtered = articles.filter(function(a) {
      return !a._clusterHidden && !primarySet[a.url] && getArticleTopic(a) === topic;
    });
    return sliceRanked(scoreAndRank(filtered, pref, seenMap, 0.25), 8);
  }, [articles.length, topic, primaryStories.length]);

  function openCard(a) { markSeen(a.url); recordSignal(a, 'open'); if (onOpenStory) onOpenStory(a); }

  var bgStyle = {
    background:
      'radial-gradient(ellipse 110% 55% at 18% 100%, rgba(' + rgb + ',0.08) 0%, rgba(235,231,216,0) 58%),' +
      'radial-gradient(ellipse 75% 42% at 88% 96%, rgba(' + rgb + ',0.05) 0%, rgba(235,231,216,0) 54%),' +
      '#EBE7D8'
  };

  var totalCount = primaryStories.length + relatedStories.length;

  return (
    <div className="gl-topic-feed" style={bgStyle}>
      <div className="gl-topic-feed-header">
        <button className="gl-back-btn" onClick={onBack}>← Back</button>
        <h1 className="gl-topic-feed-title gl-topic-gradient-title"
          style={{ '--topic-hex': hex, '--topic-rgb': rgb }}>{topic}</h1>
        {theme.subtitle && <p className="gl-topic-feed-subtitle">{theme.subtitle}</p>}
        <p className="gl-topic-feed-meta">{totalCount} stories</p>
      </div>

      {totalCount === 0 && (
        <div className="gl-topic-feed-grid">
          <p className="gl-topic-feed-empty">No stories for this topic yet.</p>
        </div>
      )}

      {primaryStories.length > 0 && (
        <div className="gl-topic-feed-grid">
          {primaryStories.map(function(a, i) {
            return (
              <TodayStoryCard
                key={a.id || a.url || i}
                article={a}
                size={variedCardSize(i)}
                onOpen={openCard}
              />
            );
          })}
        </div>
      )}

      {relatedStories.length > 0 && (
        <div className="gl-topic-related">
          <div className="gl-topic-related-hd">
            <div className="gl-topic-related-line" style={{ background: hex }} />
            <span className="gl-topic-related-label">Related global stories</span>
          </div>
          <div className="gl-topic-feed-grid">
            {relatedStories.map(function(a, i) {
              return (
                <TodayStoryCard
                  key={a.id || a.url || i}
                  article={a}
                  size={i === 0 ? 'large' : 'medium'}
                  onOpen={openCard}
                />
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

// ─── Topic section row (home page) ────────────────────────────────────────────

function TopicSectionRow(props) {
  var topic     = props.topic;
  var articles  = props.articles;
  var onOpen    = props.onOpen;
  var onTopicNav = props.onTopicNav;

  if (!articles || articles.length === 0) return null;

  var hex = TOPIC_COLORS[topic] || "#7A8A9A";

  return (
    <div className="gl-topic-row">
      <div className="gl-topic-row-hd">
        <h3 className="gl-topic-row-title" style={{ color: hex }}>{topic}</h3>
        <button className="gl-topic-row-more" onClick={function(){ onTopicNav(topic); }}>
          See all
        </button>
      </div>
      <div className="gl-topic-row-cards">
        {articles.slice(0, 3).map(function(a, i) {
          return (
            <TodayStoryCard key={a.id || i} article={a} size="small" onOpen={onOpen} />
          );
        })}
      </div>
    </div>
  );
}

// Card size rhythm for varied grids: large (full-row) at positions 0 and 7,
// small at even positions in between, medium elsewhere.
var VARIED_CARD_SIZES = ['large','small','small','large','medium','small','medium','large','small','medium','small','large','medium','small'];
function variedCardSize(i) { return VARIED_CARD_SIZES[i % VARIED_CARD_SIZES.length]; }

// ─── Today screen ─────────────────────────────────────────────────────────────

// ── TopicRail ─────────────────────────────────────────────────────────────────
// Desktop: single cream panel; all items (incl. "Today's News") are equal rows.
// Mobile: horizontal scrollable strip (base CSS handles layout).
function TopicRail(props) {
  var items       = props.items;
  var active      = props.active;
  var onSelect    = props.onSelect;
  var onFooterNav = props.onFooterNav;

  return (
    <div className="gl-rail-outer">
      <nav className="gl-topic-nav-panel" aria-label="Browse topics">
        <div className="gl-topic-nav-list">
          {items.map(function(item) {
            var isOn = active === item.id;
            return (
              <button
                key={item.id}
                className={'gl-topic-nav-item' + (isOn ? ' is-active' : '')}
                onClick={function() { onSelect(item.id); }}
                aria-current={isOn ? 'page' : undefined}
              >{item.label}</button>
            );
          })}
        </div>
        {onFooterNav && (
          <GloblyFooterLinks onNav={onFooterNav} className="gl-footer-links--rail" />
        )}
      </nav>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════════════════
//  PHASE 1 — DESIGN SYSTEM COMPONENTS
//  TodayHero · LearningBlocks · TodayStoryCard · TodayDailyCards · TodayTopics
// ═══════════════════════════════════════════════════════════════════════════════

// ── TodayHero ─────────────────────────────────────────────────────────────────
function TodayHero(props) {
  var article = props.article;
  var onOpen  = props.onOpen;

  if (!article) {
    return (
      <div className="gt2-hero-skeleton">
        <div className="gt2-skeleton-shimmer" />
      </div>
    );
  }

  var topic    = getArticleTopic(article);
  var hex      = topic ? (TOPIC_COLORS[topic] || '#7A8A9A') : '#7A8A9A';
  var rgb      = hexRgb(hex);
  var sober    = isGraveStory(article) || !!GRAVE_TOPIC_NAMES[topic];
  var heroSources = (article.sources && article.sources.length > 0) ? article.sources : null;
  var srcCount = article._clusterSize || (heroSources ? heroSources.length + 1 : 1);
  var timeAgo  = relTime(article.published);

  var heroResolvedImg = resolveArticleImage(article);
  var heroHasImg = !!heroResolvedImg;
  var cardStyle = {};
  if (heroHasImg) {
    cardStyle.backgroundImage = 'url(' + heroResolvedImg + ')';
  } else if (sober) {
    cardStyle.background = 'linear-gradient(135deg, #F0EBD8 0%, #E6E0CB 100%)';
  } else {
    cardStyle.background = 'linear-gradient(135deg, rgba(' + rgb + ',0.72) 0%, rgba(' + rgb + ',0.28) 55%, #111218 100%)';
  }

  var overlayBg = (sober && !heroHasImg)
    ? 'transparent'
    : sober
      ? 'linear-gradient(to bottom, rgba(0,0,0,0.20) 0%, rgba(0,0,0,0.55) 40%, rgba(0,0,0,0.90) 100%)'
      : 'linear-gradient(to bottom, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.08) 30%, rgba(0,0,0,0.68) 65%, rgba(0,0,0,0.90) 100%)';

  var pillStyle = (sober && !heroHasImg)
    ? { color: hex, background: 'rgba(' + rgb + ',0.12)', borderColor: 'rgba(' + rgb + ',0.28)' }
    : sober
      ? { color: 'rgba(255,255,255,0.65)', background: 'rgba(0,0,0,0.35)', borderColor: 'rgba(255,255,255,0.12)' }
      : { color: '#fff', background: hex, borderColor: 'rgba(0,0,0,0.18)', boxShadow: '0 0 12px rgba(' + rgb + ',0.60)' };

  var ctaStyle = (sober && !heroHasImg)
    ? { background: 'rgba(17,17,15,0.07)', borderColor: 'rgba(17,17,15,0.16)', color: 'rgba(17,17,15,0.75)' }
    : sober
      ? {}
      : { background: 'rgba(' + rgb + ',0.28)', borderColor: 'rgba(' + rgb + ',0.55)', color: hex };

  function handleClick() { if (onOpen) onOpen(article); }

  return (
    <div className={'gt2-hero' + (sober ? ' gt2-hero--sober' : '') + (!heroHasImg ? ' gt2-hero--noimg' : '')}
         style={cardStyle}
         onClick={handleClick}
         role="button" tabIndex={0}
         aria-label={'Read brief: ' + article.title}>
      <div className="gt2-hero-overlay" style={{ background: overlayBg }} />
      <div className="gt2-hero-body">
        <div className="gt2-hero-eyebrow">Today's Brief</div>
        <div className="gt2-hero-pills">
          {topic && (
            <span className="gt2-pill" style={pillStyle}>{topic}</span>
          )}
          {article.country && article.country !== topic && (
            <span className="gt2-pill gt2-pill--region">{article.country}</span>
          )}
        </div>
        {srcCount > 1 && (
          <div className="gt2-hero-src-count" style={(sober && !heroHasImg)
            ? { color: 'var(--g-meta)' }
            : sober
              ? { color: 'rgba(255,255,255,0.45)' }
              : { color: hex }}>
            {srcCount} SOURCES
          </div>
        )}
        <h2 className="gt2-hero-title">{displayTitle(article)}</h2>
        <div className="gt2-hero-meta">
          {timeAgo && <span>{timeAgo}</span>}
        </div>
        <button
          className={'gt2-hero-cta' + (sober ? ' gt2-hero-cta--sober' : '')}
          style={sober ? {} : ctaStyle}
          onClick={function(e){ e.stopPropagation(); handleClick(); }}
          aria-label="Read in 30 seconds">
          Read in 30 seconds →
        </button>
      </div>
    </div>
  );
}

// ── TopicHeroCard ─────────────────────────────────────────────────────────────
// Dedicated hero for topic edition pages. Uses thc- class prefix exclusively
// so no gt2-hero-* rules from styles.css can bleed in and duplicate text paint.
// Renders each text element exactly once — no shared card component, no fallback.
function TopicHeroCard(props) {
  var article = props.article;
  var topic   = props.topic;
  var isGrave = props.isGrave;
  var onOpen  = props.onOpen;

  if (!article) return null;

  var hex  = TOPIC_COLORS[topic] || '#7A8A9A';
  var rgb  = hexRgb(hex);
  var sober = isGrave || isGraveStory(article) || !!GRAVE_TOPIC_NAMES[getArticleTopic(article)];
  var timeAgo = relTime(article.published);
  var resolvedImg = resolveArticleImage(article);
  var hasImg  = !!resolvedImg;
  var country = (article.country && article.country !== topic) ? article.country : null;

  var bgStyle = {};
  if (hasImg) {
    bgStyle.backgroundImage = 'url(' + resolvedImg + ')';
  } else if (sober) {
    bgStyle.background = 'linear-gradient(135deg, #F0EBD8 0%, #E6E0CB 100%)';
  } else {
    bgStyle.background = 'linear-gradient(135deg, rgba(' + rgb + ',0.72) 0%, rgba(' + rgb + ',0.28) 55%, #111218 100%)';
  }

  var overlayBg = (sober && !hasImg)
    ? 'transparent'
    : sober
      ? 'linear-gradient(to bottom, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.55) 40%, rgba(0,0,0,0.92) 100%)'
      : 'linear-gradient(to bottom, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.10) 35%, rgba(0,0,0,0.72) 68%, rgba(0,0,0,0.92) 100%)';

  var pillStyle = (sober && !hasImg)
    ? { color: hex, background: 'rgba(' + rgb + ',0.12)', borderColor: 'rgba(' + rgb + ',0.28)' }
    : sober
      ? { color: 'rgba(255,255,255,0.65)', background: 'rgba(0,0,0,0.38)', borderColor: 'rgba(255,255,255,0.14)' }
      : { color: '#fff', background: hex, borderColor: 'rgba(0,0,0,0.18)', boxShadow: '0 0 14px rgba(' + rgb + ',0.55)' };

  var ctaAccent = (sober && !hasImg)
    ? { background: 'rgba(17,17,15,0.07)', borderColor: 'rgba(17,17,15,0.16)', color: 'rgba(17,17,15,0.75)' }
    : sober
      ? {}
      : { borderColor: 'rgba(' + rgb + ',0.55)', color: hex };

  function handleClick() { if (onOpen) onOpen(article); }

  return (
    <div className={'thc-card' + (sober ? ' thc-card--sober' : '') + (!hasImg ? ' thc-card--noimg' : '')}
         style={bgStyle}
         onClick={handleClick}
         role="button" tabIndex={0}
         aria-label={article.title}>
      <div className="thc-overlay" style={{ background: overlayBg }} />
      <div className="thc-body">
        {topic && <span className="thc-pill" style={pillStyle}>{topic}</span>}
        <h2 className="thc-title">{displayTitle(article)}</h2>
        <div className="thc-meta">
          {country && <span className="thc-location">{country}</span>}
          {country && timeAgo && <span className="thc-dot">·</span>}
          {timeAgo && <span className="thc-time">{timeAgo}</span>}
        </div>
        <button
          className="thc-cta"
          style={ctaAccent}
          onClick={function(e){ e.stopPropagation(); handleClick(); }}>
          Read brief →
        </button>
      </div>
    </div>
  );
}

// ── LearningBlocks ────────────────────────────────────────────────────────────
// Only renders when article.storyModes exists (only ~6% of articles have this)
function LearningBlocks(props) {
  var article = props.article;
  var modes   = article && article.storyModes;
  if (!modes) return null;

  var sober = isGraveStory(article);
  var topic = getArticleTopic(article);
  var hex   = topic ? (TOPIC_COLORS[topic] || '#7A8A9A') : '#7A8A9A';
  var rgb   = hexRgb(hex);
  var accentStyle = sober
    ? { background: 'rgba(255,255,255,0.25)' }
    : { background: hex };

  return (
    <div className="gt2-learn-section">
      <div className="gt2-section-hd">
        <h2 className="gt2-section-title">Understand this story</h2>
      </div>
      <div className="gt2-learn-grid">
        {modes.explain && (
          <div className={'gt2-learn-block' + (sober ? ' gt2-learn-block--sober' : '')}>
            <div className="gt2-learn-accent" style={accentStyle} />
            <div className="gt2-learn-label">What happened</div>
            <p className="gt2-learn-text">{modes.explain}</p>
          </div>
        )}
        {modes.matters && (
          <div className={'gt2-learn-block' + (sober ? ' gt2-learn-block--sober' : '')}>
            <div className="gt2-learn-accent" style={accentStyle} />
            <div className="gt2-learn-label">Why it matters</div>
            <p className="gt2-learn-text">{modes.matters}</p>
          </div>
        )}
        {modes.facts && modes.facts.length > 0 && (
          <div className={'gt2-learn-block gt2-learn-block--facts' + (sober ? ' gt2-learn-block--sober' : '')}>
            <div className="gt2-learn-accent" style={accentStyle} />
            <div className="gt2-learn-label">Key facts</div>
            <ul className="gt2-learn-facts">
              {modes.facts.slice(0, 4).map(function(f, i) {
                return <li key={i}>{f}</li>;
              })}
            </ul>
          </div>
        )}
      </div>
    </div>
  );
}

// ── TodayStoryCard ────────────────────────────────────────────────────────────
// size: 'large' | 'medium' | 'small'  — full-bleed: image IS the card background
function TodayStoryCard(props) {
  var article = props.article;
  var size    = props.size || 'medium';
  var onOpen  = props.onOpen;

  if (!article) return null;

  var topic   = getArticleTopic(article);
  var hex     = topic ? (TOPIC_COLORS[topic] || '#7A8A9A') : '#7A8A9A';
  var rgb     = hexRgb(hex);
  var sober   = isGraveStory(article) || !!GRAVE_TOPIC_NAMES[topic];
  var timeAgo = relTime(article.published);
  var resolvedImg = resolveArticleImage(article);
  var hasImg  = !!resolvedImg;
  var cardSources = (article.sources && article.sources.length > 0) ? article.sources : null;
  var srcCount = article._clusterSize || (cardSources ? cardSources.length + 1 : 1);

  // Card background: image URL, cream (sober no-image), or vivid topic gradient (non-sober no-image)
  var cardStyle = hasImg
    ? { backgroundImage: "url('" + resolvedImg.replace(/'/g, '%27') + "')" }
    : { background: sober
        ? 'linear-gradient(145deg, #F0EBD8 0%, #E6E0CB 100%)'
        : 'linear-gradient(145deg, rgba(' + rgb + ',1.0) 0%, rgba(' + rgb + ',0.62) 40%, #0e0e10 100%)' };

  // Scrim — transparent on cream sober cards, dark on images and vivid gradients
  var overlayStyle = { background: (sober && !hasImg)
    ? 'transparent'
    : sober
      ? 'linear-gradient(to bottom, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.55) 42%, rgba(0,0,0,0.92) 100%)'
      : 'linear-gradient(to bottom, rgba(0,0,0,0.02) 0%, rgba(0,0,0,0.20) 48%, rgba(0,0,0,0.88) 100%)' };

  // Topic-colour glow — none on cream sober cards, muted for grave image cards
  var glowStyle = topic ? { background: (sober && !hasImg)
    ? 'transparent'
    : sober
      ? 'linear-gradient(to top, rgba(' + rgb + ',0.10) 0%, transparent 55%)'
      : 'linear-gradient(to top, rgba(' + rgb + ',0.72) 0%, rgba(' + rgb + ',0.32) 44%, transparent 100%)'
  } : null;

  var pillStyle = (sober && !hasImg)
    ? { color: hex, background: 'rgba(' + rgb + ',0.12)', borderColor: 'rgba(' + rgb + ',0.28)' }
    : sober
      ? { color: 'rgba(255,255,255,0.50)', background: 'rgba(0,0,0,0.45)', borderColor: 'rgba(255,255,255,0.10)' }
      : { color: '#fff', background: hex, borderColor: 'rgba(0,0,0,0.18)', boxShadow: '0 0 10px rgba(' + rgb + ',0.55)' };

  var edgeStyle = (sober && !hasImg)
    ? { background: 'rgba(' + rgb + ',0.38)', boxShadow: '2px 0 8px rgba(' + rgb + ',0.18)' }
    : sober
      ? { background: 'rgba(255,255,255,0.14)' }
      : { background: hex, boxShadow: '2px 0 10px rgba(' + rgb + ',0.60)' };

  // Key stat: first real number/percentage found in storyModes.facts (non-sober only)
  var keyStatText = null;
  if (!sober && article.storyModes && article.storyModes.facts) {
    var facts = article.storyModes.facts;
    for (var fi = 0; fi < facts.length; fi++) {
      var match = facts[fi].match(/(\d[\d,]*(?:\.\d+)?\s*(?:%|million|billion|thousand|km|mph|tonnes)?)/i);
      if (match) { keyStatText = match[1].trim(); break; }
    }
  }

  // Why it matters: first sentence from storyModes.matters, capped at 110 chars
  var mattersText = null;
  if (article.storyModes && article.storyModes.matters) {
    var m = article.storyModes.matters;
    var dot = m.indexOf('. ');
    var first = dot > 0 ? m.slice(0, dot + 1) : m;
    mattersText = first.length > 110 ? first.slice(0, 107) + '…' : first;
  }

  function handleClick() { if (onOpen) onOpen(article); }

  return (
    <div className={'gt2-scard gt2-scard--' + size + (sober ? ' gt2-scard--sober' : '') + (!hasImg ? ' gt2-scard--noimg' : '')}
         style={cardStyle}
         onClick={handleClick}
         role="button" tabIndex={0}
         aria-label={article.title}>
      <div className="gt2-scard-overlay" style={overlayStyle} />
      {glowStyle && <div className="gt2-scard-topic-glow" style={glowStyle} />}
      {topic && (
        <div className="gt2-scard-edge" style={edgeStyle} />
      )}
      {topic && (
        <span className="gt2-scard-pill" style={pillStyle}>{topic}</span>
      )}
      <div className="gt2-scard-body">
        {keyStatText && (
          <div className="gt2-scard-keytag" style={{ color: hex }}>{keyStatText}</div>
        )}
        {srcCount > 1 && (
          <div className="gt2-scard-sources" style={(sober && !hasImg)
            ? { color: 'var(--g-meta)' }
            : sober
              ? { color: 'rgba(255,255,255,0.38)' }
              : { color: hex }}>
            {srcCount} SOURCES
          </div>
        )}
        <h3 className="gt2-scard-title">{displayTitle(article)}</h3>
        {mattersText && size !== 'small' && (
          <p className="gt2-scard-matters">{mattersText}</p>
        )}
        <div className="gt2-scard-meta">
          {article.country && <span className="gt2-scard-country">{article.country}</span>}
          {timeAgo && article.country && <span className="gt2-meta-dot">·</span>}
          {timeAgo && <span className="gt2-scard-time">{timeAgo}</span>}
        </div>
      </div>
    </div>
  );
}

// ── EventClusterCard (Part 12) ────────────────────────────────────────────────
function EventClusterCard(props) {
  var cluster = props.cluster;
  var size    = props.size || 'medium';
  var onOpen  = props.onOpen;
  if (!cluster) return null;

  var hex    = cluster.primaryTopic ? (TOPIC_COLORS[cluster.primaryTopic] || '#7A8A9A') : '#7A8A9A';
  var rgb    = hexRgb(hex);
  var hasImg = !!cluster.leadImageUrl && !isDocumentImage(cluster.leadImageUrl);
  var timeAgo = cluster.lastUpdatedAt ? relTime(cluster.lastUpdatedAt) : null;

  var cardStyle = hasImg
    ? { backgroundImage: "url('" + (cluster.leadImageUrl || '').replace(/'/g, '%27') + "')" }
    : { background: 'linear-gradient(145deg, rgba(' + rgb + ',1.0) 0%, rgba(' + rgb + ',0.62) 40%, #0e0e10 100%)' };
  var overlayStyle = { background: hasImg
    ? 'linear-gradient(to bottom, rgba(0,0,0,0.04) 0%, rgba(0,0,0,0.28) 44%, rgba(0,0,0,0.92) 100%)'
    : 'linear-gradient(to bottom, rgba(0,0,0,0.02) 0%, rgba(0,0,0,0.20) 48%, rgba(0,0,0,0.88) 100%)' };
  var glowStyle  = { background: 'linear-gradient(to top, rgba(' + rgb + ',0.72) 0%, rgba(' + rgb + ',0.28) 44%, transparent 100%)' };
  var pillStyle  = { color: '#fff', background: hex, borderColor: 'rgba(0,0,0,0.18)', boxShadow: '0 0 10px rgba(' + rgb + ',0.55)' };
  var edgeStyle  = { background: hex, boxShadow: '2px 0 10px rgba(' + rgb + ',0.60)' };
  var preview    = (cluster.miraThirtySecondSummary || '').slice(0, 100);
  if (preview.length === 100 && (cluster.miraThirtySecondSummary || '').length > 100) preview += '…';

  function handleClick() {
    if (onOpen) onOpen(cluster);
    else window.location.hash = '#/event/' + encodeURIComponent(cluster.slug);
  }

  return (
    <div className={'gt2-scard gt2-scard--' + size + ' gt2-scard--cluster'}
         style={cardStyle}
         onClick={handleClick}
         role="button" tabIndex={0}
         aria-label={cluster.eventTitle}>
      <div className="gt2-scard-overlay" style={overlayStyle} />
      <div className="gt2-scard-topic-glow" style={glowStyle} />
      <div className="gt2-scard-edge" style={edgeStyle} />
      <span className="gt2-scard-pill" style={pillStyle}>{cluster.primaryTopic || 'News'}</span>
      <div className="gt2-scard-body">
        <div className="gt2-scard-cluster-badge">
          <span className="gt2-scard-cluster-count">{cluster.sourceCount} sources</span>
          <span className="gt2-scard-cluster-dot">·</span>
          <span className="gt2-scard-cluster-label">Mira briefed</span>
        </div>
        <h3 className="gt2-scard-title">{cluster.eventTitle}</h3>
        {preview && size !== 'small' && (
          <p className="gt2-scard-matters">{preview}</p>
        )}
        <div className="gt2-scard-meta">
          {cluster.primaryCountry && <span className="gt2-scard-country">{cluster.primaryCountry}</span>}
          {timeAgo && cluster.primaryCountry && <span className="gt2-meta-dot">·</span>}
          {timeAgo && <span className="gt2-scard-time">{timeAgo}</span>}
        </div>
      </div>
    </div>
  );
}

// ── TodayQuizCard ─────────────────────────────────────────────────────────────
function TodayQuizCard(props) {
  var onNav     = props.onNav;
  var questions = (typeof GLOBLY_DATA !== 'undefined') ? (GLOBLY_DATA.questions || []) : [];
  var preview   = questions.length > 0 ? questions[Math.abs(dailySeed()) % questions.length] : null;

  return (
    <div className="gt2-daily-card gt2-daily-card--quiz"
         onClick={function(){ if (onNav) onNav('play'); }}
         role="button" tabIndex={0}
         aria-label="Go to daily quiz">
      <div className="gt2-daily-card-eyebrow">Daily Quiz</div>
      {preview && <p className="gt2-daily-card-q">&ldquo;{preview.q}&rdquo;</p>}
      <div className="gt2-daily-card-cta">Test Yourself →</div>
    </div>
  );
}

// ── TodayFactCard ─────────────────────────────────────────────────────────────
function TodayFactCard(props) {
  var dyk  = (typeof GLOBLY_DYK !== 'undefined') ? GLOBLY_DYK : [];
  var seed = dailySeed();
  var fact = dyk.length > 0 ? dyk[(Math.abs(seed) + 7) % dyk.length] : null;

  if (!fact) return null;

  var topicKey = fact.topics && fact.topics[0] ? fact.topics[0] : null;
  var topic    = topicKey ? (DYK_TOPIC_MAP[topicKey] || null) : null;
  var hex      = topic ? (TOPIC_COLORS[topic] || '#7A8A9A') : '#7A8A9A';
  var rgb      = hexRgb(hex);

  return (
    <div className="gt2-daily-card gt2-daily-card--fact"
         style={{ '--gt2-fact-rgb': rgb, '--gt2-fact-hex': hex }}>
      <div className="gt2-daily-card-eyebrow">Global Fact</div>
      {fact.stat && <div className="gt2-daily-fact-stat">{fact.stat}</div>}
      <p className="gt2-daily-fact-text">{fact.text}</p>
    </div>
  );
}

// Generates 4 personalised suggested questions from user setup + today's corpus.
// Falls back gracefully when no personalisation data is available.
function buildMiraSuggestions(setup, articles) {
  var userTopics  = (setup && setup.topics)  || [];
  var userRegions = (setup && setup.regions) || [];

  // Count visible corpus countries
  var countryCounts = {};
  (articles || []).forEach(function(a) {
    if (!a._clusterHidden && a.country) {
      countryCounts[a.country] = (countryCounts[a.country] || 0) + 1;
    }
  });
  var sortedCountries = Object.keys(countryCounts).sort(function(a, b) {
    return countryCounts[b] - countryCounts[a];
  });

  // Filter to countries that fall in the user's followed regions
  var inRegion = userRegions.length > 0
    ? sortedCountries.filter(function(c) {
        return userRegions.some(function(r) {
          return REGION_COUNTRIES[r] && REGION_COUNTRIES[r].indexOf(c) !== -1;
        });
      })
    : [];
  var topCountries = (inRegion.length >= 2 ? inRegion : sortedCountries).slice(0, 3);

  var sugs = [];
  if (topCountries[0]) sugs.push('What is happening in ' + topCountries[0] + '?');
  if (userTopics[0])   sugs.push('What are the latest ' + userTopics[0].toLowerCase() + ' developments?');
  if (topCountries[1]) sugs.push("What's the situation in " + topCountries[1] + '?');
  if (userTopics[1])   sugs.push('Explain the ' + userTopics[1].toLowerCase() + ' stories this week');

  // Pad to 4 with corpus-informed fallbacks
  var fallbacks = [
    'What are the biggest stories right now?',
    'What changed in climate reporting?',
    'Explain the food security situation',
    'What are the economic trends this week?',
  ];
  for (var fi = 0; sugs.length < 4 && fi < fallbacks.length; fi++) {
    if (sugs.indexOf(fallbacks[fi]) === -1) sugs.push(fallbacks[fi]);
  }
  return sugs.slice(0, 4);
}

// ── Global helper: parse Mira answer text into structured JSX blocks ──────────
function renderMiraAnswerBlocks(text) {
  if (!text) return null;
  var lines = text.split('\n');
  var blocks = [];
  var bulletGroup = null;
  var numberedGroup = null;
  function flushBullets() {
    if (bulletGroup && bulletGroup.length > 0) { blocks.push({ type: 'bullets', items: bulletGroup }); bulletGroup = null; }
  }
  function flushNumbered() {
    if (numberedGroup && numberedGroup.length > 0) { blocks.push({ type: 'numbered', items: numberedGroup }); numberedGroup = null; }
  }
  lines.forEach(function(raw) {
    var line = raw.trim();
    if (!line) { flushBullets(); flushNumbered(); return; }
    var numMatch = line.match(/^(\d+)\.\s+(.+)$/);
    if (numMatch) { flushBullets(); if (!numberedGroup) numberedGroup = []; numberedGroup.push(numMatch[2]); return; }
    // Both "• text" and "- text" and "– text" are bullet signals
    var bulletMatch = line.match(/^[•\-–]\s+(.+)$/);
    if (bulletMatch) { flushNumbered(); if (!bulletGroup) bulletGroup = []; bulletGroup.push(bulletMatch[1]); return; }
    flushBullets(); flushNumbered();
    blocks.push({ type: 'para', text: line });
  });
  flushBullets(); flushNumbered();
  return blocks.map(function(block, i) {
    if (block.type === 'para') return <p key={i} className="gl-mira-answer-para">{block.text}</p>;
    if (block.type === 'bullets') return (
      <ul key={i} className="gl-mira-answer-list">
        {block.items.map(function(item, j){ return <li key={j} className="gl-mira-answer-item">{item}</li>; })}
      </ul>
    );
    if (block.type === 'numbered') return (
      <ol key={i} className="gl-mira-answer-list gl-mira-answer-list--numbered">
        {block.items.map(function(item, j){ return <li key={j} className="gl-mira-answer-item">{item}</li>; })}
      </ol>
    );
    return null;
  });
}

// ── GloballySearch overlay ────────────────────────────────────────────────────
function GloballySearch(props) {
  var articles     = props.articles || [];
  var setup        = props.setup   || null;
  var onClose      = props.onClose;
  var onOpenStory  = props.onOpenStory;
  var initialQuery = props.initialQuery || '';

  var _q   = React.useState(initialQuery); var query = _q[0], setQuery = _q[1];
  // submittedQuery: only set when user presses Enter or clicks send — drives miraAnswer
  var _sq  = React.useState(''); var submittedQuery = _sq[0], setSubmittedQuery = _sq[1];
  var _acIdx = React.useState(-1); var acIdx = _acIdx[0], setAcIdx = _acIdx[1];
  var inputRef = React.useRef(null);

  React.useEffect(function() {
    if (initialQuery) setSubmittedQuery(initialQuery);
    if (inputRef.current) inputRef.current.focus();
    function onKey(e) { if (e.key === 'Escape') { setQuery(''); setSubmittedQuery(''); onClose(); } }
    window.addEventListener('keydown', onKey);
    return function() { window.removeEventListener('keydown', onKey); };
  }, []);

  // Reset autocomplete index when query changes
  React.useEffect(function() { setAcIdx(-1); }, [query]);

  // ── Autocomplete suggestions (live as user types) ─────────────────────────
  var acSuggestions = React.useMemo(function() {
    if (query.length < 2) return [];
    return buildMiraAutocomplete(query, articles);
  }, [query, articles.length]);

  // ── Query routing ─────────────────────────────────────────────────────────
  // Strip filler phrases + common English stop words so "give me articles
  // about earthquakes" → "earthquakes" and doesn't score every article.
  function normalizeSearchQuery(raw) {
    var s = raw.toLowerCase().trim();
    // Remove filler request phrases first (order matters: longest first)
    var fillerPhrases = [
      'give me articles about', 'give me stories about', 'give me news about',
      'show me articles about', 'show me stories about', 'show me news about',
      'find articles about', 'find stories about', 'find news about',
      'tell me about', 'articles about', 'stories about', 'news about',
      'give me', 'show me', 'find me', 'search for', 'look for',
      'what is', 'what are', 'what happened', 'whats happening in',
      'what\'s happening in', 'please',
    ];
    fillerPhrases.forEach(function(phrase) {
      s = s.replace(new RegExp('^' + phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*', ''), '');
    });
    // Remove common English stop words that cause false positives
    var stopWords = {
      'a':1,'an':1,'the':1,'and':1,'or':1,'but':1,'in':1,'on':1,'at':1,
      'to':1,'for':1,'of':1,'with':1,'by':1,'from':1,'up':1,'about':1,
      'into':1,'through':1,'during':1,'is':1,'are':1,'was':1,'were':1,
      'be':1,'been':1,'being':1,'have':1,'has':1,'had':1,'do':1,'does':1,
      'did':1,'will':1,'would':1,'could':1,'should':1,'may':1,'might':1,
      'it':1,'its':1,'this':1,'that':1,'these':1,'those':1,'as':1,'i':1,
      'me':1,'my':1,'we':1,'our':1,'you':1,'your':1,'he':1,'she':1,
      'they':1,'their':1,'them':1,'him':1,'her':1,'who':1,'which':1,
      'how':1,'when':1,'where':1,'why':1,'all':1,'some':1,'any':1,
      'not':1,'no':1,'can':1,'just':1,'more':1,'also':1,'than':1,
    };
    return s.trim().split(/\s+/).filter(function(w) {
      return w.length >= 2 && !stopWords[w];
    }).join(' ');
  }

  function scoreArticle(a, q) {
    var normalized = normalizeSearchQuery(q);
    var terms = normalized.split(/\s+/).filter(function(t){ return t.length >= 2; });
    if (!terms.length) return 0;
    var title    = (a.title || '').toLowerCase();
    var synHead  = ((a._synthesis && a._synthesis.headline) || '').toLowerCase();
    var synSum   = ((a._synthesis && a._synthesis.summary)  || '').toLowerCase();
    var summary  = (a.summary || a.description || '').toLowerCase();
    var country  = (a.country || '').toLowerCase();
    var topic    = (a.topic   || '').toLowerCase();
    var region   = (a.region  || '').toLowerCase();
    var src      = (a.sourceName || a.source || '').toLowerCase();
    var haystack = [title, synHead, synSum, summary, country, topic, region, src].join(' ');
    var score = 0;
    terms.forEach(function(t) {
      var inTitle   = title.indexOf(t) !== -1 || synHead.indexOf(t) !== -1;
      var inSummary = summary.indexOf(t) !== -1 || synSum.indexOf(t) !== -1;
      var inMeta    = country.indexOf(t) !== -1 || topic.indexOf(t) !== -1 || region.indexOf(t) !== -1;
      var inSrc     = src.indexOf(t) !== -1;
      if (inTitle)   score += 6;
      if (inSummary) score += 3;
      if (inMeta)    score += 2;
      if (inSrc)     score += 1;
      // Bonus: exact word boundary match in title
      if (new RegExp('\\b' + t + '\\b').test(title) || new RegExp('\\b' + t + '\\b').test(synHead)) score += 4;
    });
    return score;
  }

  var _normalizedQuery = normalizeSearchQuery(query);

  var results = React.useMemo(function() {
    var q = query.trim();
    if (q.length < 2) return [];
    // Only search on the meaningful keywords; if nothing survives normalisation
    // (e.g. the query was only stop words) fall back to empty.
    var nq = normalizeSearchQuery(q);
    if (!nq.length) return [];
    return articles
      .map(function(a) { return { a: a, score: scoreArticle(a, q) }; })
      .filter(function(x) { return x.score > 0; })
      .sort(function(x, y) { return y.score - x.score; })
      .slice(0, 10).map(function(x) { return x.a; });
  }, [query, articles.length]);

  // For showing live hints — does the typed text look like a question?
  var isTypingQuestion = query.length >= 5 && isMiraQuestion(query);
  var showAC = acSuggestions.length > 0 && !isTypingQuestion;

  var suggestions = React.useMemo(function() {
    return buildMiraSuggestions(setup, articles);
  }, [articles.length]);

  // Mira answer — only computed after user explicitly submits (Enter or send button)
  var _miraAnswerState  = React.useState(null);
  var miraAnswer        = _miraAnswerState[0];
  var setMiraAnswer     = _miraAnswerState[1];

  // Two-phase Mira answer: phase 1 is synchronous (Globally sources); phase 2
  // augments with external web sources when Globally coverage is thin (async).
  React.useEffect(function() {
    if (!submittedQuery || submittedQuery.length < 5)       { setMiraAnswer(null);               return; }
    if (!isMiraQuestion(submittedQuery))                    { setMiraAnswer(null);               return; }
    if (!isMiraQuerySubstantive(submittedQuery))            { setMiraAnswer({ _tooVague: true }); return; }

    // Phase 1 — sync, Globally sources only
    var phase1 = answerWithMira(submittedQuery, articles, {});
    setMiraAnswer(phase1);

    if (!phase1.needsExternalSearch) return;

    // Phase 2 — async external search; silently enriches the answer when sources arrive
    searchExternalSourcesForMira({
      question:           submittedQuery,
      understoodQuestion: phase1.understood,
      primarySubject:     getPrimaryAnswerSubject(phase1.understood, null),
    }).then(function(extSources) {
      if (!extSources || !extSources.length) return;
      var phase2 = answerWithMira(submittedQuery, articles, { externalSources: extSources });
      setMiraAnswer(phase2);
    }).catch(function() {});
  }, [submittedQuery, articles.length]);

  var isSubmitted = !!submittedQuery && submittedQuery.length >= 3;

  // Spelling correction notice
  var correctionNotice = React.useMemo(function() {
    if (!miraAnswer || !miraAnswer.understood) return null;
    if (!miraAnswer.understood.wasCorrected) return null;
    return miraAnswer.understood.correctedQuery;
  }, [miraAnswer]);

  var _searchLogId  = React.useRef(null);
  var _srchFeedback = React.useState(null); var srchFeedback = _srchFeedback[0], setSrchFeedback = _srchFeedback[1];

  React.useEffect(function() {
    if (!miraAnswer || miraAnswer._tooVague) { _searchLogId.current = null; setSrchFeedback(null); return; }
    var qid = logMiraQuery({
      question: submittedQuery, page: 'search',
      intent: miraAnswer.understood ? miraAnswer.understood.intent : null,
      sourceIds: (miraAnswer.sources || []).map(function(s){ return s.url; }),
      hadEnoughSources: (miraAnswer.sources || []).length >= 2,
      confidence: miraAnswer.confidence,
      limitations: miraAnswer.limitations || [],
    });
    _searchLogId.current = qid;
    setSrchFeedback(null);
  }, [miraAnswer]);

  // ── Submit: commit current query as the question for Mira ─────────────────
  function submitQuery(q) {
    var text = (q !== undefined ? q : query).trim();
    if (text.length < 3) return;
    setSubmittedQuery(text);
    setAcIdx(-1);
  }

  // ── Keyboard handler on the input ─────────────────────────────────────────
  function handleInputKeyDown(e) {
    if (e.key === 'Enter') {
      if (showAC && acIdx >= 0 && acIdx < acSuggestions.length) {
        e.preventDefault();
        var chosen = acSuggestions[acIdx].query;
        setQuery(chosen);
        submitQuery(chosen);
      } else {
        e.preventDefault();
        submitQuery();
      }
      return;
    }
    if (!showAC || acSuggestions.length === 0) return;
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setAcIdx(function(i){ return Math.min(i + 1, acSuggestions.length - 1); });
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setAcIdx(function(i){ return Math.max(i - 1, -1); });
    } else if (e.key === 'Tab') {
      if (acIdx >= 0 && acIdx < acSuggestions.length) {
        e.preventDefault();
        setQuery(acSuggestions[acIdx].query);
        setAcIdx(-1);
      }
    }
  }

  function selectSuggestion(sug) {
    setQuery(sug.query);
    submitQuery(sug.query);
    setAcIdx(-1);
    if (inputRef.current) inputRef.current.focus();
  }

  // ── Parse answer text into structured display blocks ─────────────────────
  function renderMiraAnswerBlocks(text) {
    if (!text) return null;
    var lines = text.split('\n');
    var blocks = [];
    var bulletGroup = null;
    var numberedGroup = null;
    function flushBullets() {
      if (bulletGroup && bulletGroup.length > 0) { blocks.push({ type: 'bullets', items: bulletGroup }); bulletGroup = null; }
    }
    function flushNumbered() {
      if (numberedGroup && numberedGroup.length > 0) { blocks.push({ type: 'numbered', items: numberedGroup }); numberedGroup = null; }
    }
    lines.forEach(function(raw) {
      var line = raw.trim();
      if (!line) { flushBullets(); flushNumbered(); return; }
      var numMatch = line.match(/^(\d+)\.\s+(.+)$/);
      if (numMatch) { flushBullets(); if (!numberedGroup) numberedGroup = []; numberedGroup.push(numMatch[2]); return; }
      var bulletMatch = line.match(/^[•\-]\s+(.+)$/);
      if (bulletMatch) { flushNumbered(); if (!bulletGroup) bulletGroup = []; bulletGroup.push(bulletMatch[1]); return; }
      flushBullets(); flushNumbered();
      blocks.push({ type: 'para', text: line });
    });
    flushBullets(); flushNumbered();
    return blocks.map(function(block, i) {
      if (block.type === 'para') {
        var _bm = /^(In general terms,|What the current sources report:?)\s*(.*)/.exec(block.text);
        if (_bm) {
          return (
            <p key={i} className="gl-mira-answer-para">
              <strong>{_bm[1]}</strong>{_bm[2] ? ' ' + _bm[2] : ''}
            </p>
          );
        }
        return <p key={i} className="gl-mira-answer-para">{block.text}</p>;
      }
      if (block.type === 'bullets') return (
        <ul key={i} className="gl-mira-answer-list">
          {block.items.map(function(item, j){ return <li key={j} className="gl-mira-answer-item">{item}</li>; })}
        </ul>
      );
      if (block.type === 'numbered') return (
        <ol key={i} className="gl-mira-answer-list gl-mira-answer-list--numbered">
          {block.items.map(function(item, j){ return <li key={j} className="gl-mira-answer-item">{item}</li>; })}
        </ol>
      );
      return null;
    });
  }

  return (
    <div className="gl-search-overlay" onClick={function(e){ if (e.target === e.currentTarget) onClose(); }}>
      <div className="gl-search-panel">

        <div className="gl-search-input-row">
          <svg className="gl-search-input-icon" width="16" height="16" viewBox="0 0 24 24"
               fill="none" stroke="#5a3b2a" strokeWidth="2.2" strokeLinecap="round"
               strokeLinejoin="round" aria-hidden="true">
            <circle cx="10.5" cy="10.5" r="6.5"/>
            <line x1="15.5" y1="15.5" x2="21" y2="21"/>
          </svg>
          <input
            ref={inputRef}
            className="gl-search-input"
            type="text"
            placeholder="Search stories, countries, topics…"
            value={query}
            onChange={function(e){ setQuery(e.target.value); setSubmittedQuery(''); }}
            onKeyDown={handleInputKeyDown}
            autoComplete="off"
            spellCheck={false}
          />
          {query && (
            <button className="gl-search-clear"
              onClick={function(){ setQuery(''); setSubmittedQuery(''); setAcIdx(-1); if (inputRef.current) inputRef.current.focus(); }}
              aria-label="Clear">
              <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor"
                   strokeWidth="2" strokeLinecap="round">
                <line x1="2" y1="2" x2="12" y2="12"/>
                <line x1="12" y1="2" x2="2" y2="12"/>
              </svg>
            </button>
          )}
          {isTypingQuestion && !isSubmitted && (
            <button className="gl-search-send-btn" onClick={function(){ submitQuery(); }} aria-label="Ask Mira">
              <svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <line x1="7.5" y1="13" x2="7.5" y2="2"/>
                <polyline points="3,6 7.5,2 12,6"/>
              </svg>
            </button>
          )}
          {query.length >= 2 && !showAC && !isTypingQuestion && (
            <span className="gl-search-mode-hint">Searching…</span>
          )}
          <button className="gl-search-esc-btn" onClick={onClose}>Esc</button>
        </div>

        {/* ── Autocomplete dropdown ────────────────────────────────────────── */}
        {showAC && (
          <div className="gl-ac-list" role="listbox">
            {acSuggestions.map(function(sug, i) {
              return (
                <button
                  key={i}
                  role="option"
                  aria-selected={i === acIdx}
                  className={'gl-ac-item' + (i === acIdx ? ' gl-ac-item--active' : '')}
                  onMouseEnter={function(){ setAcIdx(i); }}
                  onClick={function(){ selectSuggestion(sug); }}
                >
                  <span className="gl-ac-item-label">{sug.label}</span>
                  <span className="gl-ac-item-type">
                    {sug.type === 'country'  ? 'Country'  :
                     sug.type === 'topic'    ? 'Topic'    :
                     sug.type === 'question' ? 'Ask Mira' :
                     sug.type === 'story'    ? 'Story'    : ''}
                  </span>
                </button>
              );
            })}
          </div>
        )}

        {/* ── Welcome state ────────────────────────────────────────────────── */}
        {!query && (
          <div className="gl-search-mira-welcome">
            <div className="gl-search-mira-welcome-hd">
              <MiraBlockMark size={28} />
              <span className="gl-search-mira-welcome-label">Ask Mira</span>
            </div>
            <p className="gl-search-mira-welcome-sub">
              Ask about a country, topic, or story. Mira only answers from Globally's sourced corpus.
            </p>
            <div className="gl-search-suggestions">
              {suggestions.map(function(s) {
                return (
                  <button key={s} className="gl-search-suggestion"
                    onClick={function(){ setQuery(s); }}>
                    {s}
                  </button>
                );
              })}
            </div>
          </div>
        )}

        {/* ── Mira answer panel ────────────────────────────────────────────── */}
        {isSubmitted && miraAnswer && !showAC && (
          <div className="gl-search-mira-panel">

            {/* Question echo */}
            <div className="gl-mira-answer-question">{submittedQuery.replace(/\?$/, '')}?</div>

            {/* Too-vague guard */}
            {miraAnswer._tooVague ? (
              <div className="gl-mira-too-vague">
                <p className="gl-mira-too-vague-text">
                  Mira needs a more complete question to answer reliably.
                </p>
                <p className="gl-mira-too-vague-hint">
                  Try: <em>"What is happening in Sri Lanka?"</em> or <em>"Why does the IMF matter for Pakistan?"</em>
                </p>
              </div>
            ) : (
              <React.Fragment>
                {correctionNotice && (
                  <p className="gl-search-mira-correction">
                    <span className="gl-search-mira-correction-label">Interpreted as</span> {correctionNotice}
                  </p>
                )}

                {(miraAnswer.answer || miraAnswer.generalBackground) ? (
                  <React.Fragment>
                    {miraAnswer.answer && (
                      <div className="gl-mira-answer-body">
                        {renderMiraAnswerBlocks(miraAnswer.answer)}
                      </div>
                    )}

                    {miraAnswer.limitations && miraAnswer.limitations.length > 0 && (
                      <p className="gl-search-mira-limitation">{miraAnswer.limitations[0]}</p>
                    )}

                    {miraAnswer.sources && miraAnswer.sources.length > 0 && (
                      <div className="gl-search-mira-stories">
                        <p className="gl-search-mira-panel-sub">
                          {miraAnswer.hasExternalSources
                            ? 'From ' + miraAnswer.sources.length + ' sources (Globally + web) — tap to read in full'
                            : (miraAnswer._inlineReasoningUsed
                                ? 'Using this story + Mira\'s development context — tap to read in full'
                                : 'From ' + miraAnswer.sources.length + ' Globally ' + (miraAnswer.sources.length === 1 ? 'source' : 'sources') + ' — tap to read in full')}
                          {miraAnswer.confidence === 'high_confidence' && ' · High confidence'}
                          {miraAnswer.confidence === 'limited_sources' && ' · Limited sources'}
                        </p>
                        {miraAnswer.sources.slice(0, 4).map(function(a, i) {
                          return (
                            <button key={i} className="gl-search-mira-story-btn"
                              onClick={function(){
                                if (_searchLogId.current) updateMiraQueryLog(_searchLogId.current, { sourceClicked: true });
                                onOpenStory(a);
                              }}>
                              <MiraSourceCard article={a} compact={true} />
                            </button>
                          );
                        })}
                      </div>
                    )}

                    {/* Question-aware general context — inline text */}
                    {miraAnswer.generalBackground && (
                      <div className="gl-search-mira-general-ctx">
                        {miraAnswer.generalBackground.text.split('\n').map(function(line, i) {
                          if (!line.trim()) return <div key={i} style={{height:4}} />;
                          var isBullet = /^•/.test(line.trim());
                          var boldRe = /^(In general terms,)\s*(.*)/;
                          var bm = boldRe.exec(line);
                          if (bm) {
                            return (
                              <p key={i} className="gl-search-mira-line">
                                <strong>{bm[1]}</strong>{bm[2] ? ' ' + bm[2] : ''}
                              </p>
                            );
                          }
                          return (
                            <p key={i} className={'gl-search-mira-line' + (isBullet ? ' gl-search-mira-line--bullet' : '')}>
                              {line}
                            </p>
                          );
                        })}
                        <p className="gl-search-mira-disclaimer">{miraAnswer.generalBackground.disclaimer}</p>
                      </div>
                    )}

                    <div className="gl-search-mira-feedback">
                      {!srchFeedback ? (
                        <React.Fragment>
                          <span className="mira-feedback-label">Was this useful?</span>
                          <button className="mira-feedback-btn" onClick={function(){
                            setSrchFeedback('useful');
                            if (_searchLogId.current) updateMiraQueryLog(_searchLogId.current, { feedback: 'useful' });
                          }}>Yes</button>
                          <button className="mira-feedback-btn" onClick={function(){
                            setSrchFeedback('not_useful');
                            if (_searchLogId.current) updateMiraQueryLog(_searchLogId.current, { feedback: 'not_useful' });
                          }}>Not really</button>
                        </React.Fragment>
                      ) : (
                        <span className="mira-feedback-thanks">
                          {srchFeedback === 'useful' ? 'Thanks — helps Mira improve.' : 'Noted. Mira will do better.'}
                        </span>
                      )}
                    </div>
                  </React.Fragment>
                ) : (
                  <p className="gl-search-mira-none">
                    No relevant stories found in today's corpus. Try a different phrasing.
                  </p>
                )}
              </React.Fragment>
            )}
          </div>
        )}

        {/* ── Keyword search results ───────────────────────────────────────── */}
        {!showAC && !isTypingQuestion && results.length > 0 && (
          <React.Fragment>
            <p className="gl-search-results-hd">
              {results.length} {results.length === 1 ? 'story' : 'stories'} matching <em>"{_normalizedQuery || query}"</em>
            </p>
            <ul className="gl-search-results">
              {results.map(function(a, i) {
                var topic   = getArticleTopic(a);
                var country = a.country || '';
                var snippet = (a._synthesis && a._synthesis.summary) || a.summary || a.description || '';
                if (snippet.length > 120) snippet = snippet.slice(0, 120).replace(/\s+\S*$/, '') + '…';
                var src = a.sourceName || a.source || '';
                return (
                  <li key={a.id || a.url || i} className="gl-search-result"
                      onClick={function(){ onOpenStory(a); }}>
                    <div className="gl-search-result-title">
                      {(a._synthesis && a._synthesis.headline) || a.title || '(Untitled)'}
                    </div>
                    {snippet && <div className="gl-search-result-snippet">{snippet}</div>}
                    <div className="gl-search-result-meta">
                      {topic   && <span className="gl-search-result-tag">{topic}</span>}
                      {country && <span className="gl-search-result-tag gl-search-result-tag--country">{country}</span>}
                      {src     && <span className="gl-search-result-src">{src}</span>}
                    </div>
                  </li>
                );
              })}
            </ul>
          </React.Fragment>
        )}

        {!showAC && !isTypingQuestion && _normalizedQuery.length >= 2 && results.length === 0 && (
          <div className="gl-search-empty-state">
            <svg width="28" height="28" viewBox="0 0 24 24" fill="none"
                 stroke="rgba(90,59,42,0.30)" strokeWidth="1.6" strokeLinecap="round"
                 strokeLinejoin="round" aria-hidden="true">
              <circle cx="10.5" cy="10.5" r="6.5"/>
              <line x1="15.5" y1="15.5" x2="21" y2="21"/>
            </svg>
            <p className="gl-search-empty-title">No matching stories found</p>
            <p className="gl-search-empty-hint">Try searching by country, topic, or keyword.</p>
          </div>
        )}

      </div>
    </div>
  );
}


// ── TodayGlobeCard ────────────────────────────────────────────────────────────
function TodayGlobeCard(props) {
  var onNav = props.onNav;
  return (
    <div className="gt2-daily-card gt2-daily-card--globe"
         onClick={function(){ if (onNav) onNav('globe'); }}
         role="button" tabIndex={0}
         aria-label="Explore the globe">
      <div className="gt2-daily-card-eyebrow">Map of the Day</div>
      <div className="gt2-globe-card-icon" aria-hidden="true">
        {GlobeIcon
          ? React.createElement(GlobeIcon, { size: 40, strokeWidth: 1.2 })
          : <span style={{ fontSize: 40 }}>🌍</span>}
      </div>
      <p className="gt2-daily-card-desc">Explore HDI, conflict risk, climate vulnerability &amp; more — country by country.</p>
      <div className="gt2-daily-card-cta">Explore Globe →</div>
    </div>
  );
}

// ── TodayTopicPill ────────────────────────────────────────────────────────────
function TodayTopicPill(props) {
  var topic = props.topic;
  var count = props.count || 0;
  var onNav = props.onNav;
  var hex   = TOPIC_COLORS[topic] || '#7A8A9A';
  var rgb   = hexRgb(hex);

  return (
    <button className="gt2-topic-pill"
            style={{ '--gt2-pill-rgb': rgb, '--gt2-pill-hex': hex }}
            onClick={function(){ if (onNav) onNav(topic); }}
            aria-label={topic + ' — ' + count + ' stories'}>
      <span className="gt2-topic-pill-dot" style={{ background: hex }} />
      <span className="gt2-topic-pill-name">{topic}</span>
      <span className="gt2-topic-pill-count">{count}</span>
    </button>
  );
}

// ── GloblyVideoBox — cycles through login-page clips (desktop RHS) ────────────
function GloblyVideoBox() {
  var _idx    = React.useState(0);
  var _errIdx = React.useState([]);
  var clipIdx  = _idx[0],    setClipIdx  = _idx[1];
  var errIdx   = _errIdx[0], setErrIdx   = _errIdx[1];
  var clips    = CLIPS;

  function advance() { setClipIdx(function(i){ return (i + 1) % clips.length; }); }
  function onVideoErr() {
    // Mark this clip as failed and skip to the next one; hide box if all fail.
    setErrIdx(function(prev){ return prev.indexOf(clipIdx) === -1 ? prev.concat([clipIdx]) : prev; });
    advance();
  }

  // If every clip has errored, render the cream fallback instead of a broken black box.
  if (errIdx.length >= clips.length) {
    return <div className="gl-rhs-video-box gl-rhs-video-box--fallback" />;
  }

  return (
    <div className="gl-rhs-video-box">
      <div className="gl-rhs-eyebrow">Live Coverage</div>
      <video
        key={clips[clipIdx]}
        className="gl-rhs-video"
        autoPlay muted playsInline preload="metadata"
        onEnded={advance}
        onError={onVideoErr}
        src={clips[clipIdx]}
      />
      <div className="gl-rhs-video-dots">
        {clips.map(function(_, i) {
          return (
            <button key={i}
              className={"gl-rhs-video-dot" + (i === clipIdx ? " gl-rhs-video-dot--on" : "")}
              onClick={function(){ setClipIdx(i); }}
              aria-label={"Clip " + (i + 1)} />
          );
        })}
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════════════════
// Editorial subtopic definitions — broad lenses, not countries
// ═══════════════════════════════════════════════════════════════════════════════
var TOPIC_SUBTOPICS = {
  today:          ['For you','Latest','Most important','Developing','Humanitarian','Global economy','Big picture','Explained'],
  conflict:       ["What's new",'War zones','Civilian impact','Peace talks','Armed groups','Security','Sanctions','Human rights'],
  health:         ["What's new",'Outbreaks','Vaccines','Hospitals','Nutrition','Public health','Maternal health','Health funding'],
  economy:        ["What's new",'Poverty','Inflation','Jobs','Growth','Inequality','Aid','Public spending'],
  climate:        ["What's new",'Extreme weather','Food security','Water','Disasters','Adaptation','Emissions','Climate finance'],
  politics:       ["What's new",'Elections','Governance','Protests','Corruption','Policy','Rights','Institutions'],
  trade:          ["What's new",'Supply chains','Exports','Commodities','Ports','Tariffs','Investment','Regional trade'],
  migration:      ["What's new",'Refugees','Displacement','Borders','Remittances','Asylum','Urbanisation','Protection'],
  governance:     ["What's new",'Corruption','Public services','Rule of law','Accountability','Reform','Digital government','Decentralisation'],
  debt:           ["What's new",'IMF','Loans','Debt relief','Budgets','Interest rates','China lending','Credit ratings'],
  energy:         ["What's new",'Oil and gas','Renewables','Power cuts','Grids','Fuel prices','Mining','Energy access'],
  agriculture:    ["What's new",'Food prices','Crops','Livestock','Fisheries','Drought','Fertiliser','Rural livelihoods'],
  infrastructure: ["What's new",'Transport','Roads','Ports','Housing','Power','Water systems','Digital infrastructure'],
  diplomacy:      ["What's new",'Summits','Aid deals','Sanctions','Treaties','Peace talks','UN','Regional blocs'],
};

// keyword lists keyed by lowercased subtopic label — scored against title+summary
var SUBTOPIC_KEYWORDS = {
  // today subtopics
  'latest':            [],
  'most important':    ['crisis','major','critical','urgent','emergency','global','international','significant','historic','landmark','breaking'],
  'developing':        ['developing','low-income','poor','emerging','africa','asia','latin america','south asia','sub-saharan','least developed','global south','oda','aid'],
  'humanitarian':      ['humanitarian','aid','relief','refugee','displaced','famine','drought','hunger','malnutrition','shelter','ngo','unicef','unhcr','wfp','oxfam','msf','red cross','food crisis','water crisis','emergency response'],
  'global economy':    ['economy','economic','trade','gdp','growth','inflation','recession','market','finance','fiscal','monetary','debt','investment','exports','imports','currency','central bank','interest rate','imf','world bank'],
  'big picture':       ['global','international','united nations','g20','g7','climate change','pandemic','conflict','peace','geopolitical','multilateral','treaty','summit','systemic','long-term'],
  'explained':         ['explain','analysis','behind','context','understand','breakdown','guide','explainer','deep dive','what you need to know','in focus','why','how'],
  // conflict
  'war zones':         ['war','battle','combat','frontline','offensive','assault','troops','forces','military operation','bombing','airstrike','missile','artillery','siege','territory','fighting','clashes','soldiers'],
  'civilian impact':   ['civilian','casualties','killed','displaced','fled','family','children','women','hospital','school','infrastructure','homes','village','humanitarian','deaths','wounded'],
  'peace talks':       ['peace','ceasefire','talks','negotiation','deal','agreement','mediation','diplomat','truce','settlement','dialogue','envoy','accord'],
  'armed groups':      ['militia','rebel','insurgent','armed group','faction','guerrilla','terrorist','extremist','jihadist','paramilitary','gang','cartel','warlord'],
  'security':          ['security','defence','military','nato','troops','deployment','intelligence','threat','attack','terrorism','counterterrorism'],
  'sanctions':         ['sanctions','embargo','ban','restriction','penalty','punish','isolated','blocked','freeze'],
  'human rights':      ['human rights','rights abuses','torture','detention','prison','freedom','protest','journalist','free speech','discrimination','minority','persecution','accountability'],
  // health
  'outbreaks':         ['outbreak','epidemic','pandemic','infection','disease','virus','mpox','cholera','ebola','malaria','tuberculosis','hiv','dengue','spread','cases','death toll','pathogen'],
  'vaccines':          ['vaccine','vaccination','immunisation','immunization','jab','dose','rollout','coverage','gavi','covax','booster'],
  'hospitals':         ['hospital','clinic','healthcare','health system','medical','doctor','nurse','patient','surgery','health worker','facility','icu'],
  'nutrition':         ['nutrition','malnutrition','stunting','wasting','hunger','famine','diet','feeding','formula','supplementary'],
  'public health':     ['public health','sanitation','hygiene','water supply','sewage','prevention','who','health ministry','policy','programme','surveillance'],
  'maternal health':   ['maternal','mother','pregnancy','birth','childbirth','midwife','antenatal','postnatal','infant','newborn','mortality','obstetric'],
  'health funding':    ['health funding','health budget','pepfar','global fund','usaid','health aid','donor','health investment','health spending','cuts to health','health deficit'],
  // economy
  'poverty':           ['poverty','poor','low income','extreme poverty','living standards','social protection','welfare','vulnerable','inequality','deprivation'],
  'inflation':         ['inflation','price','cost of living','consumer price','cpi','food price','fuel price','rising prices','price rises'],
  'jobs':              ['jobs','employment','unemployment','labour','labor','worker','wage','salary','layoff','redundancy','hiring','workforce'],
  'growth':            ['growth','gdp','economic growth','expansion','recovery','forecast','outlook','development bank','per capita'],
  'inequality':        ['inequality','gap','disparity','wealth gap','gini','redistribute','unequal','top percent'],
  'aid':               ['aid','grant','assistance','development finance','foreign aid','donor','oda','concessional'],
  'public spending':   ['budget','spending','fiscal','government spending','public finance','deficit','surplus','tax revenue','allocation','expenditure'],
  // climate
  'extreme weather':   ['flood','cyclone','typhoon','hurricane','storm','heatwave','drought','wildfire','extreme weather','record temperature','heavy rain','torrent'],
  'food security':     ['food security','hunger','famine','harvest','crop failure','food supply','nutrition','staple','food system'],
  'water':             ['water','river','lake','water access','groundwater','dam','irrigation','water scarcity','water table','aquifer'],
  'disasters':         ['disaster','earthquake','flood','landslide','cyclone','tsunami','eruption','emergency','disaster response','affected communities','relief operations'],
  'adaptation':        ['adapt','resilience','climate resilience','early warning','coastal','vulnerable communities','green infrastructure','climate proof'],
  'emissions':         ['emissions','carbon','greenhouse gas','methane','co2','net zero','fossil fuel','coal','deforestation','carbon dioxide'],
  'climate finance':   ['climate finance','green fund','loss and damage','carbon market','climate aid','cop','climate deal','climate investment'],
  // politics
  'elections':         ['election','vote','poll','ballot','candidate','party','campaign','democracy','president','parliament','results','electoral'],
  'governance':        ['governance','government','minister','cabinet','policy reform','administration','state institution','public institution'],
  'protests':          ['protest','demonstration','march','rally','riot','unrest','opposition','civil society','strike','crowd','demonstrators'],
  'corruption':        ['corruption','bribe','fraud','embezzle','scandal','investigation','accountability','transparency','graft'],
  'policy':            ['policy','law','regulation','bill','legislation','decree','court','ruling','decision','framework'],
  'rights':            ['rights','freedom','civil liberties','discrimination','oppression','justice','equality','gender','minority rights'],
  'institutions':      ['institution','judiciary','parliament','senate','court','constitution','rule of law','checks','legislature'],
  // trade
  'supply chains':     ['supply chain','logistics','shipping','container','semiconductor','shortage','disruption','manufacturing','production'],
  'exports':           ['export','import','merchandise','trade surplus','trade deficit','shipped goods','goods trade'],
  'commodities':       ['commodity','wheat','corn','copper','gold','coffee','cocoa','mineral','resource','raw material'],
  'ports':             ['port','harbour','shipping','container terminal','cargo','freight','vessel','dock','transit route'],
  'tariffs':           ['tariff','duty','trade tax','levy','trade war','protectionism','customs','quota','import duty'],
  'investment':        ['investment','fdi','foreign direct investment','capital','venture','fund','stake','acquisition','equity'],
  'regional trade':    ['regional agreement','free trade','bilateral','afcfta','asean','rcep','trade bloc','trade deal'],
  // migration
  'refugees':          ['refugee','asylum seeker','camp','unhcr','fled','displaced','persecution','protection','stateless'],
  'displacement':      ['displaced','displacement','internally displaced','idp','forced migration','evacuate','forced to flee'],
  'borders':           ['border','crossing','frontier','immigration control','migration route','wall','fence','patrol'],
  'remittances':       ['remittance','money transfer','diaspora','migrant worker','foreign workers','send money home'],
  'asylum':            ['asylum','claim','application','refugee status','deportation','return','status determination'],
  'urbanisation':      ['urbanisation','urbanization','city','urban','slum','megacity','population growth','rural migration','urban poor'],
  'protection':        ['protection','safe passage','legal status','trafficking','detention centre','vulnerable migrant'],
  // governance
  'public services':   ['public service','education','health service','water supply','electricity','transport','welfare','social service'],
  'rule of law':       ['rule of law','judiciary','court','justice','constitutional','legal system','impunity','judicial'],
  'accountability':    ['accountability','transparency','oversight','audit','watchdog','report','responsible','ombudsman'],
  'reform':            ['reform','restructure','modernise','overhaul','amendment','new law','regulatory reform'],
  'digital government':['digital','e-government','technology','online platform','biometric','digital id','digital service','govtech'],
  'decentralisation':  ['decentralis','local government','devolution','municipal','county','regional authority'],
  // debt
  'imf':               ['imf','international monetary fund','programme','bailout','structural adjustment','conditionality'],
  'loans':             ['loan','credit','borrow','lending','concessional','bond issuance','borrowing'],
  'debt relief':       ['debt relief','restructuring','forgiveness','write-off','rescheduling','haircut','paris club','common framework'],
  'budgets':           ['budget','fiscal','spending','revenue','deficit','surplus','allocation','treasury','national budget'],
  'interest rates':    ['interest rate','central bank','rate hike','monetary policy','tightening','borrowing cost','benchmark rate'],
  'china lending':     ['china','chinese','belt and road','bri','cdb','exim bank','infrastructure loan','chinese loan','chinese financing'],
  'credit ratings':    ['credit rating','moody','fitch','standard poor','downgrade','upgrade','sovereign rating'],
  // energy
  'oil and gas':       ['oil','gas','petroleum','fossil fuel','opec','pipeline','crude','hydrocarbon','lng','offshore','natural gas'],
  'renewables':        ['renewable','solar','wind','hydropower','clean energy','green energy','transition','turbine','solar panel'],
  'power cuts':        ['power cut','blackout','outage','load shedding','electricity shortage','rolling blackout','grid failure','power outage'],
  'grids':             ['grid','electricity network','transmission','distribution','electrification','power connection'],
  'fuel prices':       ['fuel price','petrol price','diesel','fuel subsidy','pump price','cost of fuel','fuel cost'],
  'mining':            ['mining','mineral','lithium','cobalt','copper mine','gold mine','iron ore','extraction','quarry'],
  'energy access':     ['energy access','electricity access','off-grid','rural electricity','power connection','kerosene','biomass'],
  // agriculture
  'food prices':       ['food price','grain price','wheat price','rice price','staple food','market price','food inflation','food costs'],
  'crops':             ['crop','harvest','yield','wheat','rice','maize','corn','soybean','planting season','agronomic'],
  'livestock':         ['livestock','cattle','poultry','animal','sheep','goat','dairy','meat','animal disease'],
  'fisheries':         ['fish','fishing','fisheries','trawl','aquaculture','marine','ocean catch','coastal fishing'],
  'drought':           ['drought','dry season','arid','water stress','failed harvest','rainfall deficit','crop failure'],
  'fertiliser':        ['fertiliser','fertilizer','urea','nitrogen','phosphate','potash','agrochemical','crop input'],
  'rural livelihoods': ['rural','smallholder','farmer','livelihood','subsistence','smallholder farmer','village economy'],
  // infrastructure
  'transport':         ['transport','road','rail','railway','airport','bus','transit','mobility','commute','logistics network'],
  'roads':             ['road','highway','bridge','road construction','tarmac','route','pavement','traffic','road network'],
  'housing':           ['housing','home','shelter','slum','affordable housing','rent','housing construction','building','land tenure'],
  'power':             ['electricity supply','power supply','grid','electrification','connection','power infrastructure'],
  'water systems':     ['water system','water supply','pipeline','tap water','sewage','sanitation','irrigation','dam','reservoir'],
  'digital infrastructure':['broadband','internet','fibre','connectivity','4g','5g','telecoms','mobile network','digital access','last mile'],
  // diplomacy
  'summits':           ['summit','meeting','talks','conference','forum','gathering','leaders','bilateral meeting','g20','g7','cop'],
  'aid deals':         ['aid','grant','assistance','development','package','funding','deal','pledge','commitment','billion','signed'],
  'treaties':          ['treaty','agreement','pact','accord','convention','protocol','framework','signed','ratified'],
  'un':                ['united nations','un ','security council','general assembly','un resolution','secretary-general','un agency','ocha','undp','wfp','unhcr'],
  'regional blocs':    ['asean','african union','ecowas','european union','arab league','g20','mercosur','saarc','bloc','regional bloc'],
};

function scoreArticleForSubtopic(article, subtopicLabel) {
  var text = ((article.title || '') + ' ' + (article.summary || '')).toLowerCase();
  var kws = SUBTOPIC_KEYWORDS[subtopicLabel.toLowerCase()];
  if (!kws || !kws.length) return 0;
  var score = 0;
  for (var i = 0; i < kws.length; i++) {
    if (text.indexOf(kws[i]) !== -1) score++;
  }
  if (score > 0) score += (article.significance || 0) * 0.1;
  return score;
}

// Re-ranks stories by subtopic relevance — never filters, always returns full list
function reRankBySubtopic(stories, subtopicLabel) {
  if (!subtopicLabel) return stories;
  var scored = stories.map(function(a, idx) {
    return { a: a, s: scoreArticleForSubtopic(a, subtopicLabel), i: idx };
  });
  scored.sort(function(x, y) {
    if (y.s !== x.s) return y.s - x.s;
    return x.i - y.i;
  });
  return scored.map(function(x) { return x.a; });
}

// ═══════════════════════════════════════════════════════════════════════════════
// SubtopicRow — horizontal pill strip that lives under a section heading
// items: [{id, label}] where id===null means "all / For you"
// ═══════════════════════════════════════════════════════════════════════════════
function SubtopicRow(props) {
  var items    = props.items;
  var active   = props.active;   // null = "all" selected
  var onSelect = props.onSelect;
  if (!items || items.length < 2) return null;
  return (
    <div className="gl-subtopic-row" role="tablist" aria-label="Filter">
      {items.map(function(item, i) {
        var isOn = active === item.id || (active == null && item.id === null);
        return (
          <React.Fragment key={item.id == null ? '__all' : item.id}>
            {i > 0 && <span className="gl-subtopic-divider" aria-hidden="true">|</span>}
            <button
              type="button"
              role="tab"
              aria-selected={isOn}
              className={'gl-subtopic-button' + (isOn ? ' is-active' : '')}
              onClick={function() { onSelect(isOn ? null : item.id); }}
            >{item.label}</button>
          </React.Fragment>
        );
      })}
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════════════════

function TodayScreen(props) {
  var articles      = props.articles;
  var setup         = props.setup;
  var lastUpdated   = props.lastUpdated || null;
  var dataMeta      = props.dataMeta || {};
  var onNav         = props.onNav;
  var onTopicNav    = props.onTopicNav;
  var onOpenStory   = props.onOpenStory;
  var onOpenCluster = props.onOpenCluster;
  var onMenuOpen    = props.onMenuOpen;
  var eventClusters = props.eventClusters || [];

  var userSetup = setup || { regions: REGIONS, topics: TOPICS };

  // Stable key: recompute feeds whenever the user's selected regions or topics change
  // (covers both initial onboarding completion and profile preference edits).
  var _setupKey = [
    (userSetup.regions || []).slice().sort().join(','),
    (userSetup.topics  || []).slice().sort().join(','),
  ].join('|');

  var personalFeed = React.useMemo(function(){
    return buildDailyFeed(articles, userSetup);
  }, [articles.length, _setupKey, lastUpdated]);

  var heroArticle = React.useMemo(function(){
    return pickDailyHero(personalFeed, dailySeed());
  }, [personalFeed.length, _setupKey]);

  var heroId        = heroArticle ? heroArticle.id : null;
  var heroUrl       = heroArticle ? heroArticle.url : null;
  var heroCluster   = heroArticle ? (heroArticle.clusterId || null) : null;

  function isSameCluster(a) {
    if (!heroArticle) return false;
    if (a.id  === heroId)  return true;
    if (a.url === heroUrl) return true;
    if (heroCluster && a.clusterId && a.clusterId === heroCluster) return true;
    return false;
  }

  var gridStories = React.useMemo(function() {
    return personalFeed
      .filter(function(a) { return !isSameCluster(a); })
      .slice(0, 12);
  }, [personalFeed.length, heroId, heroCluster, _setupKey]);

  // Part 12: Merge top event clusters into the "For you" grid.
  // Clusters appear first (up to 3), then stories not covered by a displayed cluster.
  var mergedGrid = React.useMemo(function() {
    var topClusters = eventClusters.slice(0, 3);
    var coveredUrls = new Set();
    topClusters.forEach(function(cl) {
      (cl.sourceStoryIds || []).forEach(function(url) { coveredUrls.add(url); });
    });
    var clusterItems = topClusters.map(function(cl) { return { _isCluster: true, cluster: cl }; });
    var storyItems = gridStories
      .filter(function(a) { return !coveredUrls.has(a.url); })
      .slice(0, 12 - clusterItems.length)
      .map(function(a) { return { _isCluster: false, article: a }; });
    return clusterItems.concat(storyItems);
  }, [eventClusters.length, gridStories.length, heroId, heroCluster]);

  var popularArticles = React.useMemo(function() {
    return articles
      .filter(function(a) { return !a._clusterHidden && !isSameCluster(a) && isFresh(a); })
      .slice()
      .sort(function(a, b) {
        return new Date(b.published || 0) - new Date(a.published || 0);
      })
      .slice(0, 12);
  }, [articles.length, heroId, heroCluster]);

  function openBriefAndMark(article) {
    markSeen(article.url);
    recordSignal(article, 'open');
    if (onOpenStory) onOpenStory(article);
  }

  var dateStr = new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });

  var topicCount = React.useMemo(function() {
    var seen = {};
    articles.forEach(function(a) { var t = getArticleTopic(a); if (t) seen[t] = true; });
    return Object.keys(seen).length;
  }, [articles.length, lastUpdated]);

  // Canonical ingest timestamp — uses dataMeta.last_updated (from map-data.json top-level),
  // then falls back to max scraped_at across articles, then max published.
  var updatedAt = React.useMemo(function() {
    return getLatestDataUpdatedAt(articles, dataMeta);
  }, [articles.length, lastUpdated, dataMeta.last_updated]);

  // Human-readable label e.g. "Updated 3h ago" — explicit helper, no hardcoding.
  var lastUpdatedStr = React.useMemo(function() {
    return formatUpdatedLabel(updatedAt);
  }, [updatedAt]);

  var topicCounts = React.useMemo(function() {
    var counts = {};
    articles.forEach(function(a) {
      var t = getArticleTopic(a);
      if (t) counts[t] = (counts[t] || 0) + 1;
    });
    return counts;
  }, [articles.length, lastUpdated]);

  // ── Search state ──
  var _searchOpen = React.useState(false);
  var searchOpen = _searchOpen[0], setSearchOpen = _searchOpen[1];
  React.useEffect(function() {
    function onKey(e) {
      if (searchOpen && e.key === 'Escape') { setSearchOpen(false); return; }
      if (!searchOpen && e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
        e.preventDefault();
        setSearchOpen(true);
      }
    }
    window.addEventListener('keydown', onKey);
    return function() { window.removeEventListener('keydown', onKey); };
  }, [searchOpen]);

  // ── Topic page navigation: which page is active (not scroll-driven) ──
  var _activePage = React.useState('today');
  var activePage = _activePage[0], setActivePage = _activePage[1];

  // ── Sub-topic country filter — resets when navigating to a new topic ──
  var _subFilter = React.useState(null);
  var subFilter = _subFilter[0], setSubFilter = _subFilter[1];
  React.useEffect(function() { setSubFilter(null); }, [activePage]);

  // ── Per-section subtopic filter: { sectionId: selectedSubtopicId | null } ──
  var _sectionFilter = React.useState({});
  var sectionFilter = _sectionFilter[0], setSectionFilter = _sectionFilter[1];
  function updateSectionFilter(sectionId, val) {
    setSectionFilter(function(prev) {
      return Object.assign({}, prev, { [sectionId]: val });
    });
  }

  // ── Scroll-driven sidebar state ──
  var _visibleTopic = React.useState(null);
  var visibleTopic = _visibleTopic[0], setVisibleTopic = _visibleTopic[1];
  var _pendingScroll = React.useState(null);
  var pendingScrollTopic = _pendingScroll[0], setPendingScrollTopic = _pendingScroll[1];

  // ── Per-topic story sets (drives rail items + topic page content) ──
  var topicSections = React.useMemo(function() {
    var pref    = loadPref(userSetup);
    var seenMap = loadSeen();
    return SIDEBAR_TOPICS
      .map(function(topic) {
        var stories = sliceRanked(
          scoreAndRank(
            articles.filter(function(a) { return !a._clusterHidden && getArticleTopic(a) === topic; }),
            pref, seenMap, 0.25
          ),
          40
        );
        return { topic: topic, stories: stories, color: TOPIC_COLORS[topic] || '#7A8A9A' };
      })
      .filter(function(ts) { return ts.stories.length > 0; });
  }, [articles.length, _setupKey, lastUpdated]);

  // ── Rail items: "Today's News" + every topic that has stories ──
  var GLOBLY_ACCENT = '#ff4fa3';
  var railItems = React.useMemo(function() {
    return [{ id: 'today', label: "Today's News", color: GLOBLY_ACCENT }]
      .concat(topicSections.map(function(ts) {
        return { id: 'topic-' + ts.topic.toLowerCase(), label: ts.topic, color: ts.color };
      }));
  }, [topicSections.length]);

  // ── Active topic data + country-based sub-topics for the topic page ──
  var activeTopicData = null;
  for (var _tsi = 0; _tsi < topicSections.length; _tsi++) {
    if ('topic-' + topicSections[_tsi].topic.toLowerCase() === activePage) {
      activeTopicData = topicSections[_tsi];
      break;
    }
  }
  var subTopics = [];
  if (activeTopicData) {
    var _ctMap = {};
    activeTopicData.stories.forEach(function(a) {
      if (a.country) _ctMap[a.country] = (_ctMap[a.country] || 0) + 1;
    });
    subTopics = Object.keys(_ctMap)
      .filter(function(c) { return _ctMap[c] >= 2; })
      .sort(function(a, b) { return _ctMap[b] - _ctMap[a]; })
      .slice(0, 8);
  }
  var displayStories = (function() {
    var raw = activeTopicData
      ? (subFilter
          ? activeTopicData.stories.filter(function(a) { return a.country === subFilter; })
          : activeTopicData.stories)
      : [];
    var seen = new Set();
    return raw.filter(function(a) {
      var key = a.id || a.url;
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    });
  })();

  // ── Background tint: neutral on Today section, topic-coloured when a topic is active ──
  var activeBgStyle = {};
  if (visibleTopic && visibleTopic !== 'today') {
    var _tintId = visibleTopic;
    var _topicHex = GLOBLY_ACCENT;
    for (var _ri = 0; _ri < railItems.length; _ri++) {
      if (railItems[_ri].id === _tintId) { _topicHex = railItems[_ri].color; break; }
    }
    var _isGrave = (function() {
      for (var _gk in GRAVE_TOPIC_NAMES) {
        if ('topic-' + _gk.toLowerCase() === _tintId) return true;
      }
      return false;
    })();
    var _acHsl = hexToHsl(_topicHex);
    activeBgStyle = { backgroundColor: 'rgb(' + hslToRgbStr(_acHsl[0], _isGrave ? 18 : 28, _isGrave ? 93 : 95) + ')' };
  }

  // ── Effect: IntersectionObserver — tracks active section across unified scroll ──
  // Observes the Today sentinel plus every topic section. The detection zone is
  // clipped to the top ~20% of #gl-main-scroll so the topmost visible section wins.
  React.useEffect(function() {
    var root = document.getElementById('gl-main-scroll');
    if (!root) return;
    var observer = new IntersectionObserver(function(entries) {
      var hit = entries.filter(function(e) { return e.isIntersecting; });
      if (!hit.length) return;
      hit.sort(function(a, b) { return a.boundingClientRect.top - b.boundingClientRect.top; });
      var sid = hit[0].target.getAttribute('data-section-id');
      if (sid === 'today') {
        setVisibleTopic('today');
      } else if (sid) {
        setVisibleTopic('topic-' + sid.toLowerCase());
      }
    }, { root: root, rootMargin: '-10px 0px -40% 0px', threshold: 0 });
    var todayEl = document.getElementById('topic-section-today');
    if (todayEl) observer.observe(todayEl);
    topicSections.forEach(function(ts) {
      var el = document.getElementById('topic-section-' + ts.topic);
      if (el) observer.observe(el);
    });
    return function() { observer.disconnect(); };
  }, [topicSections.length]);

  // ── Rail click: scroll to section directly — no DOM-mode switch ──
  function handleRailSelect(itemId) {
    if (itemId === 'today') {
      var todayEl = document.getElementById('topic-section-today');
      if (todayEl) todayEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
      setVisibleTopic('today');
      return;
    }
    var ts = topicSections.find(function(s) {
      return 'topic-' + s.topic.toLowerCase() === itemId;
    });
    if (ts) {
      var el = document.getElementById('topic-section-' + ts.topic);
      if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }
    setVisibleTopic(itemId);
  }

  // ── Dynamic masthead text: Today edition info vs active topic ──
  var _mastheadTopicSect = (visibleTopic && visibleTopic !== 'today')
    ? topicSections.find(function(s) { return 'topic-' + s.topic.toLowerCase() === visibleTopic; })
    : null;
  var mastheadLabel = _mastheadTopicSect ? 'TOPIC' : "Today's Edition";
  var mastheadTitle = _mastheadTopicSect ? _mastheadTopicSect.topic : dateStr;
  var mastheadMeta = _mastheadTopicSect
    ? (function() {
        var _mc = topicCounts[_mastheadTopicSect.topic] || _mastheadTopicSect.stories.length;
        return [_mc + (_mc === 1 ? ' story' : ' stories') + ' today'];
      })()
    : [
        articles.length > 0 ? articles.length + ' stories' : null,
        topicCount > 1 ? topicCount + ' topics' : null,
        lastUpdatedStr || null
      ].filter(Boolean);

  // ── Masthead subtopic row: editorial labels from TOPIC_SUBTOPICS ──
  var _mhdSubKey  = visibleTopic || 'today';
  var _mhdTK      = (_mhdSubKey === 'today') ? 'today' : _mhdSubKey.replace(/^topic-/, '');
  var _mhdLabels  = TOPIC_SUBTOPICS[_mhdTK] || TOPIC_SUBTOPICS['today'];
  var mastheadSubItems = _mhdLabels.map(function(label) {
    return { id: (label === 'For you' || label === 'All' || label === "What's new") ? null : label.toLowerCase(), label: label };
  });
  var mastheadSubActive = sectionFilter[_mhdSubKey] || null;

  return (
    <div className="gl-today gl-today-unified">

    {/* ── Page-level header: hamburger / wordmark / search — sticky on desktop ── */}
    <div className="gl-page-header">
      <div className="gl-mast-left">
        <button className="gl-topbar-btn"
          onClick={function(){ if (onMenuOpen) onMenuOpen(); }}
          aria-label="Open menu">
          <span className="gl-hamburger-line" />
          <span className="gl-hamburger-line" />
          <span className="gl-hamburger-line" />
        </button>
        <span className="gl-mast-wordmark">Globally</span>
        <button className="gl-header-search-trigger" onClick={function(){ setSearchOpen(true); }} aria-label="Search">
          <svg className="gl-header-search-svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
               stroke="#4a3022" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
               aria-hidden="true">
            <circle cx="10.5" cy="10.5" r="6.5"/>
            <line x1="15.5" y1="15.5" x2="21" y2="21"/>
          </svg>
          <span className="gl-header-search-label">Search</span>
        </button>
        <button className="gl-header-search-icon-only" onClick={function(){ setSearchOpen(true); }} aria-label="Search">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none"
               stroke="#4a3022" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
               aria-hidden="true">
            <circle cx="10.5" cy="10.5" r="6.5"/>
            <line x1="15.5" y1="15.5" x2="21" y2="21"/>
          </svg>
        </button>
      </div>
      <div className="gl-header-right">
        <button className="gl-tp-sub gl-tp-sub--firms"
          onClick={function(){ if (onNav) onNav("mira-for-firms"); }}>
          Mira Business
        </button>
        <button className="gl-tp-sub"
          onClick={function(){ if (onNav) onNav("profile"); }}>
          My Profile
        </button>
      </div>
    </div>

    {searchOpen && (
      <GloballySearch
        articles={articles}
        setup={userSetup}
        onClose={function(){ setSearchOpen(false); }}
        onOpenStory={function(a){ setSearchOpen(false); if (onOpenStory) onOpenStory(a); }}
      />
    )}

    {/* ── Main column: nav rail + content ── */}
    <div className="gl-today-main">

      {/* ── Left topic rail ── */}
      <TopicRail
        items={railItems}
        active={visibleTopic || 'today'}
        onSelect={handleRailSelect}
        onFooterNav={onNav}
      />

      {/* ── Content column ── */}
      <div className="gl-rail-content">

        {/* ── Sticky masthead — constant shape, central text updates on scroll ── */}
        <div className="gl-edition-masthead">
          <div className="gl-mast-content">
            <MiraBlockMark size={52} state="idle" />
            <div className="gl-edition-masthead-text">
              <div className="gl-edition-label">{mastheadLabel}</div>
              <div className="gl-edition-date">{mastheadTitle}</div>
              <div className="gl-edition-status">
                {mastheadMeta.map(function(m, i) {
                  return (
                    <React.Fragment key={i}>
                      {i > 0 && <span className="gl-edition-sep">·</span>}
                      <span className="gl-edition-stat">{m}</span>
                    </React.Fragment>
                  );
                })}
              </div>
              <button className="gl-mast-briefing-btn"
                onClick={function(){ if (onNav) onNav('briefing'); }}>
                <MiraBlockMark size={11} />
                Mira's Briefing
              </button>
            </div>
          </div>
          {mastheadSubItems.length >= 2 && (
            <SubtopicRow
              items={mastheadSubItems}
              active={mastheadSubActive}
              onSelect={function(id) { updateSectionFilter(_mhdSubKey, id); }}
            />
          )}
        </div>

        {/* ── Mobile sticky subtopic bar: mirrors masthead row but sticks below page-header ── */}
        {mastheadSubItems.length >= 2 && (
          <div className="gl-subtopic-bar">
            <SubtopicRow
              items={mastheadSubItems}
              active={mastheadSubActive}
              onSelect={function(id) { updateSectionFilter(_mhdSubKey, id); }}
            />
          </div>
        )}

        {/* ── Unified scroll: Today section + all topic sections in one flow ── */}
        <div className="gl-unified-scroll">

          {/* Today sentinel + homepage cards */}
          <section id="topic-section-today" data-section-id="today" className="gl-today-section">

            {(function() {
              var _tf = sectionFilter['today'] || null;
              if (!_tf) {
                // Default: personalized hero + grid + learn something
                return (
                  <React.Fragment>
                    {heroArticle && (
                      <div className="gl-feature-grid">
                        <div className="gl-feat-hero">
                          <TodayHero article={heroArticle} onOpen={openBriefAndMark} />
                        </div>
                        <div className="gl-feat-side">
                          {(function() {
                            var first = mergedGrid[0]; var second = mergedGrid[1];
                            return (
                              <React.Fragment>
                                {first && (first._isCluster
                                  ? <EventClusterCard cluster={first.cluster} size="medium" onOpen={onOpenCluster} />
                                  : <TodayStoryCard article={first.article} size="medium" onOpen={openBriefAndMark} />)}
                                {second && (second._isCluster
                                  ? <EventClusterCard cluster={second.cluster} size="medium" onOpen={onOpenCluster} />
                                  : <TodayStoryCard article={second.article} size="medium" onOpen={openBriefAndMark} />)}
                              </React.Fragment>
                            );
                          })()}
                        </div>
                      </div>
                    )}
                    {mergedGrid.length > 2 && (
                      <div className="gl-section">
                        <div className="gl-section-hd">
                          <h2 className="gl-section-title">Latest</h2>
                        </div>
                        <div className="gt2-story-grid">
                          {mergedGrid.slice(2).map(function(item, i) {
                            if (item._isCluster) {
                              return <EventClusterCard key={item.cluster.id} cluster={item.cluster} size={variedCardSize(i)} onOpen={onOpenCluster} />;
                            }
                            return <TodayStoryCard key={item.article.id || i} article={item.article} size={variedCardSize(i)} onOpen={openBriefAndMark} />;
                          })}
                        </div>
                      </div>
                    )}
                    <div className="gl-section">
                      <div className="gl-section-hd">
                        <h2 className="gl-section-title">Learn something today</h2>
                      </div>
                      <div className="gt2-daily-row">
                        <TodayQuizCard onNav={onNav} />
                        <TodayFactCard />
                        <TodayGlobeCard onNav={onNav} />
                      </div>
                    </div>
                  </React.Fragment>
                );
              }
              // Subtopic view — re-rank personalFeed, never hard-filter
              var _ranked;
              if (_tf === 'latest') {
                _ranked = personalFeed.slice().sort(function(a, b) { return new Date(b.published || 0) - new Date(a.published || 0); });
              } else if (_tf === 'most important') {
                _ranked = personalFeed.slice().sort(function(a, b) { return (b.significance || 0) - (a.significance || 0); });
              } else {
                _ranked = reRankBySubtopic(personalFeed, _tf);
              }
              return (
                <div className="gl-section">
                  <div className="gt2-story-grid">
                    {_ranked.map(function(a, i) {
                      return <TodayStoryCard key={a.id || i} article={a} size={variedCardSize(i)} onOpen={openBriefAndMark} />;
                    })}
                  </div>
                </div>
              );
            })()}

          </section>

          {/* Topic sections — one per topic, all always in the DOM */}
          {topicSections.length > 0 ? topicSections.map(function(section) {
            var _seen = new Set();
            var _stories = section.stories.filter(function(a) {
              var key = a.id || a.url;
              if (_seen.has(key)) return false;
              _seen.add(key);
              return true;
            });
            var _sectionId = 'topic-' + section.topic.toLowerCase();
            var _activeFilter = sectionFilter[_sectionId] || null;
            var _displayStories = _activeFilter
              ? reRankBySubtopic(_stories, _activeFilter)
              : _stories.slice().sort(function(a, b) {
                  return new Date(b.published || 0) - new Date(a.published || 0);
                });
            return (
              <section key={section.topic} id={'topic-section-' + section.topic}
                data-section-id={section.topic.toLowerCase()} data-topic={section.topic}
                className="gl-tp-section">
                <div className="gl-tp-section-header">
                  <span className="gl-tp-section-label" style={{ color: section.color }}>{section.topic}</span>
                </div>
                {_displayStories.length > 0 ? (
                  <div className="gl-section">
                    <div className="gt2-story-grid gt2-story-grid--topic">
                      {_displayStories.map(function(a, i) {
                        return (
                          <TodayStoryCard key={a.id || a.url || i} article={a} size="small" onOpen={openBriefAndMark} />
                        );
                      })}
                    </div>
                  </div>
                ) : (
                  <p className="gl-tp-empty">No stories for this topic today.</p>
                )}
              </section>
            );
          }) : (
            <p className="gl-tp-empty">No topic stories today.</p>
          )}

        </div> {/* end gl-unified-scroll */}

        <GloblyPageFooter onNav={onNav} />
      </div> {/* end gl-rail-content */}

    </div> {/* end gl-today-main */}

    {/* ── Right-hand panel — desktop only ── */}
    {(function() {
      var panelCards = popularArticles
        .filter(function(a) { return a.id !== heroId; })
        .slice(0, 3);
      if (!panelCards.length) return null;
      return (
        <div className="gl-rhs-panel">
          <GloblyVideoBox />
          {panelCards.map(function(a, i) {
            return <TodayStoryCard key={a.id || a.url || i} article={a} size="small" onOpen={openBriefAndMark} />;
          })}
        </div>
      );
    })()}

    </div>
  );
}

// ─── Topic Edition Screen ─────────────────────────────────────────────────────
// Reuses the TodayScreen layout exactly, filtered to one topic.
// Tone gate: GRAVE_TOPIC_NAMES topics get a muted, desaturated background.

function TopicEditionScreen(props) {
  var articles    = props.articles;
  var topic       = props.topic;
  var onBack      = props.onBack;
  var onOpenStory = props.onOpenStory;

  var theme   = PAGE_THEMES[topic] || {};
  var hex     = TOPIC_COLORS[topic] || '#7A8A9A';
  var rgb     = hexRgb(hex);
  var isGrave = !!GRAVE_TOPIC_NAMES[topic];

  // ── Story pool: canonical topic match only — no keyword bleed-over ──
  // Uses getArticleTopic (same classifier that drives the pill badge) so
  // the filter and the display are always consistent. Articles whose topic
  // does not match are never shown, even if they mention related keywords.
  var topicStories = React.useMemo(function() {
    var pref    = loadPref(lsGet(LS.SETUP, null));
    var seenMap = loadSeen();
    var filtered = articles.filter(function(a) { return !a._clusterHidden && getArticleTopic(a) === topic; });
    return sliceRanked(scoreAndRank(filtered, pref, seenMap, 0.25), 40);
  }, [articles.length, topic]);

  // ── Hero: fresh-only, newest-first within candidates, stable by daily seed ──
  var heroArticle = React.useMemo(function() {
    if (!topicStories.length) return null;
    var fresh = topicStories.filter(function(a) { return isFresh(a); });
    if (!fresh.length) return null;
    var seed  = dailySeed();
    var cands = fresh.filter(function(a) { return a.significance >= 3; });
    if (!cands.length) cands = fresh;
    cands = cands.slice().sort(function(a, b) {
      return new Date(b.published || 0) - new Date(a.published || 0);
    });
    var top = cands.slice(0, Math.min(5, cands.length));
    return top[Math.abs(seed) % top.length];
  }, [topicStories.length]);

  var heroId      = heroArticle ? heroArticle.id              : null;
  var heroUrl     = heroArticle ? heroArticle.url             : null;
  var heroCluster = heroArticle ? (heroArticle.clusterId || null) : null;

  function isSameCluster(a) {
    if (!heroArticle) return false;
    if (a.id  === heroId)  return true;
    if (a.url === heroUrl) return true;
    if (heroCluster && a.clusterId && a.clusterId === heroCluster) return true;
    return false;
  }

  var restStories   = topicStories.filter(function(a) { return !isSameCluster(a); });
  var sideStories   = restStories.slice(0, 2);
  var latestStories = restStories.slice(2, 14);

  function openBriefAndMark(article) {
    markSeen(article.url);
    recordSignal(article, 'open');
    if (onOpenStory) onOpenStory(article);
  }

  var lastUpdatedStr = React.useMemo(function() {
    if (!topicStories.length) return '';
    var newest = topicStories.reduce(function(acc, a) {
      return (!acc || (a.published || '') > (acc.published || '')) ? a : acc;
    }, null);
    return newest ? relTime(newest.published) : '';
  }, [topicStories.length]);

  // ── Background: dark gradient tinted with topic colour ──
  var glow1, glow2, glowTop;
  if (isGrave) {
    var hslArr  = hexToHsl(hex);
    var muteRgb = hslToRgbStr(hslArr[0], 18, 26);
    glow1   = 'rgba(' + muteRgb + ',0.44)';
    glow2   = 'rgba(' + muteRgb + ',0.24)';
    glowTop = 'rgba(' + muteRgb + ',0.09)';
  } else {
    glow1   = theme.glow  || ('rgba(' + rgb + ',0.50)');
    glow2   = theme.glow2 || ('rgba(' + rgb + ',0.20)');
    glowTop = 'rgba(' + rgb + ',0.13)';
  }
  var pageStyle = {
    background:
      'radial-gradient(ellipse 120% 56% at 20% 100%, ' + glow1   + ' 0%, rgba(4,5,10,0) 60%),' +
      'radial-gradient(ellipse 80%  44% at 85%  98%, ' + glow2   + ' 0%, rgba(4,5,10,0) 56%),' +
      'radial-gradient(ellipse 48%  32% at 78%   4%, ' + glowTop + ' 0%, transparent 58%),' +
      '#04050A'
  };
  var titleStyle = isGrave ? {} : { color: hex };

  return (
    <div className="gl-today gl-te" style={pageStyle}>

      <div className="gl-today-main">

        {/* ── Back navigation ── col 1 of desktop grid */}
        <div className="gl-te-nav">
          <button className="gl-back-btn" onClick={onBack}>← Back</button>
        </div>

        {/* ── Main content ── col 2 of desktop grid (mirrors gl-rail-content in TodayScreen) */}
        <div className="gl-rail-content">

          {/* Edition masthead */}
          <div className="gl-edition-masthead">
            <div className="gl-edition-masthead-text">
              <div className="gl-edition-label">Today's Edition</div>
              <div className="gl-edition-date" style={titleStyle}>{topic}</div>
              <div className="gl-edition-status">
                {topicStories.length > 0 && (
                  <span className="gl-edition-stat">{topicStories.length} stories</span>
                )}
                {lastUpdatedStr && (
                  <React.Fragment>
                    <span className="gl-edition-sep">·</span>
                    <span className="gl-edition-stat">Updated {lastUpdatedStr}</span>
                  </React.Fragment>
                )}
              </div>
            </div>
          </div>

          {/* Feature grid: hero + 2 side cards */}
          {heroArticle && (
            <div className="gl-feature-grid">
              <div className="gl-feat-hero">
                <TopicHeroCard article={heroArticle} topic={topic} isGrave={isGrave} onOpen={openBriefAndMark} />
              </div>
              {sideStories.length > 0 && (
                <div className="gl-feat-side">
                  {sideStories[0] && (
                    <TodayStoryCard article={sideStories[0]} size="medium" onOpen={openBriefAndMark} />
                  )}
                  {sideStories[1] && (
                    <TodayStoryCard article={sideStories[1]} size="medium" onOpen={openBriefAndMark} />
                  )}
                </div>
              )}
            </div>
          )}

          {/* Latest: remaining stories in topic */}
          {latestStories.length > 0 && (
            <div className="gl-section">
              <div className="gl-section-hd">
                <h2 className="gl-section-title">Latest</h2>
              </div>
              <div className="gt2-story-grid gt2-story-grid--topic">
                {latestStories.map(function(a, i) {
                  return (
                    <TodayStoryCard key={a.id || i} article={a} size="small" onOpen={openBriefAndMark} />
                  );
                })}
              </div>
            </div>
          )}

          {/* Empty state */}
          {topicStories.length === 0 && (
            <p className="gl-te-empty">No {topic} stories today.</p>
          )}

          <div className="gt2-bottom-pad" />

        </div>
      </div>

      {/* ── Right-hand panel — desktop only; video box left as-is ── */}
      <div className="gl-rhs-panel">
        <GloblyVideoBox />
      </div>

    </div>
  );
}

function ArticleCard(props) {
  var article = props.article;
  var style   = props.style;
  var dragX   = props.dragX || 0;

  var hasBg = article.image_url;
  var region = getArticleRegion(article);

  // One-line takeaway: first 110 chars of summary, ending at a word boundary
  var takeaway = (article.summary || "").replace(/<[^>]*>/g, "").trim();
  if (takeaway.length > 110) {
    var cut = takeaway.lastIndexOf(" ", 110);
    takeaway = takeaway.slice(0, cut > 60 ? cut : 110) + "…";
  }

  var likeHint  = dragX > 40;
  var skipHint  = dragX < -40;

  return (
    <div className="g-card" style={style}>
      {hasBg && (
        <div className="g-card-bg" style={{ backgroundImage: "url('" + article.image_url + "')" }} />
      )}
      <div className={"g-card-overlay" + (!hasBg ? " g-card-overlay--solid" : "")} />

      {likeHint  && <div className="g-swipe-stamp g-swipe-stamp--like">READ ✓</div>}
      {skipHint  && <div className="g-swipe-stamp g-swipe-stamp--skip">SKIP</div>}

      <div className="g-card-body">
        <div className="g-card-tags">
          {region && <span className="g-tag">{region}</span>}
          {article.country && <span className="g-tag g-tag--country">{article.country}</span>}
        </div>
        <h2 className="g-card-headline">{article.title}</h2>
        <p className="g-card-takeaway">{takeaway}</p>
        <a href={article.url} target="_blank" rel="noopener noreferrer" className="g-card-source" onClick={function(e){ e.stopPropagation(); }}>
          Read full story ↗
        </a>
      </div>
    </div>
  );
}

// ─── Ghost layer — atmospheric background text ────────────────────────────────
// Edit these phrase lists freely; leave layout (top/left/size/rotate) as-is.



// ─── Play Hub ─────────────────────────────────────────────────────────────────

var PH_GAMES = [
  { key:"quiz",    LIcon: Crosshair, name:"Daily Challenge",   desc:"5 questions on global development. Resets at midnight.",       accent:"#E0A458" },
  { key:"country", LIcon: GlobeIcon, name:"Guess the Country", desc:"Identify the hidden country using distance & HDI clues. 6 tries.", accent:"#5E6AD2" },
  { key:"hilo",    LIcon: BarChart3, name:"Higher or Lower",   desc:"Which country has the higher HDI? Build your streak.",         accent:"#22C55E" },
];

function PlayScreen(props) {
  var onGameChange = props.onGameChange || function(){};

  var _game = React.useState(null);
  var game = _game[0], setGame = _game[1];

  function enterGame(key) { setGame(key); onGameChange(key); }
  function exitGame()     { setGame(null); onGameChange(null); }

  if (game === "quiz")    return <QuizGame    onBack={exitGame} />;
  if (game === "country") return <CountryGuesserGame onBack={exitGame} />;
  if (game === "hilo")    return <HiLoGame    onBack={exitGame} />;

  // Read today's quiz progress for hero card live state
  var SK_quiz  = 'qz_' + todayKey();
  var quizSaved = (lsGet(QZ_LS, {}))[SK_quiz] || null;
  var quizDone  = !!(quizSaved && quizSaved.done);
  var quizScore = quizSaved ? (quizSaved.score || 0) : 0;
  var totalQ    = getDailyGameQuestions().length || 5;

  // Read Country Guesser state for card live state
  var SK_cg    = 'cg_' + todayKey();
  var cgSaved  = (lsGet(LS.GAME, {}))[SK_cg] || { guesses:[], done:false, won:false };
  var cgCount  = (cgSaved.guesses || []).length;
  var cgDone   = cgSaved.done;
  var cgWon    = cgSaved.won;

  // Read HiLo best for card live state
  var hiloBest = lsGet(HL_BEST_KEY, 0);

  return (
    <div className="ph-screen">
      <div className="ph-header-wrap">
        <div className="ph-header-glow" aria-hidden="true" />
        <div className="ph-header">
          <div className="ph-header-eyebrow">Globally Games</div>
          <h2 className="ph-title">Play</h2>
          <p className="ph-sub">Build your global-literacy streak through quick daily challenges.</p>
        </div>
      </div>

      {/* ── Hero: Daily Challenge ── */}
      <button className="ph-hero" onClick={function(){ enterGame("quiz"); }}>
        <div className="ph-hero-bg" aria-hidden="true" />
        <div className="ph-hero-top">
          <span className="ph-hero-label">DAILY</span>
          <span className="ci-wrap" style={{ background:"rgba(224,164,88,0.16)" }}>
            <Crosshair size={20} color="#E0A458" strokeWidth={1.5} />
          </span>
        </div>
        <div className="ph-hero-title">Daily Challenge</div>
        <div className="ph-hero-sub">5 questions on global development.</div>
        <div className="ph-hero-state">
          {quizDone
            ? <span className="ph-hero-score">{quizScore}/{totalQ} today · Resets at midnight</span>
            : <span className="ph-hero-ready">Today's challenge is ready</span>
          }
        </div>
        <div className="ph-hero-cta">
          <span className="ph-hero-btn">{quizDone ? 'Review results →' : 'Start challenge →'}</span>
        </div>
      </button>

      <div className="ph-cards-grid">
        {/* ── Guess the Country card ── */}
        <button className="ph-card ph-card--country" onClick={function(){ enterGame("country"); }}>
          <div className="ph-card-bg" aria-hidden="true" />
          <div className="ph-card-top">
            <span className="ph-card-label">GEOGRAPHY</span>
            <span className="ci-wrap" style={{ background:"rgba(40,28,18,0.08)" }}>
              {GlobeIcon ? <GlobeIcon size={18} color="#9A7020" strokeWidth={1.5} /> : null}
            </span>
          </div>
          <div className="ph-card-title">Guess the Country</div>
          <div className="ph-card-sub">Identify the hidden country using distance &amp; HDI clues.</div>
          <div className="ph-card-state">
            {cgDone && cgWon
              ? <span className="ph-card-ready">Solved in {cgCount}/6!</span>
              : cgDone
                ? <span className="ph-card-score">Better luck tomorrow · resets at midnight</span>
                : cgCount > 0
                  ? <span className="ph-card-score">{cgCount}/6 guesses used</span>
                  : <span className="ph-card-ready">Today's country is ready</span>
            }
          </div>
          <div className="ph-card-cta">
            <span className="ph-card-btn ph-card-btn--country">
              {cgDone ? 'Review →' : cgCount > 0 ? 'Continue →' : 'Play country guesser →'}
            </span>
          </div>
        </button>

        {/* ── Higher or Lower card ── */}
        <button className="ph-card ph-card--hilo" onClick={function(){ enterGame("hilo"); }}>
          <div className="ph-card-bg" aria-hidden="true" />
          <div className="ph-card-top">
            <span className="ph-card-label">STREAK GAME</span>
            <span className="ci-wrap" style={{ background:"rgba(40,28,18,0.08)" }}>
              {BarChart3 ? <BarChart3 size={18} color="#9A7020" strokeWidth={1.5} /> : null}
            </span>
          </div>
          <div className="ph-card-title">Higher or Lower</div>
          <div className="ph-card-sub">Which country has the higher HDI? Build your streak.</div>
          <div className="ph-card-state">
            <span className={hiloBest > 0 ? "ph-card-score" : "ph-card-ready"}>
              {hiloBest > 0 ? 'Best streak: ' + hiloBest : 'Start your streak'}
            </span>
          </div>
          <div className="ph-card-cta">
            <span className="ph-card-btn ph-card-btn--hilo">{hiloBest > 0 ? 'Keep going →' : 'Start streak →'}</span>
          </div>
        </button>
      </div>
    </div>
  );
}

// ─── Daily Challenge quiz ─────────────────────────────────────────────────────

var QZ_LS = "globally_quiz";

function QuizGame(props) {
  var onBack = props.onBack;
  var questions = React.useMemo(getDailyGameQuestions, []);

  var _qIdx = React.useState(0);         var qIdx     = _qIdx[0],     setQIdx     = _qIdx[1];
  var _sel  = React.useState(null);      var selected = _sel[0],      setSelected = _sel[1];
  var _score= React.useState(0);         var score    = _score[0],    setScore    = _score[1];
  var _done = React.useState(false);     var done     = _done[0],     setDone     = _done[1];
  var _copied=React.useState(false);     var copied   = _copied[0],   setCopied   = _copied[1];

  // Load today's saved progress
  var SK = 'qz_' + todayKey();
  var _init = React.useMemo(function(){
    var s = lsGet(QZ_LS, {});
    return s[SK] || null;
  }, []);
  var _started = React.useState(!!_init);
  var started = _started[0], setStarted = _started[1];
  // Restore state if already played today
  React.useEffect(function() {
    var s = lsGet(QZ_LS, {});
    var saved = s[SK];
    if (saved && saved.done) {
      setScore(saved.score || 0);
      setQIdx(questions.length);
      setDone(true);
    }
  }, []);

  if (!questions.length) {
    return (
      <div className="qz-screen">
        <button className="ph-back" onClick={onBack}>← Games</button>
        <p style={{ color:'rgba(255,255,255,0.5)', marginTop:32, textAlign:'center' }}>No questions available.</p>
      </div>
    );
  }

  function pickAnswer(idx) {
    if (selected !== null) return;
    setSelected(idx);
    var correct = idx === questions[qIdx].answer;
    var ns = correct ? score + 1 : score;
    if (correct) setScore(ns);
    var isLast = qIdx === questions.length - 1;
    if (isLast) {
      setDone(true);
      var s = lsGet(QZ_LS, {});
      s[SK] = { done: true, score: ns };
      lsSet(QZ_LS, s);
    }
  }

  function next() {
    if (qIdx < questions.length - 1) {
      setQIdx(function(i){ return i + 1; });
      setSelected(null);
    }
  }

  function share() {
    var text = 'Globally Daily Challenge — ' + todayKey() +
      '\n' + score + '/' + questions.length + ' correct\nglobally.app';
    if (navigator.clipboard) {
      navigator.clipboard.writeText(text).then(function(){
        setCopied(true); setTimeout(function(){ setCopied(false); }, 2000);
      });
    }
  }

  if (done) {
    return (
      <div className="qz-screen qz-done">
        <button className="ph-back" style={{ alignSelf:'flex-start' }} onClick={onBack}>← Games</button>
        <div className="qz-done-icon">
          {score >= 4
            ? (Trophy ? <Trophy size={36} strokeWidth={1.5} color="#E0A458" /> : null)
            : score >= 2
              ? (ThumbsUp ? <ThumbsUp size={36} strokeWidth={1.5} color="#5E6AD2" /> : null)
              : (BookOpen ? <BookOpen size={36} strokeWidth={1.5} color="#9AAABB" /> : null)}
        </div>
        <h2 className="qz-done-title">{score}/{questions.length} correct</h2>
        <p className="qz-done-sub">{score === questions.length ? 'Perfect score!' : score >= 3 ? 'Good work.' : 'Keep reading.'}</p>
        <button className="cg-share-btn" onClick={share}>{copied ? '✓ Copied!' : 'Share result'}</button>
        <p className="cg-done-sub">New questions at midnight.</p>
      </div>
    );
  }

  var q = questions[qIdx];
  var isLast = qIdx === questions.length - 1;

  return (
    <div className="qz-screen">
      <button className="ph-back" onClick={onBack}>← Games</button>
      <div className="qz-hd">
        <div>
          <div className="qz-eyebrow">DAILY CHALLENGE</div>
          <h2 className="qz-q-label">Question {qIdx + 1} <span className="qz-q-of">of {questions.length}</span></h2>
        </div>
      </div>
      <div className="qz-progress">
        <div className="qz-progress-fill" style={{ width: ((qIdx + (selected !== null ? 1 : 0)) / questions.length * 100) + '%' }} />
      </div>
      <p className="qz-question">{q.q}</p>
      <div className="qz-options">
        {q.options.map(function(opt, i) {
          var state = selected === null ? '' :
            i === q.answer ? 'correct' :
            i === selected ? 'wrong' : 'dim';
          return (
            <button key={i}
              className={'qz-opt qz-opt--' + (state || 'idle')}
              onClick={function(){ pickAnswer(i); }}
              disabled={selected !== null}>
              {opt}
            </button>
          );
        })}
      </div>
      {selected !== null && (
        <div className="qz-explain">
          <span className="qz-explain-label">{selected === q.answer ? '✓ Correct' : '✗ Wrong'}</span>
          <p className="qz-explain-text">{q.explain}</p>
          {!isLast
            ? <button className="qz-next-btn" onClick={next}>Next question →</button>
            : <button className="qz-next-btn" onClick={function(){ setDone(true); var s=lsGet(QZ_LS,{}); s[SK]={done:true,score:score}; lsSet(QZ_LS,s); }}>See results →</button>
          }
        </div>
      )}
    </div>
  );
}

// ─── Higher or Lower (HDI) game ───────────────────────────────────────────────

function hlHdiTier(hdi) {
  if (hdi == null) return 'Development score';
  if (hdi >= 0.800) return 'Very High HDI';
  if (hdi >= 0.700) return 'High HDI';
  if (hdi >= 0.550) return 'Medium HDI';
  return 'Low HDI';
}

var HL_BEST_KEY = "globally_hl_best";

function hlBuildPool() {
  return Object.keys(HDI_DATA)
    .map(function(id){ return { id:+id, name:HDI_DATA[+id].name, hdi:HDI_DATA[+id].hdi }; })
    .filter(function(c){ return c.hdi !== null; });
}

function hlPick(pool, excludeIds, count) {
  var available = pool.filter(function(c){ return excludeIds.indexOf(c.id) === -1; });
  // Fisher-Yates shuffle then take count
  var arr = available.slice();
  for (var i = arr.length - 1; i > 0; i--) {
    var j = Math.floor(Math.random() * (i + 1));
    var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
  }
  return arr.slice(0, count);
}

function HiLoGame(props) {
  var onBack = props.onBack;
  var pool = React.useMemo(hlBuildPool, []);

  // phase: "pick" | "reveal" | "done"
  var _left   = React.useState(null);    var left    = _left[0],   setLeft    = _left[1];
  var _right  = React.useState(null);    var right   = _right[0],  setRight   = _right[1];
  var _score  = React.useState(0);       var score   = _score[0],  setScore   = _score[1];
  var _phase  = React.useState("pick");  var phase   = _phase[0],  setPhase   = _phase[1];
  var _picked = React.useState(null);    var picked  = _picked[0], setPicked  = _picked[1];
  var _copied = React.useState(false);   var copied  = _copied[0], setCopied  = _copied[1];
  var _best   = React.useState(function(){ return lsGet(HL_BEST_KEY, 0); });
  var best = _best[0], setBest = _best[1];

  // Initialise first pair
  React.useEffect(function() {
    if (pool.length >= 2 && !left) {
      var pair = hlPick(pool, [], 2);
      setLeft(pair[0]); setRight(pair[1]);
    }
  }, [pool.length]);

  function onPick(side) {
    if (phase !== "pick" || !left || !right) return;
    var winner = left.hdi >= right.hdi ? "left" : "right";
    var correct = side === winner;
    setPicked(side);
    setPhase("reveal");

    if (correct) {
      var ns = score + 1;
      setScore(ns);
      if (ns > best) { setBest(ns); lsSet(HL_BEST_KEY, ns); }
      // After brief reveal, advance: keep winner, draw new challenger
      setTimeout(function() {
        var keeper = side === "left" ? left : right;
        var fresh = hlPick(pool, [keeper.id], 1);
        if (!fresh.length) { setPhase("done"); return; }
        if (side === "left") { setRight(fresh[0]); }
        else                 { setLeft(fresh[0]); }
        setPicked(null);
        setPhase("pick");
      }, 1400);
    } else {
      setTimeout(function(){ setPhase("done"); }, 1400);
    }
  }

  function restart() {
    var pair = hlPick(pool, [], 2);
    setLeft(pair[0]); setRight(pair[1]);
    setScore(0); setPicked(null); setPhase("pick");
  }

  function share() {
    var text = 'Globally Higher or Lower — Score: ' + score + ' in a row! Best: ' + best + '\nglobally.app';
    if (navigator.clipboard) {
      navigator.clipboard.writeText(text).then(function(){
        setCopied(true); setTimeout(function(){ setCopied(false); }, 2000);
      });
    }
  }

  if (!left || !right) return <div className="hl-screen"><p style={{color:'rgba(255,255,255,0.4)',textAlign:'center',paddingTop:60}}>Loading…</p></div>;

  var winner = left && right ? (left.hdi >= right.hdi ? "left" : "right") : null;

  if (phase === "done") {
    return (
      <div className="hl-screen hl-done">
        <button className="ph-back" style={{ alignSelf:'flex-start' }} onClick={onBack}>← Games</button>
        <div className="qz-done-icon">
          {score >= 10
            ? (Trophy ? <Trophy size={36} strokeWidth={1.5} color="#E0A458" /> : null)
            : score >= 5
              ? (Sparkles ? <Sparkles size={36} strokeWidth={1.5} color="#22C55E" /> : null)
              : (Frown ? <Frown size={36} strokeWidth={1.5} color="#9AAABB" /> : null)}
        </div>
        <h2 className="qz-done-title">{score} in a row</h2>
        <p className="qz-done-sub">Best streak: {best}</p>
        <div className="hl-done-reveal">
          {[left, right].map(function(c, i) {
            var side = i === 0 ? "left" : "right";
            var isWin = side === winner;
            return (
              <div key={i} className={'hl-done-card' + (isWin ? ' hl-done-card--win' : '')}>
                <span className="hl-done-country">{c.name}</span>
                <span className="hl-done-hdi">HDI {c.hdi.toFixed(3)}</span>
                {isWin && <span className="hl-done-higher">Higher ↑</span>}
              </div>
            );
          })}
        </div>
        <button className="cg-share-btn" onClick={share}>{copied ? '✓ Copied!' : 'Share result'}</button>
        <button className="hl-play-again" onClick={restart}>Play again</button>
      </div>
    );
  }

  return (
    <div className="hl-screen">
      <button className="ph-back" onClick={onBack}>← Games</button>
      <div className="hl-game-header">
        <div className="qz-eyebrow">HIGHER OR LOWER</div>
        <h2 className="hl-game-prompt">Which country has the <strong>higher HDI</strong>?</h2>
      </div>
      <div className="hl-streaks">
        <span className="hl-streak-item">
          <span className="hl-streak-num">{score}</span>
          <span className="hl-streak-lbl">streak</span>
        </span>
        <span className="hl-streak-div">·</span>
        <span className="hl-streak-item">
          <span className="hl-streak-num">{best}</span>
          <span className="hl-streak-lbl">best</span>
        </span>
      </div>

      <div className="hl-cards">
        {[{ country: left, side: "left" }, { country: right, side: "right" }].map(function(item) {
          var c = item.country, side = item.side;
          var isCorrect = phase === "reveal" && side === winner;
          var isWrong   = phase === "reveal" && picked === side && side !== winner;
          var cls = "hl-card" +
            (isCorrect ? " hl-card--correct" : "") +
            (isWrong   ? " hl-card--wrong"   : "");
          return (
            <button key={side} className={cls}
              onClick={function(){ onPick(side); }}
              disabled={phase !== "pick"}>
              <span className="hl-tier">{phase === "reveal" ? hlHdiTier(c.hdi) : 'Human Development'}</span>
              <span className="hl-country">{c.name}</span>
              {phase === "reveal"
                ? <span className="hl-hdi">HDI {c.hdi.toFixed(3)}</span>
                : <span className="hl-hdi hl-hdi--hidden">?</span>
              }
              {phase === "pick" && <span className="hl-tap">Tap to choose</span>}
              {isCorrect && <span className="hl-result-badge hl-result-badge--correct">✓ Higher</span>}
              {isWrong   && <span className="hl-result-badge hl-result-badge--wrong">✗ Lower</span>}
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ─── Country Guesser game ─────────────────────────────────────────────────────

function CountryGuesserGame(props) {
  var onBack = props.onBack;
  var POOL   = React.useMemo(cgBuildPool, []);
  var TARGET = React.useMemo(function(){ return cgDailyTarget(POOL); }, [POOL.length]);
  var LK     = React.useMemo(cgNameLookup, []);
  var NAMES  = React.useMemo(function(){
    var ns = Object.values(HDI_DATA).map(function(d){ return d.name; }).sort();
    return ns;
  }, []);

  var MAX = 6;
  var SK  = 'cg_' + todayKey();

  var _saved = React.useMemo(function(){
    var g = lsGet(LS.GAME, {});
    return g[SK] || { guesses:[], done:false, won:false };
  }, []);

  var _gu = React.useState(_saved.guesses); var guesses = _gu[0], setGuesses = _gu[1];
  var _dn = React.useState(_saved.done);    var done    = _dn[0], setDone    = _dn[1];
  var _wn = React.useState(_saved.won);     var won     = _wn[0], setWon     = _wn[1];
  var _in = React.useState('');             var inp     = _in[0], setInp     = _in[1];
  var _sg = React.useState([]);             var sugg    = _sg[0], setSugg    = _sg[1];
  var _er = React.useState('');             var err     = _er[0], setErr     = _er[1];
  var _cp = React.useState(false);          var copied  = _cp[0], setCopied  = _cp[1];

  function persist(gs, isDone, isWon) {
    var g = lsGet(LS.GAME, {});
    g[SK] = { guesses:gs, done:isDone, won:isWon };
    lsSet(LS.GAME, g);
    if (isDone) loadStreak();
  }

  function onInput(v) {
    setInp(v); setErr('');
    setSugg(v.length > 0 ? cgAutocomplete(v, NAMES) : []);
  }

  function submit(name) {
    var nm = (name || '').trim();
    if (!nm) return;
    setSugg([]); setInp('');
    var id = LK[nm.toLowerCase()];
    if (id == null) { setErr('Country not recognised — pick from the list.'); return; }
    if (guesses.some(function(g){ return g.id === id; })) {
      setErr('Already guessed!'); return;
    }
    var info   = HDI_DATA[id];
    var coords = COUNTRY_COORDS[id];
    var dist   = coords ? cgHaversine(coords[0],coords[1],TARGET.lat,TARGET.lng) : null;
    var isWin  = id === TARGET.id;
    var row = {
      id:id, name:info.name, hdi:info.hdi,
      dist:isWin ? 0 : dist,
      dir:(coords && !isWin && dist > 0)
        ? cgArrow(cgBearing(coords[0],coords[1],TARGET.lat,TARGET.lng)) : null,
      won:isWin,
    };
    var ng = guesses.concat([row]);
    var isDone = isWin || ng.length >= MAX;
    setGuesses(ng); setErr('');
    if (isDone) { setDone(true); setWon(isWin); }
    persist(ng, isDone, isWin);
  }

  function onKey(e) {
    if (e.key === 'Enter') { submit(sugg.length > 0 ? sugg[0] : inp); }
    if (e.key === 'Escape') setSugg([]);
  }

  function share() {
    var row = guesses.map(function(g){
      return g.won ? '[+]' : (g.dist == null ? '[ ]' : cgProxEmoji(g.dist));
    }).join('');
    var text = 'Globally Country — ' + todayKey() + '\n' + row +
      '\n' + (won ? 'Solved in ' + guesses.length + '/6!' : 'Answer: ' + TARGET.name) +
      '\nglobally.app';
    if (navigator.clipboard) {
      navigator.clipboard.writeText(text).then(function(){
        setCopied(true); setTimeout(function(){ setCopied(false); }, 2000);
      });
    }
  }

  function renderRow(g, i) {
    var pct  = g.won ? 100 : (g.dist != null ? cgProxPct(g.dist) : 0);
    var pcls = g.won ? 'cg-prox--exact' : (g.dist != null ? cgProxClass(g.dist) : 'cg-prox--cold');
    var hdiDir = '';
    if (!g.won && g.hdi != null && TARGET.hdi != null)
      hdiDir = TARGET.hdi > g.hdi ? 'up' : TARGET.hdi < g.hdi ? 'down' : 'eq';
    return (
      <div key={i} className={'cg-row' + (g.won ? ' cg-row--win' : '')}>
        <div className={'cg-prox ' + pcls}>{g.won ? '✓' : pct + '%'}</div>
        <div className="cg-rname">{g.name}</div>
        <div className="cg-rdist">
          {g.dist != null ? g.dist.toLocaleString() + ' km' : '—'}
          {g.dir && !g.won && <span className="cg-rarrow">{g.dir}</span>}
        </div>
        <div className={'cg-rhdi' + (hdiDir ? ' cg-hdi--' + hdiDir : '')}>
          {g.hdi != null ? g.hdi.toFixed(3) : 'N/D'}
          {hdiDir === 'up'   && <span className="cg-hdi-arrow"> ↑</span>}
          {hdiDir === 'down' && <span className="cg-hdi-arrow"> ↓</span>}
        </div>
      </div>
    );
  }

  if (done) {
    return (
      <div className="cg-screen cg-done">
        <button className="ph-back" style={{ alignSelf:'flex-start', marginBottom:4 }} onClick={onBack}>← Games</button>
        <div className="cg-done-icon">
          {won
            ? (Trophy ? <Trophy size={36} strokeWidth={1.5} color="#5E6AD2" /> : null)
            : (Frown ? <Frown size={36} strokeWidth={1.5} color="#9AAABB" /> : null)}
        </div>
        <h2 className="cg-done-title">
          {won ? 'Got it in ' + guesses.length + '!' : 'Better luck tomorrow'}
        </h2>
        {!won && (
          <p className="cg-done-ans">
            Answer: <strong>{TARGET.name}</strong>
            <span className="cg-done-hdi"> · HDI {TARGET.hdi.toFixed(3)}</span>
          </p>
        )}
        <div className="cg-rows">
          <div className="cg-row-hd">
            <span>Prox.</span><span>Country</span>
            <span>Distance</span><span>HDI</span>
          </div>
          {guesses.map(renderRow)}
        </div>
        <button className="cg-share-btn" onClick={share}>
          {copied ? '✓ Copied!' : 'Share result'}
        </button>
        <p className="cg-done-sub">New country resets at midnight.</p>
      </div>
    );
  }

  return (
    <div className="cg-screen">
      <button className="ph-back" onClick={onBack}>← Games</button>
      <div className="cg-hd">
        <div>
          <div className="qz-eyebrow">COUNTRY GUESSER</div>
          <h2 className="cg-title">Guess the Country</h2>
          <p className="cg-subtitle">Identify the hidden country. 6 tries.</p>
        </div>
        <div className="cg-tries-badge">
          <span className="cg-tries-num">{MAX - guesses.length}</span>
          <span className="cg-tries-lbl">left</span>
        </div>
      </div>

      <div className="cg-body-layout">
        <div className="cg-input-wrap">
          <div className="cg-input-row">
            <input className="cg-input" type="text" placeholder="Type a country…"
              value={inp} autoComplete="off" spellCheck="false"
              onChange={function(e){ onInput(e.target.value); }}
              onKeyDown={onKey} />
            <button className="cg-btn" onClick={function(){ submit(sugg.length ? sugg[0] : inp); }}
              disabled={!inp.trim()}>Guess</button>
          </div>
          {sugg.length > 0 && (
            <div className="cg-sugg">
              {sugg.map(function(s){
                return <button key={s} className="cg-sugg-item"
                  onMouseDown={function(){ submit(s); }}>{s}</button>;
              })}
            </div>
          )}
          {err && <p className="cg-err">{err}</p>}
        </div>

        {guesses.length > 0
          ? <div className="cg-rows">
              <div className="cg-row-hd">
                <span>Prox.</span><span>Country</span>
                <span>Distance</span><span>HDI</span>
              </div>
              {guesses.map(renderRow)}
            </div>
          : <div className="cg-hint-box">
              <p className="cg-hint-hd">After each guess you'll see:</p>
              <p className="cg-hint-item">{MapPin ? <MapPin size={14} strokeWidth={2} style={{verticalAlign:'middle',marginRight:4}} /> : null} Distance from the target in km</p>
              <p className="cg-hint-item">{Compass ? <Compass size={14} strokeWidth={2} style={{verticalAlign:'middle',marginRight:4}} /> : null} Direction arrow pointing toward it</p>
              <p className="cg-hint-item">{BarChart3 ? <BarChart3 size={14} strokeWidth={2} style={{verticalAlign:'middle',marginRight:4}} /> : null} HDI — is the target higher ↑ or lower ↓?</p>
            </div>
        }
      </div>
    </div>
  );
}

// ─── Discover screen ──────────────────────────────────────────────────────────

function DiscoverScreen() {
  var cards = ((window.GLOBLY_DATA || {}).didYouKnow || []);

  return (
    <div className="g-discover">
      <div className="g-discover-header">
        <h2 className="g-discover-title">Did You Know?</h2>
        <p className="g-discover-sub">Surprising facts about global development — screenshot and share.</p>
      </div>
      <div className="g-dyk-list">
        {cards.map(function(card, i) {
          return (
            <div key={i} className="g-dyk-card">
              <ContentIcon iconName={card.icon} size={24} color="#9AAABB" bg="rgba(155,170,187,0.12)" />
              <div className="g-dyk-stat">{card.stat}</div>
              <div className="g-dyk-context">{card.context}</div>
              <div className="g-dyk-brand">Globally</div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ─── Profile screen ───────────────────────────────────────────────────────────

var GL_PROFILE_REGIONS = ['South Asia','Southeast Asia','Middle East','Africa','Latin America','East Asia'];
var GL_PROFILE_TOPICS  = ['Conflict','Health','Economy','Climate','Politics','Trade','Migration','Governance','Debt','Energy','Agriculture','Infrastructure','Diplomacy'];

// ─── Confirmation modal ───────────────────────────────────────────────────────
function ConfirmModal(props) {
  var open         = props.open;
  var title        = props.title;
  var body         = props.body;
  var confirmLabel = props.confirmLabel || 'Yes';
  var cancelLabel  = props.cancelLabel  || 'No, keep it';
  var danger       = props.danger;
  var onConfirm    = props.onConfirm;
  var onCancel     = props.onCancel;

  React.useEffect(function() {
    if (!open) return;
    function onKey(e) { if (e.key === 'Escape') onCancel(); }
    document.addEventListener('keydown', onKey);
    return function() { document.removeEventListener('keydown', onKey); };
  }, [open]);

  if (!open) return null;

  return (
    <div className="gl-modal-backdrop" onClick={onCancel} aria-hidden="true">
      <div
        className="gl-confirm-modal"
        role="dialog"
        aria-modal="true"
        aria-labelledby="gl-confirm-title"
        onClick={function(e) { e.stopPropagation(); }}
      >
        <h2 className="gl-confirm-title" id="gl-confirm-title">{title}</h2>
        <p className="gl-confirm-body">{body}</p>
        <div className="gl-confirm-actions">
          <button className="gl-confirm-btn gl-confirm-btn--cancel" onClick={onCancel}>
            {cancelLabel}
          </button>
          <button
            className={'gl-confirm-btn gl-confirm-btn--ok' + (danger ? ' is-danger' : '')}
            onClick={onConfirm}
          >
            {confirmLabel}
          </button>
        </div>
      </div>
    </div>
  );
}

function ProfileScreen(props) {
  var streak    = props.streak;
  var setup     = props.setup;
  var onReset   = props.onReset;
  var onSignOut = props.onSignOut;
  var onBack    = props.onBack;

  var session  = lsGet(LS.SESSION, null);
  var email    = session && session.email;
  var readCount = Object.keys(lsGet(LS.SEEN, {})).length;

  var _time = React.useState(setup.reminderTime || '08:00');
  var time = _time[0], setTime = _time[1];
  var _timeSaved = React.useState(false);
  var timeSaved = _timeSaved[0], setTimeSaved = _timeSaved[1];
  var _profileConsent = React.useState(setup.emailReminderConsent !== undefined ? setup.emailReminderConsent : null);
  var profileConsent = _profileConsent[0], setProfileConsent = _profileConsent[1];

  var _editing = React.useState(false);
  var editing = _editing[0], setEditing = _editing[1];
  var _selRegions = React.useState(setup.regions || []);
  var selRegions = _selRegions[0], setSelRegions = _selRegions[1];
  var _selTopics = React.useState(setup.topics || []);
  var selTopics = _selTopics[0], setSelTopics = _selTopics[1];

  var _tapCount  = React.useState(0);
  var tapCount   = _tapCount[0], setTapCount = _tapCount[1];
  var _showDebug = React.useState(false);
  var showDebug  = _showDebug[0], setShowDebug = _showDebug[1];

  var _hideRead       = React.useState(!!(setup.hideReadStories));
  var hideRead        = _hideRead[0],       setHideRead        = _hideRead[1];
  var _dailyGoal      = React.useState(setup.dailyGoal || 5);
  var dailyGoal       = _dailyGoal[0],      setDailyGoal       = _dailyGoal[1];
  var _autoPlayVoice  = React.useState(!!(setup.autoPlayVoice));
  var autoPlayVoice   = _autoPlayVoice[0],  setAutoPlayVoice   = _autoPlayVoice[1];
  var _briefLength    = React.useState(setup.defaultBriefLength || 'quick');
  var briefLength     = _briefLength[0],    setBriefLength     = _briefLength[1];
  var _miraReset   = React.useState(false);
  var miraReset    = _miraReset[0],   setMiraReset   = _miraReset[1];
  var _histCleared = React.useState(false);
  var histCleared  = _histCleared[0], setHistCleared = _histCleared[1];
  var _modal = React.useState(null);
  var modal = _modal[0], setModal = _modal[1];

  // "Your World" personal context
  var _worldCtx = React.useState(function(){ return lsGet(LS.WORLD, null); });
  var worldCtx = _worldCtx[0], setWorldCtxState = _worldCtx[1];
  var _editingWorld = React.useState(false);
  var editingWorld = _editingWorld[0], setEditingWorld = _editingWorld[1];
  var _worldDraft = React.useState(null);
  var worldDraft = _worldDraft[0], setWorldDraft = _worldDraft[1];
  var _worldSaved = React.useState(false);
  var worldSaved = _worldSaved[0], setWorldSaved = _worldSaved[1];

  var pref = loadPref(setup);
  var topTopics = Object.keys(pref.topicWeights)
    .sort(function(a, b){ return pref.topicWeights[b] - pref.topicWeights[a]; })
    .slice(0, 7);
  var topRegions = Object.keys(pref.regionWeights)
    .sort(function(a, b){ return pref.regionWeights[b] - pref.regionWeights[a]; })
    .slice(0, 5);

  // Sync current prefs to Resend on profile mount — ensures the user is in the
  // audience even if they signed in on a new device or localStorage was cleared.
  // "null" consent (time set but preference never explicitly chosen) is treated
  // as opted-in — setting a reminder time implies wanting the email.
  React.useEffect(function() {
    var s = lsGet(LS.SESSION, {});
    if (!s || !s.email) return;
    var currentSetup = lsGet(LS.SETUP, {});
    // If consent is null (never chosen) but a time is set, default to opted-in.
    var explicitConsent = currentSetup.emailReminderConsent;
    var enabled = explicitConsent !== false; // null → true, true → true, false → false
    var t  = currentSetup.reminderTime     || '08:00';
    var tz = currentSetup.reminderTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
    // Persist the resolved consent so localStorage stays accurate
    if (explicitConsent === null || explicitConsent === undefined) {
      lsSet(LS.SETUP, Object.assign({}, currentSetup, { emailReminderConsent: true }));
      setProfileConsent(true);
    }
    fetch('/api/save-prefs', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: s.email, emailBriefingsEnabled: enabled, dailyBriefingTime: t, timezone: tz }),
    }).catch(function(){});
  }, []);

  function pushPrefs(emailEnabled, reminderTime, tz) {
    var s = lsGet(LS.SESSION, {});
    if (!s || !s.email) return;
    fetch('/api/save-prefs', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email: s.email,
        emailBriefingsEnabled: emailEnabled,
        dailyBriefingTime: reminderTime,
        timezone: tz,
      }),
    }).catch(function(){});
  }

  function saveTime(t) {
    setTime(t);
    var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;

    // Setting a time implies the user wants email reminders.
    // If they haven't explicitly opted out, default to opted-in.
    var effectiveConsent = profileConsent !== false;
    var newConsent = (profileConsent === null) ? true : profileConsent;

    lsSet(LS.SETUP, Object.assign({}, setup, {
      reminderTime: t,
      reminderTimezone: tz,
      reminderUpdatedAt: new Date().toISOString(),
      emailReminderConsent: newConsent,
    }));

    if (profileConsent === null) {
      setProfileConsent(true);
    }

    setTimeSaved(true);
    setTimeout(function(){ setTimeSaved(false); }, 1800);
    pushPrefs(effectiveConsent, t, tz);
  }

  function saveProfileConsent(val) {
    setProfileConsent(val);
    var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
    lsSet(LS.SETUP, Object.assign({}, setup, {
      emailReminderConsent: val, reminderTimezone: tz, reminderUpdatedAt: new Date().toISOString(),
    }));
    setTimeSaved(true);
    setTimeout(function(){ setTimeSaved(false); }, 1800);
    pushPrefs(val === true, time || setup.reminderTime || '08:00', tz);
  }

  function toggleRegion(r) {
    setSelRegions(selRegions.indexOf(r) !== -1
      ? selRegions.filter(function(x){ return x !== r; })
      : selRegions.concat([r]));
  }
  function toggleTopic(t) {
    setSelTopics(selTopics.indexOf(t) !== -1
      ? selTopics.filter(function(x){ return x !== t; })
      : selTopics.concat([t]));
  }
  function saveInterests() {
    lsSet(LS.SETUP, Object.assign({}, setup, { regions: selRegions, topics: selTopics }));
    setEditing(false);
  }

  function saveHideRead(val) {
    setHideRead(val);
    lsSet(LS.SETUP, Object.assign({}, setup, { hideReadStories: val }));
  }
  function saveDailyGoal(n) {
    setDailyGoal(n);
    lsSet(LS.SETUP, Object.assign({}, setup, { dailyGoal: n }));
  }
  function saveAutoPlayVoice(val) {
    setAutoPlayVoice(val);
    lsSet(LS.SETUP, Object.assign({}, setup, { autoPlayVoice: val }));
  }
  function saveBriefLength(val) {
    setBriefLength(val);
    lsSet(LS.SETUP, Object.assign({}, setup, { defaultBriefLength: val }));
  }
  function doResetMira() {
    lsSet(LS.PREF, seedPrefFromSetup(setup));
    setMiraReset(true);
    setTimeout(function(){ setMiraReset(false); }, 2200);
  }
  function doClearHistory() {
    lsSet(LS.SEEN, {});
    setHistCleared(true);
    setTimeout(function(){ setHistCleared(false); }, 2200);
  }

  function handleVersionTap() {
    var next = tapCount + 1;
    setTapCount(next);
    if (next >= 5) { setShowDebug(true); setTapCount(0); }
  }

  function startEditInterests() {
    // Close world edit (discard unsaved draft) before opening interests edit
    if (editingWorld) {
      setWorldDraft(null);
      setEditingWorld(false);
    }
    setEditing(true);
  }
  function startEditWorld() {
    // Close interests edit (discard unsaved draft) before opening world edit
    if (editing) {
      setSelRegions(setup.regions || []);
      setSelTopics(setup.topics || []);
      setEditing(false);
    }
    var base = worldCtx ? Object.assign({}, worldCtx) : {
      from: '', grewUp: '', livesNow: '', watchPlaces: '', effects: [], level: 'balanced', freeText: ''
    };
    if (!base.effects) base.effects = [];
    setWorldDraft(base);
    setEditingWorld(true);
  }
  function saveWorld() {
    var ctx = Object.assign({}, worldDraft, { savedAt: new Date().toISOString() });
    lsSet(LS.WORLD, ctx);
    setWorldCtxState(ctx);
    setEditingWorld(false);
    setWorldSaved(true);
    setTimeout(function(){ setWorldSaved(false); }, 1800);
  }
  function cancelEditWorld() {
    setWorldDraft(null);
    setEditingWorld(false);
  }
  function toggleWorldEffect(e) {
    var curr = worldDraft ? (worldDraft.effects || []) : [];
    setWorldDraft(Object.assign({}, worldDraft, {
      effects: curr.indexOf(e) !== -1 ? curr.filter(function(x){ return x !== e; }) : curr.concat([e])
    }));
  }

  var displayRegions = editing ? selRegions : (setup.regions || []);
  var displayTopics  = editing ? selTopics  : (setup.topics  || []);

  return (
    <div className="gl-profile-page">

      <header className="gl-profile-topbar">
        <button className="gl-profile-back-btn" onClick={function(){ if (onBack) onBack('today'); }}>← Today</button>
        <span className="gl-profile-wordmark">Globally</span>
        <button className="gl-profile-signout-top-btn" onClick={onSignOut}>Sign out</button>
      </header>

      <main className="gl-profile-shell">

        {/* ── Hero ── */}
        <div className="gl-profile-hero">
          <div className="gl-profile-hero-mark">
            <MiraMark size={64} />
          </div>
          <div className="gl-profile-hero-body">
            <h1 className="gl-profile-hero-title">Your Globally</h1>
            {email && <p className="gl-profile-hero-email">{email}</p>}
            <span className={"gl-profile-plan-badge" + (setup.plan === "plus" ? " gl-profile-plan-badge--plus" : "")}>{setup.plan === "plus" ? "Mira Plus" : "Free account"}</span>
          </div>
        </div>

        {/* ── Stats ── */}
        <div className="gl-profile-stats">
          <div className="gl-profile-stat">
            <span className="gl-profile-stat-num">{streak.current}</span>
            <span className="gl-profile-stat-label">day streak</span>
          </div>
          <div className="gl-profile-stat-sep" />
          <div className="gl-profile-stat">
            <span className="gl-profile-stat-num">{streak.longest}</span>
            <span className="gl-profile-stat-label">best streak</span>
          </div>
          <div className="gl-profile-stat-sep" />
          <div className="gl-profile-stat">
            <span className="gl-profile-stat-num">{readCount}</span>
            <span className="gl-profile-stat-label">stories read</span>
          </div>
        </div>

        {/* ── Mira entry points ── */}
        <div className="gl-profile-mira-actions">
          <button className="gl-profile-mira-btn"
            onClick={function(){ if (onBack) onBack('briefing'); }}>
            <MiraBlockMark size={18} />
            <span>My Briefing</span>
            <span className="gl-profile-mira-btn-arrow">→</span>
          </button>
          <p className="gl-profile-mira-desc">
            Mira pulls together the stories that match your interests, every day.
          </p>
        </div>

        {/* ── Card grid ── */}
        <div className="gl-profile-grid">

          {/* Mira intelligence — full width */}
          <div className="gl-profile-card gl-profile-card--wide gl-profile-card--mira">
            <div className="gl-profile-card-hd">
              <span className="gl-profile-card-label">Mira intelligence</span>
              <span className="gl-profile-card-meta">Updates as you read</span>
            </div>
            <p className="gl-profile-card-note">
              Mira uses your interests, reading, and feedback to shape Today's edition. Stronger bars mean more weight in your feed.
            </p>
            <div className="gl-profile-mira-grid">
              <div className="gl-profile-mira-col">
                <p className="gl-profile-mira-col-hd">Topics</p>
                <div className="gl-profile-mira-bars">
                  {topTopics.map(function(t) {
                    var w = pref.topicWeights[t] || 1.0;
                    var pct = Math.round((w / 3.0) * 100);
                    var color = TOPIC_COLORS[t] || '#11110f';
                    return (
                      <div key={t} className="gl-profile-mira-row">
                        <span className="gl-profile-mira-name">{t}</span>
                        <div className="gl-profile-mira-track">
                          <div className="gl-profile-mira-fill" style={{ width: pct + '%', background: color }} />
                        </div>
                      </div>
                    );
                  })}
                </div>
              </div>
              <div className="gl-profile-mira-col">
                <p className="gl-profile-mira-col-hd">Regions</p>
                <div className="gl-profile-mira-bars">
                  {topRegions.map(function(r) {
                    var w = pref.regionWeights[r] || 1.0;
                    var pct = Math.round((w / 3.0) * 100);
                    return (
                      <div key={r} className="gl-profile-mira-row">
                        <span className="gl-profile-mira-name">{r}</span>
                        <div className="gl-profile-mira-track">
                          <div className="gl-profile-mira-fill" style={{ width: pct + '%', background: 'rgba(17,17,15,0.40)' }} />
                        </div>
                      </div>
                    );
                  })}
                </div>
              </div>
            </div>
          </div>

          {/* ── Your interests & world (merged) ── */}
          <div className="gl-profile-card gl-profile-card--wide">
            <div className="gl-profile-card-hd">
              <span className="gl-profile-card-label">Your interests &amp; world</span>
            </div>

            {/* ── Sub-section: Interests ── */}
            <div className="gl-iw-section">
              <div className="gl-iw-section-hd">
                <span className="gl-iw-section-label">Interests</span>
                <button className="gl-profile-edit-btn"
                  onClick={function(){
                    if (editing) { saveInterests(); } else { startEditInterests(); }
                  }}>
                  {editing ? 'Save' : 'Edit'}
                </button>
              </div>
              {editing && <p className="gl-profile-interests-group-label">Regions</p>}
              <div className="gl-profile-chips">
                {(editing ? GL_PROFILE_REGIONS : (setup.regions || [])).map(function(r) {
                  var on = selRegions.indexOf(r) !== -1;
                  return editing
                    ? <button key={r} className={'gl-profile-chip' + (on ? ' is-on' : '')} onClick={function(){ toggleRegion(r); }}>{r}</button>
                    : <span   key={r} className="gl-profile-chip is-on">{r}</span>;
                })}
              </div>
              {editing && <p className="gl-profile-interests-group-label">Topics</p>}
              <div className="gl-profile-chips">
                {(editing ? GL_PROFILE_TOPICS : (setup.topics || [])).map(function(t) {
                  var on = selTopics.indexOf(t) !== -1;
                  return editing
                    ? <button key={t} className={'gl-profile-chip' + (on ? ' is-on' : '')} onClick={function(){ toggleTopic(t); }}>{t}</button>
                    : <span   key={t} className="gl-profile-chip is-on">{t}</span>;
                })}
              </div>
              {!editing && displayRegions.length === 0 && displayTopics.length === 0 && (
                <p className="gl-profile-card-note">No interests saved yet.</p>
              )}
            </div>

            <div className="gl-iw-divider" />

            {/* ── Sub-section: Your World ── */}
            <div className="gl-iw-section">
              <div className="gl-iw-section-hd">
                <span className="gl-iw-section-label">Your World</span>
                {!editingWorld && (
                  worldSaved
                    ? <span className="gl-profile-saved-badge">Saved</span>
                    : <button className="gl-profile-edit-btn" onClick={startEditWorld}>
                        {worldCtx ? 'Edit' : 'Set up'}
                      </button>
                )}
              </div>

              {!editingWorld && !worldCtx && (
                <p className="gl-profile-card-note">
                  Tell Mira where you are from, where you live, and what kinds of global developments matter to you. Mira will use this to explain why stories may be relevant to your life.
                </p>
              )}

              {!editingWorld && worldCtx && (
                <div className="gl-world-summary">
                  {(worldCtx.from || worldCtx.grewUp || worldCtx.livesNow) && (
                    <p className="gl-world-summary-line">
                      {worldCtx.from && <span>From <strong>{worldCtx.from}</strong>. </span>}
                      {worldCtx.grewUp && <span>Grew up in <strong>{worldCtx.grewUp}</strong>. </span>}
                      {worldCtx.livesNow && <span>Lives now in <strong>{worldCtx.livesNow}</strong>.</span>}
                    </p>
                  )}
                  {worldCtx.watchPlaces && (
                    <div className="gl-world-summary-row">
                      <span className="gl-world-summary-label">Watching closely</span>
                      <div className="gl-profile-chips">
                        {(typeof worldCtx.watchPlaces === 'string'
                          ? worldCtx.watchPlaces.split(',').map(function(p){ return p.trim(); }).filter(Boolean)
                          : worldCtx.watchPlaces
                        ).map(function(p, i){
                          return <span key={i} className="gl-profile-chip is-on">{p}</span>;
                        })}
                      </div>
                    </div>
                  )}
                  {worldCtx.effects && worldCtx.effects.length > 0 && (
                    <div className="gl-world-summary-row">
                      <span className="gl-world-summary-label">Effects I care about</span>
                      <div className="gl-profile-chips">
                        {worldCtx.effects.map(function(e, i){
                          return <span key={i} className="gl-profile-chip is-on">{e}</span>;
                        })}
                      </div>
                    </div>
                  )}
                  {worldCtx.level && (
                    <p className="gl-world-summary-level">
                      Personalisation: <strong>{{light:'Light', balanced:'Balanced', strong:'Strong'}[worldCtx.level] || worldCtx.level}</strong>
                    </p>
                  )}
                  {worldCtx.freeText && (
                    <p className="gl-world-summary-free">{worldCtx.freeText}</p>
                  )}
                </div>
              )}

              {editingWorld && worldDraft && (
                <div className="gl-world-form">
                  <p className="gl-world-form-note">
                    Mira uses this to explain why global stories may matter to you. Never shared or used to target you.
                  </p>

                  <div className="gl-world-form-fields">
                    <label className="gl-world-field">
                      <span className="gl-world-field-label">Where are you from?</span>
                      <input className="gl-world-input" type="text"
                        placeholder="e.g. Sri Lanka"
                        value={worldDraft.from || ''}
                        onChange={function(e){ setWorldDraft(Object.assign({}, worldDraft, { from: e.target.value })); }} />
                    </label>
                    <label className="gl-world-field">
                      <span className="gl-world-field-label">Where did you grow up?</span>
                      <input className="gl-world-input" type="text"
                        placeholder="e.g. Abu Dhabi, UAE"
                        value={worldDraft.grewUp || ''}
                        onChange={function(e){ setWorldDraft(Object.assign({}, worldDraft, { grewUp: e.target.value })); }} />
                    </label>
                    <label className="gl-world-field">
                      <span className="gl-world-field-label">Where do you live now?</span>
                      <input className="gl-world-input" type="text"
                        placeholder="e.g. United Kingdom"
                        value={worldDraft.livesNow || ''}
                        onChange={function(e){ setWorldDraft(Object.assign({}, worldDraft, { livesNow: e.target.value })); }} />
                    </label>
                    <div className="gl-world-field">
                      <span className="gl-world-field-label">Which places do you want Globally to watch closely?</span>
                      <input className="gl-world-input" type="text"
                        placeholder="e.g. Sri Lanka, UAE, South Asia, Middle East"
                        value={worldDraft.watchPlaces || ''}
                        onChange={function(e){ setWorldDraft(Object.assign({}, worldDraft, { watchPlaces: e.target.value })); }} />
                      <span className="gl-world-field-hint">Separate with commas. Regions like "South Asia" and "Gulf region" also work.</span>
                    </div>
                  </div>

                  <div className="gl-world-form-section">
                    <span className="gl-world-field-label">What kinds of real-world effects do you care about?</span>
                    <div className="gl-world-effects-grid">
                      {WORLD_EFFECTS.map(function(e) {
                        var on = (worldDraft.effects || []).indexOf(e) !== -1;
                        return (
                          <button key={e} className={'gl-profile-chip' + (on ? ' is-on' : '')}
                            onClick={function(){ toggleWorldEffect(e); }}>
                            {e}
                          </button>
                        );
                      })}
                    </div>
                  </div>

                  <div className="gl-world-form-section">
                    <span className="gl-world-field-label">How much should Mira personalise your briefing?</span>
                    <div className="gl-world-level-btns">
                      {[
                        { val: 'light',    label: 'Light',    desc: 'Mostly global importance' },
                        { val: 'balanced', label: 'Balanced', desc: 'Mix of global + my context' },
                        { val: 'strong',   label: 'Strong',   desc: 'Explain why it matters to me' },
                      ].map(function(opt) {
                        var on = worldDraft.level === opt.val;
                        return (
                          <button key={opt.val}
                            className={'gl-world-level-btn' + (on ? ' is-on' : '')}
                            onClick={function(){ setWorldDraft(Object.assign({}, worldDraft, { level: opt.val })); }}>
                            <span className="gl-world-level-name">{opt.label}</span>
                            <span className="gl-world-level-desc">{opt.desc}</span>
                          </button>
                        );
                      })}
                    </div>
                  </div>

                  <div className="gl-world-form-section">
                    <label className="gl-world-field">
                      <span className="gl-world-field-label">What should Mira know about your background? (optional)</span>
                      <textarea className="gl-world-textarea"
                        rows="3"
                        placeholder="Example: I'm from Sri Lanka, grew up in Abu Dhabi, and now live in the UK. I care about South Asia, the Gulf, energy prices, travel, and how global development stories affect ordinary people."
                        value={worldDraft.freeText || ''}
                        onChange={function(e){ setWorldDraft(Object.assign({}, worldDraft, { freeText: e.target.value })); }} />
                    </label>
                  </div>

                  <div className="gl-world-form-actions">
                    <button className="gl-profile-edit-btn" onClick={cancelEditWorld}>Cancel</button>
                    <button className="gl-world-save-btn" onClick={saveWorld}>Save Your World</button>
                  </div>
                </div>
              )}
            </div>
          </div>

          {/* Daily reminder */}
          <div className="gl-profile-card">
            <div className="gl-profile-card-hd">
              <span className="gl-profile-card-label">Daily reminder</span>
              {timeSaved && <span className="gl-profile-saved-badge">Saved</span>}
            </div>
            <GloballyTimePicker
              value={time}
              onChange={function(v){ saveTime(v); }}
            />
            <p className="gl-profile-card-note" style={{ marginBottom: '14px' }}>
              Your preferred time for your daily Globally brief.
            </p>
            <div className="gl-profile-consent-group">
              <button
                type="button"
                className={"gl-profile-consent-btn" + (profileConsent !== false ? " gl-profile-consent-btn--on" : "")}
                onClick={function(){ saveProfileConsent(true); }}
              >
                <span className="gl-profile-consent-label">Receive daily email reminders</span>
                <span className="gl-profile-consent-copy">I agree to receive daily email reminders from Globally at my selected time. I can unsubscribe at any time.</span>
              </button>
              <button
                type="button"
                className={"gl-profile-consent-btn" + (profileConsent === false ? " gl-profile-consent-btn--on" : "")}
                onClick={function(){ saveProfileConsent(false); }}
              >
                <span className="gl-profile-consent-label">No email reminders</span>
                <span className="gl-profile-consent-copy">I do not want daily email reminders.</span>
              </button>
            </div>

            {/* Delivery status — clear signal to the user */}
            <p className="gl-profile-card-note" style={{ marginTop: '10px', fontWeight: 600,
              color: profileConsent !== false ? 'rgba(17,17,15,0.70)' : 'rgba(17,17,15,0.40)' }}>
              {profileConsent !== false
                ? ('Daily email active — briefing at ' + time + ' (' + Intl.DateTimeFormat().resolvedOptions().timeZone + ')')
                : 'Email reminders disabled.'}
            </p>

            {/* Dev test sends — localhost only */}
            {typeof window !== 'undefined' && window.location.hostname === 'localhost' && (
              <div className="gl-profile-dev-email-tests">
                <p className="gl-profile-card-note" style={{ marginTop: '12px', fontWeight: 600, color: 'rgba(17,17,15,0.55)' }}>
                  Dev: test email sends
                </p>
                <button className="gl-profile-dev-btn"
                  onClick={function(){
                    var s = lsGet(LS.SESSION, {});
                    if (!s.email) { alert('Not logged in'); return; }
                    fetch('/api/email/test-welcome', {
                      method: 'POST', headers: { 'Content-Type': 'application/json' },
                      body: JSON.stringify({ email: s.email }),
                    }).then(function(r){ return r.json(); }).then(function(d){
                      alert('Welcome email: ' + JSON.stringify(d));
                    }).catch(function(e){ alert('Error: ' + e.message); });
                  }}>
                  Send test welcome email
                </button>
                <button className="gl-profile-dev-btn"
                  onClick={function(){
                    var s = lsGet(LS.SESSION, {});
                    if (!s.email) { alert('Not logged in'); return; }
                    fetch('/api/email/test-daily', {
                      method: 'POST', headers: { 'Content-Type': 'application/json' },
                      body: JSON.stringify({ email: s.email }),
                    }).then(function(r){ return r.json(); }).then(function(d){
                      alert('Daily briefing email: ' + JSON.stringify(d));
                    }).catch(function(e){ alert('Error: ' + e.message); });
                  }}>
                  Send test daily briefing
                </button>
              </div>
            )}
          </div>

          {/* Account */}
          <div className="gl-profile-card">
            <div className="gl-profile-card-hd">
              <span className="gl-profile-card-label">Account</span>
            </div>
            <div className="gl-profile-account-rows">
              {email && (
                <div className="gl-profile-account-row">
                  <span className="gl-profile-account-key">Email</span>
                  <span className="gl-profile-account-val">{email}</span>
                </div>
              )}
              <div className="gl-profile-account-row">
                <span className="gl-profile-account-key">Plan</span>
                <span className="gl-profile-account-val">Free</span>
              </div>
              <div className="gl-profile-account-row">
                <span className="gl-profile-account-key">Version</span>
                <button className="gl-profile-version-btn" onClick={handleVersionTap} aria-label="App version">
                  v1.0{tapCount > 0 ? ' · ' + tapCount : ''}
                </button>
              </div>
            </div>
            <div className="gl-profile-account-btns">
              <button className="gl-profile-btn gl-profile-btn--ghost"
                onClick={function(){ if (onBack) onBack('today'); }}>
                Back to Today
              </button>
              <button className="gl-profile-btn gl-profile-btn--danger" onClick={onSignOut}>
                Sign out
              </button>
            </div>
          </div>

          {/* Settings */}
          {/* Knowledge history */}
          <div className="gl-profile-card gl-profile-card--wide gl-knowledge-card">
            <div className="gl-profile-card-hd">
              <span className="gl-profile-card-label">Your knowledge</span>
              <span className="gl-profile-card-meta">Built as you read</span>
            </div>
            <div className="gl-knowledge-grid">
              <div className="gl-knowledge-stat">
                <span className="gl-knowledge-num">{readCount}</span>
                <span className="gl-knowledge-label">stories read</span>
              </div>
              <div className="gl-knowledge-stat">
                <span className="gl-knowledge-num">{(setup.topics || []).length}</span>
                <span className="gl-knowledge-label">topics followed</span>
              </div>
              <div className="gl-knowledge-stat">
                <span className="gl-knowledge-num">{(setup.regions || []).length}</span>
                <span className="gl-knowledge-label">regions followed</span>
              </div>
              <div className="gl-knowledge-stat">
                <span className="gl-knowledge-num">{getMiraMemory().miraQueries.length}</span>
                <span className="gl-knowledge-label">Mira questions</span>
              </div>
            </div>
            {getMiraMemory().miraQueries.length > 0 && (
              <div className="gl-knowledge-recent">
                <p className="gl-knowledge-recent-hd">Recent Mira questions</p>
                {getMiraMemory().miraQueries.slice(0, 4).map(function(q, i) {
                  return (
                    <div key={i} className="gl-knowledge-query">
                      <span className="gl-knowledge-query-q">{q.q}</span>
                      {q.article && <span className="gl-knowledge-query-art">{q.article}</span>}
                    </div>
                  );
                })}
              </div>
            )}
            {getMiraMemory().miraQueries.length === 0 && (
              <p className="gl-profile-card-note">Ask Mira questions on any story to build your knowledge history.</p>
            )}
          </div>

          <div className="gl-profile-card gl-settings-card">
            <h2 className="gl-settings-title">Settings</h2>

            <div className="gl-settings-section">
              <p className="gl-settings-section-hd">Reading</p>

              <div className="gl-settings-row">
                <div className="gl-settings-row-text">
                  <span className="gl-settings-row-label">Hide read stories</span>
                  <span className="gl-settings-row-sub">Skip articles you've already opened</span>
                </div>
                <button
                  className={"gl-settings-toggle" + (hideRead ? " is-on" : "")}
                  onClick={function(){ saveHideRead(!hideRead); }}
                  aria-pressed={hideRead}
                  aria-label="Hide read stories"
                >
                  <span className="gl-settings-toggle-knob" />
                </button>
              </div>

              <div className="gl-settings-row">
                <div className="gl-settings-row-text">
                  <span className="gl-settings-row-label">Daily goal</span>
                  <span className="gl-settings-row-sub">Stories to read each day</span>
                </div>
                <div className="gl-settings-seg" role="group" aria-label="Daily goal">
                  {[3, 5, 10].map(function(n) {
                    return (
                      <button
                        key={n}
                        className={"gl-settings-seg-btn" + (dailyGoal === n ? " is-on" : "")}
                        onClick={function(){ saveDailyGoal(n); }}
                        aria-pressed={dailyGoal === n}
                      >{n}</button>
                    );
                  })}
                </div>
              </div>

              <div className="gl-settings-row">
                <div className="gl-settings-row-text">
                  <span className="gl-settings-row-label">Auto-play Mira voice</span>
                  <span className="gl-settings-row-sub">Read Mira's answers aloud automatically</span>
                </div>
                <button
                  className={"gl-settings-toggle" + (autoPlayVoice ? " is-on" : "")}
                  onClick={function(){ saveAutoPlayVoice(!autoPlayVoice); }}
                  aria-pressed={autoPlayVoice}
                  aria-label="Auto-play Mira voice"
                >
                  <span className="gl-settings-toggle-knob" />
                </button>
              </div>

              <div className="gl-settings-row">
                <div className="gl-settings-row-text">
                  <span className="gl-settings-row-label">Default brief length</span>
                  <span className="gl-settings-row-sub">How Mira answers open by default</span>
                </div>
                <div className="gl-settings-seg" role="group" aria-label="Default brief length">
                  {[
                    { val: 'quick',   label: 'Quick'    },
                    { val: 'deeper',  label: 'In depth' },
                  ].map(function(opt) {
                    return (
                      <button
                        key={opt.val}
                        className={"gl-settings-seg-btn" + (briefLength === opt.val ? " is-on" : "")}
                        onClick={function(){ saveBriefLength(opt.val); }}
                        aria-pressed={briefLength === opt.val}
                      >{opt.label}</button>
                    );
                  })}
                </div>
              </div>
            </div>

            <div className="gl-settings-section">
              <p className="gl-settings-section-hd">Mira</p>

              <div className="gl-settings-row">
                <div className="gl-settings-row-text">
                  <span className="gl-settings-row-label">Reset Mira learning</span>
                  <span className="gl-settings-row-sub">Resets recommendation weights to your saved interests</span>
                </div>
                <button
                  className={"gl-settings-action-btn" + (miraReset ? " is-done" : "")}
                  onClick={function() {
                    setModal({
                      title: 'Reset Mira learning?',
                      body: "This will clear Mira's recommendation weights and recent learning signals. Your account, saved stories, and interests will not be deleted.",
                      confirmLabel: 'Yes, reset Mira',
                      cancelLabel: 'No, keep it',
                      danger: false,
                      onConfirm: function() { doResetMira(); setModal(null); },
                    });
                  }}
                >
                  {miraReset ? 'Done' : 'Reset'}
                </button>
              </div>
            </div>

            <div className="gl-settings-section">
              <p className="gl-settings-section-hd">Data</p>

              <div className="gl-settings-row">
                <div className="gl-settings-row-text">
                  <span className="gl-settings-row-label">Clear read history</span>
                  <span className="gl-settings-row-sub">Removes your reading history and resets profile stats</span>
                </div>
                <button
                  className={"gl-settings-action-btn gl-settings-action-btn--warn" + (histCleared ? " is-done" : "")}
                  onClick={function() {
                    setModal({
                      title: 'Clear read history?',
                      body: 'This will remove your reading history from this device and reset related profile stats. Saved stories will not be deleted.',
                      confirmLabel: 'Yes, clear history',
                      cancelLabel: 'No, keep it',
                      danger: true,
                      onConfirm: function() { doClearHistory(); setModal(null); },
                    });
                  }}
                >
                  {histCleared ? 'Cleared' : 'Clear'}
                </button>
              </div>
            </div>

            <div className="gl-settings-section">
              <p className="gl-settings-section-hd">Account &amp; Support</p>

              <div className="gl-settings-row gl-settings-row--link" onClick={function(){ if (onBack) onBack('help'); }}>
                <div className="gl-settings-row-text">
                  <span className="gl-settings-row-label">Help center</span>
                  <span className="gl-settings-row-sub">Guides and frequently asked questions</span>
                </div>
                <span className="gl-settings-row-arrow">›</span>
              </div>

              <div className="gl-settings-row gl-settings-row--link">
                <div className="gl-settings-row-text">
                  <a className="gl-settings-link-anchor" href="mailto:support@globally.news">
                    <span className="gl-settings-row-label">Contact support</span>
                    <span className="gl-settings-row-sub">support@globally.news</span>
                  </a>
                </div>
                <span className="gl-settings-row-arrow">›</span>
              </div>

              <div className="gl-settings-row gl-settings-row--link" onClick={function(){ if (onBack) onBack('privacy'); }}>
                <div className="gl-settings-row-text">
                  <span className="gl-settings-row-label">Privacy &amp; data controls</span>
                  <span className="gl-settings-row-sub">How we use and protect your data</span>
                </div>
                <span className="gl-settings-row-arrow">›</span>
              </div>

              <div className="gl-settings-row gl-settings-row--link" onClick={function(){ if (onBack) onBack('terms'); }}>
                <div className="gl-settings-row-text">
                  <span className="gl-settings-row-label">Terms of service</span>
                  <span className="gl-settings-row-sub">Your rights and responsibilities</span>
                </div>
                <span className="gl-settings-row-arrow">›</span>
              </div>
            </div>
          </div>

        </div>

      </main>

      <GloblyPageFooter onNav={onBack} />

      <ConfirmModal
        open={!!modal}
        title={modal ? modal.title : ''}
        body={modal ? modal.body : ''}
        confirmLabel={modal ? modal.confirmLabel : ''}
        cancelLabel={modal ? modal.cancelLabel : ''}
        danger={modal ? modal.danger : false}
        onConfirm={modal ? modal.onConfirm : function(){}}
        onCancel={function(){ setModal(null); }}
      />

      {showDebug && (function() {
        var dbgPref = loadPref(setup);
        return (
          <div className="g-debug-panel">
            <div className="g-debug-header">
              <span>Preference weights</span>
              <button className="g-debug-close" onClick={function(){ setShowDebug(false); }}>✕</button>
            </div>
            <div className="g-debug-section">Regions</div>
            {Object.keys(dbgPref.regionWeights)
              .sort(function(a, b){ return dbgPref.regionWeights[b] - dbgPref.regionWeights[a]; })
              .map(function(r) {
                var w = dbgPref.regionWeights[r];
                return (
                  <div key={r} className="g-debug-row">
                    <span className="g-debug-label">{r}</span>
                    <div className="g-debug-bar-wrap">
                      <div className="g-debug-bar" style={{ width: Math.round((w / 3.0) * 100) + '%' }} />
                    </div>
                    <span className="g-debug-val">{w.toFixed(2)}</span>
                  </div>
                );
              })}
            <div className="g-debug-section">Topics</div>
            {Object.keys(dbgPref.topicWeights)
              .sort(function(a, b){ return dbgPref.topicWeights[b] - dbgPref.topicWeights[a]; })
              .map(function(t) {
                var w = dbgPref.topicWeights[t];
                return (
                  <div key={t} className="g-debug-row">
                    <span className="g-debug-label">{t}</span>
                    <div className="g-debug-bar-wrap">
                      <div className="g-debug-bar" style={{ width: Math.round((w / 3.0) * 100) + '%' }} />
                    </div>
                    <span className="g-debug-val">{w.toFixed(2)}</span>
                  </div>
                );
              })}
          </div>
        );
      })()}

    </div>
  );
}

// ─── Feed screen — Phase 3 vertical swipe feed ───────────────────────────────

var GF_SAVES_KEY = 'gf_saves';
var GF_LIKES_KEY = 'gf_likes';

function gfLsLoad(key) {
  try { return JSON.parse(localStorage.getItem(key) || '[]'); } catch(e) { return []; }
}
function gfLsSave(key, arr) {
  try { localStorage.setItem(key, JSON.stringify(arr)); } catch(e) {}
}

// Infer topic from a DYK card's text
function gfDykTopic(d) {
  var txt = ((d.stat || '') + ' ' + (d.context || '')).toLowerCase();
  if (/climat|emiss|warm|flood|1\.5|sea.level|vulnerabl/.test(txt))         return 'Climate';
  if (/health|vaccin|immunis|disease|hunger|famine|refugee|displace/.test(txt)) return 'Health';
  if (/conflict|war|military|insurgenc/.test(txt))                           return 'Conflict';
  if (/election|authoritarian|democrac|govern|parliament/.test(txt))         return 'Politics';
  if (/trade|export|garment/.test(txt))                                      return 'Trade';
  return 'Economy';
}

// Pick up to `total` articles, max `perRegion` from each region.
// Uses shared scoreAndRank (recency × personalisation × sig) with 25% explore floor.
function gfSelectBriefs(articles, perRegion, total) {
  var pref    = loadPref(lsGet(LS.SETUP, null));
  var seenMap = loadSeen();
  var ranked  = scoreAndRank(articles, pref, seenMap, 0.25);
  var rc = {}, out = [];
  for (var i = 0; i < ranked.length && out.length < total; i++) {
    var r = getArticleRegion(ranked[i].article) || 'Other';
    rc[r] = (rc[r] || 0);
    if (rc[r] < perRegion) { out.push(ranked[i].article); rc[r]++; }
  }
  return out;
}

// Build the interleaved For-You queue (max 20 items)
function buildGFQueue(articles) {
  var dyks       = (window.GLOBLY_DATA && window.GLOBLY_DATA.didYouKnow) ? window.GLOBLY_DATA.didYouKnow : [];
  var explainers = (typeof GLOBLY_EXPLAINERS !== 'undefined') ? GLOBLY_EXPLAINERS : [];
  var briefs     = gfSelectBriefs(articles, 2, 12);

  function mkFact(d, i)  { return { type:'fact',  id:'fact-'+i,    data:d, topic: gfDykTopic(d) }; }
  function mkRead(e)      { return { type:'read',  id:'read-'+e.id, data:e, topic: EXPLAINER_TOPIC[e.id] || 'Economy' }; }
  function mkStory(a)     { return { type:'story', id:'story-'+a.url, data:a, topic: getArticleTopic(a) || 'World' }; }
  function mkBrief(a)     {
    if (a.sources && a.sources.length >= 1) return mkStory(a);
    return { type:'brief', id:'brief-'+a.url, data:a, topic: getArticleTopic(a) || 'Economy' };
  }

  // Shuffle facts and explainers with today's date seed so they rotate daily
  var seed = dailySeed();
  var factPool  = seededShuffle(dyks.map(mkFact), seed);
  var readPool  = seededShuffle(explainers.map(mkRead), seed + 1);
  var briefPool = briefs.map(mkBrief);

  var pattern = ['brief', 'fact', 'brief', 'read'];
  var pools   = { brief: briefPool, fact: factPool, read: readPool };
  var queue   = [];
  var lastHex = null;
  var pi = 0, guard = 80;

  while (queue.length < 20 && guard-- > 0) {
    var type = pattern[pi % pattern.length];
    pi++;

    // Advance to a non-empty pool type
    var tries = 0;
    while ((!pools[type] || pools[type].length === 0) && tries < 4) {
      type = pattern[(pi + tries++) % pattern.length];
    }
    var pool = pools[type];
    if (!pool || pool.length === 0) {
      if (!Object.keys(pools).some(function(t){ return pools[t].length > 0; })) break;
      continue;
    }

    // Pick first candidate whose topic colour != previous
    var chosen = 0;
    for (var k = 0; k < pool.length; k++) {
      if ((TOPIC_COLORS[pool[k].topic] || '') !== lastHex) { chosen = k; break; }
    }
    var item = pool.splice(chosen, 1)[0];
    lastHex = TOPIC_COLORS[item.topic] || '#7A8A9A';
    queue.push(item);
  }

  // Inject reel cards at ~position 8 and ~17 (1 per 9 items)
  var reelArts = articles
    .filter(function(a){ return !!a.reelScript; })
    .sort(function(a, b){ return (b.significance || 1) - (a.significance || 1); })
    .slice(0, 3);
  var ri = 0;
  var finalQ = [];
  for (var qi = 0; qi < queue.length; qi++) {
    if ((qi === 8 || qi === 17) && ri < reelArts.length) {
      finalQ.push({ type:'reel', id:'reel-'+ri, data:reelArts[ri],
        topic: getArticleTopic(reelArts[ri]) || 'World' });
      ri++;
    }
    finalQ.push(queue[qi]);
  }

  return finalQ;
}

// Build the per-item deck card descriptors
function gfDeckCards(item) {
  if (item.type === 'fact') {
    var cards = [{ slot:'fact-main' }];
    if (item.data.context) cards.push({ slot:'fact-ctx' });
    return cards;
  }
  if (item.type === 'read') {
    return (item.data.steps || []).map(function(_, i){ return { slot:'read-step', idx:i }; });
  }
  if (item.type === 'brief') {
    return [{ slot:'brief-headline' }, { slot:'brief-summary' }, { slot:'brief-source' }];
  }
  if (item.type === 'reel')  return [];
  if (item.type === 'story') return [];
  return [{ slot:'placeholder' }];
}

// Decorative heading text for each item
function gfHeading(item) {
  if (item.type === 'fact')  return 'Did you know?';
  if (item.type === 'read')  return item.data.title || 'Explained';
  if (item.type === 'brief') return item.topic || 'In the news';
  if (item.type === 'reel')  return '';
  if (item.type === 'story') return '';
  return '';
}

function gfRelTime(iso) {
  if (!iso) return '';
  var s = (Date.now() - new Date(iso).getTime()) / 1000;
  if (s < 3600)   return Math.floor(s / 60)   + 'm ago';
  if (s < 86400)  return Math.floor(s / 3600)  + 'h ago';
  return Math.floor(s / 86400) + 'd ago';
}

function gfDomain(url) {
  try { return new URL(url).hostname.replace(/^www\./, ''); } catch(e) { return url; }
}

function gfFormatDate(iso) {
  if (!iso) return '';
  try { return new Date(iso).toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' }); }
  catch(e) { return iso.slice(0, 10); }
}

// ── Scene inference + registry ─────────────────────────────────────────────

function inferSceneId(article, topic) {
  if (article.scene && article.scene.archetype) return article.scene.archetype;
  var t = topic || getArticleTopic(article);
  if (t === 'Conflict') return 'conflict.displaced';
  if (t === 'Economy')  return 'economy.prices';
  if (t === 'Trade')    return 'trade.shipping';
  if (t === 'Climate')  return 'climate.heat';
  if (t === 'Health')   return 'health.care';
  if (t === 'Politics') return 'politics.vote';
  return '_neutral';
}

function GFSceneSvgEconomy() {
  var css = '@keyframes gfs_ep_bar{0%,100%{transform:scaleY(1)}50%{transform:scaleY(1.14)}}' +
    '@keyframes gfs_ep_arrow{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}';
  return (
    <svg width="100%" height="100%" viewBox="0 0 320 120"
      preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <style>{css}</style>
      <line x1="58" y1="112" x2="272" y2="112" stroke="rgba(255,255,255,0.10)" strokeWidth="1"/>
      <rect x="74" y="84" width="38" height="28" rx="3" fill="rgba(232,146,62,0.45)"
        style={{transformOrigin:'93px 112px',animation:'gfs_ep_bar 2.8s ease-in-out infinite'}}/>
      <rect x="141" y="62" width="38" height="50" rx="3" fill="rgba(232,146,62,0.65)"
        style={{transformOrigin:'160px 112px',animation:'gfs_ep_bar 2.8s ease-in-out infinite 0.35s'}}/>
      <rect x="208" y="36" width="38" height="76" rx="3" fill="rgba(232,146,62,0.88)"
        style={{transformOrigin:'227px 112px',animation:'gfs_ep_bar 2.8s ease-in-out infinite 0.7s'}}/>
      <g style={{animation:'gfs_ep_arrow 2.8s ease-in-out infinite'}}>
        <polyline points="93,80 160,58 227,32 252,18" fill="none"
          stroke="rgba(224,82,74,0.88)" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round"/>
        <polyline points="243,12 252,18 245,27" fill="none"
          stroke="rgba(224,82,74,0.88)" strokeWidth="2.5" strokeLinecap="round"/>
      </g>
    </svg>
  );
}

function GFSceneSvgTrade() {
  var css = '@keyframes gfs_ts_ship{0%{transform:translateX(-26px)}100%{transform:translateX(26px)}}';
  return (
    <svg width="100%" height="100%" viewBox="0 0 320 120"
      preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <style>{css}</style>
      <rect x="0" y="92" width="320" height="28" fill="rgba(94,106,210,0.12)"/>
      <path d="M0,92 Q40,86 80,92 Q120,98 160,92 Q200,86 240,92 Q280,98 320,92"
        fill="none" stroke="rgba(94,106,210,0.38)" strokeWidth="1.5"/>
      <g style={{animation:'gfs_ts_ship 5.5s ease-in-out infinite alternate'}}>
        <path d="M52,92 L60,72 L260,72 L268,92 Z"
          fill="rgba(255,255,255,0.13)" stroke="rgba(255,255,255,0.18)" strokeWidth="1"/>
        <rect x="168" y="56" width="54" height="18" rx="2" fill="rgba(255,255,255,0.18)"/>
        <rect x="190" y="44" width="10" height="14" rx="2" fill="rgba(255,255,255,0.16)"/>
        <rect x="68" y="63" width="34" height="10" rx="1.5" fill="rgba(232,146,62,0.55)"/>
        <rect x="106" y="63" width="34" height="10" rx="1.5" fill="rgba(94,106,210,0.55)"/>
        <rect x="144" y="63" width="20" height="10" rx="1.5" fill="rgba(63,163,77,0.52)"/>
        <rect x="68" y="54" width="34" height="10" rx="1.5" fill="rgba(94,106,210,0.42)"/>
        <rect x="106" y="54" width="34" height="10" rx="1.5" fill="rgba(232,146,62,0.38)"/>
      </g>
    </svg>
  );
}

function GFSceneSvgClimate() {
  var css = '@keyframes gfs_ch_rays{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}';
  return (
    <svg width="100%" height="100%" viewBox="0 0 320 120"
      preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <style>{css}</style>
      <circle cx="130" cy="62" r="20" fill="rgba(255,185,50,0.52)"/>
      <g style={{transformOrigin:'130px 62px',animation:'gfs_ch_rays 10s linear infinite'}}>
        <line x1="130" y1="36" x2="130" y2="28" stroke="rgba(255,185,50,0.55)" strokeWidth="2.5" strokeLinecap="round"/>
        <line x1="130" y1="88" x2="130" y2="96" stroke="rgba(255,185,50,0.55)" strokeWidth="2.5" strokeLinecap="round"/>
        <line x1="104" y1="62" x2="96"  y2="62" stroke="rgba(255,185,50,0.55)" strokeWidth="2.5" strokeLinecap="round"/>
        <line x1="156" y1="62" x2="164" y2="62" stroke="rgba(255,185,50,0.55)" strokeWidth="2.5" strokeLinecap="round"/>
        <line x1="112" y1="44" x2="106" y2="38" stroke="rgba(255,185,50,0.45)" strokeWidth="2" strokeLinecap="round"/>
        <line x1="148" y1="80" x2="154" y2="86" stroke="rgba(255,185,50,0.45)" strokeWidth="2" strokeLinecap="round"/>
        <line x1="148" y1="44" x2="154" y2="38" stroke="rgba(255,185,50,0.45)" strokeWidth="2" strokeLinecap="round"/>
        <line x1="112" y1="80" x2="106" y2="86" stroke="rgba(255,185,50,0.45)" strokeWidth="2" strokeLinecap="round"/>
      </g>
      <rect x="192" y="36" width="14" height="70" rx="7"
        fill="rgba(255,255,255,0.08)" stroke="rgba(255,255,255,0.22)" strokeWidth="1.5"/>
      <circle cx="199" cy="114" r="9" fill="rgba(224,82,74,0.68)"/>
      <rect x="196" width="6" rx="3" fill="rgba(224,82,74,0.68)">
        <animate attributeName="height" values="28;46;28" dur="3.4s" repeatCount="indefinite"/>
        <animate attributeName="y" values="78;60;78" dur="3.4s" repeatCount="indefinite"/>
      </rect>
    </svg>
  );
}

function GFSceneSvgHealth() {
  var css = '@keyframes gfs_hc_draw{0%{stroke-dashoffset:430}85%,100%{stroke-dashoffset:0}}' +
    '@keyframes gfs_hc_cross{0%,100%{transform:scale(1)}45%{transform:scale(1.16)}}';
  return (
    <svg width="100%" height="100%" viewBox="0 0 320 120"
      preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <style>{css}</style>
      <line x1="20" y1="62" x2="300" y2="62"
        stroke="rgba(224,86,138,0.13)" strokeWidth="1" strokeDasharray="4 4"/>
      <polyline
        points="20,62 52,62 68,36 80,88 92,62 128,62 144,48 153,78 161,62 200,62 240,62 280,62 300,62"
        fill="none" stroke="rgba(224,86,138,0.82)" strokeWidth="2.5"
        strokeLinecap="round" strokeLinejoin="round"
        strokeDasharray="430"
        style={{animation:'gfs_hc_draw 2.8s ease-in-out infinite'}}/>
      <g style={{transformOrigin:'268px 62px',animation:'gfs_hc_cross 2.2s ease-in-out infinite'}}>
        <rect x="261" y="50" width="14" height="24" rx="3.5" fill="rgba(224,86,138,0.65)"/>
        <rect x="254" y="57" width="28" height="10" rx="3.5" fill="rgba(224,86,138,0.65)"/>
      </g>
    </svg>
  );
}

function GFSceneSvgPolitics() {
  var css = '@keyframes gfs_pv_drop{' +
    '0%{transform:translateY(-52px);opacity:0}' +
    '18%{opacity:1}' +
    '65%,78%{transform:translateY(0);opacity:1}' +
    '92%,100%{transform:translateY(0);opacity:0}}';
  return (
    <svg width="100%" height="100%" viewBox="0 0 320 120"
      preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <style>{css}</style>
      <rect x="108" y="70" width="104" height="44" rx="5"
        fill="rgba(155,93,229,0.14)" stroke="rgba(155,93,229,0.50)" strokeWidth="1.5"/>
      <rect x="134" y="67" width="52" height="8" rx="4" fill="rgba(155,93,229,0.35)"/>
      <line x1="126" y1="88" x2="194" y2="88" stroke="rgba(155,93,229,0.20)" strokeWidth="1"/>
      <circle cx="160" cy="100" r="5" fill="none" stroke="rgba(155,93,229,0.28)" strokeWidth="1.5"/>
      <g style={{animation:'gfs_pv_drop 3s ease-in infinite'}}>
        <rect x="122" y="18" width="76" height="54" rx="4"
          fill="rgba(255,255,255,0.11)" stroke="rgba(155,93,229,0.44)" strokeWidth="1.5"/>
        <line x1="137" y1="32" x2="186" y2="32" stroke="rgba(155,93,229,0.36)" strokeWidth="1.5"/>
        <line x1="137" y1="41" x2="180" y2="41" stroke="rgba(155,93,229,0.26)" strokeWidth="1.5"/>
        <line x1="137" y1="50" x2="172" y2="50" stroke="rgba(155,93,229,0.20)" strokeWidth="1.5"/>
        <polyline points="137,60 148,70 169,43" fill="none"
          stroke="rgba(155,93,229,0.72)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
      </g>
    </svg>
  );
}

function GFSceneSvgConflict() {
  var css = '@keyframes gfs_cd_drift{0%{transform:translateX(-34px)}100%{transform:translateX(34px)}}';
  return (
    <svg width="100%" height="100%" viewBox="0 0 320 120"
      preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <style>{css}</style>
      <line x1="0" y1="108" x2="320" y2="108" stroke="rgba(255,255,255,0.07)" strokeWidth="1"/>
      <g style={{animation:'gfs_cd_drift 8s ease-in-out infinite alternate'}}>
        <ellipse cx="88"  cy="72" rx="7"   ry="8"   fill="rgba(180,180,180,0.22)"/>
        <line x1="88"  y1="80" x2="88"  y2="98" stroke="rgba(180,180,180,0.20)" strokeWidth="4"   strokeLinecap="round"/>
        <line x1="88"  y1="88" x2="78"  y2="95" stroke="rgba(180,180,180,0.18)" strokeWidth="3"   strokeLinecap="round"/>
        <line x1="88"  y1="88" x2="98"  y2="94" stroke="rgba(180,180,180,0.18)" strokeWidth="3"   strokeLinecap="round"/>
        <line x1="88"  y1="98" x2="81"  y2="108" stroke="rgba(180,180,180,0.16)" strokeWidth="3.5" strokeLinecap="round"/>
        <line x1="88"  y1="98" x2="95"  y2="108" stroke="rgba(180,180,180,0.16)" strokeWidth="3.5" strokeLinecap="round"/>
        <ellipse cx="100" cy="86" rx="9" ry="7" fill="rgba(150,150,150,0.18)"/>
        <ellipse cx="158" cy="73" rx="6"   ry="7"   fill="rgba(160,160,160,0.18)"/>
        <line x1="158" y1="80" x2="158" y2="97" stroke="rgba(160,160,160,0.16)" strokeWidth="3.5" strokeLinecap="round"/>
        <line x1="158" y1="88" x2="148" y2="94" stroke="rgba(160,160,160,0.15)" strokeWidth="3"   strokeLinecap="round"/>
        <line x1="158" y1="88" x2="168" y2="94" stroke="rgba(160,160,160,0.15)" strokeWidth="3"   strokeLinecap="round"/>
        <line x1="158" y1="97" x2="151" y2="107" stroke="rgba(160,160,160,0.14)" strokeWidth="3"   strokeLinecap="round"/>
        <line x1="158" y1="97" x2="165" y2="107" stroke="rgba(160,160,160,0.14)" strokeWidth="3"   strokeLinecap="round"/>
        <ellipse cx="220" cy="74" rx="5.5" ry="6.5" fill="rgba(140,140,140,0.15)"/>
        <line x1="220" y1="81" x2="220" y2="97" stroke="rgba(140,140,140,0.13)" strokeWidth="3"   strokeLinecap="round"/>
        <line x1="220" y1="89" x2="212" y2="94" stroke="rgba(140,140,140,0.12)" strokeWidth="2.5" strokeLinecap="round"/>
        <line x1="220" y1="89" x2="228" y2="94" stroke="rgba(140,140,140,0.12)" strokeWidth="2.5" strokeLinecap="round"/>
        <line x1="220" y1="97" x2="213" y2="107" stroke="rgba(140,140,140,0.11)" strokeWidth="3"   strokeLinecap="round"/>
        <line x1="220" y1="97" x2="227" y2="107" stroke="rgba(140,140,140,0.11)" strokeWidth="3"   strokeLinecap="round"/>
      </g>
    </svg>
  );
}

function GFSceneSvgSomber() {
  var css = '@keyframes gfs_som{0%,100%{transform:scale(1);opacity:0.22}50%{transform:scale(1.55);opacity:0.07}}';
  return (
    <svg width="100%" height="100%" viewBox="0 0 320 120"
      preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <style>{css}</style>
      <circle cx="160" cy="60" r="8" fill="rgba(255,255,255,0.22)"/>
      <circle cx="160" cy="60" r="24" fill="none" stroke="rgba(255,255,255,0.28)" strokeWidth="1.5"
        style={{transformOrigin:'160px 60px',animation:'gfs_som 3.4s ease-in-out infinite'}}/>
      <circle cx="160" cy="60" r="42" fill="none" stroke="rgba(255,255,255,0.18)" strokeWidth="1"
        style={{transformOrigin:'160px 60px',animation:'gfs_som 3.4s ease-in-out infinite 0.75s'}}/>
      <circle cx="160" cy="60" r="58" fill="none" stroke="rgba(255,255,255,0.10)" strokeWidth="1"
        style={{transformOrigin:'160px 60px',animation:'gfs_som 3.4s ease-in-out infinite 1.5s'}}/>
    </svg>
  );
}

function GFSceneSvgNeutral() {
  var css = '@keyframes gfs_neu{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}';
  return (
    <svg width="100%" height="100%" viewBox="0 0 320 120"
      preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <style>{css}</style>
      <circle cx="160" cy="60" r="40" fill="none" stroke="rgba(255,255,255,0.22)" strokeWidth="1.5"/>
      <ellipse cx="160" cy="60" rx="40" ry="16" fill="none" stroke="rgba(255,255,255,0.14)" strokeWidth="1"/>
      <line x1="120" y1="60" x2="200" y2="60" stroke="rgba(255,255,255,0.14)" strokeWidth="1"/>
      <g style={{transformOrigin:'160px 60px',animation:'gfs_neu 14s linear infinite'}}>
        <ellipse cx="160" cy="60" rx="22" ry="40" fill="none" stroke="rgba(255,255,255,0.18)" strokeWidth="1"/>
        <ellipse cx="160" cy="60" rx="38" ry="40" fill="none" stroke="rgba(255,255,255,0.11)" strokeWidth="1"/>
      </g>
    </svg>
  );
}

function GFSceneSvg(props) {
  var id = props.archetypeId;
  if (id === 'economy.prices')     return <GFSceneSvgEconomy />;
  if (id === 'trade.shipping')     return <GFSceneSvgTrade />;
  if (id === 'climate.heat')       return <GFSceneSvgClimate />;
  if (id === 'health.care')        return <GFSceneSvgHealth />;
  if (id === 'politics.vote')      return <GFSceneSvgPolitics />;
  if (id === 'conflict.displaced') return <GFSceneSvgConflict />;
  if (id === '_somber')            return <GFSceneSvgSomber />;
  return <GFSceneSvgNeutral />;
}

// Ambient blur backdrop — absolutely-positioned blurred image behind card content.
// Renders nothing when no imageUrl. Card must set background:transparent + isolation:isolate.
function GFAmbientBackdrop(props) {
  if (!props.imageUrl) return null;
  var rgb  = hexRgb(props.hex || '#7A8A9A');
  var aTop = props.somber ? '0.64' : '0.46';
  var aBot = props.somber ? '0.86' : '0.74';
  return (
    <div className="gf-amb" aria-hidden="true">
      <img className="gf-amb-img" src={props.imageUrl} alt="" />
      <div className="gf-amb-overlay" style={{
        background:
          'linear-gradient(180deg,rgba(0,0,0,'+aTop+') 0%,rgba(0,0,0,'+aBot+') 100%),' +
          'rgba('+rgb+',0.13)'
      }} />
    </div>
  );
}

function GFSceneStrip(props) {
  var article = props.article;
  var hex     = props.hex;
  var topic   = props.topic;
  var rgb     = hexRgb(hex);
  // Image is handled by GFAmbientBackdrop — scene strip always shows SVG animation.
  var archetypeId = inferSceneId(article, topic);
  return (
    <div className="gf-scene-strip gf-scene-strip--svg"
      style={{background:'linear-gradient(180deg,rgba('+rgb+',0.08) 0%,rgba('+rgb+',0.03) 100%)'}}>
      <GFSceneSvg archetypeId={archetypeId} />
    </div>
  );
}

// ── Full-screen narrated reel player ──
function GFReelPlayer(props) {
  var item     = props.item;
  var hex      = props.hex;
  var isSomber = props.isSomber;
  var d        = item.data || {};
  var timings  = d.reelTimings || [];
  var hasAudio = !!d.reelAudioUrl;
  var totalDur = timings.length > 0 ? (timings[timings.length - 1].e || 28) : 28;

  var _muted   = React.useState(true);
  var muted    = _muted[0], setMuted = _muted[1];
  var _curTime = React.useState(0);
  var curTime  = _curTime[0], setCurTime = _curTime[1];
  var _started = React.useState(false);
  var started  = _started[0], setStarted = _started[1];

  var audioRef    = React.useRef(null);
  var intervalRef = React.useRef(null);
  var startWall   = React.useRef(0);
  var startOffset = React.useRef(0);

  // Caption-only: auto-start simulated playback after 600ms
  React.useEffect(function() {
    if (hasAudio) return;
    var t = setTimeout(function() {
      startWall.current  = Date.now();
      startOffset.current = 0;
      setStarted(true);
      intervalRef.current = setInterval(function() {
        var elapsed = (Date.now() - startWall.current) / 1000;
        var ct = startOffset.current + elapsed;
        if (ct >= totalDur) {
          ct = totalDur;
          clearInterval(intervalRef.current);
        }
        setCurTime(ct);
      }, 80);
    }, 600);
    return function() {
      clearTimeout(t);
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, []);

  function onAudioTimeUpdate() {
    if (audioRef.current) setCurTime(audioRef.current.currentTime);
  }

  function toggleAudio() {
    var audio = audioRef.current;
    if (!audio) return;
    if (muted) {
      audio.muted = false;
      audio.currentTime = 0;
      audio.play().catch(function(){});
      setMuted(false);
      setStarted(true);
    } else {
      audio.pause();
      audio.muted = true;
      setMuted(true);
    }
  }

  // Find active word
  var activeIdx = -1;
  for (var i = 0; i < timings.length; i++) {
    if (curTime >= timings[i].s && curTime < timings[i].e) { activeIdx = i; break; }
  }

  var progress = totalDur > 0 ? Math.min(1, curTime / totalDur) : 0;

  return (
    <div className={'gf-reel-player' + (isSomber ? ' gf-reel-player--somber' : '')}>
      {hasAudio && (
        <audio ref={audioRef} src={d.reelAudioUrl} muted={true} preload="auto"
          onTimeUpdate={onAudioTimeUpdate} />
      )}

      {/* Header badge row */}
      <div className="gf-reel-header">
        <span className="gf-reel-label">Narrated</span>
        {item.topic && <span className="gf-reel-topic" style={{color: hex}}>{item.topic}</span>}
      </div>

      {/* Karaoke caption */}
      <div className="gf-reel-caption">
        {timings.length > 0 ? timings.map(function(t, wi) {
          var isActive = wi === activeIdx;
          var isPast   = !isActive && started && curTime >= t.e;
          return React.createElement('span', {
            key: wi,
            className: 'gf-reel-word' +
              (isActive ? ' gf-reel-word--active' : '') +
              (isPast   ? ' gf-reel-word--past'   : ''),
            style: isActive ? {color: hex} : undefined,
          }, t.w + ' ');
        }) : (d.reelScript || '')}
      </div>

      {/* Footer: progress + audio control */}
      <div className="gf-reel-footer">
        <div className="gf-reel-progress-track">
          <div className="gf-reel-progress-fill"
            style={{width: (progress * 100).toFixed(1) + '%', background: isSomber ? 'rgba(255,255,255,0.55)' : hex}} />
        </div>
        <div className="gf-reel-controls">
          {hasAudio ? (
            <button className="gf-reel-audio-btn" onClick={toggleAudio} aria-label={muted ? 'Play audio' : 'Mute'}>
              {muted ? (
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
                  strokeLinecap="round" strokeLinejoin="round">
                  <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
                  <line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
                </svg>
              ) : (
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
                  strokeLinecap="round" strokeLinejoin="round">
                  <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
                  <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/>
                </svg>
              )}
              <span>{muted ? 'Tap to hear' : 'Mute'}</span>
            </button>
          ) : (
            <span className="gf-reel-caption-badge">Captions only</span>
          )}
        </div>
      </div>
    </div>
  );
}

// ── A single horizontal card inside a deck ──
// ── Story Sheet — full-screen overlay opened by tapping a story card ──
function GFStorySheet(props) {
  var article  = props.article;
  var hex      = props.hex;
  var isSomber = props.isSomber;
  var onClose  = props.onClose;

  var _mode = React.useState('brief');
  var mode = _mode[0], setMode = _mode[1];

  var modes        = article.storyModes || {};
  var persp        = article.storyPerspectives || null;
  var timeline     = article.storyTimeline || [];
  var clusterSynth = article._clusterSynthesis || null;
  var clusterMbrs  = article._clusterMembers   || null;
  var sources      = clusterMbrs ? [] : (article.sources || []);
  var srcCount     = article._clusterSize || (1 + sources.length);

  var accentStyle = { color: hex };
  var borderStyle = { borderColor: 'rgba(' + hexRgb(hex) + ',0.22)' };

  return (
    <div className="gf-story-sheet" onClick={function(e){ e.stopPropagation(); }}>
      {/* Close row */}
      <div className="gf-story-sheet-header">
        <div className="gf-story-sheet-src" style={accentStyle}>{srcCount} sources</div>
        <button className="gf-story-sheet-close" onClick={onClose} aria-label="Close story">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
            <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
          </svg>
        </button>
      </div>

      {/* Cluster headline */}
      <h2 className="gf-story-sheet-title">{article.storyClusterHeadline || article.title}</h2>

      {/* THE BRIEF / SYNTHESIS */}
      <div className="gf-story-sec">
        <div className="gf-story-sec-label" style={accentStyle}>
          {clusterSynth ? 'Synthesised brief' : 'The Brief'}
        </div>
        <p className="gf-story-brief-text">
          {clusterSynth ? clusterSynth.overview : (article.storyBrief || article.summary || '')}
        </p>
        {clusterSynth && clusterSynth.points && clusterSynth.points.length > 0 && (
          <ol className="gf-story-facts-list gf-story-facts-list--synth">
            {clusterSynth.points.map(function(pt, i){ return <li key={i}>{pt}</li>; })}
          </ol>
        )}
        {clusterSynth && (
          <div className="gf-story-synth-badge">Neutral summary · {srcCount} sources</div>
        )}
      </div>

      {/* NAMED MODES */}
      {(modes.facts || modes.explain || modes.matters) && (
        <div className="gf-story-sec">
          <div className="gf-story-modes-tabs">
            {['facts','explain','matters'].map(function(m){
              var labels = { facts:'Just the facts', explain:'Explain it simply', matters:'Why it matters' };
              return (
                <button key={m}
                  className={'gf-story-mode-btn' + (mode === m ? ' gf-story-mode-btn--on' : '')}
                  style={mode === m ? {color: hex, borderColor: hex} : {}}
                  onClick={function(){ setMode(m); }}>
                  {labels[m]}
                </button>
              );
            })}
          </div>
          <div className="gf-story-mode-body">
            {mode === 'facts' && (
              <ol className="gf-story-facts-list">
                {(modes.facts || []).map(function(f, i){ return <li key={i}>{f}</li>; })}
              </ol>
            )}
            {mode === 'explain' && <p className="gf-story-mode-para">{modes.explain}</p>}
            {mode === 'matters' && <p className="gf-story-mode-para">{modes.matters}</p>}
          </div>
        </div>
      )}

      {/* PERSPECTIVES — only when both field + wire present */}
      {persp && persp.ground && persp.headlines && (
        <div className="gf-story-sec">
          <div className="gf-story-sec-label" style={accentStyle}>On the ground vs. The headlines</div>
          <div className="gf-story-persp-pair">
            <div className="gf-story-persp-item">
              <div className="gf-story-persp-tag">On the ground</div>
              <p>{persp.ground}</p>
            </div>
            <div className="gf-story-persp-item">
              <div className="gf-story-persp-tag">The headlines</div>
              <p>{persp.headlines}</p>
            </div>
          </div>
        </div>
      )}

      {/* TIMELINE */}
      {timeline.length > 0 && (
        <div className="gf-story-sec">
          <div className="gf-story-sec-label" style={accentStyle}>Timeline</div>
          <div className="gf-story-timeline">
            {timeline.map(function(ev, i){
              return (
                <div key={i} className="gf-story-tl-row">
                  <div className="gf-story-tl-dot" style={{background: hex}} />
                  <div className="gf-story-tl-body">
                    <div className="gf-story-tl-meta">{gfRelTime(ev.published)}{ev.outlet ? ' · ' + ev.outlet : ''}</div>
                    <div className="gf-story-tl-title">{ev.title}</div>
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      )}

      {/* SOURCES */}
      <div className="gf-story-sec gf-story-sec--last">
        <div className="gf-story-sec-label" style={accentStyle}>{srcCount} Sources</div>
        {clusterMbrs ? (
          clusterMbrs.map(function(m, i){
            return (
              <a key={i} className={'gf-story-src-item' + (i === 0 ? ' gf-story-src-item--primary' : '')}
                 href={m.url} target="_blank" rel="noopener noreferrer"
                 onClick={function(e){ e.stopPropagation(); }}>
                <div className="gf-story-src-outlet" style={i === 0 ? accentStyle : {}}>{gfDomain(m.url)}</div>
                <div className="gf-story-src-title">{m.title}</div>
                <div className="gf-story-src-time">{gfRelTime(m.published)}</div>
                <span className="gf-story-src-arrow">→</span>
              </a>
            );
          })
        ) : (
          <React.Fragment>
            <a className="gf-story-src-item gf-story-src-item--primary"
               href={article.url} target="_blank" rel="noopener noreferrer"
               onClick={function(e){ e.stopPropagation(); }}>
              <div className="gf-story-src-outlet" style={accentStyle}>{gfDomain(article.url)}</div>
              <div className="gf-story-src-title">{article.title}</div>
              <div className="gf-story-src-time">{gfRelTime(article.published)}</div>
              <span className="gf-story-src-arrow">→</span>
            </a>
            {sources.map(function(s, i){
              return (
                <a key={i} className="gf-story-src-item"
                   href={s.url} target="_blank" rel="noopener noreferrer"
                   onClick={function(e){ e.stopPropagation(); }}>
                  <div className="gf-story-src-outlet">{s.name || gfDomain(s.url)}</div>
                  <div className="gf-story-src-title">{s.title}</div>
                  <div className="gf-story-src-time">{gfRelTime(s.published)}</div>
                  <span className="gf-story-src-arrow">→</span>
                </a>
              );
            })}
          </React.Fragment>
        )}
      </div>
    </div>
  );
}

function GFCard(props) {
  var slot = props.slot;
  var item = props.item;
  var hex  = props.hex;
  var hRgb = hexRgb(hex);
  var d    = item.data;

  if (slot === 'fact-main') {
    var sLen = (d.stat || '').length;
    var sFontSize = sLen > 45 ? '22px' : sLen > 25 ? '36px' : '54px';
    var sFontWeight = sLen > 45 ? '700' : '900';
    return (
      <div className="gf-card">
        <div className="gf-card-label">Fact</div>
        {d.icon && <ContentIcon iconName={d.icon} size={28} color={hex} bg={'rgba('+hRgb+',0.15)'} />}
        <div className="gf-fact-stat" style={{fontSize:sFontSize, fontWeight:sFontWeight}}>{d.stat}</div>
        <div className="gf-fact-text">{d.context || ''}</div>
      </div>
    );
  }

  if (slot === 'fact-ctx') {
    return (
      <div className="gf-card">
        <div className="gf-card-label">Why it matters</div>
        <div className="gf-fact-text" style={{fontSize:'17px'}}>{d.context}</div>
      </div>
    );
  }

  if (slot === 'read-step') {
    var step  = (d.steps || [])[props.idx] || {};
    var total = (d.steps || []).length;
    return (
      <div className="gf-card">
        <div className="gf-step-num">{props.idx + 1} / {total}</div>
        <div className="gf-step-heading">{step.heading || ''}</div>
        <div className="gf-step-body">{step.body || ''}</div>
      </div>
    );
  }

  if (slot === 'brief-headline') {
    var bRegion = getArticleRegion(d) || d.country || '';
    var kicker  = item.topic && bRegion ? item.topic + ' · ' + bRegion
                : (item.topic || bRegion || '');
    return (
      <div className="gf-card gf-card--brief-v2">
        <div className="gf-brief-top-v2">
          {kicker && (
            <div className="gf-brief-kicker" style={{color: hex}}>{kicker}</div>
          )}
          <div className="gf-brief-headline">{d.title}</div>
          {d.summary && (
            <div className="gf-brief-teaser">{d.summary}</div>
          )}
        </div>
        <div className="gf-brief-meta">
          {d.image_url && (
            <img className="gf-brief-thumb" src={d.image_url} alt="" />
          )}
          <span className="gf-brief-meta-source">{gfDomain(d.url)}</span>
          <span className="gf-brief-meta-sep">·</span>
          <span className="gf-brief-meta-time">{gfRelTime(d.published)}</span>
          <span className="gf-brief-meta-swipe">swipe for summary →</span>
        </div>
      </div>
    );
  }

  if (slot === 'brief-summary') {
    return (
      <div className="gf-card">
        <div className="gf-card-label">Summary</div>
        <div className="gf-brief-summary">{d.summary || ''}</div>
      </div>
    );
  }

  if (slot === 'brief-source') {
    return (
      <div className="gf-card">
        <div className="gf-card-label">Source</div>
        <div className="gf-source-domain">{gfDomain(d.url)}</div>
        <div className="gf-source-date">{gfFormatDate(d.published)}{d.published ? '  ·  ' + gfRelTime(d.published) : ''}</div>
        <a href={d.url} target="_blank" rel="noopener noreferrer"
           className="gf-source-link"
           onClick={function(e){ e.stopPropagation(); }}>
          Read full story &rarr;
        </a>
      </div>
    );
  }

  return (
    <div className="gf-card">
      <div className="gf-card-label">Coming soon</div>
    </div>
  );
}

// ── Bookmark SVG icon ──
function IcoBookmark(props) {
  return (
    <svg width="18" height="18" viewBox="0 0 24 24" fill={props.filled ? 'currentColor' : 'none'}
      stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
    </svg>
  );
}

// ── Heart SVG icon ──
function IcoHeart(props) {
  return (
    <svg width="18" height="18" viewBox="0 0 24 24" fill={props.filled ? 'currentColor' : 'none'}
      stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
    </svg>
  );
}

// ── Dots (share/more) icon ──
function IcoDots() {
  return (
    <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
      <circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/>
    </svg>
  );
}

// ── Full-screen feed item ──
function GFFeedItem(props) {
  var item    = props.item;
  var saved   = props.saved;
  var liked   = props.liked;
  var onSave  = props.onSave;
  var onLike  = props.onLike;
  var onShare = props.onShare;

  var hex = TOPIC_COLORS[item.topic] || '#7A8A9A';
  var rgb = hexRgb(hex);

  var d0            = item.data || {};
  var imageUrl      = d0.image_url || null;
  var dominantColor = d0.dominantColor || null;
  var isSomber      = (item.topic === 'Conflict') || (d0.significance >= 4);

  // ── Dominant-colour gradient or topic-gradient fallback ──
  var gradBg, imgOpacity;
  if (dominantColor) {
    var domHsl   = hexToHsl(dominantColor);
    var topicHsl = hexToHsl(hex);
    // Blend dominant hue 80% + topic hue 20% so topics stay distinguishable
    // Simple hue blend — handles wraparound by picking the shorter arc
    var dH = domHsl[0], tH = topicHsl[0];
    var diff = ((tH - dH + 540) % 360) - 180;   // signed shortest arc
    var bH   = Math.round(((dH + diff * 0.20) + 360) % 360);
    var bS   = isSomber ? Math.min(Math.round(domHsl[1] * 0.45), 30) : Math.min(domHsl[1], 82);
    if (isSomber) {
      gradBg = 'linear-gradient(180deg,' +
        'hsl('+bH+','+bS+'%,4%) 0%,' +
        'hsl('+bH+','+bS+'%,11%) 22%,' +
        'hsl('+bH+','+bS+'%,18%) 55%,' +
        'hsl('+bH+','+Math.round(bS*0.7)+'%,6%) 82%,' +
        'hsl('+bH+','+Math.round(bS*0.4)+'%,2%) 100%)';
    } else {
      gradBg = 'linear-gradient(180deg,' +
        'hsl('+bH+','+bS+'%,8%) 0%,' +
        'hsl('+bH+','+bS+'%,26%) 20%,' +
        'hsl('+bH+','+bS+'%,42%) 52%,' +
        'hsl('+bH+','+Math.round(bS*0.75)+'%,16%) 80%,' +
        'hsl('+bH+','+Math.round(bS*0.4)+'%,5%) 100%)';
    }
    imgOpacity = isSomber ? 0.18 : 0.26;
  } else {
    // No dominant colour: rich topic-colour radial on dark
    gradBg     = 'radial-gradient(ellipse 150% 100% at 50% 15%,rgba('+rgb+',0.65) 0%,rgba('+rgb+',0.30) 42%,rgba('+rgb+',0.10) 72%,transparent 100%),#0D0D10';
    imgOpacity = isSomber ? 0.48 : 0.58;
  }
  // Vignette: strong bands at top (heading) and bottom (meta row), light in the middle
  var vignetteBg = isSomber
    ? 'linear-gradient(180deg,rgba(0,0,0,0.72) 0%,rgba(0,0,0,0.28) 15%,rgba(0,0,0,0.18) 70%,rgba(0,0,0,0.62) 87%,rgba(0,0,0,0.76) 100%)'
    : 'linear-gradient(180deg,rgba(0,0,0,0.60) 0%,rgba(0,0,0,0.14) 15%,rgba(0,0,0,0.08) 70%,rgba(0,0,0,0.52) 87%,rgba(0,0,0,0.68) 100%)';

  var cards   = gfDeckCards(item);
  var heading = gfHeading(item);

  var _cidx = React.useState(0);
  var cidx = _cidx[0], setCidx = _cidx[1];

  var deckRef = React.useRef(null);

  // Scroll deck to active card index
  React.useEffect(function() {
    var el = deckRef.current;
    if (!el) return;
    var w = el.offsetWidth || el.clientWidth;
    if (!w) return;
    el.scrollTo({ left: cidx * (w + 12), behavior: 'smooth' });
  }, [cidx]);

  function onDeckScroll() {
    var el = deckRef.current;
    if (!el) return;
    var w = el.offsetWidth || el.clientWidth;
    if (!w) return;
    var newIdx = Math.round(el.scrollLeft / (w + 12));
    if (newIdx !== cidx) {
      setCidx(newIdx);
      if (newIdx > 0 && item.type === 'brief' && item.data && item.data.url) {
        markSeen(item.data.url);
      }
    }
  }

  function advanceDeck() {
    if (cidx < cards.length - 1) {
      var next = cidx + 1;
      setCidx(next);
      if (next > 0 && item.type === 'brief' && item.data && item.data.url) {
        markSeen(item.data.url);
      }
    }
  }

  // ── Story card: full-screen cluster card, tap opens story sheet ──
  if (item.type === 'story') {
    var _cSynth         = d0._clusterSynthesis || null;
    var srcCount        = d0._clusterSize || (1 + ((d0.sources && d0.sources.length) || 0));
    var clusterHeadline = (_cSynth && _cSynth.headline) || d0.storyClusterHeadline || d0.title;
    var briefFirst      = (_cSynth ? _cSynth.overview : (d0.storyBrief || '')).split('. ')[0];
    if (briefFirst && !briefFirst.endsWith('.')) briefFirst += '.';

    var _stOpen = React.useState(false);
    var stOpen = _stOpen[0], setStOpen = _stOpen[1];

    return (
      <div className="gf-item gf-item--story" onClick={function(){ setStOpen(true); }}>
        <div className="gf-item-bg" aria-hidden="true">
          <div className="gf-item-bg-grad" style={{background: gradBg}} />
          {imageUrl && <img className="gf-item-bg-img" src={imageUrl} alt="" style={{opacity: isSomber ? 0.12 : 0.20}} />}
          <div className="gf-item-bg-vignette" style={{background: vignetteBg}} />
        </div>
        <div className="gf-story-card">
          <div className="gf-story-card-kicker" style={{color: hex}}>
            {item.topic || 'World'}{d0.country ? ' · ' + d0.country : ''}
          </div>
          <div className="gf-story-card-badge">
            <span className="gf-story-card-badge-num" style={{color: hex}}>{srcCount}</span>
            <span className="gf-story-card-badge-word">sources</span>
          </div>
          <h2 className="gf-story-card-headline">{clusterHeadline}</h2>
          {briefFirst && <p className="gf-story-card-teaser">{briefFirst}</p>}
          <div className="gf-story-card-cta" style={{color: hex}}>Read the story →</div>
        </div>
        {stOpen && (
          <GFStorySheet article={d0} hex={hex} isSomber={isSomber}
            onClose={function(e){ if (e && e.stopPropagation) e.stopPropagation(); setStOpen(false); }} />
        )}
        <div className="gf-rail">
          <button className={'gf-rail-btn' + (liked ? ' gf-rail-btn--liked' : '')}
            onClick={function(e){ e.stopPropagation(); onLike && onLike(); }} aria-label={liked ? 'Unlike' : 'Like'}>
            <IcoHeart filled={liked} />
          </button>
          <button className={'gf-rail-btn' + (saved ? ' gf-rail-btn--saved' : '')}
            onClick={function(e){ e.stopPropagation(); onSave && onSave(); }} aria-label={saved ? 'Unsave' : 'Save'}>
            <IcoBookmark filled={saved} />
          </button>
          <button className="gf-rail-btn" onClick={function(e){ e.stopPropagation(); onShare && onShare(); }} aria-label="Share">
            <IcoDots />
          </button>
        </div>
      </div>
    );
  }

  // ── Reel: full-screen narrated layout, no deck ──
  if (item.type === 'reel') {
    var reelVideoUrl  = d0.reelVideoUrl  || null;
    var reelPosterUrl = d0.reelPosterUrl || null;
    var reelAiGenerated = !!d0.reelVideoAiGenerated;
    return (
      <div className="gf-item gf-item--reel">
        <div className="gf-item-bg" aria-hidden="true">
          <div className="gf-item-bg-grad" style={{background: gradBg}} />
          {reelVideoUrl ? (
            <video className="gf-item-bg-vid" autoPlay loop muted playsInline
              poster={reelPosterUrl || undefined}>
              <source src={reelVideoUrl} type="video/mp4" />
            </video>
          ) : imageUrl ? (
            <img className={'gf-item-bg-img' + (isSomber ? '' : ' gf-item-bg-img--kenburns')}
              src={imageUrl} alt="" style={{opacity: isSomber ? 0.12 : 0.32}} />
          ) : null}
          <div className="gf-item-bg-vignette" style={{background: vignetteBg}} />
        </div>
        {reelAiGenerated && <div className="gf-reel-ai-label" aria-hidden="true">AI-generated visual</div>}
        <GFReelPlayer item={item} hex={hex} isSomber={isSomber} />
        <div className="gf-rail">
          <button className={'gf-rail-btn' + (liked ? ' gf-rail-btn--liked' : '')}
            onClick={onLike} aria-label={liked ? 'Unlike' : 'Like'}>
            <IcoHeart filled={liked} />
          </button>
          <button className={'gf-rail-btn' + (saved ? ' gf-rail-btn--saved' : '')}
            onClick={onSave} aria-label={saved ? 'Unsave' : 'Save'}>
            <IcoBookmark filled={saved} />
          </button>
          <button className="gf-rail-btn" onClick={onShare} aria-label="Share">
            <IcoDots />
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="gf-item">

      {/* Per-item ambient backdrop — gradient wash → texture image → vignette bands */}
      <div className="gf-item-bg" aria-hidden="true">
        <div className="gf-item-bg-grad" style={{background: gradBg}} />
        {imageUrl && <img className="gf-item-bg-img" src={imageUrl} alt="" style={{opacity: imgOpacity}} />}
        <div className="gf-item-bg-vignette" style={{background: vignetteBg}} />
      </div>

      {/* Decorative heading */}
      <div className="gf-heading">{heading}</div>

      {/* Horizontal deck */}
      <div className="gf-deck-wrap">
        <div className="gf-deck" ref={deckRef} onScroll={onDeckScroll}>
          {cards.map(function(c, j) {
            return (
              <GFCard key={j} slot={c.slot} idx={c.idx !== undefined ? c.idx : j}
                item={item} hex={hex} />
            );
          })}
        </div>
      </div>

      {/* Pagination dots + advance */}
      <div className="gf-dots">
        {cards.map(function(_, j) {
          return <div key={j} className={'gf-dot' + (j === cidx ? ' gf-dot--on' : '')}
            style={j === cidx ? {background: hex} : {}} />;
        })}
        {cidx < cards.length - 1 && (
          <button className="gf-dot-next" onClick={advanceDeck} aria-label="Next card">›</button>
        )}
      </div>

      {/* Right action rail */}
      <div className="gf-rail">
        <button className={'gf-rail-btn' + (liked ? ' gf-rail-btn--liked' : '')}
          onClick={onLike} aria-label={liked ? 'Unlike' : 'Like'}>
          <IcoHeart filled={liked} />
        </button>
        <button className={'gf-rail-btn' + (saved ? ' gf-rail-btn--saved' : '')}
          onClick={onSave} aria-label={saved ? 'Unsave' : 'Save'}>
          <IcoBookmark filled={saved} />
        </button>
        <button className="gf-rail-btn" onClick={onShare} aria-label="Share">
          <IcoDots />
        </button>
      </div>
    </div>
  );
}

// ── FeedScreen root — editorial card grid ──
var FEED_FILTERS = [
  { id: 'foryou',   label: 'For you'  },
  { id: 'Conflict', label: 'Conflict' },
  { id: 'Health',   label: 'Health'   },
  { id: 'Economy',  label: 'Economy'  },
  { id: 'Climate',  label: 'Climate'  },
  { id: 'Politics', label: 'Politics' },
  { id: 'Trade',    label: 'Trade'    },
  { id: 'saved',    label: 'Saved'    },
];

function FeedScreen(props) {
  var articles    = props.articles;
  var onOpenStory = props.onOpenStory;

  var _filter = React.useState('foryou');
  var activeFilter = _filter[0], setActiveFilter = _filter[1];

  var _saves = React.useState(function(){ return gfLsLoad(GF_SAVES_KEY); });
  var saves  = _saves[0], setSaves = _saves[1];

  function toggleSave(url) {
    var next = saves.indexOf(url) >= 0
      ? saves.filter(function(s){ return s !== url; })
      : saves.concat([url]);
    setSaves(next);
    gfLsSave(GF_SAVES_KEY, next);
  }

  var displayArticles = React.useMemo(function() {
    if (activeFilter === 'saved') {
      return articles.filter(function(a){ return saves.indexOf(a.url) >= 0; }).slice(0, 40);
    }
    var pref    = loadPref(lsGet(LS.SETUP, null));
    var seenMap = loadSeen();
    var filtered = (activeFilter === 'foryou')
      ? articles.filter(function(a){ return !a._clusterHidden; })
      : articles.filter(function(a){ return !a._clusterHidden && getArticleTopic(a) === activeFilter; });
    return sliceRanked(scoreAndRank(filtered, pref, seenMap, 0.25), 40);
  }, [articles.length, activeFilter, saves.join(',')]);

  var feedBg = React.useMemo(function() {
    var t = PAGE_THEMES[activeFilter];
    if (!t) return {};
    return {
      background:
        'radial-gradient(ellipse 100% 48% at 15% 100%, ' + t.glow + ' 0%, rgba(4,5,10,0) 56%),' +
        'radial-gradient(ellipse 65% 38% at 88% 94%, ' + t.glow2 + ' 0%, rgba(4,5,10,0) 52%),' +
        '#04050A'
    };
  }, [activeFilter]);

  return (
    <div className="gf2-page" style={feedBg}>

      {/* Topic filter pills */}
      <div className="gf2-filters">
        <div className="gf2-filter-scroll">
          {FEED_FILTERS.map(function(f) {
            var on  = activeFilter === f.id;
            var hex = TOPIC_COLORS[f.id] || null;
            var rgb = hex ? hexRgb(hex) : null;
            return (
              <button key={f.id}
                className={'gf2-filter' + (on ? ' gf2-filter--on' : '')}
                style={on && hex ? {
                  background: 'rgba(' + rgb + ',0.16)',
                  color: hex,
                  borderColor: 'rgba(' + rgb + ',0.32)'
                } : {}}
                onClick={function(){ setActiveFilter(f.id); }}>
                {f.label}
              </button>
            );
          })}
        </div>
      </div>

      {/* Grid */}
      {displayArticles.length > 0 ? (
        <div className="gf2-grid">
          {displayArticles.map(function(a, i) {
            var isSaved = saves.indexOf(a.url) >= 0;
            var size = (i === 0 || i === 7 || i === 16) ? 'large' : 'medium';
            return (
              <div key={a.url || i} className={'gf2-card-wrap gf2-card-wrap--' + size}>
                <TodayStoryCard
                  article={a}
                  size={size}
                  onOpen={function(art) {
                    recordSignal(art, 'open');
                    markSeen(art.url);
                    if (onOpenStory) onOpenStory(art);
                  }}
                />
                <button
                  className={'gf2-save-btn' + (isSaved ? ' gf2-save-btn--on' : '')}
                  aria-label={isSaved ? 'Unsave' : 'Save'}
                  onClick={function(e){ e.stopPropagation(); toggleSave(a.url); }}>
                  <IcoBookmark filled={isSaved} />
                </button>
              </div>
            );
          })}
        </div>
      ) : (
        <div className="gf2-empty">
          <div className="gf2-empty-icon">
            {activeFilter === 'saved'
              ? (Bookmark ? <Bookmark size={32} strokeWidth={1.4} color="rgba(255,255,255,0.25)" /> : null)
              : (Sparkles ? <Sparkles size={32} strokeWidth={1.4} color="rgba(255,255,255,0.25)" /> : null)}
          </div>
          <div className="gf2-empty-text">
            {activeFilter === 'saved'
              ? 'Nothing saved yet. Tap the bookmark on any story.'
              : articles.length === 0 ? 'Loading stories…' : 'No stories for this topic right now.'}
          </div>
        </div>
      )}

      <div className="gf2-bottom-pad" />
    </div>
  );
}

// ─── Shell SVG icons (no emoji) ──────────────────────────────────────────────

function IcoToday() {
  return (
    <svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor"
      strokeWidth="1.65" strokeLinecap="round" strokeLinejoin="round">
      <path d="M2.5 9.5L11 2.5l8.5 7V19.5a1 1 0 0 1-1 1h-5V14h-5v6.5h-5a1 1 0 0 1-1-1V9.5z"/>
    </svg>
  );
}
function IcoFeed() {
  return (
    <svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor"
      strokeWidth="1.65" strokeLinecap="round" strokeLinejoin="round">
      <rect x="3" y="8" width="16" height="11" rx="2"/>
      <path d="M6.5 8V6a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v2"/>
      <path d="M9 5V3.5A.5.5 0 0 1 9.5 3h3a.5.5 0 0 1 .5.5V5"/>
    </svg>
  );
}
function IcoPlay() {
  return (
    <svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor"
      strokeWidth="1.65" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="11" cy="11" r="9"/>
      <path d="M9 7.5l5.5 3.5L9 14.5V7.5z" strokeWidth="1.4" fill="currentColor" stroke="none"/>
    </svg>
  );
}
function IcoProfile() {
  return (
    <svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor"
      strokeWidth="1.65" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="11" cy="7.5" r="3.5"/>
      <path d="M2.5 20.5c0-4.694 3.806-8.5 8.5-8.5s8.5 3.806 8.5 8.5"/>
    </svg>
  );
}
function IcoGlobe() {
  return (
    <svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor"
      strokeWidth="1.65" strokeLinecap="round">
      <circle cx="11" cy="11" r="9"/>
      <line x1="2" y1="11" x2="20" y2="11"/>
      <path d="M11 2 C8.2 5.8 8.2 16.2 11 20"/>
      <path d="M11 2 C13.8 5.8 13.8 16.2 11 20"/>
      <path d="M3.8 7.5 Q11 5.8 18.2 7.5"/>
      <path d="M3.8 14.5 Q11 16.2 18.2 14.5"/>
    </svg>
  );
}

// ─── HDI data — UNDP Human Development Report 2023/2024 (2022 values) ─────

var HDI_DATA = {
  // Africa
  12:{name:'Algeria',hdi:0.745}, 24:{name:'Angola',hdi:0.586},
  204:{name:'Benin',hdi:0.504}, 854:{name:'Burkina Faso',hdi:0.449},
  108:{name:'Burundi',hdi:0.426}, 120:{name:'Cameroon',hdi:0.587},
  132:{name:'Cabo Verde',hdi:0.640}, 140:{name:'Cent. African Rep.',hdi:0.381},
  148:{name:'Chad',hdi:0.394}, 174:{name:'Comoros',hdi:0.558},
  178:{name:'Congo',hdi:0.591}, 180:{name:'DR Congo',hdi:0.481},
  384:{name:"Côte d'Ivoire",hdi:0.550}, 262:{name:'Djibouti',hdi:0.519},
  818:{name:'Egypt',hdi:0.736}, 226:{name:'Equatorial Guinea',hdi:0.596},
  231:{name:'Ethiopia',hdi:0.492}, 266:{name:'Gabon',hdi:0.703},
  270:{name:'Gambia',hdi:0.496}, 288:{name:'Ghana',hdi:0.602},
  324:{name:'Guinea',hdi:0.481}, 624:{name:'Guinea-Bissau',hdi:0.473},
  404:{name:'Kenya',hdi:0.601}, 426:{name:'Lesotho',hdi:0.502},
  430:{name:'Liberia',hdi:0.481}, 434:{name:'Libya',hdi:0.718},
  450:{name:'Madagascar',hdi:0.476}, 454:{name:'Malawi',hdi:0.508},
  466:{name:'Mali',hdi:0.410}, 478:{name:'Mauritania',hdi:0.540},
  480:{name:'Mauritius',hdi:0.796}, 504:{name:'Morocco',hdi:0.698},
  508:{name:'Mozambique',hdi:0.461}, 516:{name:'Namibia',hdi:0.615},
  562:{name:'Niger',hdi:0.394}, 566:{name:'Nigeria',hdi:0.548},
  646:{name:'Rwanda',hdi:0.548}, 686:{name:'Senegal',hdi:0.517},
  694:{name:'Sierra Leone',hdi:0.452}, 706:{name:'Somalia',hdi:null},
  710:{name:'South Africa',hdi:0.717}, 728:{name:'South Sudan',hdi:0.381},
  729:{name:'Sudan',hdi:0.516}, 748:{name:'Eswatini',hdi:0.596},
  768:{name:'Togo',hdi:0.547}, 788:{name:'Tunisia',hdi:0.732},
  800:{name:'Uganda',hdi:0.544}, 834:{name:'Tanzania',hdi:0.532},
  894:{name:'Zambia',hdi:0.565}, 716:{name:'Zimbabwe',hdi:0.550},
  // Americas
  32:{name:'Argentina',hdi:0.849}, 84:{name:'Belize',hdi:0.683},
  68:{name:'Bolivia',hdi:0.698}, 76:{name:'Brazil',hdi:0.760},
  124:{name:'Canada',hdi:0.935}, 152:{name:'Chile',hdi:0.860},
  170:{name:'Colombia',hdi:0.752}, 188:{name:'Costa Rica',hdi:0.806},
  192:{name:'Cuba',hdi:0.764}, 214:{name:'Dominican Republic',hdi:0.767},
  218:{name:'Ecuador',hdi:0.765}, 222:{name:'El Salvador',hdi:0.675},
  320:{name:'Guatemala',hdi:0.627}, 328:{name:'Guyana',hdi:0.742},
  332:{name:'Haiti',hdi:0.535}, 340:{name:'Honduras',hdi:0.624},
  388:{name:'Jamaica',hdi:0.706}, 484:{name:'Mexico',hdi:0.781},
  558:{name:'Nicaragua',hdi:0.667}, 591:{name:'Panama',hdi:0.812},
  600:{name:'Paraguay',hdi:0.724}, 604:{name:'Peru',hdi:0.762},
  740:{name:'Suriname',hdi:0.738}, 780:{name:'Trinidad & Tobago',hdi:0.814},
  840:{name:'United States',hdi:0.927}, 858:{name:'Uruguay',hdi:0.830},
  862:{name:'Venezuela',hdi:0.699},
  // Asia
  4:{name:'Afghanistan',hdi:0.462}, 51:{name:'Armenia',hdi:0.791},
  31:{name:'Azerbaijan',hdi:0.760}, 50:{name:'Bangladesh',hdi:0.661},
  64:{name:'Bhutan',hdi:0.681}, 96:{name:'Brunei',hdi:0.823},
  116:{name:'Cambodia',hdi:0.600}, 156:{name:'China',hdi:0.788},
  626:{name:'Timor-Leste',hdi:0.607}, 268:{name:'Georgia',hdi:0.802},
  356:{name:'India',hdi:0.644}, 360:{name:'Indonesia',hdi:0.713},
  364:{name:'Iran',hdi:0.780}, 368:{name:'Iraq',hdi:0.686},
  376:{name:'Israel',hdi:0.915}, 392:{name:'Japan',hdi:0.920},
  400:{name:'Jordan',hdi:0.736}, 398:{name:'Kazakhstan',hdi:0.802},
  408:{name:'North Korea',hdi:null}, 410:{name:'South Korea',hdi:0.929},
  414:{name:'Kuwait',hdi:0.847}, 417:{name:'Kyrgyzstan',hdi:0.701},
  418:{name:'Laos',hdi:0.620}, 422:{name:'Lebanon',hdi:0.723},
  458:{name:'Malaysia',hdi:0.807}, 462:{name:'Maldives',hdi:0.762},
  496:{name:'Mongolia',hdi:0.741}, 104:{name:'Myanmar',hdi:0.585},
  524:{name:'Nepal',hdi:0.601}, 512:{name:'Oman',hdi:0.816},
  586:{name:'Pakistan',hdi:0.540}, 275:{name:'Palestine',hdi:0.715},
  608:{name:'Philippines',hdi:0.710}, 634:{name:'Qatar',hdi:0.855},
  682:{name:'Saudi Arabia',hdi:0.875}, 702:{name:'Singapore',hdi:0.939},
  144:{name:'Sri Lanka',hdi:0.780}, 760:{name:'Syria',hdi:0.557},
  762:{name:'Tajikistan',hdi:0.685}, 764:{name:'Thailand',hdi:0.803},
  792:{name:'Turkey',hdi:0.855}, 795:{name:'Turkmenistan',hdi:0.745},
  784:{name:'UAE',hdi:0.937}, 860:{name:'Uzbekistan',hdi:0.727},
  704:{name:'Vietnam',hdi:0.726}, 887:{name:'Yemen',hdi:0.424},
  // Europe
  8:{name:'Albania',hdi:0.789}, 40:{name:'Austria',hdi:0.926},
  112:{name:'Belarus',hdi:0.801}, 56:{name:'Belgium',hdi:0.942},
  70:{name:'Bosnia & Herz.',hdi:0.779}, 100:{name:'Bulgaria',hdi:0.795},
  191:{name:'Croatia',hdi:0.878}, 196:{name:'Cyprus',hdi:0.896},
  203:{name:'Czechia',hdi:0.900}, 208:{name:'Denmark',hdi:0.952},
  233:{name:'Estonia',hdi:0.899}, 246:{name:'Finland',hdi:0.940},
  250:{name:'France',hdi:0.910}, 276:{name:'Germany',hdi:0.950},
  300:{name:'Greece',hdi:0.893}, 348:{name:'Hungary',hdi:0.851},
  352:{name:'Iceland',hdi:0.959}, 372:{name:'Ireland',hdi:0.950},
  380:{name:'Italy',hdi:0.906}, 428:{name:'Latvia',hdi:0.879},
  438:{name:'Liechtenstein',hdi:0.935}, 440:{name:'Lithuania',hdi:0.882},
  442:{name:'Luxembourg',hdi:0.927}, 498:{name:'Moldova',hdi:0.763},
  499:{name:'Montenegro',hdi:0.832}, 807:{name:'N. Macedonia',hdi:0.770},
  528:{name:'Netherlands',hdi:0.946}, 578:{name:'Norway',hdi:0.966},
  616:{name:'Poland',hdi:0.881}, 620:{name:'Portugal',hdi:0.874},
  642:{name:'Romania',hdi:0.821}, 643:{name:'Russia',hdi:0.821},
  688:{name:'Serbia',hdi:0.805}, 703:{name:'Slovakia',hdi:0.855},
  705:{name:'Slovenia',hdi:0.926}, 724:{name:'Spain',hdi:0.911},
  752:{name:'Sweden',hdi:0.952}, 756:{name:'Switzerland',hdi:0.967},
  804:{name:'Ukraine',hdi:0.773}, 826:{name:'United Kingdom',hdi:0.940},
  // Oceania
  36:{name:'Australia',hdi:0.946},
  598:{name:'Papua New Guinea',hdi:0.578},
  554:{name:'New Zealand',hdi:0.939},
};

function globeHdiColor(hdi) {
  if (hdi == null) return 'rgba(90,90,105,0.82)';
  var t = Math.max(0, Math.min(1, (hdi - 0.35) / 0.62));
  return 'hsl(' + Math.round(t * 120) + ',70%,38%)';
}
function globeHdiSideColor(hdi) {
  if (hdi == null) return 'rgba(50,50,60,0.65)';
  var t = Math.max(0, Math.min(1, (hdi - 0.35) / 0.62));
  return 'hsla(' + Math.round(t * 120) + ',55%,22%,0.75)';
}
function globeHdiBand(hdi) {
  if (hdi == null) return 'No data';
  if (hdi >= 0.800) return 'Very High';
  if (hdi >= 0.700) return 'High';
  if (hdi >= 0.550) return 'Medium';
  return 'Low';
}

// ─── Country centroids (lat, lng) — standard geographic centres, same IDs as HDI_DATA ─

var COUNTRY_COORDS = {
  12:[28.0,1.7],24:[-11.2,17.9],204:[9.3,2.3],854:[12.4,-1.6],
  108:[-3.4,29.9],120:[5.7,12.4],132:[15.1,-23.6],140:[6.6,20.9],
  148:[15.5,18.7],174:[-11.6,43.3],178:[-0.2,15.8],180:[-4.0,21.8],
  384:[7.5,-5.5],262:[11.8,42.6],818:[26.8,30.8],226:[1.7,10.3],
  231:[9.1,40.5],266:[-0.8,11.6],270:[13.5,-15.3],288:[7.9,-1.0],
  324:[11.0,-11.0],624:[12.0,-15.2],404:[0.0,37.9],426:[-29.6,28.2],
  430:[6.4,-9.4],434:[25.0,17.2],450:[-20.0,47.0],454:[-13.3,34.3],
  466:[17.6,-4.0],478:[20.3,-10.9],480:[-20.3,57.6],504:[31.8,-7.1],
  508:[-18.7,35.5],516:[-22.0,17.1],562:[17.6,8.1],566:[9.1,8.7],
  646:[-2.0,30.0],686:[14.5,-14.5],694:[8.5,-11.8],706:[6.0,46.2],
  710:[-29.0,25.1],728:[7.9,30.2],729:[15.6,32.5],748:[-26.5,31.5],
  768:[8.6,0.8],788:[33.9,9.6],800:[1.4,32.3],834:[-6.4,34.9],
  894:[-13.1,27.9],716:[-19.0,29.9],
  32:[-34.0,-64.0],84:[17.2,-88.5],68:[-17.1,-64.7],76:[-10.0,-53.2],
  124:[56.1,-106.3],152:[-35.7,-71.5],170:[4.6,-74.3],188:[9.7,-84.2],
  192:[22.0,-79.5],214:[18.9,-70.2],218:[-1.8,-78.2],222:[13.7,-88.9],
  320:[15.8,-90.2],328:[4.9,-59.0],332:[18.9,-72.7],340:[15.0,-86.2],
  388:[18.1,-77.3],484:[23.6,-102.6],558:[12.9,-85.2],591:[8.5,-80.8],
  600:[-23.2,-58.4],604:[-9.2,-75.0],740:[3.9,-56.0],780:[10.7,-61.2],
  840:[39.8,-98.6],858:[-32.8,-56.0],862:[8.0,-66.2],
  4:[33.9,67.7],51:[40.1,45.0],31:[40.1,47.6],50:[23.7,90.4],
  64:[27.5,90.4],96:[4.5,114.7],116:[12.6,104.9],156:[36.6,101.8],
  626:[-8.9,125.7],268:[42.0,43.4],356:[22.5,80.9],360:[-4.7,116.4],
  364:[32.4,53.7],368:[33.2,43.7],376:[31.5,34.9],392:[36.2,138.3],
  400:[30.6,36.7],398:[47.2,67.2],408:[40.3,127.5],410:[36.6,127.8],
  414:[29.3,47.5],417:[41.2,74.8],418:[17.4,102.6],422:[33.9,35.9],
  458:[3.8,112.3],462:[4.2,73.2],496:[46.9,102.9],104:[19.2,96.7],
  524:[28.4,84.1],512:[21.0,57.0],586:[30.4,69.3],275:[31.9,35.2],
  608:[12.9,122.1],634:[25.4,51.2],682:[24.2,45.1],702:[1.4,103.8],
  144:[7.9,80.8],760:[34.8,38.9],762:[38.9,71.3],764:[15.9,100.9],
  792:[39.1,35.2],795:[39.6,59.6],784:[24.0,54.0],860:[41.4,63.9],
  704:[16.6,106.3],887:[16.0,47.6],
  8:[41.2,20.2],40:[47.5,14.6],112:[53.5,28.0],56:[50.6,4.5],
  70:[44.2,17.4],100:[42.8,25.2],191:[45.2,15.9],196:[35.2,33.4],
  203:[49.8,15.5],208:[56.3,10.5],233:[58.8,25.0],246:[64.1,26.0],
  250:[46.8,2.3],276:[51.2,10.5],300:[39.1,22.0],348:[47.2,19.5],
  352:[65.0,-18.5],372:[53.4,-8.2],380:[42.8,12.8],428:[57.0,25.0],
  438:[47.2,9.6],440:[55.9,23.9],442:[49.8,6.2],498:[47.4,28.5],
  499:[42.7,19.4],807:[41.6,21.8],528:[52.2,5.3],578:[64.6,17.4],
  616:[52.1,19.2],620:[39.6,-8.2],642:[45.8,24.6],643:[60.8,100.1],
  688:[44.2,20.8],703:[48.7,19.7],705:[46.2,14.8],724:[40.4,-3.7],
  752:[62.2,17.0],756:[47.0,8.2],804:[49.2,31.2],826:[54.6,-3.4],
  36:[-25.3,133.8],598:[-6.3,143.9],554:[-41.5,172.8],
};

// Aliases: typed text (lowercase) → canonical name in HDI_DATA
var COUNTRY_ALIASES = {
  'usa':'United States','us':'United States','america':'United States',
  'united states of america':'United States',
  'uk':'United Kingdom','britain':'United Kingdom',
  'great britain':'United Kingdom','england':'United Kingdom',
  'uae':'UAE','united arab emirates':'UAE',
  'drc':'DR Congo','democratic republic of congo':'DR Congo',
  'democratic republic of the congo':'DR Congo',
  'ivory coast':"Côte d'Ivoire",'cote divoire':"Côte d'Ivoire",
  "cote d'ivoire":"Côte d'Ivoire",
  'timor leste':'Timor-Leste','east timor':'Timor-Leste',
  'trinidad':'Trinidad & Tobago','tobago':'Trinidad & Tobago',
  'trinidad and tobago':'Trinidad & Tobago',
  'north korea':'North Korea','south korea':'South Korea',
  'north macedonia':'N. Macedonia','macedonia':'N. Macedonia',
  'bosnia':'Bosnia & Herz.','bosnia and herzegovina':'Bosnia & Herz.',
  'cape verde':'Cabo Verde','eswatini':'Eswatini','swaziland':'Eswatini',
  'czech republic':'Czechia','czech':'Czechia',
  'russian federation':'Russia',
  'central african republic':'Cent. African Rep.',
  'palestine':'Palestine','west bank':'Palestine',
  'republic of congo':'Congo','republic of the congo':'Congo',
  'guinea bissau':'Guinea-Bissau',
  'south africa':'South Africa','south sudan':'South Sudan',
  'sri lanka':'Sri Lanka','saudi arabia':'Saudi Arabia',
  'new zealand':'New Zealand','papua new guinea':'Papua New Guinea',
  'costa rica':'Costa Rica','burkina faso':'Burkina Faso',
  'sierra leone':'Sierra Leone','equatorial guinea':'Equatorial Guinea',
  'dominican republic':'Dominican Republic','el salvador':'El Salvador',
};

// ─── Country Guesser helpers ─────────────────────────────────────────────────

function cgHaversine(lat1, lon1, lat2, lon2) {
  var R = 6371, d2r = Math.PI / 180;
  var dLat = (lat2 - lat1) * d2r, dLon = (lon2 - lon1) * d2r;
  var a = Math.sin(dLat/2)*Math.sin(dLat/2) +
    Math.cos(lat1*d2r)*Math.cos(lat2*d2r)*Math.sin(dLon/2)*Math.sin(dLon/2);
  return Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)));
}

function cgBearing(lat1, lon1, lat2, lon2) {
  var d2r = Math.PI / 180;
  var dLon = (lon2 - lon1) * d2r;
  var y = Math.sin(dLon) * Math.cos(lat2 * d2r);
  var x = Math.cos(lat1*d2r)*Math.sin(lat2*d2r) -
    Math.sin(lat1*d2r)*Math.cos(lat2*d2r)*Math.cos(dLon);
  return (Math.atan2(y, x) * 180/Math.PI + 360) % 360;
}

function cgArrow(deg) {
  return ['↑','↗','→','↘','↓','↙','←','↖'][Math.round(deg / 45) % 8];
}

function cgProxPct(km)   { return Math.max(0, Math.round((1 - km / 20000) * 100)); }
function cgProxEmoji(km) {
  if (km === 0)    return '[+]';
  if (km < 1000)  return '[~]';
  if (km < 3000)  return '[-]';
  return '[x]';
}
function cgProxClass(km) {
  if (km < 500)  return 'cg-prox--near';
  if (km < 2000) return 'cg-prox--mild';
  if (km < 5000) return 'cg-prox--far';
  return 'cg-prox--cold';
}

function cgBuildPool() {
  var pool = [];
  Object.keys(HDI_DATA).forEach(function(id) {
    var info = HDI_DATA[+id], coords = COUNTRY_COORDS[+id];
    if (coords && info.hdi != null)
      pool.push({ id:+id, name:info.name, hdi:info.hdi, lat:coords[0], lng:coords[1] });
  });
  return pool.sort(function(a,b){ return a.name < b.name ? -1 : 1; });
}

function cgDailyTarget(pool) {
  var key = new Date().toDateString(), h = 0;
  for (var i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
  return pool[Math.abs(h) % pool.length];
}

function cgNameLookup() {
  var lk = {};
  Object.keys(HDI_DATA).forEach(function(id) {
    lk[HDI_DATA[+id].name.toLowerCase()] = +id;
  });
  Object.keys(COUNTRY_ALIASES).forEach(function(alias) {
    var canon = COUNTRY_ALIASES[alias];
    Object.keys(HDI_DATA).forEach(function(id) {
      if (HDI_DATA[+id].name === canon) lk[alias] = +id;
    });
  });
  return lk;
}

function cgAutocomplete(text, allNames) {
  if (!text) return [];
  var lo = text.toLowerCase();
  var starts = allNames.filter(function(n){ return n.toLowerCase().indexOf(lo) === 0; });
  var contains = allNames.filter(function(n){
    return n.toLowerCase().indexOf(lo) > 0 && starts.indexOf(n) === -1;
  });
  return starts.concat(contains).slice(0, 6);
}

// ─── Globe screen — HDI choropleth ──────────────────────────────────────────

// HDI_DATA uses some shortened/alternative names vs article .country values.
var HDI_TO_ARTICLE_COUNTRY = {
  'DR Congo':           'Democratic Republic of Congo',
  "Côte d'Ivoire":      'Ivory Coast',
  'Türkiye':            'Turkey',
  'Bosnia & Herz.':     'Bosnia and Herzegovina',
  'Cent. African Rep.': 'Central African Republic',
  'Korea, Rep.':        'South Korea',
  'Korea, South':       'South Korea',
};

// ── ClusterDetailScreen (Part 14) ─────────────────────────────────────────────
function ClusterDetailScreen(props) {
  var cluster     = props.cluster;
  var articles    = props.articles || [];
  var onClose     = props.onClose;
  var onOpenStory = props.onOpenStory;
  if (!cluster) return null;

  var topic    = cluster.primaryTopic || null;
  var topicHex = topic ? (TOPIC_COLORS[topic] || '#7A8A9A') : '#7A8A9A';
  var topicRgb = hexRgb(topicHex);
  var region   = getArticleRegion({ country: cluster.primaryCountry || '' });
  var sober    = isGraveStory({ title: cluster.eventTitle || '', summary: cluster.miraThirtySecondSummary || '', significance: cluster.importanceScore || 0 });
  var timeAgo  = cluster.lastUpdatedAt ? relTime(cluster.lastUpdatedAt) : null;
  var mainImage = (cluster.leadImageUrl && !isDocumentImage(cluster.leadImageUrl)) ? cluster.leadImageUrl : null;
  var coverageCount = cluster.sourceCount || 1;

  var sourceArts = React.useMemo(function() {
    var urls = new Set(cluster.sourceStoryIds || []);
    return articles.filter(function(a) { return urls.has(a.url); });
  }, [cluster.id, articles.length]);

  function getDomain(url) {
    try { return new URL(url).hostname.replace(/^www\./, ''); } catch(e) { return ''; }
  }

  var allSourceCards = React.useMemo(function() {
    return sourceArts.map(function(a, i) {
      var d = getDomain(a.url);
      return {
        domain: d,
        name:   a.sourceName || d,
        label:  null,
        title:  a.title,
        url:    a.url,
        image:  (i === 0 && a.image_url && !isDocumentImage(a.image_url)) ? a.image_url : null,
        time:   a.published ? relTime(a.published) : '',
        isCanonical: i === 0,
      };
    });
  }, [cluster.id, sourceArts.length]);

  var relatedStories = React.useMemo(function() {
    if (!articles.length) return [];
    var sourceSet = new Set(cluster.sourceStoryIds || []);
    return articles
      .filter(function(a) { return !a._clusterHidden && !sourceSet.has(a.url) && getArticleTopic(a) === topic; })
      .slice(0, 4);
  }, [articles.length, cluster.id, topic]);

  // Collect all source images for the gallery (only real, non-document images)
  var clusterImages = React.useMemo(function() {
    return sourceArts
      .map(function(a) {
        var img = (a.image_url && !isDocumentImage(a.image_url)) ? a.image_url : null;
        if (!img) return null;
        return { url: img, domain: getDomain(a.url), sourceName: a.sourceName || getDomain(a.url) };
      })
      .filter(Boolean);
  }, [cluster.id, sourceArts.length]);

  var heroBg = sober
    ? '#EBE7D8'
    : ('radial-gradient(ellipse 85% 70% at 12% 40%, rgba(' + topicRgb + ',0.12) 0%, transparent 62%),' +
       'radial-gradient(ellipse 55% 50% at 88% 10%, rgba(' + topicRgb + ',0.06) 0%, transparent 55%),' +
       '#EBE7D8');

  var entityHex   = sober ? 'rgba(17,17,15,0.70)' : topicHex;
  var entityTitle = highlightHeadlineTerms(cluster.eventTitle || '', topic, cluster.primaryCountry, region, entityHex);
  var accentStyle = sober ? {} : { color: topicHex };
  var bulletStyle = sober ? 'rgba(17,17,15,0.30)' : topicHex;

  var deckText = cluster.miraThirtySecondSummary
    ? cluster.miraThirtySecondSummary.split(/\.\s+/)[0] + '.'
    : '';

  function handleShare() {
    var url = window.location.origin + window.location.pathname + '#/event/' + encodeURIComponent(cluster.slug || cluster.id);
    if (navigator.share) {
      navigator.share({ title: cluster.eventTitle, url: url }).catch(function(){});
    } else if (navigator.clipboard) {
      navigator.clipboard.writeText(url).catch(function(){});
    }
  }

  var factCards = [];
  if (coverageCount > 1) factCards.push({ label: 'Sources',   value: coverageCount + ' outlets' });
  if (region)            factCards.push({ label: 'Region',    value: region });
  if (cluster.primaryCountry) factCards.push({ label: 'Country', value: cluster.primaryCountry });
  if (topic)             factCards.push({ label: 'Topic',     value: topic });
  if (timeAgo)           factCards.push({ label: 'Updated',   value: timeAgo });

  // Enrich contextArticle with cluster metadata so vague "this" questions anchor correctly
  var contextArticle = sourceArts[0]
    ? Object.assign({}, sourceArts[0], {
        country: sourceArts[0].country || cluster.primaryCountry,
        topic:   sourceArts[0].topic   || cluster.primaryTopic,
      })
    : {
        url:     cluster.slug || cluster.id,
        title:   cluster.eventTitle,
        summary: cluster.miraThirtySecondSummary || '',
        country: cluster.primaryCountry,
        topic:   cluster.primaryTopic,
      };

  return (
    <div className={"gbd2-ep-page" + (sober ? " gbd2-ep-page--sober" : "")}
         style={{ background: heroBg }}>

      {/* ── STICKY TOPBAR ── */}
      <div className="gbd2-ep-topbar">
        <button className="gbd2-ep-back" onClick={onClose} aria-label="Back">
          <svg width="15" height="15" viewBox="0 0 16 16" fill="none" style={{verticalAlign:'-2px'}}>
            <path d="M10 3L5 8l5 5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
          Back
        </button>
        <span className="gbd2-ep-brand">Globally</span>
        <button className="gbd2-ep-share" onClick={handleShare} aria-label="Share">
          <svg width="16" height="16" viewBox="0 0 18 18" fill="none">
            <path d="M13 2l3 3-3 3M16 5H8a5 5 0 000 10h1" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
        </button>
      </div>

      {/* ── SCROLL ROOT ── */}
      <div className="gbd2-ep-scroll">

        {/* ════════════ HERO ════════════ */}
        <div className="gbd2-ep-hero">

          {/* LEFT: meta + headline + deck + source chips */}
          <div className="gbd2-ep-hero-left">
            <div className="gbd2-ep-meta">
              {topic && (
                <span className="gbd2-ep-meta-topic" style={accentStyle}>{topic.toUpperCase()}</span>
              )}
              {region && (
                <React.Fragment>
                  <span className="gbd2-ep-meta-sep">·</span>
                  <span className="gbd2-ep-meta-loc">{region.toUpperCase()}</span>
                </React.Fragment>
              )}
              {cluster.primaryCountry && cluster.primaryCountry !== region && (
                <React.Fragment>
                  <span className="gbd2-ep-meta-sep">·</span>
                  <span className="gbd2-ep-meta-loc">{cluster.primaryCountry.toUpperCase()}</span>
                </React.Fragment>
              )}
              {coverageCount > 1 && (
                <React.Fragment>
                  <span className="gbd2-ep-meta-sep">·</span>
                  <span className="gbd2-ep-meta-srcs">{coverageCount} SOURCES</span>
                </React.Fragment>
              )}
              {timeAgo && (
                <React.Fragment>
                  <span className="gbd2-ep-meta-sep">·</span>
                  <span className="gbd2-ep-meta-time">{timeAgo}</span>
                </React.Fragment>
              )}
            </div>

            <h1 className="gbd2-ep-headline">{entityTitle}</h1>

            {deckText && <p className="gbd2-ep-deck">{deckText}</p>}

            {allSourceCards.length > 0 && (
              <div className="gbd2-ep-src-chips">
                {allSourceCards.slice(0, 5).map(function(s, i) {
                  return (
                    <div key={i} className={"gbd2-ep-src-chip" + (s.isCanonical ? " gbd2-ep-src-chip--lead" : "")}
                         style={s.isCanonical && !sober ? { borderColor:'rgba('+topicRgb+',0.50)' } : {}}>
                      <img src={'/favicons/'+s.domain+'.png'} alt="" width="13" height="13"
                           style={{borderRadius:2,flexShrink:0}}
                           onError={function(e){e.currentTarget.style.display='none';}} />
                      <span>{s.domain}</span>
                    </div>
                  );
                })}
                {allSourceCards.length > 5 && (
                  <span className="gbd2-ep-src-more">+{allSourceCards.length - 5}</span>
                )}
              </div>
            )}
          </div>

          {/* RIGHT: image collage */}
          <div className="gbd2-ep-hero-right">
            <div className="gbd2-ep-collage">
              {mainImage ? (
                <div className="gbd2-ep-collage-main"
                     style={{backgroundImage:"url('"+mainImage.replace(/'/g,'%27')+"')"}}>
                  <div className="gbd2-ep-collage-scrim" />
                  {!sober && (
                    <div className="gbd2-ep-collage-tint"
                         style={{background:'linear-gradient(to top,rgba('+topicRgb+',0.38) 0%,transparent 50%)'}} />
                  )}
                  <div className="gbd2-ep-collage-foot">
                    {allSourceCards[0] && (
                      <div className="gbd2-ep-collage-badge">
                        <img src={'/favicons/'+allSourceCards[0].domain+'.png'} alt="" width="11" height="11"
                             style={{borderRadius:2,flexShrink:0}}
                             onError={function(e){e.currentTarget.style.display='none';}} />
                        <span>{allSourceCards[0].name || allSourceCards[0].domain}</span>
                      </div>
                    )}
                    {coverageCount > 1 && (
                      <div className="gbd2-ep-collage-coverage"
                           style={{color: sober ? 'rgba(255,255,255,0.72)' : topicHex}}>
                        {coverageCount} sources
                      </div>
                    )}
                  </div>
                </div>
              ) : (
                <div className="gbd2-ep-collage-main gbd2-ep-collage-main--gradient"
                     style={sober
                       ? {background:'linear-gradient(155deg,#1c1c26 0%,#0e0e16 100%)'}
                       : {background:'radial-gradient(ellipse 90% 80% at 25% 25%,rgba('+topicRgb+',0.75) 0%,rgba('+topicRgb+',0.30) 45%,#060608 80%)'}
                     }>
                  <div className="gbd2-ep-collage-grad-inner">
                    <span className="gbd2-ep-collage-grad-topic" style={accentStyle}>{topic || 'World'}</span>
                    {allSourceCards.length > 0 && (
                      <div className="gbd2-ep-collage-grad-srcs">
                        {allSourceCards.slice(0, 3).map(function(s, i) {
                          return (
                            <div key={i} className="gbd2-ep-collage-grad-src">
                              <img src={'/favicons/'+s.domain+'.png'} alt="" width="12" height="12"
                                   style={{borderRadius:2,flexShrink:0,opacity:0.75}}
                                   onError={function(e){e.currentTarget.style.display='none';}} />
                              <span>{s.domain}</span>
                            </div>
                          );
                        })}
                      </div>
                    )}
                    <span className="gbd2-ep-collage-grad-brand">Globally</span>
                  </div>
                </div>
              )}

              {sourceArts.slice(1, 4).map(function(a, i) {
                var d  = getDomain(a.url);
                var nm = a.sourceName || d;
                return (
                  <div key={i} className="gbd2-ep-collage-mini">
                    <div className="gbd2-ep-collage-mini-src-row">
                      <img src={'/favicons/'+d+'.png'} alt="" width="14" height="14"
                           style={{borderRadius:2,flexShrink:0}}
                           onError={function(e){e.currentTarget.style.display='none';}} />
                      <span className="gbd2-ep-collage-mini-src">{nm}</span>
                      {a.published && <span className="gbd2-ep-collage-mini-time">{relTime(a.published)}</span>}
                    </div>
                    {a.title && (
                      <p className="gbd2-ep-collage-mini-title">
                        {a.title.length > 80 ? a.title.slice(0,80)+'…' : a.title}
                      </p>
                    )}
                  </div>
                );
              })}
            </div>
          </div>
        </div>{/* /gbd2-ep-hero */}

        {/* ════════════ BODY ════════════ */}
        <div className="gbd2-ep-body">

          <div className="gbd2-ep-main">

            {/* ── Premium Mira event brief module ── */}
            <ArticleBriefModule
              type="event"
              thirtySecText={cluster.miraThirtySecondSummary || null}
              sections={cluster.miraSections || (cluster.miraLongArticle
                ? [{ heading: 'Analysis', body: cluster.miraLongArticle }]
                : [])}
              keyFacts={cluster.keyFacts || []}
              coverageCount={coverageCount}
              accentColor={sober ? 'rgba(17,17,15,0.45)' : topicHex}
              sober={sober}
            />

            {/* Ask Mira — above images so it's discovered early in the reading flow */}
            <AskMiraPanel article={contextArticle} articles={sourceArts.length ? sourceArts : articles} setup={{}} onOpenStory={onOpenStory} />

            {/* ── Source image gallery (cluster only) ── */}
            {clusterImages.length > 1 && (
              <ClusterImageGallery
                images={clusterImages}
                accentColor={topicHex}
                sober={sober}
              />
            )}

            {/* Facts grid */}
            {factCards.length > 0 && (
              <section className="gbd2-ep-section">
                <h2 className="gbd2-ep-section-h">Facts</h2>
                <div className="gbd2-ep-facts-grid">
                  {factCards.map(function(fc, i) {
                    return (
                      <div key={i} className="gbd2-ep-fact-card"
                           style={{borderColor: sober ? 'rgba(255,255,255,0.07)' : 'rgba('+topicRgb+',0.22)'}}>
                        <div className="gbd2-ep-fact-label">{fc.label}</div>
                        <div className="gbd2-ep-fact-value" style={accentStyle}>{fc.value}</div>
                      </div>
                    );
                  })}
                </div>
              </section>
            )}

            {/* Source list — mobile */}
            <div className="gbd2-ep-mobile-sources">
              <section className="gbd2-ep-section">
                <h2 className="gbd2-ep-section-h">
                  Articles <span style={accentStyle}>{allSourceCards.length}</span>
                </h2>
                <div className="gbd2-ep-src-list">
                  {allSourceCards.map(function(s, i) {
                    return (
                      <a key={i} href={s.url} target="_blank" rel="noopener noreferrer"
                         className={'gbd2-ep-src-card'+(s.isCanonical?' gbd2-ep-src-card--lead':'')}
                         style={s.isCanonical&&!sober ? {borderLeftColor:topicHex} : {}}>
                        {s.image && (
                          <div className="gbd2-ep-src-card-thumb"
                               style={{backgroundImage:"url('"+s.image+"')"}} />
                        )}
                        <div className="gbd2-ep-src-card-body">
                          <div className="gbd2-ep-src-card-head">
                            <img src={'/favicons/'+s.domain+'.png'} alt="" width="14" height="14"
                                 style={{borderRadius:2,flexShrink:0}}
                                 onError={function(e){e.currentTarget.style.display='none';}} />
                            <span className="gbd2-ep-src-card-name"
                                  style={s.isCanonical&&!sober ? accentStyle : {}}>{s.name||s.domain}</span>
                            {s.time && <span className="gbd2-ep-src-card-time">{s.time}</span>}
                          </div>
                          {s.title && <p className="gbd2-ep-src-card-title">{s.title}</p>}
                        </div>
                      </a>
                    );
                  })}
                </div>
              </section>
            </div>

            {/* Related stories */}
            {relatedStories.length > 0 && (
              <section className="gbd2-ep-section gbd2-ep-section--related">
                <h2 className="gbd2-ep-section-h">Related stories</h2>
                <div className="gbd2-ep-related-grid">
                  {relatedStories.map(function(s, i) {
                    var sImg   = s.image_url && !isDocumentImage(s.image_url) ? s.image_url : null;
                    var sTopic = getArticleTopic(s);
                    var sHex   = sTopic ? (TOPIC_COLORS[sTopic]||topicHex) : topicHex;
                    return (
                      <button key={i} className="gbd2-ep-related-card"
                              onClick={function(){ if(onOpenStory) onOpenStory(s); }}>
                        {sImg && (
                          <div className="gbd2-ep-related-img"
                               style={{backgroundImage:"url('"+sImg.replace(/'/g,'%27')+"')"}} />
                        )}
                        <div className="gbd2-ep-related-body">
                          {sTopic && (
                            <span className="gbd2-ep-related-topic" style={{color:sHex}}>{sTopic}</span>
                          )}
                          <p className="gbd2-ep-related-title">{displayTitle(s)}</p>
                          <span className="gbd2-ep-related-meta">
                            {getDomain(s.url)}{s.published ? ' · '+relTime(s.published) : ''}
                          </span>
                        </div>
                      </button>
                    );
                  })}
                </div>
              </section>
            )}

            <p className="gbd2-copyright" style={{padding:'0 0 0 0',marginTop:32}}>
              Analysis by Mira · {coverageCount} source{coverageCount !== 1 ? 's' : ''}
            </p>
            <div style={{height:80}} />
          </div>{/* /gbd2-ep-main */}

          {/* ── RIGHT RAIL (desktop only) ── */}
          <aside className="gbd2-ep-rail">
            <div className="gbd2-ep-rail-sticky">
              <div className="gbd2-ep-rail-head">
                <span className="gbd2-ep-rail-head-label">Articles</span>
                <span className="gbd2-ep-rail-head-count" style={accentStyle}>{allSourceCards.length}</span>
              </div>
              <div className="gbd2-ep-rail-items">
                {allSourceCards.map(function(s, i) {
                  return (
                    <a key={i} href={s.url} target="_blank" rel="noopener noreferrer"
                       className={'gbd2-ep-rail-item'+(s.isCanonical?' gbd2-ep-rail-item--lead':'')}
                       style={s.isCanonical&&!sober ? {borderLeftColor:topicHex} : {}}>
                      {s.image && (
                        <div className="gbd2-ep-rail-thumb"
                             style={{backgroundImage:"url('"+s.image+"')"}} />
                      )}
                      <div className="gbd2-ep-rail-body">
                        <div className="gbd2-ep-rail-src-row">
                          <img src={'/favicons/'+s.domain+'.png'} alt="" width="11" height="11"
                               style={{borderRadius:2,flexShrink:0}}
                               onError={function(e){e.currentTarget.style.display='none';}} />
                          <span className="gbd2-ep-rail-src-name"
                                style={s.isCanonical&&!sober ? accentStyle : {}}>{s.name||s.domain}</span>
                          {s.time && <span className="gbd2-ep-rail-time">{s.time}</span>}
                        </div>
                        {s.title && <p className="gbd2-ep-rail-title">{s.title}</p>}
                      </div>
                    </a>
                  );
                })}
              </div>
            </div>
          </aside>

        </div>{/* /gbd2-ep-body */}
      </div>{/* /gbd2-ep-scroll */}

    </div>
  );
}

function GlobeScreen(props) {
  var onMenuOpen = props.onMenuOpen;
  var articles   = props.articles || [];
  var containerRef = React.useRef(null);
  var globeRef = React.useRef(null);
  var _hs = React.useState(null);
  var hovered = _hs[0], setHovered = _hs[1];
  var _ld = React.useState(true);
  var loading = _ld[0], setLoading = _ld[1];

  var countryArticles = [];
  if (hovered) {
    var _cname = (HDI_TO_ARTICLE_COUNTRY[hovered.name] || hovered.name).toLowerCase();
    countryArticles = articles
      .filter(function(a) { return a.country && a.country.toLowerCase() === _cname; })
      .sort(function(a, b) { return (b.significance || 0) - (a.significance || 0); })
      .slice(0, 5);
  }

  React.useEffect(function() {
    var el = containerRef.current;
    if (!el) return;
    if (typeof Globe === 'undefined' || typeof topojson === 'undefined') {
      setLoading(false);
      return;
    }
    var mounted = true;
    var g = Globe({ animateIn: false })(el);
    globeRef.current = g;

    g.width(el.offsetWidth).height(el.offsetHeight);
    g.backgroundColor('#0e0e10');
    g.backgroundImageUrl('');

    // Solid sea-blue ocean (country polygons sit on top)
    var oc = document.createElement('canvas');
    oc.width = 1; oc.height = 1;
    oc.getContext('2d').fillStyle = '#1a6fa3';
    oc.getContext('2d').fillRect(0, 0, 1, 1);
    g.globeImageUrl(oc.toDataURL());

    g.atmosphereColor('#5baae0');
    g.atmosphereAltitude(0.16);

    // Flat/even lighting — full white ambient, kill the directional sun
    g.scene().traverse(function(obj) {
      if (obj.isLight) {
        if (obj.type === 'AmbientLight') {
          obj.color.set(0xffffff);
          obj.intensity = 1.0;
        } else {
          obj.intensity = 0;
        }
      }
    });
    setLoading(false);

    fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
      .then(function(r) { return r.json(); })
      .then(function(world) {
        if (!mounted) return;
        var features = topojson.feature(world, world.objects.countries).features;
        g.polygonsData(features)
          .polygonGeoJsonGeometry(function(d) { return d.geometry; })
          .polygonCapColor(function(d) {
            var info = HDI_DATA[+d.id];
            return globeHdiColor(info ? info.hdi : null);
          })
          .polygonSideColor(function(d) {
            var info = HDI_DATA[+d.id];
            return globeHdiSideColor(info ? info.hdi : null);
          })
          .polygonStrokeColor(function() { return 'rgba(10,10,18,0.75)'; })
          .polygonAltitude(0.012)
          .polygonLabel(function(d) {
            var info = HDI_DATA[+d.id];
            if (!info) return '';
            var h = info.hdi;
            return '<div style="font-family:Figtree,system-ui,sans-serif;padding:8px 12px;' +
              'background:rgba(10,10,14,0.94);border-radius:9px;border:1px solid rgba(255,255,255,0.12)">' +
              '<b style="color:#f0f0f5;font-size:13px">' + info.name + '</b>' +
              (h != null
                ? '<div style="color:#aaa;font-size:12px;margin-top:3px">HDI <span style="color:#fff;font-weight:700">' +
                  h.toFixed(3) + '</span></div>' +
                  '<div style="color:' + globeHdiColor(h) + ';font-size:11px;font-weight:600;margin-top:1px">' +
                  globeHdiBand(h) + ' Development</div>'
                : '<div style="color:#666;font-size:12px;margin-top:3px">No data</div>') +
              '</div>';
          })
          .onPolygonHover(function(poly) {
            if (!mounted) return;
            el.style.cursor = poly ? 'pointer' : 'default';
            var info = poly ? (HDI_DATA[+poly.id] || null) : null;
            setHovered(info);
          });

        g.controls().autoRotate = true;
        g.controls().autoRotateSpeed = 0.35;
        g.controls().enableDamping = true;
        g.controls().addEventListener('start', function() {
          g.controls().autoRotate = false;
        });
      })
      .catch(function() {});

    var ro = new ResizeObserver(function() {
      if (mounted) g.width(el.offsetWidth).height(el.offsetHeight);
    });
    ro.observe(el);

    return function() {
      mounted = false;
      ro.disconnect();
      try {
        var r = g.renderer();
        if (r) { r.setAnimationLoop(null); r.dispose(); }
      } catch(e) {}
    };
  }, []);

  return (
    <div className="gs2-page">
      <div className="gl-page-header">
        <div className="gl-mast-left">
          <button className="gl-topbar-btn"
            onClick={function(){ if (onMenuOpen) onMenuOpen(); }}
            aria-label="Open menu">
            <span className="gl-hamburger-line" />
            <span className="gl-hamburger-line" />
            <span className="gl-hamburger-line" />
          </button>
          <span className="gl-mast-wordmark">Globally</span>
        </div>
      </div>
      <div className="gs2-header-wrap">
        <div className="gs2-header-glow" aria-hidden="true" />
        <div className="gs2-header">
          <div className="gs2-header-eyebrow">WORLD ATLAS</div>
          <h2 className="gs2-title">Explore the world</h2>
          <p className="gs2-sub">Countries by Human Development Index</p>
        </div>
      </div>
      <div className="gs2-layout">
        {/* Globe canvas */}
        <div className="gs2-globe-wrap">
          <div ref={containerRef} className="gs2-canvas" />
          {loading && <div className="gs2-loading">Loading globe…</div>}
          {/* HDI floating card — top-right overlay on the globe */}
          {hovered && (
            <div className="atlas-hdi-floating-card">
              <div className="atlas-country-name">{hovered.name}</div>
              {hovered.hdi != null ? (
                <React.Fragment>
                  <div className="atlas-hdi-score">
                    {hovered.hdi.toFixed(3)}
                    <span className="atlas-hdi-score-label">HDI</span>
                  </div>
                  <div className="atlas-hdi-category" style={{color: globeHdiColor(hovered.hdi)}}>
                    {globeHdiBand(hovered.hdi)} development
                  </div>
                </React.Fragment>
              ) : (
                <div className="atlas-hdi-nodata">No HDI data</div>
              )}
            </div>
          )}
        </div>
        {/* Sidebar: legend + country news articles */}
        <div className="gs2-sidebar">
          <div className="gs2-legend">
            <div className="gs2-legend-head">Human Development Index</div>
            <div className="gs2-legend-bar" />
            <div className="gs2-legend-rows">
              {[
                { label: 'Very High', range: '≥ 0.800', val: 0.900 },
                { label: 'High',      range: '0.700 – 0.799', val: 0.750 },
                { label: 'Medium',    range: '0.550 – 0.699', val: 0.620 },
                { label: 'Low',       range: '< 0.550',       val: 0.450 },
                { label: 'No data',   range: '',              val: null  },
              ].map(function(row, i) {
                return (
                  <div key={i} className="gs2-legend-row">
                    <span className="gs2-legend-sw"
                      style={{background: row.val != null ? globeHdiColor(row.val) : 'rgba(90,90,105,0.9)'}} />
                    <span className="gs2-legend-label">{row.label}</span>
                    {row.range && <span className="gs2-legend-range">{row.range}</span>}
                  </div>
                );
              })}
            </div>
            <div className="gs2-legend-src">UNDP Human Development Report 2023/24</div>
          </div>
          {/* Country articles panel */}
          <div className="gs2-country-articles">
            {!hovered ? (
              <div className="gs2-ca-empty">
                <div className="gs2-ca-empty-text">Hover a country to see related stories</div>
              </div>
            ) : countryArticles.length === 0 ? (
              <div className="gs2-ca-empty">
                <div className="gs2-ca-empty-text">No current stories for {hovered.name}</div>
              </div>
            ) : (
              <React.Fragment>
                <div className="gs2-ca-head">{hovered.name}</div>
                <div className="gs2-ca-list">
                  {countryArticles.map(function(a) {
                    return (
                      <div key={a.id} className="gs2-ca-item">
                        {a.image_url && (
                          <img src={a.image_url} alt="" className="gs2-ca-img" loading="lazy" />
                        )}
                        <div className="gs2-ca-content">
                          {a.sourceName && <div className="gs2-ca-source">{a.sourceName}</div>}
                          <div className="gs2-ca-title">{a.title}</div>
                        </div>
                      </div>
                    );
                  })}
                </div>
              </React.Fragment>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}
function IcoFlame() {
  return (
    <svg width="12" height="14" viewBox="0 0 12 14" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
      <path d="M6 0C6 0 9.5 3.8 9.5 7C9.5 8.7 8.7 10.2 7.3 11C7.5 10 7.1 9 6.4 8.4C6.6 9.4 6.1 10.8 5.1 11.5C4.4 10.7 4 9.6 4 8.6C4 9.9 4.7 11.3 4.7 11.3C3.7 10.7 3 9.5 3 8.1C3 5.4 4.5 3 6 0Z"/>
    </svg>
  );
}

// ─── Footer links + simple placeholder pages ─────────────────────────────────

var FOOTER_LINKS = [
  { id: 'blog',     label: 'Blog'             },
  { id: 'jobs',     label: 'Jobs'             },
  { id: 'terms',    label: 'Terms of Service' },
  { id: 'privacy',  label: 'Privacy Policy'   },
  { id: 'cookies',  label: 'Cookies'          },
  { id: 'help',     label: 'Help'             },
  { id: 'partners', label: 'Partners'         },
  { id: 'about',    label: 'About Globally'    },
];

var FOOTER_PAGE_CONTENT = {

  'about': {
    title: 'About Globally',
    eyebrow: 'Who we are',
    intro: 'Globally is an independent platform for reading and understanding global development news. We surface, organise, and explain stories from trusted source feeds — covering conflict, climate, economy, health, governance, migration, debt, trade, and more.',
    sections: [
      {
        heading: 'What we cover',
        body: 'Most news cycles focus on a narrow set of countries and recurring events. Globally is built for people who want to understand the wider picture: what is happening in places that rarely lead the front page, and why it matters for people, policy, and the planet.',
      },
      {
        heading: 'How stories are sourced',
        body: 'Stories are drawn from published journalism, UN and development agency reporting, and verified humanitarian and economics feeds. We do not write or commission original reporting. All stories link to their original source.',
      },
      {
        heading: 'Mira',
        body: 'Mira is Globally’s AI reading assistant. She can explain individual stories, summarise situations, and connect themes across regions — using the sources available in the feed. Mira does not invent information, manufacture quotes, or fill gaps with speculation. Where sources do not cover something, she says so clearly.',
      },
      {
        heading: 'Content integrity',
        body: 'We do not fabricate statistics, invent quotes, or present inference as fact. If something is uncertain, we mark it as uncertain. If a source is unavailable, Mira acknowledges the gap. This is a core commitment, not an afterthought.',
      },
      {
        heading: 'Contact',
        body: 'General enquiries: contact details coming soon.',
      },
    ],
  },

  'help': {
    title: 'Help',
    eyebrow: 'Support',
    intro: 'A short guide to getting the most out of Globally and Mira.',
    sections: [
      {
        heading: 'Reading the daily edition',
        body: 'The Today screen shows the latest stories from development sources, refreshed daily. Stories are grouped and ranked to surface the most significant developments. Each headline links to its original source.',
      },
      {
        heading: 'Topics',
        body: 'Use the topic rail to focus on a specific theme — conflict, climate, economy, health, governance, and more. Each topic has subtopics you can use to filter further. The "What’s new" tab shows the most recent stories in that area.',
      },
      {
        heading: 'Asking Mira',
        body: 'On any story page, scroll to the Ask Mira section. Type a question about the story — "what are the effects on civilians?" or "how does this connect to the food crisis?" Mira will answer using what the available sources say.',
      },
      {
        heading: 'When Mira says sources don’t cover it',
        body: 'This is intentional. Mira will not speculate beyond what sources report. If the answer is not in the available sources, she says so. This keeps answers grounded and honest. If you need more depth, the original source links are always visible.',
      },
      {
        heading: 'Profile and email briefings',
        body: 'Create an account to receive a daily email briefing. You can set your preferred delivery time and manage your preferences in your profile.',
      },
      {
        heading: 'Contact support',
        body: 'Support contact details are coming soon.',
      },
    ],
  },

  'privacy': {
    title: 'Privacy Policy',
    eyebrow: 'Legal',
    lastUpdated: '30 June 2026',
    legalNote: 'This policy is a working draft. It should be reviewed by a qualified legal professional before Globally is publicly launched.',
    intro: 'Globally is committed to handling personal data responsibly. This policy explains what data we may collect, how we use it, and your rights.',
    sections: [
      {
        heading: 'What data we may collect',
        bullets: [
          'Account information: your email address, if you create an account.',
          'Email preferences: your preferred briefing time and timezone, if you enable daily email reminders.',
          'Usage data: topic and story preferences saved locally in your browser.',
          'Mira interactions: questions and feedback you submit to Mira, used to improve responses.',
          'Device and browser data: browser type, device type, and approximate location, used for analytics and debugging.',
          'Cookies and local storage: used to keep you signed in, remember settings, and improve the experience.',
        ],
      },
      {
        heading: 'How we use your data',
        bullets: [
          'To provide and operate the Globally app.',
          'To personalise your daily briefing and topic recommendations.',
          'To send daily email briefings if you have opted in.',
          'To improve Mira’s accuracy and usefulness.',
          'To monitor app performance and fix bugs.',
          'To protect the security of the service.',
        ],
      },
      {
        heading: 'Local storage',
        body: 'Globally stores your session, preferences, and saved stories in your browser’s local storage. This data stays on your device and is not sent to our servers unless you have an account and are signed in.',
      },
      {
        heading: 'Mira questions',
        body: 'Do not enter sensitive personal information into Mira. Mira is designed to answer questions about news stories and development topics. Questions you submit may be logged for quality improvement purposes.',
      },
      {
        heading: 'Your rights',
        body: 'Depending on where you are located, you may have rights to access, correct, or delete personal data we hold about you. To make a request, contact us at the address below when available.',
      },
      {
        heading: 'Data retention',
        body: 'We retain account data for as long as your account is active. Email preferences are stored in our email delivery platform and removed if you unsubscribe. Local storage data is stored on your device and can be cleared at any time through your browser settings.',
      },
      {
        heading: 'Third-party services',
        body: 'We use third-party services for email delivery (Resend) and hosting (Vercel). These services have their own privacy policies. We do not sell your data to third parties.',
      },
      {
        heading: 'Contact',
        body: 'Privacy-related enquiries: contact details coming soon.',
      },
    ],
  },

  'terms': {
    title: 'Terms of Service',
    eyebrow: 'Legal',
    lastUpdated: '30 June 2026',
    legalNote: 'These terms are a working draft and should be reviewed by a qualified legal professional before Globally is publicly launched.',
    intro: 'By using Globally, you agree to these terms. Please read them carefully.',
    sections: [
      {
        heading: 'What Globally provides',
        body: 'Globally is an information service that aggregates, organises, and explains global development news. It is not a source of professional legal, financial, medical, or policy advice. Mira’s explanations are based on available sourced reporting and are provided for informational purposes only.',
      },
      {
        heading: 'Using Globally',
        bullets: [
          'You may use Globally for personal, non-commercial purposes.',
          'Do not attempt to scrape, copy, or extract Globally’s content in bulk.',
          'Do not attempt to reverse-engineer, compromise, or interfere with the service.',
          'Do not misuse Mira — including attempts to produce harmful, false, or abusive content.',
          'Do not redistribute sourced content without permission from original publishers.',
        ],
      },
      {
        heading: 'Mira and AI-generated content',
        body: 'Mira summarises and explains stories using available sources. She can be limited by what sources cover and may occasionally make mistakes. Always verify important information with original sources before acting on it. Globally does not guarantee the accuracy, completeness, or timeliness of any content.',
      },
      {
        heading: 'Intellectual property',
        body: 'The Globally name, branding, and original user interface are the property of Globally. Stories and articles are the property of their original publishers and are attributed with links to source.',
      },
      {
        heading: 'Accounts',
        body: 'If you create an account, you are responsible for keeping your login details secure. We may suspend or remove accounts that breach these terms.',
      },
      {
        heading: 'Service availability',
        body: 'We aim to keep Globally available but cannot guarantee uninterrupted access. The service may be updated, changed, or unavailable from time to time.',
      },
      {
        heading: 'Limitation of liability',
        body: 'To the extent permitted by law, Globally is not liable for losses or damages arising from your use of the service, reliance on Mira’s outputs, or unavailability of the platform.',
      },
      {
        heading: 'Changes to these terms',
        body: 'We may update these terms. Continued use of Globally after changes are posted means you accept the updated terms.',
      },
      {
        heading: 'Contact',
        body: 'Terms-related enquiries: contact details coming soon.',
      },
    ],
  },

  'cookies': {
    title: 'Cookie Policy',
    eyebrow: 'Legal',
    lastUpdated: '30 June 2026',
    intro: 'Globally uses cookies and browser local storage to keep the app working and to improve your experience.',
    sections: [
      {
        heading: 'What we use',
        bullets: [
          'Local storage: used to keep you signed in, remember your topic preferences, save story settings, and store briefing preferences. This data stays in your browser.',
          'Session cookies: used to manage your active session if you are signed in.',
          'Essential cookies: required for the app to function. Clearing them will sign you out and reset your preferences.',
        ],
      },
      {
        heading: 'Analytics',
        body: 'Globally does not currently use third-party analytics cookies. If analytics are introduced in the future, this policy will be updated and appropriate consent mechanisms will be added.',
      },
      {
        heading: 'Managing cookies',
        body: 'You can clear local storage and cookies at any time through your browser settings.',
      },
      {
        heading: 'Contact',
        body: 'Cookie-related enquiries: contact details coming soon.',
      },
    ],
  },

  'partners': {
    title: 'Partners',
    eyebrow: 'Work with us',
    intro: 'Globally is open to conversations with organisations working in global development, humanitarian response, research, and responsible media.',
    sections: [
      {
        heading: 'Partnership areas',
        body: 'We are interested in partnerships that help Globally cover the world more completely and accessibly. Areas we are exploring include:',
        bullets: [
          'Source coverage: organisations publishing reliable development data or field reporting.',
          'Development research: research groups and think tanks covering humanitarian, climate, economic, or governance topics.',
          'Humanitarian signposting: organisations that can help Globally direct users to relevant support and information.',
          'Education: institutions using Globally for teaching or research.',
          'Mira Business: intelligence and briefing use cases for NGOs, consultancies, and professional organisations.',
        ],
      },
      {
        heading: 'Our approach',
        body: 'We do not display partner logos or claim affiliations without formal agreement. Any formal partnership will be disclosed transparently. We do not take paid placements in the news feed.',
      },
      {
        heading: 'Get in touch',
        body: 'Partnership enquiries: contact details coming soon.',
      },
    ],
  },

  'jobs': {
    title: 'Jobs',
    eyebrow: 'Careers',
    intro: 'Globally is not currently hiring. We will post openings here when they become available.',
    sections: [
      {
        heading: 'What we are building towards',
        body: 'When we grow, the team will focus on editorial quality, development intelligence, and responsible AI tools for understanding the world. Future roles may include:',
        bullets: [
          'Editorial research and fact-checking',
          'Development analysis and regional expertise',
          'Product and design',
          'Engineering — full-stack and AI',
          'Partnerships and outreach',
        ],
      },
      {
        heading: 'Stay updated',
        body: 'Check back here for openings. We will also announce roles on the Globally blog when it launches.',
      },
    ],
  },

  'blog': {
    title: 'Blog',
    eyebrow: 'Updates',
    intro: 'The Globally blog will share product updates, development explainers, and notes on how we build Mira responsibly. No posts have been published yet.',
    emptyCards: [
      {
        label: 'Product updates',
        desc: 'Notes on new features, improvements, and changes to Globally and Mira.',
      },
      {
        label: 'Development explainers',
        desc: 'Plain-language pieces on the issues and regions Globally covers.',
      },
      {
        label: 'Behind Mira',
        desc: 'How Mira works, what she can and cannot do, and how we think about AI in journalism.',
      },
    ],
  },

};

var FOOTER_TAB_IDS = FOOTER_LINKS.map(function(l) { return l.id; });

function GloblyFooterLinks(props) {
  var onNav      = props.onNav;
  var extraClass = props.className || '';
  return (
    <div className={'gl-footer-links ' + extraClass}>
      {FOOTER_LINKS.map(function(item) {
        return (
          <button key={item.id} className="gl-footer-link" onClick={function(){ onNav(item.id); }}>
            {item.label}
          </button>
        );
      })}
      <span className="gl-footer-copy">© 2026 Globally</span>
    </div>
  );
}

// ─── Mira Business page ────────────────────────────────────────────────────────
function MiraBusinessScreen(props) {
  var onBack     = props.onBack;
  var onMenuOpen = props.onMenuOpen;

  var _fd = React.useState({ name:'', email:'', organisation:'', role:'', usecase:'', message:'' });
  var formData = _fd[0], setFormData = _fd[1];
  var _submitted = React.useState(false);
  var submitted = _submitted[0], setSubmitted = _submitted[1];

  function handleField(e) {
    var k = e.target.name, v = e.target.value;
    setFormData(function(p){ var n = Object.assign({}, p); n[k] = v; return n; });
  }

  function handleSubmit(e) {
    e.preventDefault();
    try {
      var list = JSON.parse(localStorage.getItem('globally_business_interests') || '[]');
      list.push(Object.assign({}, formData, { submittedAt: new Date().toISOString() }));
      localStorage.setItem('globally_business_interests', JSON.stringify(list));
    } catch(_){}
    setSubmitted(true);
  }

  var ORG_CARDS = [
    { label: 'Daily briefings',           body: 'Structured daily or weekly summaries of the development news that matters to your team.' },
    { label: 'Issue monitoring',          body: 'Track specific topics — conflict, climate, debt, health — across the countries you follow.' },
    { label: 'Region tracking',           body: 'Understand what is changing in a specific area and why it matters for your work.' },
    { label: 'Source-grounded summaries', body: 'Every Mira answer links to the underlying news. No unsupported claims.' },
  ];

  var TEAM_CARDS = [
    { label: 'What changed',     body: 'Get a plain-language briefing on the most important development shifts of the day.' },
    { label: 'Why it matters',   body: 'Understand implications — for markets, operations, policy, or communities.' },
    { label: 'Who is affected',  body: 'See which countries, organisations, and populations are most directly involved.' },
    { label: 'What to watch',    body: 'Mira surfaces the signals that indicate where a situation may be heading.' },
  ];

  var USE_CASES = [
    { label: 'Risk and policy monitoring',           body: 'Track how political, humanitarian, and regulatory events may affect your operations or clients.' },
    { label: 'ESG and sustainability context',       body: 'Ground your ESG work in real development data — beyond headline scores.' },
    { label: 'Humanitarian and development awareness', body: 'Brief teams on the issues affecting the places and people you work with.' },
    { label: 'Regional briefings',                   body: 'Understand what\'s changing in a market or country through a development lens.' },
    { label: 'Internal education',                   body: 'Help teams understand the context behind global development issues.' },
  ];

  return (
    <div className="tak-page tak-page--business">

      <header className="tak-topbar">
        <button className="tak-topbar-hamburger"
          onClick={function(){ if (onMenuOpen) onMenuOpen(); }} aria-label="Open menu">
          <span className="gl-hamburger-line" />
          <span className="gl-hamburger-line" />
          <span className="gl-hamburger-line" />
        </button>
        <span className="tak-topbar-wordmark">Globally</span>
        <div className="tak-topbar-spacer" />
      </header>

      <section className="tak-hero">
        <div className="tak-hero-inner">
          <div className="tak-hero-top-row">
            <span className="tak-hero-brand">Globally</span>
            <span className="tak-hero-label">MIRA BUSINESS</span>
          </div>
          <h1 className="tak-hero-title">Global development intelligence for teams.</h1>
          <p className="tak-hero-sub">
            Use Mira to brief teams, track issues, and understand how global events may affect
            markets, operations, policy, and communities.
          </p>
          <div className="gsect-cta-row">
            <a className="gsect-btn gsect-btn--primary" href="#mib-form">Contact us</a>
            <a className="gsect-btn gsect-btn--secondary" href="#mib-usecases">Explore use cases</a>
          </div>
        </div>
      </section>

      <div className="tak-trust-bar">
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
             strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>
          <line x1="12" y1="16" x2="12.01" y2="16"/>
        </svg>
        <span>Mira Business is currently in early development. We are speaking with organisations to shape the product. No commitments are implied.</span>
      </div>

      <div className="gsect-body">

        <section className="gsect-section">
          <div className="gsect-section-hd">
            <h2 className="gsect-section-title">For organisations</h2>
            <p className="gsect-section-sub">Designed for NGOs, consultancies, policy teams, research groups, and firms working in or alongside development contexts.</p>
          </div>
          <div className="gsect-cards gsect-cards--4">
            {ORG_CARDS.map(function(c, i){
              return (
                <div key={i} className="gsect-card">
                  <p className="gsect-card-label">{c.label}</p>
                  <p className="gsect-card-body">{c.body}</p>
                </div>
              );
            })}
          </div>
        </section>

        <section className="gsect-section">
          <div className="gsect-section-hd">
            <h2 className="gsect-section-title">For teams</h2>
            <p className="gsect-section-sub">Give your team a shared understanding of what is happening in the regions and issues that affect your work.</p>
          </div>
          <div className="gsect-cards gsect-cards--4">
            {TEAM_CARDS.map(function(c, i){
              return (
                <div key={i} className="gsect-card">
                  <p className="gsect-card-label">{c.label}</p>
                  <p className="gsect-card-body">{c.body}</p>
                </div>
              );
            })}
          </div>
        </section>

        <section className="gsect-section" id="mib-usecases">
          <div className="gsect-section-hd">
            <h2 className="gsect-section-title">Use cases</h2>
          </div>
          <div className="gsect-usecases">
            {USE_CASES.map(function(u, i){
              return (
                <div key={i} className="gsect-usecase">
                  <p className="gsect-usecase-label">{u.label}</p>
                  <p className="gsect-usecase-body">{u.body}</p>
                </div>
              );
            })}
          </div>
        </section>

        <section className="gsect-section gsect-section--form" id="mib-form">
          <div className="gsect-section-hd">
            <h2 className="gsect-section-title">Get in touch</h2>
            <p className="gsect-section-sub">Tell us about your organisation and how Mira Business could support your team.</p>
          </div>

          {submitted ? (
            <div className="gsect-form-success">
              <div className="gsect-form-success-check">✓</div>
              <p className="gsect-form-success-title">Thanks — your details have been saved locally for now.</p>
              <p className="gsect-form-success-body">We are not yet sending these to a server. We'll be in touch as Mira Business develops.</p>
            </div>
          ) : (
            <form className="gsect-form" onSubmit={handleSubmit} autoComplete="on">
              <div className="gsect-form-row">
                <div className="gsect-form-field">
                  <label className="gsect-form-label">Name</label>
                  <input className="gsect-form-input" type="text" name="name" required
                    placeholder="Your name" value={formData.name} onChange={handleField} />
                </div>
                <div className="gsect-form-field">
                  <label className="gsect-form-label">Work email</label>
                  <input className="gsect-form-input" type="email" name="email" required
                    placeholder="you@organisation.org" value={formData.email} onChange={handleField} />
                </div>
              </div>
              <div className="gsect-form-row">
                <div className="gsect-form-field">
                  <label className="gsect-form-label">Organisation</label>
                  <input className="gsect-form-input" type="text" name="organisation" required
                    placeholder="Organisation name" value={formData.organisation} onChange={handleField} />
                </div>
                <div className="gsect-form-field">
                  <label className="gsect-form-label">Role</label>
                  <input className="gsect-form-input" type="text" name="role"
                    placeholder="Your role" value={formData.role} onChange={handleField} />
                </div>
              </div>
              <div className="gsect-form-field">
                <label className="gsect-form-label">What would you use Mira Business for?</label>
                <input className="gsect-form-input" type="text" name="usecase"
                  placeholder="e.g. team briefings, country monitoring, ESG context…"
                  value={formData.usecase} onChange={handleField} />
              </div>
              <div className="gsect-form-field">
                <label className="gsect-form-label">Message</label>
                <textarea className="gsect-form-textarea" name="message" rows="4"
                  placeholder="Anything else you'd like us to know…"
                  value={formData.message} onChange={handleField} />
              </div>
              <div className="gsect-form-footer">
                <p className="gsect-form-note">Your details are saved locally for now — not yet sent to a server.</p>
                <button className="gsect-btn gsect-btn--primary" type="submit">Send interest</button>
              </div>
            </form>
          )}
        </section>

      </div>
    </div>
  );
}
// Legacy alias so existing render references continue to work
var MiraForFirmsScreen = MiraBusinessScreen;

// ─── Charity page: discovery constants ───────────────────────────────────────────

var TAK_TOPICS = [
  { id: 'conflict',      label: 'Conflict'       },
  { id: 'health',        label: 'Health'         },
  { id: 'education',     label: 'Education'      },
  { id: 'climate',       label: 'Climate'        },
  { id: 'food',          label: 'Food'           },
  { id: 'refugees',      label: 'Refugees'       },
  { id: 'poverty',       label: 'Poverty'        },
  { id: 'water',         label: 'Water'          },
  { id: 'children',      label: 'Children'       },
  { id: 'women',         label: 'Women & Girls'  },
  { id: 'disaster',      label: 'Disaster relief' },
  { id: 'human-rights',  label: 'Human rights'  },
  { id: 'development',   label: 'Development'    },
];

var TAK_REGIONS = [
  'Africa', 'South Asia', 'Middle East', 'East Asia',
  'Latin America', 'Europe', 'Southeast Asia', 'Global',
];

var _TAK_TOPIC_TERMS = {
  conflict:       ['conflict','war','armed','military','ceasefire','attack','killed','troops','fighting','violence','offensive','forces','bombing','shelling','coup','militia'],
  health:         ['health','hospital','disease','outbreak','malaria','cholera','vaccine','medical','treatment','clinic','epidemic','pandemic','mortality','nutrition','virus'],
  education:      ['school','education','student','teacher','learning','literacy','university','college','classroom','dropout','enrolment'],
  climate:        ['climate','carbon','emission','temperature','drought','flood','cyclone','deforestation','fossil','renewable','environment','pollution','wildfire','heat'],
  food:           ['food','famine','hunger','malnutrition','crop','harvest','agricultural','starvation','wheat','grain','food security','food prices'],
  refugees:       ['refugee','displaced','asylum','migrant','migration','fleeing','shelter','camp','stateless','displacement','internally displaced'],
  poverty:        ['poverty','poor','income','inequality','economic','unemployment','debt','slum','destitute'],
  water:          ['water','sanitation','drought','drinking water','sewage','hygiene','rainfall','water access'],
  children:       ['children','child','infant','youth','orphan','juvenile','minors','newborn','under-five'],
  women:          ['women','girls','gender','maternal','trafficking','domestic violence','sexual violence','female'],
  disaster:       ['earthquake','flood','hurricane','cyclone','disaster','relief','emergency','tsunami','landslide','storm'],
  'human-rights': ['rights','justice','prison','detained','protest','freedom','democracy','torture','persecution','execution','abduction'],
  development:    ['development','infrastructure','growth','investment','programme','sustainable','reconstruction','capacity'],
};

var _TAK_REGION_TERMS = {
  'Africa':        ['africa','african','niger','nigeria','chad','mali','ethiopia','kenya','sudan','somalia','mozambique','zimbabwe','uganda','tanzania','ghana','senegal','cameroon','angola','zambia','malawi','liberia','guinea','sierra leone','rwanda','burundi'],
  'East Africa':   ['kenya','ethiopia','somalia','tanzania','uganda','rwanda','burundi','south sudan','east africa','eritrea','djibouti'],
  'West Africa':   ['nigeria','ghana','mali','burkina faso','senegal','guinea','sierra leone','west africa','ivory coast','togo','benin','liberia'],
  'South Asia':    ['india','pakistan','bangladesh','sri lanka','nepal','afghanistan','south asia','bhutan','maldives'],
  'Middle East':   ['gaza','palestine','israel','syria','iraq','iran','jordan','lebanon','yemen','middle east','saudi','gulf','oman','bahrain','qatar'],
  'Southeast Asia':['myanmar','cambodia','vietnam','laos','thailand','indonesia','philippines','southeast asia','burma','timor'],
  'Latin America': ['venezuela','colombia','brazil','guatemala','honduras','haiti','mexico','latin america','south america','peru','bolivia','ecuador','nicaragua'],
  'Europe':        ['ukraine','europe','european','balkans','moldova','georgia','caucasus'],
  'Global':        [],
};

function takGetRelatedStories(charity, articles) {
  var results = [];
  (articles || []).forEach(function(a) {
    var text = ((a.title || '') + ' ' + (a.summary || '') + ' ' + (a.country || '') + ' ' + (a.region || '')).toLowerCase();
    var score = 0;
    var reasons = [];
    (charity.topics || []).forEach(function(topic) {
      var terms = _TAK_TOPIC_TERMS[topic] || [];
      var matched = terms.some(function(w){ return text.indexOf(w) !== -1; });
      if (matched) {
        score += 3;
        var tl = TAK_TOPICS.find(function(t){ return t.id === topic; });
        var label = tl ? tl.label : topic;
        if (reasons.indexOf(label) === -1) reasons.push(label);
      }
    });
    (charity.regions || []).forEach(function(region) {
      if (region === 'Global') return;
      var terms = _TAK_REGION_TERMS[region] || [region.toLowerCase()];
      var matched = terms.some(function(w){ return text.indexOf(w) !== -1; });
      if (matched) {
        score += 3;
        if (reasons.indexOf(region) === -1) reasons.push(region);
      }
    });
    (charity.countries || []).forEach(function(country) {
      if (text.indexOf(country.toLowerCase()) !== -1) {
        score += 4;
        if (reasons.indexOf(country) === -1) reasons.push(country);
      }
    });
    if (score >= 3) {
      results.push({ article: a, score: score, reasons: reasons.slice(0, 2) });
    }
  });
  results.sort(function(a, b){ return b.score - a.score; });
  return results.slice(0, 3).map(function(r){
    return { article: r.article, score: r.score, matchLabel: 'Matched on: ' + r.reasons.join(' + ') };
  });
}

function takGetUrgentTopics(articles) {
  var counts = {};
  (articles || []).forEach(function(a) {
    var text = ((a.title || '') + ' ' + (a.summary || '')).toLowerCase();
    TAK_TOPICS.forEach(function(t) {
      var terms = _TAK_TOPIC_TERMS[t.id] || [];
      if (terms.some(function(w){ return text.indexOf(w) !== -1; })) {
        counts[t.id] = (counts[t.id] || 0) + 1;
      }
    });
  });
  return TAK_TOPICS
    .filter(function(t){ return (counts[t.id] || 0) >= 2; })
    .sort(function(a, b){ return (counts[b.id] || 0) - (counts[a.id] || 0); })
    .slice(0, 6)
    .map(function(t){ return { id: t.id, label: t.label, count: counts[t.id] }; });
}

// ─── Charity matching helpers (used by story sidebar + CharityScreen) ────────────

// Module-level cache so we fetch charities.json at most once per page load
var _charityDataCache = null;
var _charityDataPromise = null;
function _loadCharitiesOnce(cb) {
  if (_charityDataCache) { cb(_charityDataCache); return; }
  if (!_charityDataPromise) {
    _charityDataPromise = fetch('/charities.json').then(function(r){ return r.json(); }).then(function(d){
      _charityDataCache = d; return d;
    });
  }
  _charityDataPromise.then(cb).catch(function(){});
}

// Given a story article, return up to 3 best-matching charities from the directory
function findRelatedCharities(article, charities) {
  var text = ((article.title || '') + ' ' + (article.summary || '') + ' ' + (article.country || '') + ' ' + (article.region || '')).toLowerCase();
  var results = [];
  (charities || []).forEach(function(c) {
    var score = 0;
    (c.topics || []).forEach(function(topic) {
      var terms = _TAK_TOPIC_TERMS[topic] || [];
      if (terms.some(function(w){ return text.indexOf(w) !== -1; })) score += 3;
    });
    (c.regions || []).forEach(function(region) {
      if (region === 'Global') return;
      var terms = _TAK_REGION_TERMS[region] || [region.toLowerCase()];
      if (terms.some(function(w){ return text.indexOf(w) !== -1; })) score += 3;
    });
    (c.countries || []).forEach(function(country) {
      if (text.indexOf(country.toLowerCase()) !== -1) score += 4;
    });
    if (score >= 3) results.push({ charity: c, score: score });
  });
  results.sort(function(a, b){ return b.score - a.score; });
  return results.slice(0, 3).map(function(r){ return r.charity; });
}

// Short one-line relevance description for an org in the story sidebar
function orgRelevanceLine(c) {
  var topicLabels = (c.topics || []).slice(0, 2).map(function(t) {
    var tl = TAK_TOPICS.find(function(x){ return x.id === t; });
    return tl ? tl.label.toLowerCase() : t;
  });
  var specifics = (c.countries || []).filter(function(co){ return co; }).slice(0, 2);
  if (specifics.length === 0) {
    specifics = (c.regions || []).filter(function(r){ return r !== 'Global'; }).slice(0, 2);
  }
  return 'Works on ' + topicLabels.join(' and ') + (specifics.length ? ' in ' + specifics.join(' and ') : '');
}

// ─── CharityScreen (Charity) ────────────────────────────────────────────────────

var TOPIC_GRADIENTS = {
  conflict:       'linear-gradient(135deg,#2d1212 0%,#7a1a1a 100%)',
  health:         'linear-gradient(135deg,#0a2a1a 0%,#1a6040 100%)',
  education:      'linear-gradient(135deg,#101828 0%,#1e3a8a 100%)',
  climate:        'linear-gradient(135deg,#0a2018 0%,#0f5040 100%)',
  food:           'linear-gradient(135deg,#2a1a04 0%,#8a5010 100%)',
  refugees:       'linear-gradient(135deg,#1e1028 0%,#5c2880 100%)',
  poverty:        'linear-gradient(135deg,#201404 0%,#703010 100%)',
  water:          'linear-gradient(135deg,#061428 0%,#0a3080 100%)',
  children:       'linear-gradient(135deg,#102014 0%,#1a5a30 100%)',
  women:          'linear-gradient(135deg,#28101e 0%,#8a1850 100%)',
  disaster:       'linear-gradient(135deg,#220e04 0%,#8a3010 100%)',
  'human-rights': 'linear-gradient(135deg,#101010 0%,#383838 100%)',
  development:    'linear-gradient(135deg,#0e1e08 0%,#205020 100%)',
};

function takMatchBadge(related) {
  if (!related || related.length === 0) return 'general';
  var topScore = related[0].score || 0;
  if (topScore >= 9) return 'strong';
  if (topScore >= 4) return 'relevant';
  return 'general';
}

function takWhyMatchesText(charity, related) {
  var topicLabels = (charity.topics || []).slice(0, 2).map(function(t) {
    var tl = TAK_TOPICS.find(function(x){ return x.id === t; });
    return tl ? tl.label.toLowerCase() : t;
  });
  var countries = (charity.countries || []).slice(0, 2);
  var regions = (charity.regions || []).filter(function(r){ return r !== 'Global'; }).slice(0, 2);
  var place = (countries.length > 0 ? countries : regions).join(' and ');
  if (related.length === 0) {
    return 'This organisation works on ' + topicLabels.join(' and ') +
      (place ? ' in ' + place : '') +
      '. No closely matching stories were found in today\'s edition.';
  }
  var matchTerms = related[0].matchLabel.replace('Matched on: ', '');
  return 'This organisation works on ' + topicLabels.join(' and ') +
    (place ? ' in ' + place : '') +
    '. Today\'s edition includes stories that cover ' + matchTerms +
    ', which overlaps with this organisation\'s focus areas. Check the organisation\'s official website to learn more and take action.';
}

function isValidExternalUrl(url) {
  return typeof url === 'string' && /^https?:\/\/.+/.test(url.trim());
}

function CharityScreen(props) {
  var onBack      = props.onBack;
  var onMenuOpen  = props.onMenuOpen;
  var articles    = props.articles || [];
  var onOpenStory = props.onOpenStory;

  var _charities   = React.useState([]);
  var charities    = _charities[0], setCharities = _charities[1];
  var _loading     = React.useState(true);
  var loading      = _loading[0], setLoading = _loading[1];
  var _query       = React.useState('');
  var query        = _query[0], setQuery = _query[1];
  var _topicF      = React.useState(null);
  var activeTopic  = _topicF[0], setActiveTopic = _topicF[1];
  var _regionF     = React.useState(null);
  var activeRegion = _regionF[0], setActiveRegion = _regionF[1];
  var _selected    = React.useState(null);
  var selected     = _selected[0], setSelected = _selected[1];
  var _saved       = React.useState(function(){ return lsGet('globally_saved_charities', []); });
  var saved        = _saved[0], setSaved = _saved[1];
  var _sitesOpened = React.useState(function(){ return lsGet('globally_tak_sites_opened', 0); });
  var sitesOpened  = _sitesOpened[0], setSitesOpened = _sitesOpened[1];
  var _showFilters = React.useState(false);
  var showFilters  = _showFilters[0], setShowFilters = _showFilters[1];
  var _showSaved   = React.useState(false);
  var showSaved    = _showSaved[0], setShowSaved = _showSaved[1];

  var _connsMade    = React.useState(0);
  var connectionsMade = _connsMade[0], setConnectionsMade = _connsMade[1];

  React.useEffect(function() {
    fetch('/charities.json')
      .then(function(r){ return r.json(); })
      .then(function(data){
        setCharities(data);
        setLoading(false);
        // Auto-open charity drawer when arriving from story sidebar "View organisation"
        try {
          var focusId = sessionStorage.getItem('globally_focus_charity');
          if (focusId) {
            sessionStorage.removeItem('globally_focus_charity');
            var target = data.find(function(c){ return c.id === focusId; });
            if (target) setSelected(target);
          }
        } catch(e) {}
      })
      .catch(function(){ setLoading(false); });
  }, []);

  var urgentTopics = React.useMemo(function(){
    return takGetUrgentTopics(articles);
  }, [articles.length]);

  var filtered = React.useMemo(function() {
    var q = query.trim().toLowerCase();
    var pool = showSaved ? charities.filter(function(c){ return saved.indexOf(c.id) !== -1; }) : charities;
    return pool.filter(function(c) {
      if (activeTopic && (c.topics || []).indexOf(activeTopic) === -1) return false;
      if (activeRegion && (c.regions || []).indexOf(activeRegion) === -1) return false;
      if (q) {
        var text = (c.name + ' ' + c.description + ' ' + (c.topics || []).join(' ') + ' ' + (c.regions || []).join(' ') + ' ' + (c.countries || []).join(' ')).toLowerCase();
        if (text.indexOf(q) === -1) return false;
      }
      return true;
    });
  }, [charities, query, activeTopic, activeRegion, saved, showSaved]);

  // Compute card-level related stories — limited to filtered set
  var cardRelated = React.useMemo(function() {
    var map = {};
    filtered.slice(0, 80).forEach(function(c) {
      map[c.id] = takGetRelatedStories(c, articles);
    });
    return map;
  }, [filtered.length, articles.length, activeTopic, activeRegion, query]);

  // Stories connected to action: unique stories matched to at least one charity
  var storiesConnected = React.useMemo(function() {
    if (charities.length === 0 || articles.length === 0) return 0;
    var seen = new Set();
    charities.forEach(function(c) {
      var rel = takGetRelatedStories(c, articles);
      rel.forEach(function(r){ seen.add(r.article.url || r.article.title || ''); });
    });
    return seen.size;
  }, [charities.length, articles.length]);

  function toggleSaved(id) {
    var next = saved.indexOf(id) !== -1
      ? saved.filter(function(s){ return s !== id; })
      : saved.concat([id]);
    setSaved(next);
    lsSet('globally_saved_charities', next);
  }

  function recordSiteOpen() {
    var next = sitesOpened + 1;
    setSitesOpened(next);
    lsSet('globally_tak_sites_opened', next);
    setConnectionsMade(function(n){ return n + 1; });
  }

  function clearFilters() {
    setActiveTopic(null);
    setActiveRegion(null);
    setQuery('');
    setShowSaved(false);
  }

  // Sidebar filter row
  function SidebarItem(sprops) {
    var on      = sprops.on;
    var label   = sprops.label;
    var count   = sprops.count;
    var onClick = sprops.onClick;
    return (
      <button className={'tak-sb-item' + (on ? ' is-on' : '')} onClick={onClick}>
        <span className="tak-sb-item-label">{label}</span>
        {count != null && <span className="tak-sb-item-count">{count}</span>}
      </button>
    );
  }

  // Card image gradient
  function CardImage(cprops) {
    var c       = cprops.charity;
    var rel     = cprops.related || [];
    var badge   = takMatchBadge(rel);
    var bg      = TOPIC_GRADIENTS[(c.topics || [])[0]] || 'linear-gradient(135deg,#101828 0%,#1e3a8a 100%)';
    var imgUrl  = rel.length > 0 && rel[0].article.image_url && !rel[0].article.image_url.includes('document') ? rel[0].article.image_url : null;
    var badgeLabels = { strong: 'Strong match', relevant: 'Relevant', general: 'Global' };

    // Primary geography label
    var geoLabel = '';
    if ((c.countries || []).length > 0) {
      geoLabel = c.countries.slice(0, 2).join(', ');
    } else if ((c.regions || []).filter(function(r){ return r !== 'Global'; }).length > 0) {
      geoLabel = c.regions.filter(function(r){ return r !== 'Global'; }).slice(0, 2).join(', ');
    } else {
      geoLabel = 'Global';
    }

    return (
      <div className="tak-card-img" style={{background: bg}}>
        {imgUrl && (
          <div className="tak-card-img-photo" style={{backgroundImage: 'url(' + imgUrl + ')'}} />
        )}
        <div className="tak-card-img-overlay" />
        <div className="tak-card-img-meta">
          <span className={'tak-badge tak-badge--' + badge}>{badgeLabels[badge] || badge}</span>
          <span className="tak-card-img-geo">{geoLabel}</span>
        </div>
        {rel.length > 0 && <span className="tak-card-img-story-dot" title="Matched to a story today" />}
      </div>
    );
  }

  function CharityCard(cprops) {
    var c       = cprops.charity;
    var rel     = cardRelated[c.id] || [];
    var isSaved = saved.indexOf(c.id) !== -1;
    var firstStory = rel.length > 0 ? rel[0] : null;

    // Match line
    var matchLine = '';
    if (firstStory) {
      matchLine = firstStory.matchLabel;
    } else if ((c.countries || []).length > 0) {
      matchLine = 'Target: ' + c.countries.slice(0, 2).join(', ');
    } else if ((c.regions || []).filter(function(r){ return r !== 'Global'; }).length > 0) {
      matchLine = 'Region: ' + c.regions.filter(function(r){ return r !== 'Global'; })[0];
    } else {
      matchLine = 'Global organisation';
    }

    return (
      <div className="tak-card" role="button" tabIndex={0}
        onClick={function(){ setSelected(c); }}
        onKeyDown={function(e){ if (e.key === 'Enter') setSelected(c); }}>

        <CardImage charity={c} related={rel} />

        <div className="tak-card-body">
          <div className="tak-card-hd">
            <h3 className="tak-card-name">{c.name}</h3>
            <button className="tak-save-btn" title={isSaved ? 'Remove' : 'Save'}
              onClick={function(e){ e.stopPropagation(); toggleSaved(c.id); }}
              aria-label={isSaved ? 'Remove from saved' : 'Save organisation'}>
              {isSaved ? '★' : '☆'}
            </button>
          </div>

          <p className="tak-card-match-line">{matchLine}</p>

          <p className="tak-card-desc">{c.description}</p>

          <div className="tak-card-chips">
            {(c.topics || []).slice(0, 3).map(function(t) {
              var tl = TAK_TOPICS.find(function(x){ return x.id === t; });
              return <span key={t} className="tak-topic-chip">{tl ? tl.label : t}</span>;
            })}
          </div>

          {firstStory && (
            <div className="tak-card-story-preview"
              onClick={function(e){ e.stopPropagation(); if (onOpenStory) onOpenStory(firstStory.article); }}>
              <span className="tak-card-story-tag">Story today</span>
              <span className="tak-card-story-headline">{firstStory.article.title}</span>
            </div>
          )}

          <div className="tak-card-actions">
            {isValidExternalUrl(c.websiteUrl) && (
              <a className="tak-card-cta tak-card-cta--primary"
                href={c.websiteUrl} target="_blank" rel="noopener noreferrer"
                onClick={function(e){ e.stopPropagation(); recordSiteOpen(); }}>
                Official site ↗
              </a>
            )}
            {isValidExternalUrl(c.actionUrl) && (
              <a className="tak-card-cta tak-card-cta--secondary"
                href={c.actionUrl} target="_blank" rel="noopener noreferrer"
                onClick={function(e){ e.stopPropagation(); recordSiteOpen(); }}>
                Action page ↗
              </a>
            )}
          </div>
        </div>
      </div>
    );
  }

  var relatedForSelected = React.useMemo(function() {
    if (!selected) return [];
    return takGetRelatedStories(selected, articles);
  }, [selected && selected.id, articles.length]);

  var hasActiveFilter = activeTopic || activeRegion || query || showSaved;

  return (
    <div className="tak-page">

      {/* ── Topbar ── */}
      <header className="tak-topbar">
        <button className="tak-topbar-hamburger"
          onClick={function(){ if (onMenuOpen) onMenuOpen(); }} aria-label="Open menu">
          <span className="gl-hamburger-line" />
          <span className="gl-hamburger-line" />
          <span className="gl-hamburger-line" />
        </button>
        <span className="tak-topbar-wordmark">Globally</span>
        <div className="tak-topbar-spacer" />
      </header>

      {/* ── Hero ── */}
      <section className="tak-hero">
        <div className="tak-hero-inner">
          <div className="tak-hero-top-row">
            <span className="tak-hero-brand">Globally</span>
            <span className="tak-hero-label">CHARITY</span>
          </div>
          <h1 className="tak-hero-title">Find where global stories need action.</h1>
          <p className="tak-hero-sub">
            Explore organisations working on the issues behind today's development news.
          </p>
          <div className="tak-search-card">
            <svg className="tak-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
                 stroke="#8b6c54" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="10.5" cy="10.5" r="6.5"/><line x1="15.5" y1="15.5" x2="21" y2="21"/>
            </svg>
            <input
              className="tak-search-input"
              type="search"
              placeholder="Search by cause, country, or charity…"
              value={query}
              onChange={function(e){ setQuery(e.target.value); }}
              aria-label="Search charities"
            />
            {query && (
              <button className="tak-search-clear" onClick={function(){ setQuery(''); }} aria-label="Clear">×</button>
            )}
          </div>
        </div>
      </section>

      {/* ── Stats bar ── */}
      {/* Future backend metrics to add when server-side tracking exists:
          - global official-site click counter (across all users)
          - global saved charities count
          - total users exploring charity pages
          Do NOT display any of these until a real backend counter exists. */}
      <div className="tak-stats-bar">
        <div className="tak-stat">
          <span className="tak-stat-num">{charities.length || '—'}</span>
          <span className="tak-stat-label">organisations</span>
        </div>
        <div className="tak-stat-divider" />
        <div className="tak-stat">
          <span className="tak-stat-num">{storiesConnected || '—'}</span>
          <span className="tak-stat-label">stories connected to action</span>
        </div>
        <div className="tak-stat-divider" />
        <div className="tak-stat">
          <span className="tak-stat-num">{connectionsMade || '—'}</span>
          <span className="tak-stat-label">Connections made</span>
        </div>
      </div>

      {/* ── Trust bar ── */}
      <div className="tak-trust-bar">
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
             strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>
          <line x1="12" y1="16" x2="12.01" y2="16"/>
        </svg>
        <span>Globally does not process donations or claim affiliation with these organisations. We signpost readers to official websites so they can learn more and act directly.</span>
      </div>

      {/* ── Mobile filter toggle ── */}
      <div className="tak-mobile-filter-bar">
        <button className={'tak-mobile-filter-btn' + (showFilters ? ' is-on' : '')}
          onClick={function(){ setShowFilters(!showFilters); }}>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
            <line x1="4" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/>
            <line x1="11" y1="18" x2="13" y2="18"/>
          </svg>
          Filters {hasActiveFilter ? '· active' : ''}
        </button>
        {hasActiveFilter && (
          <button className="tak-mobile-clear" onClick={clearFilters}>Clear</button>
        )}
        <span className="tak-mobile-count">
          {!loading && (filtered.length + ' found')}
        </span>
      </div>

      {/* ── Main two-column layout ── */}
      <div className="tak-layout">

        {/* Sidebar */}
        <aside className={'tak-sidebar' + (showFilters ? ' is-open' : '')}>

          {urgentTopics.length > 0 && (
            <div className="tak-sb-section">
              <div className="tak-sb-section-title">Today's focus</div>
              {urgentTopics.map(function(t) {
                var on = activeTopic === t.id;
                return (
                  <SidebarItem key={t.id} label={t.label} count={t.count} on={on}
                    onClick={function(){ setActiveTopic(on ? null : t.id); setActiveRegion(null); setShowSaved(false); setShowFilters(false); }} />
                );
              })}
            </div>
          )}

          <div className="tak-sb-section">
            <div className="tak-sb-section-title">Causes</div>
            {TAK_TOPICS.map(function(t) {
              var on = activeTopic === t.id;
              return (
                <SidebarItem key={t.id} label={t.label} on={on}
                  onClick={function(){ setActiveTopic(on ? null : t.id); setActiveRegion(null); setShowSaved(false); setShowFilters(false); }} />
              );
            })}
          </div>

          <div className="tak-sb-section">
            <div className="tak-sb-section-title">Regions</div>
            {TAK_REGIONS.map(function(r) {
              var on = activeRegion === r;
              return (
                <SidebarItem key={r} label={r} on={on}
                  onClick={function(){ setActiveRegion(on ? null : r); setActiveTopic(null); setShowSaved(false); setShowFilters(false); }} />
              );
            })}
          </div>

          <div className="tak-sb-section">
            <div className="tak-sb-section-title">Saved</div>
            <SidebarItem label="Saved organisations" count={saved.length} on={showSaved}
              onClick={function(){ setShowSaved(!showSaved); setActiveTopic(null); setActiveRegion(null); setShowFilters(false); }} />
          </div>

          {hasActiveFilter && (
            <button className="tak-sb-clear" onClick={clearFilters}>Clear all filters</button>
          )}
        </aside>

        {/* Main grid area */}
        <main className="tak-main">

          {/* Results meta — desktop */}
          {!loading && (
            <div className="tak-main-hd">
              <span className="tak-results-meta">
                {filtered.length === 0
                  ? 'No organisations match.'
                  : filtered.length + ' organisation' + (filtered.length === 1 ? '' : 's') + (hasActiveFilter ? ' found' : ' in directory')}
              </span>
              {hasActiveFilter && (
                <button className="tak-clear-all" onClick={clearFilters}>Clear filters</button>
              )}
            </div>
          )}

          {loading && (
            <div className="tak-loading">
              <div className="tak-loading-spinner" />
              <p>Loading organisations…</p>
            </div>
          )}

          {!loading && filtered.length > 0 && (
            <div className="tak-grid">
              {filtered.map(function(c){ return <CharityCard key={c.id} charity={c} />; })}
            </div>
          )}

          {!loading && filtered.length === 0 && (
            <div className="tak-empty">
              <p className="tak-empty-title">No results</p>
              <p className="tak-empty-sub">Try adjusting your search or removing a filter.</p>
              <button className="tak-clear-all" onClick={clearFilters}>Show all organisations</button>
            </div>
          )}

          <footer className="tak-footer-note">
            <p>Charities and organisations to explore. Information gathered from public sources — check the organisation's official website before donating or volunteering.</p>
            <p className="tak-footer-note-small">Globally signposts readers to public charity information. Donate via the organisation's official website.</p>
          </footer>
        </main>
      </div>

      {/* ── Detail drawer ── */}
      {selected && (
        <div className="tak-drawer-overlay" onClick={function(){ setSelected(null); }}>
          <div className="tak-drawer" onClick={function(e){ e.stopPropagation(); }}>

            <div className="tak-drawer-topbar">
              <button className="tak-drawer-close" onClick={function(){ setSelected(null); }} aria-label="Close">
                <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
                     strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                  <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
                </svg>
              </button>
              <button className="tak-drawer-save-btn"
                onClick={function(){ toggleSaved(selected.id); }}>
                {saved.indexOf(selected.id) !== -1 ? '★ Saved' : '☆ Save'}
              </button>
            </div>

            <div className="tak-drawer-scroll">

              {/* Drawer image */}
              {(function(){
                var relD = relatedForSelected;
                var imgUrl = relD.length > 0 && relD[0].article.image_url && !relD[0].article.image_url.includes('document') ? relD[0].article.image_url : null;
                var bg = TOPIC_GRADIENTS[(selected.topics || [])[0]] || 'linear-gradient(135deg,#101828 0%,#1e3a8a 100%)';
                return (
                  <div className="tak-drawer-img" style={{background: bg}}>
                    {imgUrl && <div className="tak-drawer-img-photo" style={{backgroundImage:'url('+imgUrl+')'}} />}
                    <div className="tak-card-img-overlay" />
                  </div>
                );
              })()}

              <div className="tak-drawer-header">
                <h2 className="tak-drawer-name">{selected.name}</h2>
                <p className="tak-drawer-desc">{selected.description}</p>
              </div>

              {/* Why this matches */}
              <div className="tak-drawer-section tak-drawer-section--why">
                <h3 className="tak-drawer-section-title">Why this may be relevant</h3>
                <p className="tak-drawer-why-text">{takWhyMatchesText(selected, relatedForSelected)}</p>
              </div>

              {selected.goals && selected.goals.length > 0 && (
                <div className="tak-drawer-section">
                  <h3 className="tak-drawer-section-title">Goals</h3>
                  <ul className="tak-drawer-goals">
                    {selected.goals.map(function(g, i){ return <li key={i}>{g}</li>; })}
                  </ul>
                </div>
              )}

              <div className="tak-drawer-section">
                <h3 className="tak-drawer-section-title">Causes</h3>
                <div className="tak-card-chips">
                  {(selected.topics || []).map(function(t) {
                    var tl = TAK_TOPICS.find(function(x){ return x.id === t; });
                    return <span key={t} className="tak-topic-chip">{tl ? tl.label : t}</span>;
                  })}
                </div>
              </div>

              <div className="tak-drawer-section">
                <h3 className="tak-drawer-section-title">Where they work</h3>
                <div className="tak-card-chips">
                  {(selected.regions || []).map(function(r){ return <span key={r} className="tak-region-chip">{r}</span>; })}
                  {(selected.countries || []).map(function(r){ return <span key={r} className="tak-country-chip">{r}</span>; })}
                </div>
              </div>

              <div className="tak-drawer-cta-row">
                {isValidExternalUrl(selected.websiteUrl) && (
                  <a className="tak-drawer-cta-primary"
                    href={selected.websiteUrl} target="_blank" rel="noopener noreferrer"
                    onClick={recordSiteOpen}>
                    Official website ↗
                  </a>
                )}
                {isValidExternalUrl(selected.actionUrl) && (
                  <a className="tak-drawer-cta-secondary"
                    href={selected.actionUrl} target="_blank" rel="noopener noreferrer"
                    onClick={recordSiteOpen}>
                    Open action page ↗
                  </a>
                )}
              </div>

              <div className="tak-drawer-section">
                <h3 className="tak-drawer-section-title">Related stories from today</h3>
                {relatedForSelected.length > 0 ? (
                  <div className="tak-related-list">
                    {relatedForSelected.map(function(rs, i) {
                      return (
                        <div key={i} className="tak-related-item"
                          onClick={function(){ if (onOpenStory) onOpenStory(rs.article); setSelected(null); }}>
                          <div className="tak-related-match">{rs.matchLabel}</div>
                          <div className="tak-related-headline">{rs.article.title}</div>
                          <div className="tak-related-source">{rs.article.source || rs.article.outlet}</div>
                        </div>
                      );
                    })}
                  </div>
                ) : (
                  <p className="tak-related-empty">No closely related stories in today's edition.</p>
                )}
              </div>

              <div className="tak-drawer-trust">
                <p>Information gathered from public sources. Check the organisation's official website before donating or volunteering.</p>
                {selected.lastChecked && (
                  <p className="tak-drawer-last-checked">Last checked: {selected.lastChecked}</p>
                )}
              </div>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

// ─── Volunteering page ─────────────────────────────────────────────────────────
function VolunteeringScreen(props) {
  var onBack     = props.onBack;
  var onMenuOpen = props.onMenuOpen;

  // Tab: 'org' = organisation interest, 'waitlist' = reader waitlist
  var _activeForm = React.useState(null);
  var activeForm = _activeForm[0], setActiveForm = _activeForm[1];

  var _orgFd = React.useState({ orgname:'', contact:'', email:'', website:'', country:'', support:'', message:'' });
  var orgFd = _orgFd[0], setOrgFd = _orgFd[1];
  var _orgSubmitted = React.useState(false);
  var orgSubmitted = _orgSubmitted[0], setOrgSubmitted = _orgSubmitted[1];

  var _waitFd = React.useState({ email:'' });
  var waitFd = _waitFd[0], setWaitFd = _waitFd[1];
  var _waitSubmitted = React.useState(false);
  var waitSubmitted = _waitSubmitted[0], setWaitSubmitted = _waitSubmitted[1];

  function handleOrgField(e) {
    var k = e.target.name, v = e.target.value;
    setOrgFd(function(p){ var n = Object.assign({}, p); n[k] = v; return n; });
  }

  function handleOrgSubmit(e) {
    e.preventDefault();
    try {
      var list = JSON.parse(localStorage.getItem('globally_vol_org_interests') || '[]');
      list.push(Object.assign({}, orgFd, { submittedAt: new Date().toISOString() }));
      localStorage.setItem('globally_vol_org_interests', JSON.stringify(list));
    } catch(_) {}
    setOrgSubmitted(true);
  }

  function handleWaitSubmit(e) {
    e.preventDefault();
    try {
      var list = JSON.parse(localStorage.getItem('globally_vol_waitlist') || '[]');
      list.push({ email: waitFd.email, submittedAt: new Date().toISOString() });
      localStorage.setItem('globally_vol_waitlist', JSON.stringify(list));
    } catch(_) {}
    setWaitSubmitted(true);
  }

  var AUDIENCE_CARDS = [
    {
      who:   'For organisations',
      body:  'Tell us what kind of volunteers or support you may need. We are currently speaking with organisations to understand what responsible, useful volunteering could look like.',
      cta:   'Register organisation interest',
      form:  'org',
    },
    {
      who:   'For readers',
      body:  'Join the waitlist to hear when verified volunteering opportunities open. Globally is not listing live roles yet — we will notify you when we do.',
      cta:   'Join the waitlist',
      form:  'waitlist',
    },
    {
      who:   'For development groups',
      body:  'Help shape ethical, useful volunteering around real development issues. We want to hear from educators, researchers, and development professionals.',
      cta:   'Get in touch',
      form:  'org',
    },
  ];

  return (
    <div className="tak-page tak-page--volunteering">

      <header className="tak-topbar">
        <button className="tak-topbar-hamburger"
          onClick={function(){ if (onMenuOpen) onMenuOpen(); }} aria-label="Open menu">
          <span className="gl-hamburger-line" />
          <span className="gl-hamburger-line" />
          <span className="gl-hamburger-line" />
        </button>
        <span className="tak-topbar-wordmark">Globally</span>
        <div className="tak-topbar-spacer" />
      </header>

      <section className="tak-hero">
        <div className="tak-hero-inner">
          <div className="tak-hero-top-row">
            <span className="tak-hero-brand">Globally</span>
            <span className="tak-hero-label">VOLUNTEERING</span>
          </div>
          <h1 className="tak-hero-title">Turn global awareness into useful action.</h1>
          <p className="tak-hero-sub">
            Volunteering opportunities are coming soon. We're building a trusted way for readers,
            students, and organisations to connect around development issues.
          </p>
          <div className="gsect-cta-row">
            <button className="gsect-btn gsect-btn--primary"
              onClick={function(){ setActiveForm('org'); document.getElementById('vol-form') && document.getElementById('vol-form').scrollIntoView({behavior:'smooth'}); }}>
              Register organisation interest
            </button>
            <button className="gsect-btn gsect-btn--secondary"
              onClick={function(){ setActiveForm('waitlist'); document.getElementById('vol-form') && document.getElementById('vol-form').scrollIntoView({behavior:'smooth'}); }}>
              Join the waitlist
            </button>
          </div>
        </div>
      </section>

      <div className="tak-trust-bar">
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
             strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>
          <line x1="12" y1="16" x2="12.01" y2="16"/>
        </svg>
        <span>Globally is not listing live volunteering roles yet. We are currently speaking with organisations. Organisations can contact us to discuss future opportunities.</span>
      </div>

      <div className="gsect-body">

        {/* Audience cards */}
        <section className="gsect-section">
          <div className="gsect-section-hd">
            <h2 className="gsect-section-title">Who is this for?</h2>
          </div>
          <div className="gsect-cards gsect-cards--3">
            {AUDIENCE_CARDS.map(function(c, i){
              return (
                <div key={i} className="gsect-card gsect-card--audience">
                  <p className="gsect-card-who">{c.who}</p>
                  <p className="gsect-card-body">{c.body}</p>
                  <button className="gsect-card-link"
                    onClick={function(){ setActiveForm(c.form); document.getElementById('vol-form') && document.getElementById('vol-form').scrollIntoView({behavior:'smooth'}); }}>
                    {c.cta} →
                  </button>
                </div>
              );
            })}
          </div>
        </section>

        {/* What we're building */}
        <section className="gsect-section">
          <div className="gsect-section-hd">
            <h2 className="gsect-section-title">What we're building</h2>
            <p className="gsect-section-sub">Coming soon — no live listings yet.</p>
          </div>
          <div className="gsect-usecases">
            {[
              { label: 'Verified listings',    body: 'Opportunities vetted for organisational credibility, clear requirements, and transparent outcomes. Not live yet.' },
              { label: 'Skills matching',      body: 'Connect your professional background to the opportunities where your contribution matters most.' },
              { label: 'Cause-based browsing', body: 'Filter by issue — climate, education, health, conflict response — and the region you follow.' },
              { label: 'Ethical standards',    body: 'We will only list organisations we can verify. We will not list opportunities that are unclear about terms or outcomes.' },
            ].map(function(u, i){
              return (
                <div key={i} className="gsect-usecase">
                  <p className="gsect-usecase-label">{u.label}</p>
                  <p className="gsect-usecase-body">{u.body}</p>
                </div>
              );
            })}
          </div>
        </section>

        {/* Forms */}
        <section className="gsect-section gsect-section--form" id="vol-form">
          {/* Form type toggle */}
          {!activeForm && (
            <div className="gsect-form-toggle-intro">
              <p className="gsect-form-toggle-prompt">What best describes you?</p>
              <div className="gsect-form-toggle-row">
                <button className="gsect-btn gsect-btn--primary"
                  onClick={function(){ setActiveForm('org'); }}>
                  I represent an organisation
                </button>
                <button className="gsect-btn gsect-btn--secondary"
                  onClick={function(){ setActiveForm('waitlist'); }}>
                  I want to volunteer
                </button>
              </div>
            </div>
          )}

          {activeForm === 'org' && (
            <div>
              <div className="gsect-section-hd">
                <h2 className="gsect-section-title">Register organisation interest</h2>
                <p className="gsect-section-sub">We are speaking with organisations to understand what responsible volunteering support looks like.</p>
              </div>
              {orgSubmitted ? (
                <div className="gsect-form-success">
                  <div className="gsect-form-success-check">✓</div>
                  <p className="gsect-form-success-title">Thanks — we've saved your interest locally for now.</p>
                  <p className="gsect-form-success-body">We are not yet sending these to a server. We'll reach out as volunteering develops.</p>
                </div>
              ) : (
                <form className="gsect-form" onSubmit={handleOrgSubmit} autoComplete="on">
                  <div className="gsect-form-row">
                    <div className="gsect-form-field">
                      <label className="gsect-form-label">Organisation name</label>
                      <input className="gsect-form-input" type="text" name="orgname" required
                        placeholder="Organisation name" value={orgFd.orgname} onChange={handleOrgField} />
                    </div>
                    <div className="gsect-form-field">
                      <label className="gsect-form-label">Contact name</label>
                      <input className="gsect-form-input" type="text" name="contact" required
                        placeholder="Your name" value={orgFd.contact} onChange={handleOrgField} />
                    </div>
                  </div>
                  <div className="gsect-form-row">
                    <div className="gsect-form-field">
                      <label className="gsect-form-label">Email</label>
                      <input className="gsect-form-input" type="email" name="email" required
                        placeholder="contact@organisation.org" value={orgFd.email} onChange={handleOrgField} />
                    </div>
                    <div className="gsect-form-field">
                      <label className="gsect-form-label">Website</label>
                      <input className="gsect-form-input" type="url" name="website"
                        placeholder="https://yourorg.org" value={orgFd.website} onChange={handleOrgField} />
                    </div>
                  </div>
                  <div className="gsect-form-row">
                    <div className="gsect-form-field">
                      <label className="gsect-form-label">Country / region</label>
                      <input className="gsect-form-input" type="text" name="country"
                        placeholder="e.g. Kenya, South Asia, Global" value={orgFd.country} onChange={handleOrgField} />
                    </div>
                    <div className="gsect-form-field">
                      <label className="gsect-form-label">What support are you looking for?</label>
                      <input className="gsect-form-input" type="text" name="support"
                        placeholder="e.g. skilled volunteers, researchers, fundraisers…" value={orgFd.support} onChange={handleOrgField} />
                    </div>
                  </div>
                  <div className="gsect-form-field">
                    <label className="gsect-form-label">Message</label>
                    <textarea className="gsect-form-textarea" name="message" rows="4"
                      placeholder="Tell us more about your organisation and what you're looking for…"
                      value={orgFd.message} onChange={handleOrgField} />
                  </div>
                  <div className="gsect-form-footer">
                    <p className="gsect-form-note">Your details are saved locally for now — not yet sent to a server.</p>
                    <button className="gsect-btn gsect-btn--primary" type="submit">Register interest</button>
                  </div>
                </form>
              )}
            </div>
          )}

          {activeForm === 'waitlist' && (
            <div>
              <div className="gsect-section-hd">
                <h2 className="gsect-section-title">Join the waitlist</h2>
                <p className="gsect-section-sub">We'll let you know when verified volunteering opportunities open. We will not share your email or send spam.</p>
              </div>
              {waitSubmitted ? (
                <div className="gsect-form-success">
                  <div className="gsect-form-success-check">✓</div>
                  <p className="gsect-form-success-title">Thanks — we've saved your email locally for now.</p>
                  <p className="gsect-form-success-body">We are not yet sending these to a server. We'll reach out when volunteering launches.</p>
                </div>
              ) : (
                <form className="gsect-form gsect-form--compact" onSubmit={handleWaitSubmit} autoComplete="on">
                  <div className="gsect-form-field">
                    <label className="gsect-form-label">Your email</label>
                    <input className="gsect-form-input" type="email" name="email" required
                      placeholder="you@email.com" value={waitFd.email}
                      onChange={function(e){ setWaitFd({ email: e.target.value }); }} />
                  </div>
                  <div className="gsect-form-footer">
                    <p className="gsect-form-note">Email saved locally for now — not yet sent to a server.</p>
                    <button className="gsect-btn gsect-btn--primary" type="submit">Join waitlist</button>
                  </div>
                </form>
              )}
            </div>
          )}
        </section>

      </div>
    </div>
  );
}


// ── useCountUp — animated counter for About page ─────────────────────────────
function useCountUp(target, active, durationMs) {
  var _v = React.useState(0);
  var v = _v[0], setV = _v[1];
  React.useEffect(function() {
    if (!active) return;
    var noAnim = typeof window !== 'undefined' && window.matchMedia
      ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
      : true;
    if (noAnim) { setV(target); return; }
    var start = null;
    var raf;
    function step(ts) {
      if (!start) start = ts;
      var p = Math.min((ts - start) / durationMs, 1);
      var ease = 1 - (1 - p) * (1 - p);
      setV(Math.round(ease * target));
      if (p < 1) { raf = requestAnimationFrame(step); }
      else { setV(target); }
    }
    raf = requestAnimationFrame(step);
    return function() { if (raf) cancelAnimationFrame(raf); };
  }, [active]);
  return v;
}

// ── AboutScreen ───────────────────────────────────────────────────────────────
function AboutScreen(props) {
  var onNav       = props.onNav;
  var articles    = props.articles || [];
  var setup       = props.setup    || null;
  var onOpenStory = props.onOpenStory;

  var _counted = React.useState(false);
  var counted  = _counted[0], setCounted = _counted[1];
  var _openAcc = React.useState(0);
  var openAcc  = _openAcc[0], setOpenAcc = _openAcc[1];
  var _step    = React.useState(0);
  var step     = _step[0], setStep = _step[1];
  var _searchCountry = React.useState(null);
  var searchCountry  = _searchCountry[0], setSearchCountry = _searchCountry[1];
  var heroRef  = React.useRef(null);

  React.useEffect(function() {
    var el = heroRef.current;
    if (!el) return;
    if (typeof window !== 'undefined' && window.matchMedia &&
        window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setCounted(true);
      return;
    }
    var obs = new IntersectionObserver(function(entries) {
      if (entries[0].isIntersecting) { setCounted(true); obs.disconnect(); }
    }, { threshold: 0.3 });
    obs.observe(el);
    return function() { obs.disconnect(); };
  }, []);

  var c1 = useCountUp(54,  counted, 900);
  var c2 = useCountUp(160, counted, 1100);

  var coveredCountries = React.useMemo(function() {
    var freq = {};
    articles.forEach(function(a) {
      if (a.country && a.country !== 'Global' && !a._clusterHidden) {
        freq[a.country] = (freq[a.country] || 0) + 1;
      }
    });
    return Object.keys(freq).sort(function(a, b) { return freq[b] - freq[a]; });
  }, [articles.length]);

  var MIRA_ITEMS = [
    {
      q: 'What can I ask Mira?',
      a: 'Anything about global development: economic trends, humanitarian crises, climate impacts, political stability, and what is happening in specific countries. Ask on any story page or from the search bar.',
    },
    {
      q: "Where do Mira's answers come from?",
      a: "Mira draws on Globally's live coverage and her training in development economics, humanitarian affairs, and global politics. She labels which parts come from current reporting and which from general knowledge.",
    },
    {
      q: 'Is Mira always right?',
      a: "Mira is a reasoning tool, not a fact database. She is accurate on well-reported stories and explicit when she is uncertain. On high-stakes decisions, always verify against primary sources.",
    },
    {
      q: "What Mira won't do",
      a: "Make up sources, predict elections, or give financial advice. If she doesn't know, she says so — no hallucinated citations.",
    },
  ];

  var STEPS = [
    {
      label: 'Aggregate',
      icon:  '📡',
      body:  'Our scraper reads hundreds of trusted sources every morning: UN agencies, regional newspapers, international correspondents, and humanitarian organisations across six regions.',
    },
    {
      label: 'Cluster',
      icon:  '🗂',
      body:  "Stories about the same event are grouped automatically. You see the full picture from multiple angles, not just one outlet's take.",
    },
    {
      label: 'Ask Mira',
      icon:  '💬',
      body:  "Open any story and ask Mira anything. She synthesises the live reporting with her development knowledge to give you context you won't find in a single article.",
    },
  ];

  return (
    <div className="gl-about-page">
      <div className="gl-about-inner">

        <button className="gl-back-btn gl-about-back" onClick={function(){ if (onNav) onNav('today'); }}>
          ← Today
        </button>

        {/* ─── Hero ──────────────────────────────────────────────── */}
        <section className="gl-about-hero" ref={heroRef}>
          <p className="gl-about-eyebrow">Globally</p>
          <h1 className="gl-about-heading">What is Globally?</h1>
          <p className="gl-about-sub">
            The world's most important development news, in one place — and a guide to help you understand it.
          </p>
          <div className="gl-about-counters">
            <div className="gl-about-counter">
              <span className="gl-about-counter-num">{counted ? c1 : 0}</span>
              <span className="gl-about-counter-label">countries monitored</span>
            </div>
            <div className="gl-about-counter-divider" />
            <div className="gl-about-counter">
              <span className="gl-about-counter-num">~{counted ? c2 : 0}</span>
              <span className="gl-about-counter-label">trusted sources</span>
            </div>
            <div className="gl-about-counter-divider" />
            <div className="gl-about-counter">
              <span className="gl-about-counter-num">1</span>
              <span className="gl-about-counter-label">place to read it</span>
            </div>
          </div>
        </section>

        {/* ─── Country strip ─────────────────────────────────────── */}
        {coveredCountries.length > 0 && (
          <section className="gl-about-section">
            <h2 className="gl-about-section-heading">In today's coverage</h2>
            <p className="gl-about-section-sub">Tap a country to search today's stories.</p>
            <div className="gl-about-country-strip">
              {coveredCountries.map(function(c) {
                return (
                  <button key={c} className="gl-about-country-chip"
                    onClick={function(){ setSearchCountry(c); }}>
                    {c}
                  </button>
                );
              })}
            </div>
          </section>
        )}

        {/* ─── Meet Mira ─────────────────────────────────────────── */}
        <section className="gl-about-section">
          <h2 className="gl-about-section-heading">Meet Mira</h2>
          <p className="gl-about-section-sub">
            Globally's AI development analyst. Mira reads the same stories you do and can answer questions about them.
          </p>
          <div className="gl-about-acc">
            {MIRA_ITEMS.map(function(item, i) {
              var isOpen = openAcc === i;
              return (
                <div key={i} className={'gl-about-acc-item' + (isOpen ? ' is-open' : '')}>
                  <button className="gl-about-acc-trigger"
                    onClick={function(){ setOpenAcc(isOpen ? -1 : i); }}
                    aria-expanded={isOpen}>
                    <span className="gl-about-acc-q">{item.q}</span>
                    <span className="gl-about-acc-icon" aria-hidden="true">{isOpen ? '−' : '+'}</span>
                  </button>
                  <div className="gl-about-acc-body" style={{ maxHeight: isOpen ? '300px' : '0px' }}>
                    <p className="gl-about-acc-text">{item.a}</p>
                  </div>
                </div>
              );
            })}
          </div>
        </section>

        {/* ─── How it works ──────────────────────────────────────── */}
        <section className="gl-about-section">
          <h2 className="gl-about-section-heading">How it works</h2>
          <div className="gl-about-stepper">
            {STEPS.map(function(s, i) {
              var isActive = step === i;
              return (
                <div key={i} className={'gl-about-step' + (isActive ? ' is-active' : '')}>
                  <button className="gl-about-step-trigger"
                    onClick={function(){ setStep(isActive ? -1 : i); }}>
                    <span className="gl-about-step-num">{i + 1}</span>
                    <span className="gl-about-step-icon" aria-hidden="true">{s.icon}</span>
                    <span className="gl-about-step-label">{s.label}</span>
                    <span className="gl-about-step-chevron" aria-hidden="true">{isActive ? '▾' : '▸'}</span>
                  </button>
                  <div className="gl-about-step-body" style={{ maxHeight: isActive ? '200px' : '0px' }}>
                    <p className="gl-about-step-text">{s.body}</p>
                  </div>
                </div>
              );
            })}
          </div>
        </section>

        {/* ─── Our promise ───────────────────────────────────────── */}
        <section className="gl-about-section">
          <h2 className="gl-about-section-heading">Our promise</h2>
          <p className="gl-about-promise-text">
            Globally will never pay for placement, accept advertising, or hide sources.
            Every story shows exactly where it came from.
            Mira tells you when she is working from general knowledge rather than current reporting.
          </p>
        </section>

        {/* ─── CTA ───────────────────────────────────────────────── */}
        <div className="gl-about-cta-wrap">
          <button className="gl-about-cta" onClick={function(){ if (onNav) onNav('today'); }}>
            Start reading →
          </button>
        </div>

      </div>

      {searchCountry && (
        <GloballySearch
          articles={articles}
          setup={setup}
          initialQuery={searchCountry}
          onClose={function(){ setSearchCountry(null); }}
          onOpenStory={function(a){ setSearchCountry(null); if (onOpenStory) onOpenStory(a); }}
        />
      )}
    </div>
  );
}


function GloblySimplePage(props) {
  var pageId = props.pageId;
  var onBack = props.onBack;
  var page   = FOOTER_PAGE_CONTENT[pageId] || { title: pageId, eyebrow: 'Globally' };

  return (
    <div className="gl-simple-page gl-fp">
      <button className="gl-back-btn" onClick={function(){ onBack('today'); }}>← Back</button>

      <div className="gl-fp-inner">

        <div className="gl-fp-header">
          <p className="gl-page-eyebrow">{page.eyebrow || 'Globally'}</p>
          <h1 className="gl-fp-title">{page.title}</h1>
          {page.intro ? <p className="gl-fp-intro">{page.intro}</p> : null}
        </div>

        {page.legalNote ? (
          <div className="gl-fp-legal-note">
            <p>{page.legalNote}</p>
          </div>
        ) : null}

        {page.emptyCards ? (
          <div className="gl-fp-empty-cards">
            {page.emptyCards.map(function(card, i) {
              return (
                <div key={i} className="gl-fp-empty-card">
                  <p className="gl-fp-empty-card-label">{card.label}</p>
                  <p className="gl-fp-empty-card-desc">{card.desc}</p>
                </div>
              );
            })}
          </div>
        ) : null}

        {page.sections ? page.sections.map(function(s, i) {
          return (
            <div key={i} className="gl-fp-section">
              {s.heading ? <h2 className="gl-fp-section-heading">{s.heading}</h2> : null}
              {s.body    ? <p  className="gl-fp-section-body">{s.body}</p> : null}
              {s.bullets ? (
                <ul className="gl-fp-bullets">
                  {s.bullets.map(function(b, j) { return <li key={j}>{b}</li>; })}
                </ul>
              ) : null}
            </div>
          );
        }) : null}

        {page.lastUpdated ? (
          <p className="gl-fp-last-updated">Last updated: {page.lastUpdated}</p>
        ) : null}

      </div>

      <GloblyPageFooter onNav={onBack} />
    </div>
  );
}

// ─── AboutScreen was moved above GloblySimplePage ─────────────────────────────
function _AboutScreenLegacy_UNUSED(props) {
  var onBack   = props.onBack;
  var articles = props.articles || [];
  var _count   = React.useState(0);
  var count    = _count[0];
  var setCount = _count[1];
  React.useEffect(function() {
    var target = articles.length;
    if (!target) return;
    var mq = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)');
    if (mq && mq.matches) { setCount(target); return; }
    var startTime = null;
    var duration  = 1100;
    function step(ts) {
      if (!startTime) startTime = ts;
      var pct    = Math.min((ts - startTime) / duration, 1);
      var eased  = 1 - Math.pow(1 - pct, 3);
      setCount(Math.round(eased * target));
      if (pct < 1) requestAnimationFrame(step);
    }
    var raf = requestAnimationFrame(step);
    return function() { cancelAnimationFrame(raf); };
  }, [articles.length]);

  // Accordion — one open at a time, first open by default
  var _open   = React.useState(0);
  var open    = _open[0];
  var setOpen = _open[1];

  var ACCORDIONS = [
    {
      title: 'Where Mira’s answers come from',
      body:  'Mira draws only from the sourced articles in Globally’s daily feed — published journalism, UN and development agency reporting, and verified humanitarian feeds. Every explanation links to the reporting it draws from. Mira does not search the internet or synthesise from training memory.',
    },
    {
      title: 'What Mira won’t do',
      body:  'Mira will not fabricate statistics, invent quotes, predict future events, or offer personal opinions. If the answer to a question is not in the available sources, she says so plainly. Uncertainty is stated, not papered over.',
    },
    {
      title: 'How we handle hard stories',
      body:  'Conflict, famine, displacement, and death are covered because they matter — not because they are dramatic. Globally presents grave stories with appropriate weight. Mira does not use minimising language or sensational framing on stories involving serious harm.',
    },
  ];

  var STEPS = [
    {
      n: '1',
      heading: 'Aggregate',
      body: 'We pull published reporting daily from journalism, development agencies, and verified humanitarian feeds — across 14 topics and dozens of countries.',
    },
    {
      n: '2',
      heading: 'Cluster',
      body: 'Related stories about the same event are grouped so you read the situation once, clearly — not the same headline repeated from different outlets.',
    },
    {
      n: '3',
      heading: 'Ask Mira',
      body: 'On any story, ask Mira a specific question. She answers from the sourced text, cites what she draws from, and says clearly when the sources don’t cover it.',
    },
  ];

  return (
    <div className="gl-simple-page gl-about-page">
      <button className="gl-back-btn" onClick={function(){ onBack('today'); }}>← Back</button>

      {/* ── Hero ──────────────────────────────────────────────────────── */}
      <section className="gl-about-hero">
        <p className="gl-page-eyebrow">Globally</p>
        {articles.length > 0 ? (
          <div className="gl-about-count-row">
            <span className="gl-about-count-num">{count}</span>
            <span className="gl-about-count-label">stories in today’s feed</span>
          </div>
        ) : null}
        <h1 className="gl-about-mission">
          Globally puts the overlooked story of global development in one place, every day.
        </h1>
      </section>

      {/* ── Why Globally exists ───────────────────────────────────────── */}
      <section className="gl-about-section">
        <h2 className="gl-about-section-hd">Why Globally exists</h2>
        <p className="gl-about-prose">
          Most development news — crisis response, debt negotiations, food security, governance — goes unreported or buried in specialist feeds that most people never see.
          Globally exists to change that: one daily feed, 14 topics, every country that matters.
          No noise, no opinion columns, no viral churn — just the reporting that should have been easier to find.
        </p>
      </section>

      {/* ── Meet Mira ─────────────────────────────────────────────────── */}
      <section className="gl-about-section">
        <h2 className="gl-about-section-hd">Meet Mira</h2>
        <p className="gl-about-prose">Mira is Globally’s AI reading assistant. She reads the day’s sources so you can go deeper on any story.</p>
        <div className="gl-about-accordions">
          {ACCORDIONS.map(function(item, i) {
            var isOpen = open === i;
            return (
              <div key={i} className={'gl-about-accordion' + (isOpen ? ' gl-about-accordion--open' : '')}>
                <button
                  className="gl-about-accordion-trigger"
                  onClick={function(){ setOpen(isOpen ? -1 : i); }}
                  aria-expanded={isOpen}>
                  <span className="gl-about-accordion-title">{item.title}</span>
                  <span className="gl-about-accordion-icon" aria-hidden="true">{isOpen ? '−' : '+'}</span>
                </button>
                {isOpen ? (
                  <div className="gl-about-accordion-body">
                    <p>{item.body}</p>
                  </div>
                ) : null}
              </div>
            );
          })}
        </div>
      </section>

      {/* ── How it works ──────────────────────────────────────────────── */}
      <section className="gl-about-section">
        <h2 className="gl-about-section-hd">How it works</h2>
        <div className="gl-about-steps">
          {STEPS.map(function(step, i) {
            return (
              <div key={i} className="gl-about-step">
                <div className="gl-about-step-num" aria-hidden="true">{step.n}</div>
                <div className="gl-about-step-content">
                  <p className="gl-about-step-heading">{step.heading}</p>
                  <p className="gl-about-step-body">{step.body}</p>
                </div>
              </div>
            );
          })}
        </div>
      </section>

      {/* ── Our promise (static — no animation) ──────────────────────── */}
      <section className="gl-about-section gl-about-promise-section">
        <h2 className="gl-about-section-hd">Our promise</h2>
        <ol className="gl-about-promise-list">
          <li>
            <span className="gl-about-promise-n" aria-hidden="true">1</span>
            <span className="gl-about-promise-text">No fabricated statistics, quotes, or analysis. Ever.</span>
          </li>
          <li>
            <span className="gl-about-promise-n" aria-hidden="true">2</span>
            <span className="gl-about-promise-text">Every Mira claim traces to a real source you can open.</span>
          </li>
          <li>
            <span className="gl-about-promise-n" aria-hidden="true">3</span>
            <span className="gl-about-promise-text">Grave stories told with weight, never padded or minimised.</span>
          </li>
        </ol>
      </section>

      {/* ── CTA ───────────────────────────────────────────────────────── */}
      <section className="gl-about-cta">
        <button className="gl-about-cta-btn" onClick={function(){ onBack('today'); }}>
          Start reading
        </button>
      </section>

      <GloblyPageFooter onNav={onBack} />
    </div>
  );
}

function GloblyPageFooter(props) {
  var onNav = props.onNav;
  return (
    <footer className="gl-footer">
      <div className="gl-footer-inner">
        <div className="gl-footer-brand">
          <span className="gl-footer-wordmark">Globally</span>
        </div>
        <p className="gl-footer-tagline">
          Globally helps you understand global development in 30 seconds a day.
        </p>
        <nav className="gl-footer-nav" aria-label="Footer links">
          {FOOTER_LINKS.map(function(item) {
            return (
              <button key={item.id} className="gl-footer-nav-link" onClick={function(){ if (onNav) onNav(item.id); }}>
                {item.label}
              </button>
            );
          })}
        </nav>
        <p className="gl-footer-legal">Copyright © 2026 Globally</p>
      </div>
    </footer>
  );
}

// ─── Left sidebar (desktop) ───────────────────────────────────────────────────

// ── Sticky topbar — lives inside gl-main (z-index: 501, above overlay) ────────
function GloblyTopbar(props) {
  var menuOpen     = props.menuOpen;
  var onToggle     = props.onToggle;
  var articleCount = props.articleCount || 0;

  return (
    <div className="gl-topbar">
      <button
        className={"gl-topbar-btn" + (menuOpen ? " gl-topbar-btn--open" : "")}
        onClick={function(){ onToggle(!menuOpen); }}
        aria-label={menuOpen ? "Close menu" : "Open menu"}>
        <span className="gl-hamburger-line" />
        <span className="gl-hamburger-line" />
        <span className="gl-hamburger-line" />
      </button>
      <img src="globly-logo-white.png" className="gl-topbar-logo" alt="Globally" />
      {articleCount > 0 && (
        <span className="gl-topbar-count">{articleCount} stories</span>
      )}
    </div>
  );
}

// ── Left-panel overlay — rendered at gl-app level (position: fixed) ────────────
function GloblyMenuPanel(props) {
  var active      = props.active;
  var activeTopic = props.activeTopic;
  var onNav       = props.onNav;
  var onTopicNav  = props.onTopicNav;
  var onClose     = props.onClose;
  var onSignOut   = props.onSignOut;

  var MAIN_ITEMS = [
    { id: "today",             label: "Today"             },
    { id: "mira-for-firms",    label: "Mira Business"    },
    { id: "charity",           label: "Charity"           },
    { id: "volunteering",      label: "Volunteering"      },
    { id: "play",              label: "Play"              },
    { id: "globe",             label: "Map"               },
  ];

  function handleNav(id) { onNav(id); onClose(); }
  function handleTopicNav(t) { onTopicNav(t); onClose(); }

  return (
    <div className="gl-menu-overlay" onClick={onClose}>
      <div className="gl-menu-inner" onClick={function(e){ e.stopPropagation(); }}>
        <nav className="gl-menu-nav">
          {MAIN_ITEMS.map(function(item) {
            var on = active === item.id && !activeTopic;
            return (
              <button
                key={item.id}
                className={"gl-menu-item" + (on ? " gl-menu-item--on" : "")}
                onClick={function(){ handleNav(item.id); }}>
                {item.label}
              </button>
            );
          })}
        </nav>
        {onSignOut && (
          <div className="gl-menu-signout">
            <button
              className="gl-menu-signout-btn"
              type="button"
              onClick={function(){ onClose(); onSignOut(); }}>
              Sign out
            </button>
          </div>
        )}
        <GloblyFooterLinks onNav={handleNav} className="gl-footer-links--menu" />
      </div>
    </div>
  );
}

// ─── Mobile bottom nav ────────────────────────────────────────────────────────

function GloblyMobileNav(props) {
  var active   = props.active;
  var onChange = props.onChange;

  var MOBILE_TABS = [
    { id: "today", label: "Home",   Icon: IcoToday },
    { id: "play",  label: "Games",  Icon: IcoPlay  },
    { id: "globe", label: "Map",    Icon: IcoGlobe },
    { id: "feed",  label: "Saved",  Icon: IcoFeed  },
  ];

  return (
    <nav className="gl-mobile-nav">
      {MOBILE_TABS.map(function(tab) {
        var on = active === tab.id;
        return (
          <button
            key={tab.id}
            className={"gl-mobile-nav-tab" + (on ? " gl-mobile-nav-tab--on" : "")}
            onClick={function(){ onChange(tab.id); }}
            aria-label={tab.label}>
            {React.createElement(tab.Icon)}
            <span>{tab.label}</span>
          </button>
        );
      })}
    </nav>
  );
}

// ─── App top bar ──────────────────────────────────────────────────────────────

function GloblyTopBar(props) {
  var streak      = props.streak;
  var onStreak    = props.onStreak;
  var onProfile   = props.onProfile;
  var lastUpdated = props.lastUpdated;

  return (
    <header className="gs-topbar">
      <div className="gs-topbar-brand">
        <img src="globly-logo-white.png" className="gs-logo-img" alt="Globally" />
        {lastUpdated && (
          <span className="gs-topbar-freshness">Updated {gfRelTime(lastUpdated)}</span>
        )}
      </div>
      <div className="gs-topbar-right">
        <button className="gs-streak-pill" onClick={onStreak}>
          {Flame ? <Flame size={13} strokeWidth={2.5} /> : <IcoFlame />}
          <span className="gs-streak-num">{streak.current}</span>
        </button>
        <button className="gs-profile-btn" onClick={onProfile} aria-label="Profile">
          <IcoProfile />
        </button>
      </div>
    </header>
  );
}

// ─── Bottom nav ───────────────────────────────────────────────────────────────

var NAV_TABS = [
  { id: "today",   label: "Today",   Icon: IcoToday  },
  { id: "feed",    label: "Feed",    Icon: IcoFeed   },
  { id: "play",    label: "Play",    Icon: IcoPlay   },
  { id: "globe",   label: "Globe",   Icon: IcoGlobe  },
];

function GloblyNav(props) {
  var active   = props.active;
  var onChange = props.onChange;

  return (
    <nav className="gs-nav">
      {NAV_TABS.map(function(tab) {
        var on = active === tab.id;
        return (
          <button
            key={tab.id}
            className={"gs-nav-tab" + (on ? " gs-nav-tab--on" : "")}
            onClick={function(){ onChange(tab.id); }}
            aria-label={tab.label}
          >
            <tab.Icon />
            <span className="gs-nav-label">{tab.label}</span>
          </button>
        );
      })}
    </nav>
  );
}

// ─── Main app shell ───────────────────────────────────────────────────────────


// ─── Global TTS audio singleton — only one clip plays at a time ───────────────
// Module-level singletons: shared across all MiraVoiceControl instances.
var _G_ttsAudio      = null;
var _G_ttsBlobUrl    = null;
var _G_ttsBlobCached = false;  // true = URL lives in cache; do NOT revoke on stop
var _G_ttsIdle       = null;   // setter that resets the active component to 'idle'
var _G_ttsCache      = {};     // hash → blobUrl (session memory, never persisted to localStorage)
var _G_ttsPending    = {};     // hash → Promise — deduplicates in-flight fetches
var _G_ttsAbortCtl   = null;  // AbortController for the current play() network fetch

// Normalize text before hashing/sending: collapse whitespace so identical answers
// always produce the same cache key regardless of minor formatting differences.
function _ttsNormalize(raw) {
  return (raw || '').trim().replace(/\s+/g, ' ').slice(0, 4000);
}

// FNV-1a hash of first 800 chars.  v5 busts all pre-voice-ID cached blobs.
function _ttsHash(text) {
  var h = 0x811c9dc5;
  var len = Math.min(text.length, 800);
  for (var i = 0; i < len; i++) {
    h ^= text.charCodeAt(i);
    h = (h * 0x01000193) >>> 0;
  }
  return 'v5:' + h.toString(36);
}

// Silently prefetch audio into the session cache.
// Skips if already cached OR a request is already in-flight for the same text.
function _ttsPrefetch(text) {
  if (!text || !text.trim()) return;
  var norm = _ttsNormalize(text);
  if (!norm) return;
  var hash = _ttsHash(norm);
  if (_G_ttsCache[hash] || _G_ttsPending[hash]) return;
  var p = fetch('/api/tts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text: norm }),
  })
  .then(function(r) {
    if (!r.ok || (r.headers.get('content-type') || '').indexOf('audio') === -1) return null;
    return r.blob();
  })
  .then(function(blob) {
    delete _G_ttsPending[hash];
    if (blob && !_G_ttsCache[hash]) _G_ttsCache[hash] = URL.createObjectURL(blob);
  })
  .catch(function() { delete _G_ttsPending[hash]; });
  _G_ttsPending[hash] = p;
}

function _globalTtsStop() {
  if (_G_ttsAbortCtl) { try { _G_ttsAbortCtl.abort(); } catch(e){} _G_ttsAbortCtl = null; }
  if (_G_ttsAudio)    { try { _G_ttsAudio.pause();  } catch(e){} _G_ttsAudio = null; }
  if (_G_ttsBlobUrl && !_G_ttsBlobCached) {
    try { URL.revokeObjectURL(_G_ttsBlobUrl); } catch(e) {}
  }
  _G_ttsBlobUrl    = null;
  _G_ttsBlobCached = false;
  if (_G_ttsIdle) { try { _G_ttsIdle(); } catch(e){} _G_ttsIdle = null; }
}

// ─── useVoice hook ────────────────────────────────────────────────────────────
// States: idle | loading | playing | paused | error | not_configured
function useVoice() {
  var _vs = React.useState('idle'); var vState = _vs[0], setVState = _vs[1];
  var _activeHash = React.useRef(null);

  React.useEffect(function() {
    return function() {
      if (_G_ttsIdle === setVState) _globalTtsStop();
    };
  }, []);

  function play(text) {
    if (!text || !text.trim()) return;
    var norm = _ttsNormalize(text);
    var hash = _ttsHash(norm);

    // Toggle pause/resume only when the same text is active
    if (_activeHash.current === hash && _G_ttsAudio) {
      if (vState === 'playing') { _G_ttsAudio.pause(); setVState('paused'); return; }
      if (vState === 'paused')  { _G_ttsAudio.play();  setVState('playing'); return; }
    }
    if (vState === 'loading') return;

    _globalTtsStop();
    _activeHash.current = hash;
    setVState('loading');

    // Pre-create Audio within the user gesture so Safari/iOS allow .play() later.
    var audio = new Audio();
    _G_ttsAudio = audio;
    _G_ttsIdle  = function() { setVState('idle'); };
    audio.onended = function() {
      setVState('idle');
      _G_ttsAudio = null; _G_ttsBlobUrl = null; _G_ttsBlobCached = false; _G_ttsIdle = null;
    };
    audio.onerror = function(e) {
      console.error('[Mira TTS] Audio element error:', e && e.type, audio.error && audio.error.message);
      if (_activeHash.current !== hash) return;
      setVState('error');
      _G_ttsAudio = null; _G_ttsBlobUrl = null; _G_ttsBlobCached = false; _G_ttsIdle = null;
      delete _G_ttsCache[hash];
    };

    function _playFromUrl(url) {
      if (_activeHash.current !== hash) return Promise.resolve();
      _G_ttsBlobUrl    = url;
      _G_ttsBlobCached = true;
      audio.src = url;
      return audio.play().then(function() { setVState('playing'); });
    }

    function _onFail(err) {
      if (err && err.name === 'AbortError') {
        // Stale request aborted by tab switch — not an error, just clean up silently.
        return;
      }
      var code = err && err.message;
      var notCfg = code === 'tts_not_configured' || code === 'cartesia_voice_not_configured';
      if (notCfg) {
        console.warn('[Mira TTS] Not configured — set CARTESIA_API_KEY and CARTESIA_VOICE_ID.');
      } else {
        console.error('[Mira TTS] playback failed:', err && err.name, err && err.message);
      }
      if (_activeHash.current === hash) setVState(notCfg ? 'not_configured' : 'error');
      _G_ttsAudio = null; _G_ttsBlobUrl = null; _G_ttsBlobCached = false; _G_ttsIdle = null;
    }

    // If cached → play instantly (no loading dots shown at all in practice).
    var cachedUrl = _G_ttsCache[hash];
    if (cachedUrl) { _playFromUrl(cachedUrl).catch(_onFail); return; }

    // If a prefetch is already in-flight, wait for its promise instead of starting a new fetch.
    // This eliminates duplicate network requests when the user clicks before idle prefetch completes.
    if (_G_ttsPending[hash]) {
      _G_ttsPending[hash].then(function() {
        var url = _G_ttsCache[hash];
        if (!url) { if (_activeHash.current === hash) setVState('error'); return; }
        return _playFromUrl(url);
      }).catch(_onFail);
      return;
    }

    // Not cached and no prefetch in-flight — start a fresh fetch with AbortController.
    var ctl = new AbortController();
    _G_ttsAbortCtl = ctl;
    fetch('/api/tts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: norm }),
      signal: ctl.signal,
    })
    .then(function(r) {
      if (!r.ok) return r.json().then(function(j){ throw new Error(j.error || 'http_' + r.status); });
      var ct = r.headers.get('content-type') || '';
      if (ct.indexOf('audio') === -1) return r.json().then(function(j){ throw new Error(j.error || 'no_audio'); });
      return r.blob();
    })
    .then(function(blob) {
      _G_ttsAbortCtl = null;
      if (_activeHash.current !== hash) return;  // user switched tab before download finished
      var url = URL.createObjectURL(blob);
      _G_ttsCache[hash] = url;
      return _playFromUrl(url);
    })
    .catch(function(err) {
      _G_ttsAbortCtl = null;
      _onFail(err);
    });
  }

  return { state: vState, play: play };
}

// ─── My Briefing page ─────────────────────────────────────────────────────────
function BriefingScreen(props) {
  var articles    = props.articles    || [];
  var setup       = props.setup       || {};
  var onBack      = props.onBack;
  var onOpenStory = props.onOpenStory;
  var seen    = loadSeen();

  var briefItems = React.useMemo(function() {
    return buildBriefingItems(articles, setup, seen);
  }, [articles.length]);

  var whatChanged = briefItems.slice(0, 4);
  var whoAffected = briefItems.filter(function(a) {
    var t = ((a.summary || '') + (a.title || '')).toLowerCase();
    return /people|million|families|communit|civilian|population|worker|farmer|patient/.test(t);
  }).slice(0, 3);
  var howConnects = briefItems.filter(function(a) {
    return (a._clusterSize || 0) >= 2 || !!a._clusterSynthesis;
  }).slice(0, 3);
  var sourcesNext = briefItems.filter(function(a) {
    return a.storyModes && (a.storyModes.facts || a.storyModes.matters);
  }).slice(0, 3);
  var storiesToday = briefItems.slice(0, 6);

  var topicList  = (setup.topics  || []).slice(0, 5);
  var regionList = (setup.regions || []).slice(0, 3);
  var interestLabel = topicList.concat(regionList).join(' · ');
  var dateLabel  = new Date().toLocaleDateString('en-GB', { weekday:'long', day:'numeric', month:'long' });
  var sourceCount = Math.min(briefItems.length, 20);

  var worldCtx = lsGet(LS.WORLD, null);
  var _worldKey = worldCtx ? JSON.stringify([worldCtx.level, worldCtx.watchPlaces, worldCtx.effects]) : null;
  var personalItems = React.useMemo(function() {
    if (!worldCtx || worldCtx.level === 'light') return [];
    return briefItems.filter(function(a) {
      return buildPersonalRelevance(a, worldCtx) !== null;
    }).slice(0, 4);
  }, [briefItems.length, _worldKey]);

  var briefingScript = React.useMemo(function() {
    var lines = ['Globally. Your briefing for ' + dateLabel + '.', ''];
    function addSection(title, items) {
      if (!items || !items.length) return;
      lines.push(title + '.');
      items.slice(0, 4).forEach(function(a) {
        var headline = (a._synthesis && a._synthesis.headline) || a.title || '';
        var summary  = (a._synthesis && a._synthesis.summary)  || a.summary || '';
        if (headline) lines.push(headline + (summary ? ' ' + summary.slice(0, 120) : '') + '.');
      });
      lines.push('');
    }
    addSection('Today\'s most important developments', whatChanged);
    addSection('Spotlight on today\'s top story', whoAffected);
    addSection('Connected stories to watch', howConnects);
    addSection('Stories worth reading today', storiesToday.slice(0, 4));
    return lines.join('\n');
  }, [briefItems.length]);

  function MiraBriefSection(sprops) {
    var num      = sprops.num;
    var title    = sprops.title;
    var items    = sprops.items || [];
    var emptyMsg = sprops.emptyMsg || "Mira couldn't find enough stories for this section today.";
    var intro    = sprops.intro;
    return (
      <div className="mbf-section">
        <div className="mbf-section-hd">
          <span className="mbf-section-num">{num}</span>
          <h2 className="mbf-section-title">{title}</h2>
        </div>
        {intro && <p className="mbf-section-intro">{intro}</p>}
        {items.length > 0 ? (
          <div className="mbf-story-list">
            {items.map(function(a, i) {
              return (
                <button key={i} className="mbf-story-btn"
                  onClick={function(){ if (onOpenStory) onOpenStory(a); }}>
                  <MiraSourceCard article={a} compact={true} />
                </button>
              );
            })}
          </div>
        ) : (
          <p className="mbf-empty">{emptyMsg}</p>
        )}
      </div>
    );
  }

  return (
    <div className="mbf-page">
      <header className="mbf-topbar">
        <button className="mbf-back"
          onClick={function(){ if (onBack) onBack('today'); }}>
          <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
               strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
            <polyline points="10,2 4,8 10,14"/>
          </svg>
          <span>Today</span>
        </button>
        <span className="mbf-topbar-brand">Globally</span>
        <div style={{width:70}} />
      </header>

      <div className="mbf-scroll">

        {/* Hero */}
        <div className="mbf-hero">
          <div className="mbf-hero-mark">
            <MiraBlockMark size={52} />
          </div>
          <div className="mbf-hero-text">
            <p className="mbf-hero-eyebrow">Mira's briefing</p>
            <h1 className="mbf-hero-title">Your 5-minute global briefing</h1>
            <p className="mbf-hero-subtitle">
              Mira uses your interests, reading habits, and personal context to explain which global stories matter — and why they may matter to you.
            </p>
          </div>
        </div>

        {/* Meta bar */}
        <div className="mbf-meta-bar">
          <span className="mbf-meta-date">{dateLabel}</span>
          {interestLabel && (
            <React.Fragment>
              <span className="mbf-meta-sep">·</span>
              <span className="mbf-meta-topics">{interestLabel}</span>
            </React.Fragment>
          )}
          {sourceCount > 0 && (
            <React.Fragment>
              <span className="mbf-meta-sep">·</span>
              <span className="mbf-meta-sources">{sourceCount} sources</span>
            </React.Fragment>
          )}
        </div>

        {/* Mira briefing voice */}
        <div className="mbf-voice-row">
          <MiraVoiceControl text={briefingScript} label="Listen to Mira briefing" className="mira-vc--briefing" />
          <span className="mbf-voice-label">Mira voice</span>
        </div>

        {/* Mira intro line */}
        <p className="mbf-mira-intro">
          Mira has pulled together the stories that best match what you follow.
        </p>

        <div className="mbf-sections">
          <MiraBriefSection
            num="01"
            title="What changed"
            items={whatChanged}
            intro={buildBriefingSectionSynth(whatChanged, 'what_changed')}
            emptyMsg="Mira couldn't find recent stories matching your interests yet. Read more stories to improve your briefing." />

          <MiraBriefSection
            num="02"
            title="Who is affected"
            items={whoAffected}
            intro={buildBriefingSectionSynth(whoAffected, 'who_affected')}
            emptyMsg="Mira couldn't find enough affected-population stories for your interests today." />

          <MiraBriefSection
            num="03"
            title="How this connects"
            items={howConnects}
            intro={buildBriefingSectionSynth(howConnects, 'how_connects')}
            emptyMsg="Mira couldn't find enough multi-source stories to show connections today." />

          <MiraBriefSection
            num="04"
            title="What sources report about what to watch"
            items={sourcesNext}
            intro={buildBriefingSectionSynth(sourcesNext, 'sources_next')}
            emptyMsg="Mira couldn't find enough stories with sourced next-steps reporting today." />

          <MiraBriefSection
            num="05"
            title="Stories worth reading today"
            items={storiesToday}
            emptyMsg="No stories ready yet — check back later today." />

          {personalItems.length > 0 && (
            <div className="mbf-section mbf-personal-section">
              <div className="mbf-section-hd">
                <span className="mbf-section-num mbf-section-num--personal">✦</span>
                <h2 className="mbf-section-title">Why this may matter to you</h2>
              </div>
              <p className="mbf-section-intro">
                Mira has identified stories that could connect to the world context you shared. These are possible pathways — not confirmed outcomes.
              </p>
              <div className="mbf-story-list">
                {personalItems.map(function(a, i) {
                  var rel = buildPersonalRelevance(a, worldCtx);
                  return (
                    <div key={i} className="mbf-personal-item">
                      <button className="mbf-story-btn mbf-personal-story-btn"
                        onClick={function(){ if (onOpenStory) onOpenStory(a); }}>
                        <MiraSourceCard article={a} compact={true} />
                      </button>
                      {rel && (
                        <div className="mbf-personal-why">
                          <span className="mbf-personal-why-label">Why this may matter</span>
                          <p className="mbf-personal-why-text">{rel}</p>
                        </div>
                      )}
                    </div>
                  );
                })}
              </div>
            </div>
          )}
        </div>

        <footer className="mbf-footer">
          <MiraBlockMark size={20} />
          <p className="mbf-footer-text">
            Every item is a real sourced story from Globally's live corpus. Mira does not generate facts.
          </p>
        </footer>

        <div style={{height:80}} />
      </div>
    </div>
  );
}


function MainApp(props) {
  var articles      = props.articles;
  var setup         = props.setup;
  var sourceLabels  = props.sourceLabels;
  var orgAppeals    = props.orgAppeals;
  var onReset       = props.onReset;
  var onSignOut     = props.onSignOut;
  var lastUpdated   = props.lastUpdated;
  var dataMeta      = props.dataMeta || {};
  var eventClusters = props.eventClusters || [];

  var _tab = React.useState("today");
  var tab = _tab[0], setTab = _tab[1];

  var _topicFilter = React.useState(null);
  var topicFilter = _topicFilter[0], setTopicFilter = _topicFilter[1];

  // Story page routing
  var _routeStory = React.useState(null);
  var routeStory = _routeStory[0], setRouteStory = _routeStory[1];

  // Event cluster page routing (Part 14)
  var _routeCluster = React.useState(null);
  var routeCluster = _routeCluster[0], setRouteCluster = _routeCluster[1];

  // streak detail overlay
  var _streakOpen = React.useState(false);
  var streakOpen = _streakOpen[0], setStreakOpen = _streakOpen[1];

  // hamburger menu open/close
  var _menuOpen = React.useState(false);
  var menuOpen = _menuOpen[0], setMenuOpen = _menuOpen[1];

  // track whether user is inside a game (to hide topbar while playing)
  var _activeGame = React.useState(null);
  var activeGame = _activeGame[0], setActiveGame = _activeGame[1];

  var streak = React.useMemo(loadStreak, []);

  // Desktop shell — add body.desktop on wide screens (all tabs) + tab-specific class
  React.useEffect(function() {
    var TAB_CLASSES = ['tab-today','tab-feed','tab-play','tab-globe','tab-profile','tab-topic-feed','tab-charity','tab-volunteering','tab-briefing','tab-mira-for-firms'];
    function syncBodyClasses() {
      var isDesktop = window.innerWidth >= 1024;
      document.body.classList.toggle('desktop', isDesktop);
      TAB_CLASSES.forEach(function(c){ document.body.classList.remove(c); });
      var activeTab = (tab === 'topic-feed') ? 'tab-topic-feed' : 'tab-' + tab;
      document.body.classList.add(activeTab);
    }
    syncBodyClasses();
    window.addEventListener('resize', syncBodyClasses);
    return function() {
      window.removeEventListener('resize', syncBodyClasses);
      document.body.classList.remove('desktop');
      TAB_CLASSES.forEach(function(c){ document.body.classList.remove(c); });
    };
  }, [tab]);

  // Unsubscribe confirmation state
  var _unsubConfirmed = React.useState(false);
  var unsubConfirmed = _unsubConfirmed[0], setUnsubConfirmed = _unsubConfirmed[1];

  // Hash-based story routing + event cluster routing + unsubscribe route
  React.useEffect(function() {
    function onHash() {
      var hash = window.location.hash;
      if (/^#\/unsubscribe/.test(hash)) {
        setUnsubConfirmed(true);
        setRouteStory(null);
        setRouteCluster(null);
        return;
      }
      // #/event/slug — cluster detail page (Part 14)
      var em = hash.match(/^#\/event\/(.+)$/);
      if (em) {
        var eslug = decodeURIComponent(em[1]);
        var foundCluster = eventClusters.find(function(c){ return c.slug === eslug; }) || null;
        setRouteCluster(foundCluster);
        setRouteStory(null);
        setUnsubConfirmed(false);
        return;
      }
      // #/charity or #/action — navigate to the Charity tab
      if (hash === '#/charity' || hash === '#/action') {
        setTab('charity');
        setRouteStory(null);
        setRouteCluster(null);
        setUnsubConfirmed(false);
        return;
      }
      // #/volunteering — navigate to Volunteering tab
      if (hash === '#/volunteering') {
        setTab('volunteering');
        setRouteStory(null);
        setRouteCluster(null);
        setUnsubConfirmed(false);
        return;
      }
      // #/business — navigate to Mira Business tab
      if (hash === '#/business') {
        setTab('mira-for-firms');
        setRouteStory(null);
        setRouteCluster(null);
        setUnsubConfirmed(false);
        return;
      }
      // #/ or empty — go to today
      if (hash === '#/' || hash === '') {
        setRouteStory(null);
        setRouteCluster(null);
        return;
      }
      var m = hash.match(/^#\/story\/(.+)$/);
      if (m) {
        var slug = decodeURIComponent(m[1]);
        var found = articles.find(function(a){ return articleSlug(a) === slug; }) || null;
        setRouteStory(found);
        setRouteCluster(null);
        setUnsubConfirmed(false);
      } else {
        // Unknown hash — clear story/cluster routes, stay on current tab
        setRouteStory(null);
        setRouteCluster(null);
      }
    }
    window.addEventListener('hashchange', onHash);
    onHash();
    return function() { window.removeEventListener('hashchange', onHash); };
  }, [articles.length, eventClusters.length]);

  function openStoryRoute(article) {
    setMenuOpen(false);
    window.location.hash = '#/story/' + encodeURIComponent(articleSlug(article));
    setRouteStory(article);
  }

  function closeStoryRoute() {
    if (routeStory) recordSignal(routeStory, 'finish');
    window.location.hash = '';
    setRouteStory(null);
  }

  function openClusterRoute(cluster) {
    setMenuOpen(false);
    window.location.hash = '#/event/' + encodeURIComponent(cluster.slug);
    setRouteCluster(cluster);
  }

  function closeClusterRoute() {
    window.location.hash = '';
    setRouteCluster(null);
  }

  function handleTabChange(t) {
    if (t !== "topic-feed") setTopicFilter(null);
    if (t !== "play") setActiveGame(null);
    setTab(t);
  }

  function handleTopicNav(topic) {
    setTopicFilter(topic);
    setTab("topic-feed");
  }

  var navActive = (tab === "topic-feed") ? "today" : tab;

  // Unsubscribe confirmation screen (rare path — shows after email link click)
  if (unsubConfirmed) {
    return (
      <div className="gl-app gl-unsub-screen">
        <div className="gl-unsub-card">
          <span className="gl-unsub-wordmark">Globally</span>
          <h2 className="gl-unsub-title">You've been unsubscribed.</h2>
          <p className="gl-unsub-body">
            You'll no longer receive daily briefing emails.
            You can re-enable them any time from your profile settings.
          </p>
          <button className="gl-unsub-cta"
            onClick={function(){
              window.location.hash = '';
              setUnsubConfirmed(false);
            }}>
            Return to Globally
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="gl-app">
      {/* Menu panel — position:fixed overlay, rendered at app level */}
      {!activeGame && !routeStory && menuOpen && (
        <GloblyMenuPanel
          active={navActive}
          activeTopic={tab === "topic-feed" ? topicFilter : null}
          onNav={handleTabChange}
          onTopicNav={handleTopicNav}
          onClose={function(){ setMenuOpen(false); }}
          onSignOut={onSignOut}
        />
      )}

      {routeCluster ? (
        <ClusterDetailScreen cluster={routeCluster} articles={articles} onClose={closeClusterRoute} onOpenStory={openStoryRoute} />
      ) : routeStory ? (
        <BriefDetailScreen article={routeStory} articles={articles} sourceLabels={sourceLabels} orgAppeals={orgAppeals} setup={setup} onClose={closeStoryRoute} onOpenStory={openStoryRoute} />
      ) : (
        <main className="gl-main" id="gl-main-scroll">
          {tab === "today"        && <TodayScreen  articles={articles} setup={setup} streak={streak} lastUpdated={lastUpdated} dataMeta={dataMeta} onNav={handleTabChange} onTopicNav={handleTopicNav} onOpenStory={openStoryRoute} onOpenCluster={openClusterRoute} eventClusters={eventClusters} onMenuOpen={function(){ setMenuOpen(true); }} />}
          {tab === "topic-feed"   && <TopicEditionScreen articles={articles} topic={topicFilter} onBack={function(){ handleTabChange("today"); }} onOpenStory={openStoryRoute} />}
          {tab === "feed"         && <FeedScreen   articles={articles} setup={setup} onOpenStory={openStoryRoute} />}
          {tab === "briefing"     && (GL_FEATURES.personalBriefing ? <BriefingScreen articles={articles} setup={setup} onBack={handleTabChange} onOpenStory={openStoryRoute} /> : null)}
          {tab === "play"         && (GL_FEATURES.games ? <PlayScreen onGameChange={setActiveGame} /> : null)}
          {tab === "globe"        && <GlobeScreen articles={articles} onMenuOpen={function(){ setMenuOpen(true); }} />}
          {tab === "profile"      && <ProfileScreen streak={streak} setup={setup} onReset={onReset} onSignOut={onSignOut} onBack={handleTabChange} />}
          {tab === "mira-for-firms"    && <MiraForFirmsScreen onBack={handleTabChange} onMenuOpen={function(){ setMenuOpen(true); }} />}
          {tab === "charity"      && (GL_FEATURES.charityImpactFinder ? <CharityScreen articles={articles} onBack={handleTabChange} onMenuOpen={function(){ setMenuOpen(true); }} onOpenStory={openStoryRoute} /> : null)}
          {tab === "volunteering" && <VolunteeringScreen onBack={handleTabChange} onMenuOpen={function(){ setMenuOpen(true); }} />}
          {tab === 'about' && <AboutScreen articles={articles} setup={setup} onNav={handleTabChange} onOpenStory={openStoryRoute} />}
          {FOOTER_TAB_IDS.indexOf(tab) !== -1 && tab !== 'about' && <GloblySimplePage pageId={tab} onBack={handleTabChange} />}
          {/* Fallback: unknown tab → show Today so the page is never blank */}
          {tab !== 'today' && tab !== 'topic-feed' && tab !== 'feed' && tab !== 'briefing' &&
           tab !== 'play' && tab !== 'globe' && tab !== 'profile' && tab !== 'mira-for-firms' &&
           tab !== 'charity' && tab !== 'volunteering' && tab !== 'about' &&
           FOOTER_TAB_IDS.indexOf(tab) === -1 && (
            <TodayScreen articles={articles} setup={setup} streak={streak} lastUpdated={lastUpdated}
              dataMeta={dataMeta} onNav={handleTabChange} onTopicNav={handleTopicNav}
              onOpenStory={openStoryRoute} onOpenCluster={openClusterRoute}
              eventClusters={eventClusters} onMenuOpen={function(){ setMenuOpen(true); }} />
          )}
        </main>
      )}

      {!activeGame && !routeStory && !routeCluster && <GloblyMobileNav active={navActive} onChange={handleTabChange} />}

      {streakOpen && (
        <div className="gs-streak-modal-backdrop" onClick={function(){ setStreakOpen(false); }}>
          <div className="gs-streak-modal" onClick={function(e){ e.stopPropagation(); }}>
            <div className="gs-streak-modal-flame">
              {Flame ? React.createElement(Flame, { size: 40, strokeWidth: 1.8 }) : React.createElement(IcoFlame)}
            </div>
            <div className="gs-streak-modal-num">{streak.current}</div>
            <div className="gs-streak-modal-label">day streak</div>
            <div className="gs-streak-modal-best">Best: {streak.longest} days</div>
            <div className="gs-streak-modal-nudge">Read a brief today to keep it going.</div>
            <button className="gs-streak-modal-close" onClick={function(){ setStreakOpen(false); }}>
              Done
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════════════════
//  EVENT CLUSTERING ENGINE  (Parts 1-29)
//  Merges related stories client-side into editorial event packages.
//  All functions are global / vanilla Babel — no imports, no build step.
// ═══════════════════════════════════════════════════════════════════════════════

// ── Country normalisation ──────────────────────────────────────────────────────
var _CMAP = {
  'us': 'United States', 'usa': 'United States', 'united states of america': 'United States',
  'uk': 'United Kingdom', 'britain': 'United Kingdom', 'great britain': 'United Kingdom',
  'drc': 'DR Congo', 'democratic republic of congo': 'DR Congo', 'democratic republic of the congo': 'DR Congo',
  'palestine': 'Palestine', 'west bank': 'Palestine', 'palestinian territories': 'Palestine',
  'myanmar': 'Myanmar', 'burma': 'Myanmar',
  'srilanka': 'Sri Lanka', 'ceylon': 'Sri Lanka',
  'uae': 'UAE', 'united arab emirates': 'UAE',
  'dprk': 'North Korea', 'russian federation': 'Russia',
};
function _cNorm(c) {
  if (!c) return c;
  var k = c.trim().toLowerCase().replace(/[^a-z ]/g, '');
  return _CMAP[k] || c.trim();
}

// ── Part 3: Event type detection ───────────────────────────────────────────────
var _ET = {
  earthquake:             ['earthquake','quake','seismic','aftershock','magnitude','epicenter','rubble','search and rescue','trapped under'],
  flood:                  ['flood','flooding','flash flood','deluge','submerged','inundation','waterlog'],
  drought:                ['drought','water scarcity','dry spell','water shortage'],
  storm:                  ['hurricane','typhoon','cyclone','tornado','tropical storm'],
  conflict_escalation:    ['offensive','shelling','airstrike','air strike','bombing raid','drone strike','troops advance','frontline','military operation','clashes kill'],
  ceasefire:              ['ceasefire','cease-fire','truce agreement','halt in fighting'],
  peace_talks:            ['peace talks','peace negotiation','peace deal','peace agreement','mediation'],
  aid_pledge:             ['aid pledge','humanitarian relief','relief aid','million in aid','billion in aid','aid package'],
  debt_deal:              ['debt restructuring','debt relief','debt deal','creditor talks','debt default'],
  imf_programme:          ['imf programme','imf program','imf deal','imf tranche','imf loan','standby arrangement'],
  election:               ['election results','election day','polling day','ballot count','runoff election','election held'],
  protest:                ['protest march','demonstration','protesters','demonstrators','civil unrest','street protests','mass rally'],
  policy_change:          ['new law','legislation passed','policy announced','executive order','decree','parliament approved'],
  disease_outbreak:       ['outbreak','epidemic','disease spread','cases surged','health emergency','infection rate'],
  food_security:          ['famine','food insecurity','food crisis','malnutrition','ipc phase','wfp warning','hunger crisis'],
  migration_displacement: ['displaced people','displacement crisis','refugee camp','asylum seekers','mass exodus','fleeing violence'],
  trade_deal:             ['trade agreement','trade deal','tariff deal','trade pact'],
  infrastructure_project: ['infrastructure project','construction begins','dam opened','bridge opened'],
  energy_project:         ['energy project','oil field','gas field','power plant opened','pipeline','solar farm'],
  climate_policy:         ['climate agreement','carbon target','net zero pledge','paris agreement','cop decision'],
  governance_reform:      ['governance reform','anti-corruption law','transparency reform','institutional reform'],
  corruption_case:        ['corruption charge','bribery case','embezzlement','graft','indicted for corruption','arrested for bribery'],
  human_rights:           ['human rights violation','rights abuse','arbitrary detention','torture report','persecution'],
};
function detectEventType(story) {
  var text = ((story.title || '') + ' ' + (story.summary || '')).toLowerCase();
  var best = 'general'; var bestN = 0;
  Object.keys(_ET).forEach(function(et) {
    var kws = _ET[et]; var n = 0;
    kws.forEach(function(kw) { if (text.indexOf(kw) !== -1) n += kw.split(' ').length; });
    if (n > bestN) { bestN = n; best = et; }
  });
  return bestN >= 2 ? best : 'general';
}

// ── Part 2: Normalise a story for clustering ───────────────────────────────────
var _STOP = new Set(['that','this','with','from','have','been','were','they','said','will','than','more','also','after','when','which','their','what','into','over','then','here','some','would','could','should','about','just','those','these','while','even','between','through','before','under','other','there','only','where','within','still','both','being','because','since','around','during','against','along','already','another','although','among','across','above','below','each','every','much','such','same','says','amid','near','have','has','its','him','her','his','them','who','whose','down','upon','very']);
function normalizeStoryForClustering(story) {
  var title = (story.title || '').trim()
    .replace(/^(LIVE|BREAKING|UPDATE)[:\-\s]+/i, '')
    .replace(/\s*[|–\-]\s*[A-Z][a-zA-Z\s]{2,30}$/, '')
    .replace(/\s*\([^)]{2,20}\)\s*$/, '')
    .trim();
  var nTitle = title.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
  var kws = nTitle.split(' ').filter(function(w) { return w.length >= 4 && !_STOP.has(w); });
  return {
    url:         story.url,
    title:       title,
    nTitle:      nTitle,
    country:     _cNorm(story.country || null),
    topic:       story._topic || null,
    eventType:   detectEventType(story),
    publishedAt: story.published || null,
    significance: story.significance || 1,
    imageUrl:    story.image_url || null,
    summary:     story.summary || '',
    keywords:    kws,
    sourceName:  story.source_name || '',
    _raw:        story,
  };
}

// ── Part 4: Pairwise similarity score ─────────────────────────────────────────
function _simScore(na, nb) {
  var sc = 0;
  if (na.country && nb.country && na.country === nb.country) sc += 25;
  else if (na.country && nb.country) sc -= 30;
  var etA = na.eventType, etB = nb.eventType;
  if (etA !== 'general' && etB !== 'general' && etA === etB) sc += 30;
  else if (etA !== 'general' && etB !== 'general') sc -= 45;
  var shared = na.keywords.filter(function(w) { return nb.keywords.indexOf(w) !== -1; });
  if (shared.length >= 4) sc += 20;
  else if (shared.length >= 2) sc += 10;
  else if (shared.length === 1) sc += 4;
  if (na.publishedAt && nb.publishedAt) {
    var diffH = Math.abs(new Date(na.publishedAt) - new Date(nb.publishedAt)) / 3600000;
    if (diffH <= 24) sc += 10;
    else if (diffH <= 72) sc += 5;
    else if (diffH > 168) sc -= 10;
  }
  return sc;
}

// ── Parts 4+5: Group stories into raw event clusters ──────────────────────────
function clusterStoriesIntoEvents(articles) {
  var now = Date.now();
  var MAX_AGE = 7 * 86400000;
  var candidates = articles.filter(function(a) {
    if (a._clusterHidden) return false;
    return (now - new Date(a.published || 0).getTime()) < MAX_AGE;
  });
  var normed = candidates.map(normalizeStoryForClustering);
  normed.sort(function(a, b) {
    var sd = (b.significance - a.significance);
    return sd !== 0 ? sd : (new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0));
  });
  var inCluster = {};
  var clusters = [];
  for (var i = 0; i < normed.length; i++) {
    var seed = normed[i];
    if (inCluster[seed.url]) continue;
    var members = [seed];
    inCluster[seed.url] = true;
    for (var j = i + 1; j < normed.length; j++) {
      var cand = normed[j];
      if (inCluster[cand.url]) continue;
      var sim = _simScore(seed, cand);
      var thresh = (seed.country && seed.country === cand.country && seed.eventType !== 'general' && seed.eventType === cand.eventType) ? 40 : 55;
      if (sim >= thresh) { members.push(cand); inCluster[cand.url] = true; }
    }
    if (members.length < 2 && (seed.significance || 1) < 5) continue;
    var allC = [];
    members.forEach(function(m) { if (m.country && allC.indexOf(m.country) === -1) allC.push(m.country); });
    var bestEt = 'general';
    members.forEach(function(m) { if (m.eventType !== 'general') bestEt = m.eventType; });
    var lastPubMs  = Math.max.apply(null, members.map(function(m){ return new Date(m.publishedAt||0).getTime(); }));
    var firstPubMs = Math.min.apply(null, members.map(function(m){ return new Date(m.publishedAt||0).getTime(); }));
    var slg = ((allC[0]||'global') + '-' + (bestEt !== 'general' ? bestEt.replace(/_/g,'-') : 'event') + '-' + lastPubMs.toString(36))
      .toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
    clusters.push({
      id:            'event_' + slg,
      slug:          slg,
      primaryTopic:  seed.topic || 'News',
      primaryCountry: allC[0] || null,
      countries:     allC,
      eventType:     bestEt,
      status:        'active',
      sourceStoryIds: members.map(function(m){ return m.url; }),
      sourceCount:   members.length,
      leadStoryId:   null,
      leadImageUrl:  null,
      imageConfidence: 'fallback',
      importanceScore: 0,
      firstSeenAt:   new Date(firstPubMs).toISOString(),
      lastUpdatedAt: new Date(lastPubMs).toISOString(),
      publishedAt:   new Date(lastPubMs).toISOString(),
      eventTitle:    '',
      miraThirtySecondSummary: '',
      miraLongArticle: '',
      miraSections:  [],
      keyFacts:      [],
      clusterConfidence: members.length >= 3 ? 'high' : members.length >= 2 ? 'medium' : 'low',
      _members:      members,
      _arts:         members.map(function(m){ return m._raw; }),
    });
  }
  return clusters;
}

// ── Part 6: Choose lead story ──────────────────────────────────────────────────
function chooseClusterLeadStory(cluster) {
  var arts = cluster._arts || [];
  if (!arts.length) return null;
  function lsc(a) {
    var s = (a.significance || 1) * 10;
    if (a.image_url && !isDocumentImage(a.image_url)) s += 15;
    s += Math.min((a.summary || '').length / 50, 10);
    var pub = new Date(a.published || 0).getTime();
    var maxPub = Math.max.apply(null, arts.map(function(x){ return new Date(x.published||0).getTime(); }));
    if (pub === maxPub) s += 8;
    if (/opinion|commentary|analysis|editorial|sponsored|advertis/.test((a.title||'').toLowerCase())) s -= 25;
    if (cluster.primaryCountry && a.country && a.country !== cluster.primaryCountry) s -= 8;
    return s;
  }
  return arts.slice().sort(function(a, b){ return lsc(b) - lsc(a); })[0];
}

// ── Part 7: Generate editorial title ──────────────────────────────────────────
function generateClusterTitle(cluster) {
  var lead = cluster._lead;
  var baseTitle = lead ? (lead.title || '') : '';
  var cleaned = baseTitle
    .replace(/^(LIVE|BREAKING|UPDATE)[:\-\s]+/i, '')
    .replace(/\s*[|–]\s*[A-Z][a-zA-Z\s]{2,30}$/, '')
    .replace(/\s*\([^)]{2,20}\)\s*$/, '')
    .trim();
  if (cleaned.split(/\s+/).length >= 6 && cleaned.split(/\s+/).length <= 16) return cleaned;
  var cty = cluster.primaryCountry || 'the region';
  var fb = {
    earthquake:             cty + ' earthquake: rescue efforts and aftermath',
    flood:                  cty + ' flooding displaces thousands',
    drought:                cty + ' facing worsening drought and water shortage',
    storm:                  cty + ' braces for storm as warnings issued',
    conflict_escalation:    'Escalating conflict and military operations in ' + cty,
    ceasefire:              'Ceasefire talks and negotiations in ' + cty,
    peace_talks:            'Peace negotiations under way in ' + cty,
    aid_pledge:             'International aid response directed at ' + cty,
    debt_deal:              cty + ' debt restructuring talks and developments',
    imf_programme:          cty + ' navigates IMF programme and fiscal pressure',
    election:               cty + ' election: results, claims, and next steps',
    protest:                'Protests and civil unrest in ' + cty,
    policy_change:          'Major policy changes announced in ' + cty,
    disease_outbreak:       'Disease outbreak and public health emergency in ' + cty,
    food_security:          'Food security crisis and hunger warnings in ' + cty,
    migration_displacement: 'Displacement crisis and refugee movements in ' + cty,
    trade_deal:             'Trade agreement and economic diplomacy involving ' + cty,
    governance_reform:      'Governance reform and institutional change in ' + cty,
    corruption_case:        'High-profile corruption case in ' + cty,
    human_rights:           'Human rights concerns and reported abuses in ' + cty,
  };
  return fb[cluster.eventType] || cleaned || ('Major developments in ' + cty);
}

// ── Part 19: Extract key facts ─────────────────────────────────────────────────
function extractClusterKeyFacts(cluster) {
  var arts = cluster._arts || [];
  var facts = [];
  arts.forEach(function(a) {
    if (a.storyModes && a.storyModes.facts) {
      a.storyModes.facts.slice(0, 2).forEach(function(f) {
        if (f && f.length >= 25 && facts.indexOf(f) === -1) facts.push(f);
      });
    }
  });
  if (facts.length < 2) {
    arts.slice(0, 3).forEach(function(a) {
      var s = (a.summary || '').trim();
      var dot = s.indexOf('. ');
      var first = dot > 0 ? s.slice(0, dot + 1) : s.slice(0, 160);
      if (first.length >= 30 && facts.indexOf(first) === -1) facts.push(first);
    });
  }
  return facts.slice(0, 6);
}

// ── Part 21: Resolve cluster image ────────────────────────────────────────────
function resolveClusterImage(cluster) {
  var lead = cluster._lead;
  if (lead && lead.image_url && !isDocumentImage(lead.image_url)) return { url: lead.image_url, confidence: 'high' };
  var arts = cluster._arts || [];
  for (var _i = 0; _i < arts.length; _i++) {
    var img = arts[_i].image_url;
    if (img && !isDocumentImage(img)) return { url: img, confidence: 'medium' };
  }
  return { url: null, confidence: 'fallback' };
}

// ── Part 11: Score cluster importance ─────────────────────────────────────────
function scoreClusterImportance(cluster) {
  var arts = cluster._arts || [];
  var text = arts.map(function(a){ return ((a.title||'')+' '+(a.summary||'')); }).join(' ').toLowerCase();
  var sc = 0;
  if (/casualt|dead|killed|death toll|missing|injured|wounded/.test(text)) sc += 20;
  if (/displace|refugee|evacuate/.test(text)) sc += 15;
  if (/million|billion/.test(text)) sc += 8;
  if (/un |united nations|who |wfp|unhcr|unicef|world bank|imf |ocha/.test(text)) sc += 12;
  if (/emergency|disaster|catastrophe|critical/.test(text)) sc += 10;
  var hiEts = ['earthquake','flood','conflict_escalation','disease_outbreak','imf_programme','debt_deal'];
  if (hiEts.indexOf(cluster.eventType) !== -1) sc += 15;
  sc += Math.min(cluster.sourceCount * 8, 25);
  var avgSig = arts.reduce(function(s, a){ return s + (a.significance || 1); }, 0) / Math.max(arts.length, 1);
  sc += Math.round(avgSig * 5);
  var ageH = (Date.now() - new Date(cluster.lastUpdatedAt || 0).getTime()) / 3600000;
  if (ageH < 6) sc += 15; else if (ageH < 24) sc += 10; else if (ageH < 48) sc += 5;
  return Math.min(sc, 100);
}

// ── Part 8: 30-second Mira brief ──────────────────────────────────────────────
function generateMiraClusterThirtySecondSummary(cluster) {
  var arts = cluster._arts || [];
  var lead = cluster._lead;
  var parts = [];
  arts.slice(0, 4).forEach(function(a) {
    if (a.storyModes && a.storyModes.summary) {
      var s = a.storyModes.summary.trim();
      if (s.length >= 40 && parts.indexOf(s) === -1) parts.push(s);
    }
  });
  if (parts.length < 2) {
    arts.slice(0, 3).forEach(function(a) {
      var raw = (a.summary || '').trim();
      var dot = raw.search(/\.\s+[A-Z"']/);
      var first = dot > 0 ? raw.slice(0, dot + 1) : raw.slice(0, 200);
      if (first.length >= 40 && parts.indexOf(first) === -1) parts.push(first);
    });
  }
  var allText = arts.map(function(a){ return (a.summary||'')+(a.title||''); }).join(' ');
  var reasoning = buildMiraReasoningLayer(cluster.primaryTopic, cluster.primaryCountry, allText, arts.length);
  if (reasoning && reasoning.length >= 30) parts.push(reasoning);
  if (!parts.length && lead) parts.push((lead.summary || '').slice(0, 200));
  return parts.slice(0, 2).join(' ') || 'No detailed reporting available yet.';
}

// ── Part 10: Should generate long article? ─────────────────────────────────────
function shouldGenerateMiraArticleForCluster(cluster) {
  if (cluster.sourceCount >= 3) return true;
  if (cluster.importanceScore >= 60) return true;
  var hiEts = ['earthquake','flood','conflict_escalation','disease_outbreak','food_security','migration_displacement','airstrike','imf_programme','debt_deal'];
  return hiEts.indexOf(cluster.eventType) !== -1;
}

// ── Part 9: Generate Mira long article ────────────────────────────────────────
function generateMiraClusterArticle(cluster) {
  var arts = cluster._arts || [];
  var allText = arts.map(function(a){ return ((a.title||'')+' '+(a.summary||'')); }).join('\n\n');
  var sections = [];
  // What happened
  var what = [];
  arts.slice(0, 3).forEach(function(a) {
    if (a.storyModes && a.storyModes.summary) what.push(a.storyModes.summary.trim());
    else { var s = (a.summary||'').trim().slice(0, 250); if (s.length >= 40) what.push(s); }
  });
  if (what.length) sections.push({ heading: 'What happened', body: what.slice(0, 2).join('\n\n') });
  // What is happening now
  var happening = [];
  arts.forEach(function(a) {
    if (a.storyModes && a.storyModes.facts) {
      a.storyModes.facts.slice(0, 3).forEach(function(f) { if (happening.indexOf(f) === -1) happening.push(f); });
    }
  });
  if (happening.length) sections.push({ heading: 'What is happening now', body: happening.slice(0, 5).map(function(f){ return '• ' + f; }).join('\n') });
  // Who is affected — only if we have concrete sourced content; no placeholder filler
  var affected = [];
  arts.forEach(function(a) {
    if (a.storyModes && a.storyModes.summary) {
      var s = a.storyModes.summary.toLowerCase();
      if (/people|population|civilian|family|community|victim|survivor|displaced|thousand|million/.test(s)) {
        affected.push(a.storyModes.summary.trim());
      }
    }
    // Also use deeper_summary bullets that mention populations
    if (a.deeper_summary) {
      miraBullets(a.deeper_summary).forEach(function(b) {
        var bl = b.toLowerCase();
        if (/people|thousand|million|population|victim|survivor|displaced|household|children|family/.test(bl)) {
          if (affected.indexOf(b) === -1) affected.push(b);
        }
      });
    }
  });
  if (affected.length) sections.push({ heading: 'Who is affected', body: affected.slice(0, 3).map(function(t){ return '• ' + t; }).join('\n') });
  // Why it matters
  var matters = [];
  var reasoning = buildMiraReasoningLayer(cluster.primaryTopic, cluster.primaryCountry, allText, arts.length);
  if (reasoning && reasoning.length >= 30) matters.push(reasoning);
  arts.forEach(function(a) {
    if (a.storyModes && a.storyModes.matters) {
      var m = a.storyModes.matters.trim();
      if (m.length >= 30 && matters.indexOf(m) === -1) matters.push(m);
    }
  });
  if (matters.length) sections.push({ heading: 'Why it matters', body: matters.slice(0, 2).join('\n\n') });
  // What sources report next
  var next = [];
  arts.forEach(function(a) {
    if (a.storyModes && a.storyModes.facts) {
      a.storyModes.facts.forEach(function(f) {
        if (/expect|upcoming|plan|will|could|response|aid|relief|ongoing|monitor/.test(f.toLowerCase()) && next.indexOf(f) === -1) {
          next.push(f);
        }
      });
    }
  });
  if (next.length) sections.push({ heading: 'What sources report next', body: 'From source reporting — not a prediction:\n\n' + next.slice(0, 4).map(function(f){ return '• ' + f; }).join('\n') });
  cluster.miraSections = sections;
  return sections.map(function(s){ return '**' + s.heading + '**\n\n' + s.body; }).join('\n\n---\n\n') || 'Mira is synthesising this event.';
}

// ── Part 22: Validate cluster quality ─────────────────────────────────────────
function validateClusterQuality(cluster) {
  if (!cluster.sourceCount || cluster.sourceCount < 1) return false;
  if (!cluster.eventTitle || cluster.eventTitle.length < 5) return false;
  if (!cluster._arts || cluster._arts.length < 1) return false;
  return true;
}

// ── Part 20: Flag multi-country conflicts ─────────────────────────────────────
function _markSourceConflicts(cluster) {
  if ((cluster.countries || []).length >= 3) cluster._multiCountry = true;
}

// ── Part 18: Match story to existing cluster ───────────────────────────────────
function matchStoryToExistingCluster(story, existingClusters) {
  var n = normalizeStoryForClustering(story);
  var best = null; var bestSc = 0;
  existingClusters.forEach(function(cl) {
    var maxSim = 0;
    (cl._members || []).forEach(function(m) { var s = _simScore(n, m); if (s > maxSim) maxSim = s; });
    if (maxSim >= 55 && maxSim > bestSc) { bestSc = maxSim; best = cl; }
  });
  return best;
}

// ── Part 26: Source hash ───────────────────────────────────────────────────────
function getClusterSourceHash(cluster) {
  return (cluster.sourceStoryIds || []).slice().sort().join('|');
}

// ── Part 17: Main pipeline ─────────────────────────────────────────────────────
function runStoryClusteringPipeline(articles) {
  if (!articles || !articles.length) return [];
  var raw = clusterStoriesIntoEvents(articles);
  var enriched = raw.map(function(cl) {
    cl._lead = chooseClusterLeadStory(cl);
    cl.eventTitle = generateClusterTitle(cl);
    cl.slug = cl.eventTitle.toLowerCase()
      .replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').slice(0, 64).replace(/-$/, '');
    cl.id = 'event_' + cl.slug;
    var img = resolveClusterImage(cl);
    cl.leadImageUrl  = img.url;
    cl.imageConfidence = img.confidence;
    cl.keyFacts       = extractClusterKeyFacts(cl);
    cl.importanceScore = scoreClusterImportance(cl);
    if (cl._lead) cl.leadStoryId = cl._lead.url;
    cl.miraThirtySecondSummary = generateMiraClusterThirtySecondSummary(cl);
    if (shouldGenerateMiraArticleForCluster(cl)) cl.miraLongArticle = generateMiraClusterArticle(cl);
    _markSourceConflicts(cl);
    cl._arts.forEach(function(a) { a._eventClusterId = cl.id; a._eventClusterSlug = cl.slug; });
    return cl;
  });
  var valid = enriched.filter(function(cl) { return validateClusterQuality(cl) && cl.sourceCount >= 2; });
  valid.sort(function(a, b) { return (b.importanceScore || 0) - (a.importanceScore || 0); });
  console.log('[Globally Clustering]', { input: articles.length, clusters: valid.length,
    top: valid[0] ? { title: valid[0].eventTitle, sources: valid[0].sourceCount, importance: valid[0].importanceScore } : null });
  return valid;
}

// ─── Cluster pre-processing ───────────────────────────────────────────────────
// Called once at data load time. Mutates articles in-place to add cluster fields.
// Primary article per cluster gets: _clusterSize, _clusterSynthesis, _clusterMembers.
// Secondary cluster members get: _clusterHidden = true (so they're skipped in lists).
function preprocessClusters(articles, clusters) {
  if (!clusters || !clusters.length) return;
  var artByUrl = {};
  articles.forEach(function(a) { artByUrl[a.url] = a; });
  clusters.forEach(function(cluster) {
    var ids = cluster.articleIds;
    if (!ids || ids.length < 2) return;
    var members = ids.map(function(url) { return artByUrl[url]; }).filter(Boolean);
    if (members.length < 2) return;
    var primary = members.slice().sort(function(a, b) {
      var sd = (b.significance || 1) - (a.significance || 1);
      return sd !== 0 ? sd : (a.url < b.url ? -1 : 1);
    })[0];
    primary._clusterSize      = members.length;
    primary._clusterSynthesis = cluster.synthesis || null;
    primary._clusterMembers   = members;
    members.forEach(function(m) { if (m !== primary) m._clusterHidden = true; });
    // Propagate image from a cluster member if the head has no usable image
    if (!primary.image_url || isDocumentImage(primary.image_url)) {
      for (var mi = 0; mi < members.length; mi++) {
        var mu = members[mi].image_url;
        if (mu && !isDocumentImage(mu)) { primary.image_url = mu; break; }
      }
    }
  });
}

// ─── Error boundary — catches runtime crashes so the page is never blank ──────
class GlobalErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
    this.handleReset = this.handleReset.bind(this);
  }
  static getDerivedStateFromError(error) {
    return { hasError: true, error: error };
  }
  componentDidCatch(error, info) {
    if (typeof console !== 'undefined') {
      console.error('[Globally] Render error caught by boundary:', error);
      console.error('[Globally] Component stack:', info && info.componentStack);
    }
  }
  handleReset() {
    this.setState({ hasError: false, error: null });
    try { window.location.hash = ''; } catch(e) {}
  }
  render() {
    if (this.state.hasError) {
      return (
        <div style={{
          minHeight: '100vh', display: 'flex', flexDirection: 'column',
          alignItems: 'center', justifyContent: 'center',
          background: '#fffaf0', padding: '40px 24px', fontFamily: 'Inter, sans-serif',
        }}>
          <span style={{fontFamily:'Instrument Serif,Georgia,serif', fontSize:22, color:'#17130f', marginBottom:8}}>Globally</span>
          <h2 style={{fontSize:18, fontWeight:700, color:'#17130f', margin:'0 0 10px', textAlign:'center'}}>Globally couldn't load this view.</h2>
          <p style={{fontSize:14, color:'#5a3c2a', textAlign:'center', maxWidth:340, marginBottom:28, lineHeight:1.6}}>
            Something went wrong while rendering this page. Go back to Today or refresh.
          </p>
          <div style={{display:'flex', gap:12, flexWrap:'wrap', justifyContent:'center'}}>
            <button onClick={this.handleReset}
              style={{padding:'10px 20px', borderRadius:20, border:'1.5px solid #3f291f', background:'#3f291f',
                      color:'#fffaf0', fontFamily:'inherit', fontSize:14, fontWeight:600, cursor:'pointer'}}>
              Back to Today
            </button>
            <button onClick={function(){ window.location.reload(); }}
              style={{padding:'10px 20px', borderRadius:20, border:'1.5px solid #d4c4b0', background:'transparent',
                      color:'#3f291f', fontFamily:'inherit', fontSize:14, fontWeight:600, cursor:'pointer'}}>
              Refresh
            </button>
          </div>
        </div>
      );
    }
    return this.props.children;
  }
}

// ─── Root component ───────────────────────────────────────────────────────────

function GloblyApp() {
  var _articles = React.useState([]);
  var articles = _articles[0], setArticles = _articles[1];

  var _sourceLabels = React.useState({});
  var sourceLabels = _sourceLabels[0], setSourceLabels = _sourceLabels[1];

  var _orgAppeals = React.useState({});
  var orgAppeals = _orgAppeals[0], setOrgAppeals = _orgAppeals[1];

  var _setup = React.useState(function(){ return lsGet(LS.SETUP, null); });
  var setup = _setup[0], setSetup = _setup[1];

  var _session = React.useState(function(){ return lsGet(LS.SESSION, null); });
  var session = _session[0], setSession = _session[1];

  var _loading = React.useState(true);
  var loading = _loading[0], setLoading = _loading[1];

  var _lastUpdated = React.useState(null);
  var lastUpdated = _lastUpdated[0], setLastUpdated = _lastUpdated[1];

  // dataMeta: top-level map-data.json fields (excluding articles/clusters) passed to TodayScreen
  // so getLatestDataUpdatedAt() can pick the right ingest timestamp.
  var _dataMeta = React.useState({});
  var dataMeta = _dataMeta[0], setDataMeta = _dataMeta[1];

  // Event clusters from client-side clustering pipeline (Part 25: in-memory, no Supabase)
  var _eventClusters = React.useState([]);
  var eventClusters = _eventClusters[0], setEventClusters = _eventClusters[1];

  // sbCallbackPending: true when the URL contains OAuth tokens that Supabase needs to process.
  // Keeps the splash screen visible until onAuthStateChange resolves — prevents AuthFlow flash.
  var _sbCallbackPending = React.useState(_supabaseCallbackDetected);
  var sbCallbackPending = _sbCallbackPending[0], setSbCallbackPending = _sbCallbackPending[1];

  // Safety: if the OAuth callback processing hangs for any reason, unblock the UI after 8s.
  React.useEffect(function() {
    if (!sbCallbackPending) return;
    var t = setTimeout(function() { setSbCallbackPending(false); }, 8000);
    return function() { clearTimeout(t); };
  }, [sbCallbackPending]);

  // ── Supabase init — fetch public config from /api/config and create client once ──
  React.useEffect(function() {
    if (_supabase) return; // already initialised in a prior render
    fetch('/api/config')
      .then(function(r) { return r.json(); })
      .then(function(cfg) {
        if (cfg.supabaseUrl && cfg.supabaseAnonKey) {
          initSupabase(cfg.supabaseUrl, cfg.supabaseAnonKey, handleAuthDone);
        } else {
          console.warn('[Globally auth] Supabase not configured — auth falls back to localStorage. Add SUPABASE_URL + SUPABASE_ANON_KEY in Vercel Settings → Environment Variables.');
          setSbCallbackPending(false); // no Supabase → unblock UI immediately
        }
      })
      .catch(function(e) {
        console.warn('[Globally auth] Could not fetch /api/config:', e.message);
        setSbCallbackPending(false); // config error → unblock UI
      });
  }, []);

  // Toggle body class so CSS can widen the root out of phone-frame mode
  React.useEffect(function() {
    var inPreApp = !session || !setup || !setup.done;
    document.body.classList.toggle('globly-preapp', inPreApp);
    return function() { document.body.classList.remove('globly-preapp'); };
  }, [!!session, !!(setup && setup.done)]);

  // ── Data fetch — extracted so it can be called for initial load and daily refresh ──
  var _fetching = React.useRef(false);

  function fetchAllData(opts) {
    opts = opts || {};
    if (_fetching.current && !opts.force) return;
    _fetching.current = true;
    var t = Date.now();
    Promise.all([
      fetch("map-data.json?t=" + t).then(function(r){ return r.json(); }),
      fetch("source-labels.json?t=" + t).then(function(r){ return r.json(); }).catch(function(){ return {}; }),
      fetch("org-appeals.json?t=" + t).then(function(r){ return r.json(); }).catch(function(){ return {}; }),
    ]).then(function(results) {
      var d = results[0], labels = results[1], appeals = results[2];
      var arts = d.articles || [];
      preprocessClusters(arts, d.clusters || []);
      arts.forEach(function(a) { a._topic = classifyArticleTopic(a); });

      // ── Load persistent event clusters from data file (Part 25) ──────────
      // cluster_stories.py runs after each scrape and writes d.event_clusters.
      // Use these as the primary source — much higher quality than client-side.
      var dataEventClusters = d.event_clusters || [];
      if (dataEventClusters.length > 0) {
        var artByUrl = {};
        arts.forEach(function(a) { artByUrl[a.url] = a; });
        dataEventClusters.forEach(function(ec) {
          // Mark secondary cluster members as hidden from main grids.
          // Lead story stays visible so topic sections keep coverage depth.
          var lead = ec.leadStoryId;
          (ec.sourceStoryIds || []).forEach(function(url) {
            var a = artByUrl[url];
            if (a && url !== lead) a._clusterHidden = true;
          });
        });
      }
      if (window.location.search.indexOf('debug_topics=1') !== -1) {
        var byTopic = {};
        arts.forEach(function(a) { var k = a._topic || 'General'; byTopic[k] = (byTopic[k] || 0) + 1; });
        console.log('[Globally Topics]', byTopic);
        arts.filter(function(a) { return a._topic; }).forEach(function(a) {
          console.log('[Globally Topic]', a._topic, '|', (a.title || '').slice(0, 70));
        });
      }
      // Build dataMeta — top-level JSON fields minus the large arrays
      var meta = { last_updated: d.last_updated || null };
      setDataMeta(meta);

      setArticles(arts);
      if (d.last_updated) setLastUpdated(d.last_updated);
      setSourceLabels(labels);
      setOrgAppeals(appeals);
      // Record the edition date so we can detect day changes
      try { localStorage.setItem(LS.EDITION, getTodayKey()); } catch(e) {}

      // ── Set event clusters: persistent data first, client-side as fallback ──
      if (dataEventClusters.length > 0) {
        // Use pre-computed clusters from cluster_stories.py (run after each scrape).
        // Supplement with client-side clustering for any unclustered stories.
        setEventClusters(dataEventClusters);
        setTimeout(function() {
          try {
            var unclustered = arts.filter(function(a) { return !a.eventClusterId && !a._clusterHidden; });
            if (unclustered.length >= 4) {
              var extra = runStoryClusteringPipeline(unclustered);
              if (extra.length > 0) {
                setEventClusters(dataEventClusters.concat(extra).sort(function(a,b){
                  return (b.importanceScore||0) - (a.importanceScore||0);
                }));
              }
            }
          } catch(e) { console.warn('[Globally Clustering] Supplement error:', e); }
        }, 0);
      } else {
        // No pre-computed clusters — run full client-side pipeline
        setTimeout(function() {
          try { setEventClusters(runStoryClusteringPipeline(arts)); } catch(e) {
            console.warn('[Globally Clustering] Pipeline error:', e);
          }
        }, 0);
      }

      // ── Masthead freshness debug ──────────────────────────────────────────
      var updatedAt    = getLatestDataUpdatedAt(arts, meta);
      var updatedLabel = formatUpdatedLabel(updatedAt);
      var seenTopic    = {};
      arts.forEach(function(a) { var tp = a._topic; if (tp) seenTopic[tp] = true; });
      var topicCnt     = Object.keys(seenTopic).length;
      console.log('[Globally] Masthead data loaded', {
        todayKey:            getTodayKey(),
        storyCount:          arts.length,
        topicCount:          topicCnt,
        latestDataUpdatedAt: updatedAt,
        updatedLabel:        updatedLabel,
        dataMeta:            meta,
      });
      if (isStoryDataStale(updatedAt, 1440)) { // warn if data is > 24 hours old
        console.warn('[Globally] Story data is stale', { updatedAt: updatedAt, updatedLabel: updatedLabel });
      }
      // ─────────────────────────────────────────────────────────────────────

      setLoading(false);
      _fetching.current = false;
    }).catch(function(){
      setLoading(false);
      _fetching.current = false;
    });
  }

  // Initial data load
  React.useEffect(function() {
    fetchAllData();
  }, []);

  // Auto-refresh when user returns to the tab on a new day
  React.useEffect(function() {
    function handleVisibilityChange() {
      if (document.visibilityState !== 'visible') return;
      var storedEdition = '';
      try { storedEdition = localStorage.getItem(LS.EDITION) || ''; } catch(e) {}
      if (storedEdition !== getTodayKey()) {
        // It's a new day — refresh data and clear stale daily game state
        try {
          var seenRaw = localStorage.getItem(LS.SEEN);
          if (seenRaw) {
            var seen = JSON.parse(seenRaw);
            // Trim seen entries older than 3 days so "For you" stays fresh
            var cutoff = Date.now() - 3 * 86400000;
            Object.keys(seen).forEach(function(k){ if (seen[k] < cutoff) delete seen[k]; });
            localStorage.setItem(LS.SEEN, JSON.stringify(seen));
          }
        } catch(e) {}
        fetchAllData({ force: true });
      }
    }
    document.addEventListener('visibilitychange', handleVisibilityChange);
    return function() { document.removeEventListener('visibilitychange', handleVisibilityChange); };
  }, []);

  function handleAuthDone() {
    setSession(lsGet(LS.SESSION, null));
    setSetup(lsGet(LS.SETUP, null));
    setSbCallbackPending(false); // OAuth callback fully resolved — stop holding splash
  }

  function handleOnboardingDone(s) {
    setSetup(s);
    // Persist onboarding completion to Supabase profiles so returning users go straight to news.
    var userId    = session && session.userId;
    var userEmail = session && session.email;
    if (_supabase && userId) {
      _supabase.from('profiles').upsert({
        id:                   userId,
        email:                userEmail || null,
        onboarding_completed: true,
        updated_at:           new Date().toISOString(),
      }, { onConflict: 'id' }).catch(function(e) {
        console.warn('[Globally] Profile onboarding_completed update error:', e.message);
      });
    }
  }

  function handleSignOut() {
    if (_supabase) _supabase.auth.signOut().catch(function(){});
    lsSet(LS.SESSION, null);
    setSession(null);
  }

  function handleOnboardingBack() {
    lsSet(LS.SESSION, null);
    setSession(null);
  }

  function handleReset() {
    lsSet(LS.SETUP, null);
    setSetup(null);
  }

  var inner;
  if (loading || sbCallbackPending) {
    // Show splash while data loads OR while an OAuth callback is being processed.
    // sbCallbackPending prevents AuthFlow from flashing between the redirect return
    // and the onAuthStateChange + profile-check completing.
    // Show "Signing you in…" when the OAuth callback is the reason we're waiting.
    inner = (
      <div className="g-splash">
        <img src="globly-logo-white.png" className="g-splash-logo-img" alt="Globally" />
        {(sbCallbackPending && !loading)
          ? <p style={{color:'rgba(255,255,255,0.65)',marginTop:'18px',fontSize:'14px',letterSpacing:'0.02em',fontFamily:'inherit'}}>Signing you in…</p>
          : <div className="g-splash-spinner" />
        }
      </div>
    );
  } else if (!session) {
    inner = <AuthFlow onDone={handleAuthDone} />;
  } else if (!setup || !setup.done) {
    inner = <OnboardingFlow onDone={handleOnboardingDone} onBack={handleOnboardingBack} />;
  } else {
    inner = <MainApp articles={articles} setup={setup} sourceLabels={sourceLabels} orgAppeals={orgAppeals} onReset={handleReset} onSignOut={handleSignOut} lastUpdated={lastUpdated} dataMeta={dataMeta} eventClusters={eventClusters} />;
  }

  return (
    <GlobalErrorBoundary>
      <div className="g-phone-frame">{inner}</div>
    </GlobalErrorBoundary>
  );
}
