// Tempo — Fondamental : contexte macro par catégorie (jour / semaine).
// Composants exposés : FondamentalPage({ state }) (desktop) et FondamentalMobile({ state }) (mobile).
//
// Source : MOTEUR LOCAL GRATUIT window.buildFundamentalContext (aucun worker obligatoire, aucune IA).
//   buildFundamentalContext({ scope, categories, onStep }) → Promise<ctx>
//   ctx v1 = { generatedAt, scope, riskEvent, summary, pivotEvent, degradedSources, categories:[...] }
//   ctx v2 (rétrocompatible, ctx.v === 2) ajoute :
//     categories[i].series  = [{ label, unit, points:[{d,v}] }]  (≤2 séries → sparklines)
//     categories[i].cot     = { net, prevNet, date } | null       (positionnement des fonds)
//     ctx.weekAhead         = { days:[{date,label,events:[{title,country,impact,timeFR}]}] } (scope weekly)
//     ctx.weekReview        = { text }                            (scope weekly)
//   v2.2 (ajouts OPTIONNELS, aucun changement de shape) : pivotEvent.ts et
//     nextCatalysts[i].ts (timestamps ms) → péremption événementielle via
//     window.fondCtxOutdatedByEvent (bandeau « événement majeur passé »).
//   Le rendu GUARDE chaque champ v2 : un vieux ctx v1 s'affiche toujours sans drill-down enrichi.
//
// Historique : chaque génération réussie est sauvegardée en fire-and-forget dans la table
// Supabase `fondamental_snapshots` (RLS owner-only, voir supabase-migration-fondamental.sql)
// PAR LE MOTEUR (feSaveSnapshot, design/fondamental-engine.jsx) — l'UI ne fait que lire
// (hydratation, panneau Historique) et supprimer. Relecture en lecture seule via le panneau.
//
// Vocabulaire : biais / scénarios / contexte uniquement — jamais « prédiction » ni « signal ».
// Aucune couleur en dur — tout via CSS vars. Dark-safe. UI 100 % français.

// ─── Constantes ──────────────────────────────────────────────────────
const FOND_CATS_ALL = [
  { id: 'USD',     label: 'Dollar' },
  { id: 'INDICES', label: 'Indices US' },
  { id: 'GOLD',    label: 'Or' },
  { id: 'EUR',     label: 'Euro' },
  { id: 'AUD',     label: 'Dollar australien' },
];
const FOND_CAT_IDS = FOND_CATS_ALL.map(c => c.id);

const FOND_LS_CATEGORIES = 'tempo:fond:categories';
const FOND_LS_CTX = scope => `tempo:fond:ctx:${scope}`;
const FOND_CACHE_TTL = 30 * 60 * 1000; // 30 min
const FOND_SNAPSHOT_MAX = 40;          // snapshots conservés par utilisateur et par scope (weekly)
// v35 : la rétention DAILY passe à 120 côté moteur (feSaveSnapshot) pour nourrir
// la fiabilité du biais sur 30 jours — le plafond de lecture du panneau suit
// (120 daily + 40 weekly).
const FOND_SNAPSHOT_FETCH_LIMIT = 160;
// Fenêtre de la carte « Fiabilité du biais » : 30 derniers jours d'analyses Jour évaluées.
const FOND_RELIABILITY_DAYS = 30;

const FOND_SCOPES = [
  { id: 'daily',  label: 'Jour' },
  { id: 'weekly', label: 'Semaine' },
];

// Étapes réelles du moteur (l'index arrive via onStep(stepIndex, label)).
// Source de vérité : window.FE_STEPS exposé par fondamental-engine.jsx (6 étapes
// en v2) — lu à l'exécution pour rester synchrone avec le moteur ; repli local
// identique si le moteur n'est pas encore chargé.
const FOND_STEPS_FALLBACK = [
  'Lecture des marchés (indices, taux, dollar, or)…',
  'Positionnement des fonds (COT)…',
  'Sentiment et appétit au risque…',
  'Lecture du calendrier économique…',
  'Lecture des catalyseurs presse…',
  'Synthèse du contexte…',
];
function fondSteps() {
  const s = (typeof window !== 'undefined') ? window.FE_STEPS : null;
  return (Array.isArray(s) && s.length) ? s : FOND_STEPS_FALLBACK;
}

// ─── Helpers ─────────────────────────────────────────────────────────
function fondTz() {
  return (typeof window !== 'undefined' && window.__appTz) || 'Europe/Paris';
}

function fondReadCategories() {
  try {
    const raw = (typeof localStorage !== 'undefined') ? localStorage.getItem(FOND_LS_CATEGORIES) : null;
    const arr = raw ? JSON.parse(raw) : null;
    if (Array.isArray(arr)) {
      const valid = FOND_CAT_IDS.filter(id => arr.includes(id));
      if (valid.length) return valid;
    }
  } catch (e) {}
  return FOND_CAT_IDS.slice(); // toutes précochées par défaut
}

function fondWriteCategories(list) {
  try {
    if (typeof localStorage !== 'undefined') localStorage.setItem(FOND_LS_CATEGORIES, JSON.stringify(list));
  } catch (e) {}
}

// Péremption ÉVÉNEMENTIELLE d'un ctx (v34) : true si un événement d'impact FORT
// (High) est passé entre ctx.generatedAt et maintenant. Délégué au moteur
// (window.fondCtxOutdatedByEvent, fondamental-engine.jsx) — défensif : moteur
// pas encore chargé ou vieux ctx sans timestamps d'événements → false (seul
// l'âge décide alors, comportement identique à avant).
// ⚠️ NOM DISTINCT de window.fondCtxOutdatedByEvent : les scripts Babel
// s'exécutent en portée GLOBALE, une déclaration homonyme écraserait l'export
// du moteur et créerait une auto-récursion (toujours false via le catch).
function fondUiCtxOutdatedByEvent(ctx) {
  try {
    if (typeof window !== 'undefined' && typeof window.fondCtxOutdatedByEvent === 'function') {
      return !!window.fondCtxOutdatedByEvent(ctx, Date.now());
    }
  } catch (e) {}
  return false;
}

// Lecture du cache local d'un scope ({ ctx, savedAt }) si < 30 min, sinon null.
// Un événement FORT passé depuis la génération n'invalide PAS l'hydratation :
// le ctx s'affiche AVEC le bandeau ambré « événement majeur passé »
// (ctxEventOutdated au rendu, même chemin que les snapshots) — sinon, hors
// connexion, l'analyse disparaissait au lieu d'être signalée (le comportement
// dépendait de l'auth pour une donnée purement locale). L'invalidation
// événementielle reste active côté MOTEUR (feReadCache) : « Actualiser » /
// « Analyser » régénère bien au lieu de resservir le cache.
function fondReadCache(scope) {
  try {
    if (typeof localStorage === 'undefined') return null;
    const raw = localStorage.getItem(FOND_LS_CTX(scope));
    if (!raw) return null;
    const obj = JSON.parse(raw);
    if (!obj || !obj.ctx || !obj.savedAt) return null;
    if (Date.now() - obj.savedAt > FOND_CACHE_TTL) return null;
    return obj; // { ctx, savedAt }
  } catch (e) { return null; }
}

function fondWriteCache(scope, ctx) {
  try {
    if (typeof localStorage !== 'undefined') {
      localStorage.setItem(FOND_LS_CTX(scope), JSON.stringify({ ctx, savedAt: Date.now() }));
    }
  } catch (e) {}
}

function fondPurgeCache(scope) {
  try {
    if (typeof localStorage !== 'undefined') localStorage.removeItem(FOND_LS_CTX(scope));
  } catch (e) {}
}

function fondDateTime(ms) {
  if (!ms) return '';
  try {
    return new Date(ms).toLocaleString('fr-FR', {
      weekday: 'long', day: 'numeric', month: 'long',
      hour: '2-digit', minute: '2-digit', timeZone: fondTz(),
    });
  } catch (e) {
    return new Date(ms).toLocaleString('fr-FR');
  }
}

// Date courte (« 3 juil. ») depuis 'YYYY-MM-DD' ou ISO — défensif.
function fondDateShort(d) {
  try {
    const t = new Date(d);
    if (isNaN(t.getTime())) return String(d || '');
    return t.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', timeZone: fondTz() });
  } catch (e) { return String(d || ''); }
}

// Le moteur renvoie déjà des valeurs propres ; on borne défensivement pour ne JAMAIS crasher.
function fondCatLabel(id) {
  const c = FOND_CATS_ALL.find(x => x.id === id);
  return c ? c.label : (id || '—');
}
function fondBiasNorm(b) {
  const v = String(b || '').toLowerCase();
  if (v.startsWith('hauss')) return 'haussier';
  if (v.startsWith('baiss')) return 'baissier';
  return 'neutre';
}
function fondRiskNorm(r) {
  const v = String(r || '').toLowerCase();
  if (v.startsWith('faible')) return 'faible';
  if (v.startsWith('elev') || v.startsWith('élev')) return 'eleve';
  return 'moyen';
}
function fondConvictionN(c) {
  const n = Math.round(Number(c));
  if (!isFinite(n)) return 3;
  return Math.max(1, Math.min(5, n));
}

// Lien externe SÛR : uniquement http(s) explicite (pas de javascript:, data:, etc.).
function fondSafeLink(u) {
  return (typeof u === 'string' && /^https?:\/\//i.test(u)) ? u : null;
}

// Entier signé formaté fr-FR : +45 200 / −3 100 / 0.
function fondSignedInt(n) {
  const v = Math.round(Number(n));
  if (!isFinite(v)) return '—';
  const s = Math.abs(v).toLocaleString('fr-FR');
  return v > 0 ? '+' + s : (v < 0 ? '−' + s : s);
}

// Valeur de série formatée fr-FR (décimales selon l'ordre de grandeur).
function fondSerieVal(v) {
  const n = Number(v);
  if (!isFinite(n)) return '—';
  const dec = Math.abs(n) >= 1000 ? 0 : (Math.abs(n) >= 10 ? 1 : 2);
  return n.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: dec });
}

// Path SVG « lissé » : quadratiques passant par les milieux des segments
// (même style que la courbe d'équité du Backtester).
function fondSmoothPath(pts) {
  if (!pts.length) return '';
  if (pts.length < 3) return 'M' + pts.map(p => p[0] + ',' + p[1]).join(' L');
  let d = 'M' + pts[0][0] + ',' + pts[0][1];
  for (let i = 1; i < pts.length - 1; i++) {
    const mx = (pts[i][0] + pts[i + 1][0]) / 2;
    const my = (pts[i][1] + pts[i + 1][1]) / 2;
    d += ' Q' + pts[i][0] + ',' + pts[i][1] + ' ' + mx + ',' + my;
  }
  d += ' L' + pts[pts.length - 1][0] + ',' + pts[pts.length - 1][1];
  return d;
}

// ─── Icônes locales (SVG, pas d'emoji) : kind driver + biais + risque ──
const FondIco = {
  // kind = 'marché' : mini graphe
  marche: <svg className="ico" width="13" height="13" viewBox="0 0 16 16"><path d="M2 12l3-3 2.5 2L14 4" /><path d="M10 4h4v4" /></svg>,
  // kind = 'calendrier'
  calendrier: <svg className="ico" width="13" height="13" viewBox="0 0 16 16"><rect x="2" y="3" width="12" height="11" rx="1.5" /><path d="M2 6.5h12M5.5 2v2.5M10.5 2v2.5" /></svg>,
  // kind = 'actualité'
  news: <svg className="ico" width="13" height="13" viewBox="0 0 16 16"><rect x="2" y="3" width="10" height="10" rx="1.3" /><path d="M12 6h2v5.5A1.5 1.5 0 0 1 12.5 13M4.5 6h4M4.5 8.5h4M4.5 11h2.5" /></svg>,
  // kind = 'positionnement' : balance (positions longues vs courtes)
  positionnement: <svg className="ico" width="13" height="13" viewBox="0 0 16 16"><path d="M8 3v10M5.5 13h5M3.5 5h9" /><path d="M3.5 5L2 8.5a1.9 1.9 0 0 0 3 0zM12.5 5L11 8.5a1.9 1.9 0 0 0 3 0z" /></svg>,
  // kind = 'taux' : pourcentage
  taux: <svg className="ico" width="13" height="13" viewBox="0 0 16 16"><path d="M12.5 3.5l-9 9" /><circle cx="5" cy="5.2" r="1.7" /><circle cx="11" cy="10.8" r="1.7" /></svg>,
  // kind = 'sentiment' : jauge d'appétit au risque
  sentiment: <svg className="ico" width="13" height="13" viewBox="0 0 16 16"><path d="M2.5 11.5a5.5 5.5 0 0 1 11 0" /><path d="M8 11.5l2.6-3.6" /></svg>,
  up: <svg className="ico" width="11" height="11" viewBox="0 0 12 12"><path d="M6 2v8M3 5l3-3 3 3" /></svg>,
  down: <svg className="ico" width="11" height="11" viewBox="0 0 12 12"><path d="M6 10V2M3 7l3 3 3-3" /></svg>,
  flat: <svg className="ico" width="11" height="11" viewBox="0 0 12 12"><path d="M2 6h8" /></svg>,
  ext: <svg className="ico" width="11" height="11" viewBox="0 0 12 12"><path d="M4 2h6v6M10 2L5 7M8 8v2H2V4h2" /></svg>,
  // historique : horloge « retour dans le temps »
  histo: <svg className="ico" width="13" height="13" viewBox="0 0 16 16"><path d="M2.5 8a5.5 5.5 0 1 1 1.6 3.9" /><path d="M2.5 8V5M2.5 8h3M8 5.2V8l2 1.5" /></svg>,
  // suppression d'un snapshot
  trash: <svg className="ico" width="13" height="13" viewBox="0 0 16 16"><path d="M2.5 4.5h11M6.2 4.5V3a1 1 0 0 1 1-1h1.6a1 1 0 0 1 1 1v1.5M4 4.5l.7 8.4a1.2 1.2 0 0 0 1.2 1.1h4.2a1.2 1.2 0 0 0 1.2-1.1l.7-8.4M6.6 7.2v3.8M9.4 7.2v3.8" /></svg>,
};
function fondKindIco(kind) {
  // Normalise (minuscules, sans accents) : 'marché' → 'marche', 'actualité' → 'actualite'.
  let k = String(kind || '').toLowerCase();
  try { k = k.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } catch (e) {}
  if (k === 'calendrier') return FondIco.calendrier;
  if (k === 'actualite' || k === 'news') return FondIco.news;
  if (k === 'positionnement') return FondIco.positionnement;
  if (k === 'taux') return FondIco.taux;
  if (k === 'sentiment') return FondIco.sentiment;
  return FondIco.marche; // 'marche' + défaut
}

// ─── Supabase : snapshots d'analyse (lecture / suppression, best-effort) ────
// Table `fondamental_snapshots` (RLS owner-only). Toute erreur est silencieuse
// (console.warn) : l'app fonctionne à l'identique sans connexion / sans migration.
// NB : la SAUVEGARDE (fire-and-forget après chaque génération réussie, rétention
// ≤ FOND_SNAPSHOT_MAX/scope) est faite par le MOTEUR (feSaveSnapshot dans
// design/fondamental-engine.jsx) — une seule écriture par génération, jamais deux.

async function fondHasSession() {
  try {
    if (typeof window === 'undefined' || !window.sb) return false;
    const { data } = await window.sb.auth.getSession();
    return !!(data && data.session);
  } catch (e) { return false; }
}

// Dernier snapshot d'un scope → { id, scope, generated_at, ctx } | null.
async function fondFetchLatestSnapshot(scope) {
  try {
    if (!(await fondHasSession())) return null;
    const res = await window.sb.from('fondamental_snapshots')
      .select('id, scope, generated_at, ctx')
      .eq('scope', scope)
      .order('generated_at', { ascending: false })
      .limit(1);
    if (res.error || !Array.isArray(res.data) || !res.data.length) return null;
    const row = res.data[0];
    return (row && row.ctx && typeof row.ctx === 'object') ? row : null;
  } catch (e) { return null; }
}

// Liste des snapshots (tous scopes, plus récents d'abord) pour le panneau Historique.
// Projection LÉGÈRE : la liste n'a besoin que de la date, du scope, du résumé et
// des biais (fil d'évolution) — jamais du ctx complet (~15-25 Ko par snapshot,
// séries + weekAhead). Le ctx complet est chargé À LA DEMANDE (fondFetchSnapshotCtx)
// au moment de la relecture. `biases` est absent des vieux snapshots (pré-v2.1) :
// le fil dégrade proprement (pastille pointillée).
async function fondFetchSnapshots() {
  try {
    if (typeof window === 'undefined' || !window.sb) return { error: 'offline', rows: [] };
    if (!(await fondHasSession())) return { error: 'auth', rows: [] };
    const res = await window.sb.from('fondamental_snapshots')
      // v35 : + evaluation/evaluated_at (verdict du biais) — la projection reste
      // légère : evaluation est un petit objet (≤5 catégories, quelques champs),
      // jamais le ctx complet.
      .select('id, scope, generated_at, summary:ctx->>summary, biases:ctx->biases, evaluation, evaluated_at')
      .order('generated_at', { ascending: false })
      .limit(FOND_SNAPSHOT_FETCH_LIMIT); // les deux scopes (120 daily + 40 weekly)
    if (res.error) return { error: res.error.message || 'query', rows: [] };
    return { error: null, rows: Array.isArray(res.data) ? res.data.filter(r => r && r.id) : [] };
  } catch (e) { return { error: 'network', rows: [] }; }
}

// Charge le ctx COMPLET d'un snapshot (relecture)
// → { id, scope, generated_at, ctx, evaluation, evaluated_at } | null.
// evaluation/evaluated_at (v35) alimentent le tableau des verdicts en relecture ;
// colonnes absentes (migration pas encore passée) → la requête échoue → null,
// même dégradé silencieux qu'avant.
async function fondFetchSnapshotCtx(id) {
  try {
    if (!id || typeof window === 'undefined' || !window.sb) return null;
    const res = await window.sb.from('fondamental_snapshots')
      .select('id, scope, generated_at, ctx, evaluation, evaluated_at')
      .eq('id', id)
      .limit(1);
    if (res.error || !Array.isArray(res.data) || !res.data.length) return null;
    const row = res.data[0];
    return (row && row.ctx && typeof row.ctx === 'object') ? row : null;
  } catch (e) { return null; }
}

async function fondDeleteSnapshot(id) {
  try {
    if (!id || typeof window === 'undefined' || !window.sb) return false;
    const res = await window.sb.from('fondamental_snapshots').delete().eq('id', id);
    return !res.error;
  } catch (e) { return false; }
}

// ─── Primitives UI ───────────────────────────────────────────────────
function FondSegmented({ options, value, onChange, disabled }) {
  return (
    <div style={{ display: 'inline-flex', gap: 4, background: 'var(--bg-elev)', border: '1px solid var(--line)', borderRadius: 11, padding: 3, opacity: disabled ? 0.6 : 1 }}>
      {options.map(o => {
        const on = o.id === value;
        return (
          <button key={o.id} onClick={() => !disabled && onChange(o.id)} disabled={disabled} className="tap" style={{
            padding: '7px 18px', borderRadius: 8, border: 'none', cursor: disabled ? 'default' : 'pointer',
            background: on ? 'var(--bg-card)' : 'transparent',
            color: on ? 'var(--fg)' : 'var(--fg-3)',
            fontSize: 12.5, fontWeight: on ? 600 : 500, letterSpacing: '-.005em',
            boxShadow: on ? '0 1px 3px rgba(0,0,0,0.08)' : 'none',
            whiteSpace: 'nowrap', fontFamily: 'inherit',
          }}>{o.label}</button>
        );
      })}
    </div>
  );
}

// Chips de catégories togglables (toutes précochées, min. une).
function FondCategoryPicker({ categories, onToggle, disabled }) {
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
      <span style={{ fontSize: 11, color: 'var(--fg-3)', marginRight: 2 }}>Catégories</span>
      {FOND_CATS_ALL.map(c => {
        const on = categories.includes(c.id);
        const lastOne = on && categories.length === 1;
        return (
          <button key={c.id} onClick={() => !disabled && onToggle(c.id)} disabled={disabled} className="tap"
            title={lastOne ? 'Garde au moins une catégorie' : (on ? 'Retirer ' + c.label : 'Ajouter ' + c.label)}
            style={{
              padding: '5px 12px', borderRadius: 999,
              background: on ? 'var(--blue-soft)' : 'var(--bg-card)',
              border: '1px solid ' + (on ? 'var(--blue-border)' : 'var(--line)'),
              color: on ? 'var(--blue-600)' : 'var(--fg-2)',
              fontSize: 11.5, fontWeight: 600, cursor: disabled ? 'default' : 'pointer',
              display: 'inline-flex', alignItems: 'center', gap: 5,
              opacity: disabled ? 0.55 : 1, fontFamily: 'inherit', whiteSpace: 'nowrap',
              transition: 'background .15s var(--ease), border-color .15s var(--ease)',
            }}>
            {on && <span style={{ display: 'inline-flex' }}>{Ico.check}</span>}{c.label}
          </button>
        );
      })}
    </div>
  );
}

const FOND_RISK_STYLE = {
  faible: { bg: 'var(--green-soft)', fg: 'var(--green-text)', dot: 'var(--green)', label: 'Risque événement faible' },
  moyen:  { bg: 'var(--amber-soft)', fg: 'var(--amber-text)', dot: 'var(--amber)', label: 'Risque événement moyen' },
  eleve:  { bg: 'var(--red-soft)',   fg: 'var(--red-text)',   dot: 'var(--red)',   label: 'Risque événement élevé' },
};
function FondRiskBadge({ level }) {
  const l = fondRiskNorm(level);
  const s = FOND_RISK_STYLE[l];
  return (
    <span className="pill" style={{ background: s.bg, color: s.fg, border: '1px solid var(--line)', fontSize: 11, fontWeight: 600, padding: '4px 10px', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
      <span style={{ width: 7, height: 7, borderRadius: '50%', background: s.dot, display: 'inline-block', color: s.dot, animation: l === 'eleve' ? 'pulse-dot 1.8s var(--ease) infinite' : 'none' }}></span>
      {s.label}
    </span>
  );
}

const FOND_BIAS_STYLE = {
  haussier: { bg: 'var(--green-soft)', fg: 'var(--green-text)', dot: 'var(--green)', label: 'Haussier', arrow: FondIco.up },
  baissier: { bg: 'var(--red-soft)',   fg: 'var(--red-text)',   dot: 'var(--red)',   label: 'Baissier', arrow: FondIco.down },
  neutre:   { bg: 'var(--bg-elev)',    fg: 'var(--fg-2)',       dot: 'var(--fg-4)',  label: 'Neutre',   arrow: FondIco.flat },
};
function FondBiasBadge({ biais }) {
  const s = FOND_BIAS_STYLE[fondBiasNorm(biais)];
  return (
    <span className="pill" style={{ background: s.bg, color: s.fg, border: '1px solid var(--line)', fontSize: 11, fontWeight: 600, padding: '4px 10px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
      <span style={{ display: 'inline-flex' }}>{s.arrow}</span>{s.label}
    </span>
  );
}

// Conviction en 5 points remplis, colorés selon le biais.
function FondConvictionDots({ n, biais }) {
  const s = FOND_BIAS_STYLE[fondBiasNorm(biais)];
  const filled = fondConvictionN(n);
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }} title={`Conviction ${filled}/5`}>
      {[0, 1, 2, 3, 4].map(i => (
        <span key={i} style={{
          width: 8, height: 8, borderRadius: '50%',
          background: i < filled ? s.dot : 'var(--bg-elev)',
          border: '1px solid ' + (i < filled ? s.dot : 'var(--line-2)'),
        }}></span>
      ))}
    </span>
  );
}

// Pastille par tone d'un driver.
const FOND_TONE_DOT = { hausse: 'var(--green)', baisse: 'var(--red)', neutre: 'var(--fg-4)' };
function fondToneDot(tone) { return FOND_TONE_DOT[tone] || FOND_TONE_DOT.neutre; }

// Couleur de pastille d'impact d'un événement ('High'/'Medium'/autre).
function fondImpactDot(impact) {
  const v = String(impact || '').toLowerCase();
  if (v.startsWith('high') || v.includes('elev') || v.includes('élev') || v.includes('fort')) return 'var(--red)';
  if (v.startsWith('med') || v.includes('moyen')) return 'var(--amber)';
  return 'var(--fg-4)';
}

// Pastille impact d'un catalyseur ('High' | 'Medium').
function FondImpactPastille({ impact }) {
  const v = String(impact || '').toLowerCase();
  const high = v.startsWith('high') || v.includes('elev') || v.includes('élev');
  const dot = high ? 'var(--red)' : 'var(--amber)';
  const label = high ? 'Fort' : 'Moyen';
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--fg-3)', whiteSpace: 'nowrap' }}>
      <span style={{ width: 7, height: 7, borderRadius: '50%', background: dot, flexShrink: 0 }}></span>
      {label}
    </span>
  );
}

function FondSpinner({ size = 18 }) {
  return (
    <span style={{
      width: size, height: size, borderRadius: '50%', flexShrink: 0, display: 'inline-block',
      border: '2px solid var(--line-2)', borderTopColor: 'var(--blue)',
      animation: 'fond-spin .8s linear infinite',
    }}></span>
  );
}

// Titre de section (Bilan de la semaine / Semaine à venir…).
function FondSectionTitle({ children }) {
  return (
    <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--fg-3)', letterSpacing: '.06em', textTransform: 'uppercase', margin: '4px 2px -4px' }}>
      {children}
    </div>
  );
}

// ─── Écran d'accueil : bouton central « Analyser le contexte » ────────
function FondWelcomeCard({ scope, onAnalyse }) {
  return (
    <div className="card" style={{ padding: '52px 24px', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
      <div style={{ color: 'var(--blue)', marginBottom: 16, display: 'inline-flex', width: 52, height: 52, borderRadius: 14, background: 'var(--blue-soft)', border: '1px solid var(--blue-border)', alignItems: 'center', justifyContent: 'center' }}>
        <span style={{ display: 'inline-flex', transform: 'scale(1.5)', color: 'var(--blue-600)' }}>{Ico.fondamental}</span>
      </div>
      <div style={{ fontSize: 16, fontWeight: 700, letterSpacing: '-.015em', marginBottom: 7 }}>Contexte fondamental</div>
      <div style={{ fontSize: 12.5, color: 'var(--fg-3)', maxWidth: 420, margin: '0 auto 22px', lineHeight: 1.6 }}>
        Analyse locale et gratuite du contexte {scope === 'weekly' ? 'de la semaine' : 'du jour'} :
        biais par catégorie, catalyseurs à venir et pouls du marché — à partir de données publiques.
      </div>
      <button className="btn btn-blue tap lift" onClick={onAnalyse} style={{ fontSize: 13.5, padding: '11px 26px', display: 'inline-flex', alignItems: 'center', gap: 8 }}>
        <span style={{ display: 'inline-flex' }}>{Ico.stats}</span> Analyser le contexte
      </button>
    </div>
  );
}

// ─── Écran de progression : barre fluide + étapes réelles du moteur ───
// stepIndex : index de l'étape EN COURS. Les < sont ✓, l'égal est spinner, les > grisées.
function FondProgressCard({ stepIndex, stepLabel }) {
  const steps = fondSteps();
  const total = steps.length;
  const done = Math.max(0, Math.min(total, stepIndex));
  const pct = Math.round(((done + 0.5) / total) * 100);
  return (
    <div className="card" style={{ padding: '30px 24px 26px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
      <div style={{ fontSize: 14.5, fontWeight: 600, letterSpacing: '-.01em', marginBottom: 4 }}>Analyse du contexte en cours</div>
      <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginBottom: 20 }}>Analyse locale · données publiques</div>

      {/* Barre de progression fluide, gradient bleu animé + shimmer */}
      <div style={{ width: '100%', maxWidth: 360, height: 7, borderRadius: 999, background: 'var(--bg-elev)', overflow: 'hidden', marginBottom: 24 }}>
        <div style={{
          width: `${Math.max(6, pct)}%`, height: '100%', borderRadius: 999,
          background: 'linear-gradient(90deg, var(--blue) 0%, var(--blue-600) 100%)',
          transition: 'width .5s var(--ease)', position: 'relative', overflow: 'hidden',
        }}>
          <span style={{ position: 'absolute', top: 0, bottom: 0, left: 0, width: '50%', background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.35), transparent)', animation: 'shimmer 1.6s var(--ease) infinite' }}></span>
        </div>
      </div>

      <div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 260, width: '100%', maxWidth: 360 }}>
        {steps.map((s, i) => {
          const isDone = i < done, current = i === done;
          const text = (current && stepLabel) ? stepLabel : s;
          return (
            <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 11, opacity: isDone || current ? 1 : 0.4, transition: 'opacity .4s var(--ease)' }}>
              {isDone ? (
                <span style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--green-soft)', color: 'var(--green-text)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{Ico.check}</span>
              ) : current ? (
                <FondSpinner />
              ) : (
                <span style={{ width: 18, height: 18, borderRadius: '50%', border: '2px solid var(--line-2)', flexShrink: 0 }}></span>
              )}
              <span style={{ fontSize: 12.5, fontWeight: current ? 600 : 500, color: current ? 'var(--fg)' : 'var(--fg-3)' }}>{text}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ─── Erreur totale (aucune source) ────────────────────────────────────
function FondErrorCard({ onRetry }) {
  return (
    <div className="card" style={{ padding: '42px 20px', textAlign: 'center' }}>
      <div style={{ fontSize: 14.5, fontWeight: 600, marginBottom: 6 }}>Analyse impossible</div>
      <div style={{ fontSize: 12.5, color: 'var(--fg-3)', maxWidth: 420, margin: '0 auto 18px', lineHeight: 1.55 }}>
        Aucune source de données publique n'a pu être jointe. Vérifie ta connexion internet et réessaie.
      </div>
      <button className="btn btn-blue tap" onClick={onRetry} style={{ fontSize: 13, padding: '9px 20px', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
        <span style={{ display: 'inline-flex' }}>{Ico.refresh}</span> Réessayer
      </button>
    </div>
  );
}

// ─── Sparkline SVG pur d'une série v2 ({ label, unit, points }) ────────
// ~60 px de haut, path lissé, teinté selon le biais de la catégorie,
// dernière valeur affichée à droite du libellé.
function FondSparkline({ serie, bias }) {
  const raw = (serie && Array.isArray(serie.points)) ? serie.points : [];
  const pts0 = raw.filter(p => p && isFinite(Number(p.v))).slice(-60);
  if (pts0.length < 2) return null;

  const W = 260, H = 60, pad = 5;
  let min = Infinity, max = -Infinity;
  for (const p of pts0) {
    const v = Number(p.v);
    if (v < min) min = v;
    if (v > max) max = v;
  }
  if (!(max > min)) { max = min + Math.max(1, Math.abs(min) * 0.001); } // série plate : évite ÷0

  const n = pts0.length;
  const x = i => pad + (i / (n - 1)) * (W - 2 * pad);
  const y = v => pad + (1 - (v - min) / (max - min)) * (H - 2 * pad);
  const pts = pts0.map((p, i) => [Math.round(x(i) * 10) / 10, Math.round(y(Number(p.v)) * 10) / 10]);
  const line = fondSmoothPath(pts);
  const area = line + ' L' + pts[n - 1][0] + ',' + (H - 1) + ' L' + pts[0][0] + ',' + (H - 1) + ' Z';

  const b = fondBiasNorm(bias);
  const stroke = b === 'haussier' ? 'var(--green)' : (b === 'baissier' ? 'var(--red)' : 'var(--fg-4)');
  const fill = b === 'haussier' ? 'var(--green-soft)' : (b === 'baissier' ? 'var(--red-soft)' : 'var(--bg-elev)');
  const last = Number(pts0[n - 1].v);
  const unit = serie.unit ? String(serie.unit) : '';

  return (
    <div style={{ background: 'var(--bg-elev)', border: '1px solid var(--line)', borderRadius: 10, padding: '9px 11px 6px', minWidth: 0 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
        <span style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.03em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
          {serie.label || 'Série'}{unit ? <span style={{ color: 'var(--fg-4)', fontWeight: 500 }}> · {unit}</span> : null}
        </span>
        <span className="num" style={{ fontSize: 12, fontWeight: 700, color: 'var(--fg)', whiteSpace: 'nowrap' }}>
          {fondSerieVal(last)}{unit ? <span style={{ fontSize: 10, color: 'var(--fg-3)', fontWeight: 500 }}> {unit}</span> : null}
        </span>
      </div>
      <svg viewBox={'0 0 ' + W + ' ' + H} preserveAspectRatio="none" style={{ width: '100%', height: 60, display: 'block' }} aria-hidden="true">
        <path d={area} fill={fill} stroke="none" />
        <path d={line} fill="none" stroke={stroke} strokeWidth="1.8" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
        <circle cx={pts[n - 1][0]} cy={pts[n - 1][1]} r="2.4" fill={stroke} stroke="none" />
      </svg>
    </div>
  );
}

// ─── Jauge COT : positions nettes des fonds vs semaine précédente ──────
// v2.1 : le moteur porte l'IDENTITÉ du marché mesuré (cot.market, ex 'EURO FX',
// 'Nasdaq mini') et un drapeau cot.mirror (lecture MIROIR, ex COT EUR sur la
// carte Dollar). En lecture miroir, le code couleur vert/rouge est NEUTRALISÉ :
// sur la carte Dollar, tous les autres verts signifient « haussier dollar » —
// une barre verte « fonds longs EUR » affirmerait l'inverse du signal réel.
function FondCotGauge({ cot }) {
  if (!cot || !isFinite(Number(cot.net))) return null;
  const net = Math.round(Number(cot.net));
  const hasPrev = isFinite(Number(cot.prevNet));
  const prev = hasPrev ? Math.round(Number(cot.prevNet)) : null;
  const delta = hasPrev ? net - prev : null;
  const maxAbs = Math.max(Math.abs(net), hasPrev ? Math.abs(prev) : 0, 1);
  const mirror = !!cot.mirror;
  const market = (typeof cot.market === 'string' && cot.market) ? cot.market : '';
  const title = market
    ? `Positionnement des fonds — ${market}${mirror ? ' · lecture miroir du dollar' : ''}`
    : 'Positionnement des fonds (COT)';
  // Une barre = de 0 (centre) vers la gauche (net vendeur) ou la droite (net acheteur).
  const bar = (v, h, opacity) => {
    const w = Math.max(1.5, (Math.abs(v) / maxAbs) * 50); // % d'une demi-largeur
    return {
      position: 'absolute', top: '50%', transform: 'translateY(-50%)', height: h,
      left: v >= 0 ? '50%' : (50 - w) + '%', width: w + '%',
      background: mirror ? 'var(--fg-4)' : (v >= 0 ? 'var(--green)' : 'var(--red)'),
      borderRadius: 3, opacity,
    };
  };
  const deltaTxt = delta == null ? '' : ` (${fondSignedInt(delta)} sur la semaine)`;
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginBottom: 7, flexWrap: 'wrap' }}>
        <span style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.05em', textTransform: 'uppercase' }}>{title}</span>
        {cot.date && <span className="num" style={{ fontSize: 10.5, color: 'var(--fg-4)' }}>au {fondDateShort(cot.date)}</span>}
      </div>
      <div style={{ position: 'relative', height: 22, background: 'var(--bg-elev)', border: '1px solid var(--line)', borderRadius: 7, overflow: 'hidden' }}>
        {/* Semaine précédente : barre fantôme fine */}
        {hasPrev && <span style={bar(prev, 5, 0.4)}></span>}
        {/* Net actuel */}
        <span style={bar(net, 11, 0.9)}></span>
        {/* Ligne zéro centrale */}
        <span style={{ position: 'absolute', top: 2, bottom: 2, left: '50%', width: 1, background: 'var(--line-2)' }}></span>
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
        <span className="num" style={{ fontSize: 12, fontWeight: 700, color: mirror ? 'var(--fg-2)' : (net >= 0 ? 'var(--green-text)' : 'var(--red-text)') }}>
          Fonds nets{market ? ` ${market}` : ''} : {fondSignedInt(net)}
        </span>
        {delta != null && <span className="num" style={{ fontSize: 11, color: 'var(--fg-3)' }}>{deltaTxt.trim()}</span>}
      </div>
    </div>
  );
}

// ─── Sous-blocs d'une carte catégorie ────────────────────────────────
function FondDrivers({ drivers }) {
  if (!Array.isArray(drivers) || !drivers.length) return null;
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
      {drivers.map((d, i) => (
        <div key={i} style={{ display: 'flex', gap: 9, alignItems: 'flex-start' }}>
          <span style={{ width: 7, height: 7, borderRadius: '50%', background: fondToneDot(d && d.tone), marginTop: 6, flexShrink: 0 }}></span>
          <span style={{ color: 'var(--fg-4)', display: 'inline-flex', marginTop: 2, flexShrink: 0 }}>{fondKindIco(d && d.kind)}</span>
          <span style={{ fontSize: 12.5, lineHeight: 1.5, color: 'var(--fg-2)' }}>{(d && d.text) || ''}</span>
        </div>
      ))}
    </div>
  );
}

function FondCatalysts({ items }) {
  if (!Array.isArray(items) || !items.length) return null;
  return (
    <div>
      <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.05em', textTransform: 'uppercase', marginBottom: 8 }}>Prochains catalyseurs</div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
        {items.map((c, i) => (
          <div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
              <span className="num" style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{(c && c.whenFR) || '—'}</span>
              <span style={{ fontSize: 12.5, color: 'var(--fg)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(c && c.title) || ''}</span>
            </div>
            <FondImpactPastille impact={c && c.impact} />
          </div>
        ))}
      </div>
    </div>
  );
}

function FondHeadlines({ items }) {
  if (!Array.isArray(items) || !items.length) return null;
  return (
    <div>
      <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.05em', textTransform: 'uppercase', marginBottom: 8 }}>À la une</div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
        {items.map((h, i) => {
          // Lien SÛR uniquement : http(s) explicite, sinon rendu inerte.
          const link = fondSafeLink(h && h.link);
          const inner = (
            <div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
              <span style={{ fontSize: 12.5, lineHeight: 1.45, color: link ? 'var(--blue-600)' : 'var(--fg-2)', flex: 1 }}>{(h && h.title) || ''}</span>
              {link && <span style={{ color: 'var(--fg-4)', display: 'inline-flex', marginTop: 2, flexShrink: 0 }}>{FondIco.ext}</span>}
            </div>
          );
          const meta = (
            <div style={{ fontSize: 10.5, color: 'var(--fg-4)', marginTop: 2 }}>
              {[(h && h.source), (h && h.time)].filter(Boolean).join(' · ')}
            </div>
          );
          return link ? (
            <a key={i} href={link} target="_blank" rel="noopener noreferrer" className="tap" style={{ textDecoration: 'none', display: 'block' }}>
              {inner}{meta}
            </a>
          ) : (
            <div key={i}>{inner}{meta}</div>
          );
        })}
      </div>
    </div>
  );
}

// ─── Carte catégorie (clic → expansion inline du détail complet) ──────
function FondCategoryCard({ cat, mobile }) {
  const [open, setOpen] = React.useState(false);
  if (!cat) return null;

  const label = cat.label || fondCatLabel(cat.id);
  const drivers = Array.isArray(cat.drivers) ? cat.drivers : [];
  const catalysts = Array.isArray(cat.nextCatalysts) ? cat.nextCatalysts : [];
  const headlines = Array.isArray(cat.headlines) ? cat.headlines : [];
  // Enrichissements v2 — guards : absents sur un vieux ctx v1.
  const series = Array.isArray(cat.series) ? cat.series.filter(Boolean).slice(0, 2) : [];
  const cot = (cat.cot && typeof cat.cot === 'object') ? cat.cot : null;
  // Vue compacte : 2 premiers moteurs, 2 premiers catalyseurs. Vue étendue : tout.
  const shownDrivers = open ? drivers : drivers.slice(0, 2);
  const shownCatalysts = open ? catalysts : catalysts.slice(0, 2);

  return (
    <div className="card lift" style={{ padding: mobile ? '15px 16px' : '17px 18px', display: 'flex', flexDirection: 'column', gap: 13 }}>
      {/* En-tête cliquable */}
      <button onClick={() => setOpen(o => !o)} className="tap" style={{
        display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
        background: 'transparent', border: 'none', padding: 0, cursor: 'pointer',
        fontFamily: 'inherit', textAlign: 'left', width: '100%',
      }}>
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
          <span style={{ fontSize: 14.5, fontWeight: 700, letterSpacing: '-.012em', color: 'var(--fg)' }}>{label}</span>
          {cat.degraded && (
            <span className="pill pill-gray" style={{ fontSize: 9.5, padding: '2px 7px' }}>données partielles</span>
          )}
        </span>
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
          <FondBiasBadge biais={cat.bias} />
          <span style={{ display: 'inline-flex', color: 'var(--fg-3)', transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .2s var(--ease)' }}>{Ico.chev}</span>
        </span>
      </button>

      {/* Conviction */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
        <span style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.05em', textTransform: 'uppercase' }}>Conviction</span>
        <FondConvictionDots n={cat.conviction} biais={cat.bias} />
        <span className="num" style={{ fontSize: 11, color: 'var(--fg-3)', fontWeight: 600 }}>{fondConvictionN(cat.conviction)}/5</span>
      </div>

      {/* Moteurs */}
      {shownDrivers.length > 0 && <FondDrivers drivers={shownDrivers} />}
      {!open && drivers.length > 2 && (
        <div style={{ fontSize: 11, color: 'var(--fg-4)' }}>+ {drivers.length - 2} autre{drivers.length - 2 > 1 ? 's' : ''}…</div>
      )}

      {/* Catalyseurs (compact : 2) */}
      {shownCatalysts.length > 0 && <FondCatalysts items={shownCatalysts} />}

      {/* Contenu additionnel révélé à l'expansion : sparklines + COT + actus */}
      {open && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 13, borderTop: '1px solid var(--line)', paddingTop: 13 }}>
          {series.length > 0 && (
            <div style={{ display: 'grid', gridTemplateColumns: (mobile || series.length < 2) ? '1fr' : '1fr 1fr', gap: 10 }}>
              {series.map((s, i) => <FondSparkline key={i} serie={s} bias={cat.bias} />)}
            </div>
          )}
          {cot && <FondCotGauge cot={cot} />}
          {headlines.length > 0
            ? <FondHeadlines items={headlines} />
            : <div style={{ fontSize: 11.5, color: 'var(--fg-4)' }}>Aucune actualité notable pour cette catégorie.</div>}
        </div>
      )}
    </div>
  );
}

// ─── Semaine à venir : timeline verticale lun → ven ───────────────────
function FondWeekAhead({ weekAhead, mobile }) {
  const days = (weekAhead && Array.isArray(weekAhead.days)) ? weekAhead.days.filter(Boolean) : [];
  if (!days.length) {
    // Cas typique du week-end : le feed de la semaine courante est épuisé et
    // celui de la semaine prochaine n'est publié que dimanche soir — ce n'est
    // pas une panne, on l'explique honnêtement.
    return (
      <div className="card" style={{ padding: '26px 20px', textAlign: 'center', fontSize: 12.5, color: 'var(--fg-3)', lineHeight: 1.6 }}>
        Aucun événement à venir sur la fenêtre couverte — le calendrier de la semaine prochaine est publié dimanche soir.
      </div>
    );
  }
  return (
    <div className="card lift" style={{ padding: mobile ? '15px 16px' : '18px 20px' }}>
      <div style={{ display: 'flex', flexDirection: 'column' }}>
        {days.map((d, di) => {
          const events = Array.isArray(d.events) ? d.events.filter(Boolean) : [];
          const isLast = di === days.length - 1;
          return (
            <div key={di} style={{ display: 'flex', gap: mobile ? 10 : 14 }}>
              {/* Colonne timeline : point + trait vertical */}
              <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: 12, flexShrink: 0 }}>
                <span style={{
                  width: 9, height: 9, borderRadius: '50%', marginTop: 4, flexShrink: 0,
                  background: events.length ? 'var(--blue)' : 'var(--bg-elev)',
                  border: '2px solid ' + (events.length ? 'var(--blue)' : 'var(--line-2)'),
                }}></span>
                {!isLast && <span style={{ flex: 1, width: 2, background: 'var(--line)', borderRadius: 1, margin: '3px 0' }}></span>}
              </div>
              {/* Contenu du jour */}
              <div style={{ flex: 1, minWidth: 0, paddingBottom: isLast ? 0 : 16 }}>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: events.length ? 7 : 3 }}>
                  <span style={{ fontSize: 12.5, fontWeight: 700, letterSpacing: '-.01em', color: 'var(--fg)' }}>{d.label || fondDateShort(d.date)}</span>
                  {events.length > 0 && (
                    <span className="num" style={{ fontSize: 10.5, color: 'var(--fg-4)' }}>
                      {events.length} événement{events.length > 1 ? 's' : ''}
                    </span>
                  )}
                </div>
                {events.length ? (
                  <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
                    {events.map((ev, ei) => (
                      <div key={ei} style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
                        <span style={{ width: 7, height: 7, borderRadius: '50%', background: fondImpactDot(ev && ev.impact), flexShrink: 0 }}></span>
                        <span className="num" style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-2)', whiteSpace: 'nowrap', flexShrink: 0 }}>{(ev && ev.timeFR) || '—'}</span>
                        <span style={{ fontSize: 12, color: 'var(--fg)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{(ev && ev.title) || ''}</span>
                        {ev && ev.country && (
                          <span className="pill pill-gray" style={{ fontSize: 9.5, padding: '1px 7px', flexShrink: 0 }}>{ev.country}</span>
                        )}
                      </div>
                    ))}
                  </div>
                ) : (
                  <div style={{ fontSize: 11.5, color: 'var(--fg-4)' }}>Aucun événement majeur.</div>
                )}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ─── Rendu complet du contexte ───────────────────────────────────────
function FondContextView({ ctx, mobile }) {
  const cats = Array.isArray(ctx.categories) ? ctx.categories : [];
  const degraded = Array.isArray(ctx.degradedSources) ? ctx.degradedSources.filter(Boolean) : [];
  const pivot = ctx.pivotEvent || null;
  // Sections « Semaine » v2 — guards : jamais rendues sur un vieux ctx v1.
  const isWeeklyV2 = ctx.scope === 'weekly' && ctx.v === 2;
  const weekReviewText = (isWeeklyV2 && ctx.weekReview && ctx.weekReview.text) ? String(ctx.weekReview.text) : null;

  const catGrid = cats.length > 0 ? (
    <div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : 'repeat(2, 1fr)', gap: mobile ? 12 : 14, alignItems: 'start' }}>
      {cats.filter(Boolean).map((cat, i) => <FondCategoryCard key={cat.id || i} cat={cat} mobile={mobile} />)}
    </div>
  ) : (
    <div className="card" style={{ padding: '30px 20px', textAlign: 'center', fontSize: 12.5, color: 'var(--fg-3)' }}>
      Aucune catégorie sélectionnée.
    </div>
  );

  return (
    <div className="stagger" style={{ display: 'flex', flexDirection: 'column', gap: mobile ? 12 : 16 }}>
      {/* (a) Bandeau de tête : summary + badge risque + pivot + horodatage */}
      <div className="card lift" style={{ padding: mobile ? '16px 16px' : '20px 22px' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
          <span style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.05em', textTransform: 'uppercase' }}>
            {ctx.scope === 'weekly' ? 'Contexte de la semaine' : 'Contexte du jour'}
          </span>
          <FondRiskBadge level={ctx.riskEvent} />
        </div>

        <p style={{ fontSize: mobile ? 13.5 : 14.5, lineHeight: 1.6, color: 'var(--fg)', margin: 0, letterSpacing: '-.005em' }}>
          {ctx.summary || 'Synthèse du contexte indisponible.'}
        </p>

        {pivot && (pivot.title || pivot.whenFR) && (
          <div style={{ marginTop: 14, padding: '11px 14px', borderRadius: 10, background: 'var(--blue-soft)', border: '1px solid var(--blue-border)', display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
            <span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--blue-600)', letterSpacing: '.05em', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>Événement pivot</span>
            <span style={{ fontSize: 12.5, lineHeight: 1.5, color: 'var(--fg)', flex: 1, minWidth: 160 }}>
              {pivot.title || ''}{pivot.whenFR ? <span className="num" style={{ color: 'var(--fg-3)', marginLeft: 8 }}>{pivot.whenFR}</span> : null}
            </span>
          </div>
        )}

        <div style={{ marginTop: 14, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          {ctx.generatedAt && <span style={{ fontSize: 11, color: 'var(--fg-3)' }}>Analysé le {fondDateTime(ctx.generatedAt)}</span>}
          <span className="pill pill-gray" style={{ fontSize: 10, padding: '2px 8px' }}>Analyse locale · données publiques</span>
          {degraded.length > 0 && (
            <span style={{ fontSize: 11, color: 'var(--amber-text)', fontWeight: 500 }}>
              Sources partielles : {degraded.join(', ')}
            </span>
          )}
        </div>
      </div>

      {/* (b) Scope semaine v2 : le HÉROS d'abord (biais par catégorie — fidèle au
          flux « je clique, j'ai mon biais »), puis « Bilan de la semaine »,
          puis « Semaine à venir ». */}
      {isWeeklyV2 ? (
        <React.Fragment>
          {catGrid}
          {weekReviewText && (
            <React.Fragment>
              <FondSectionTitle>Bilan de la semaine</FondSectionTitle>
              <div className="card lift" style={{ padding: mobile ? '14px 16px' : '16px 20px' }}>
                <p style={{ fontSize: mobile ? 12.5 : 13, lineHeight: 1.65, color: 'var(--fg-2)', margin: 0 }}>{weekReviewText}</p>
              </div>
            </React.Fragment>
          )}
          <FondSectionTitle>Semaine à venir</FondSectionTitle>
          <FondWeekAhead weekAhead={ctx.weekAhead} mobile={mobile} />
        </React.Fragment>
      ) : (
        catGrid
      )}
    </div>
  );
}

// ─── Historique : évolution du biais sur les 10 derniers snapshots ────
// Une ligne par catégorie : suite de petites flèches colorées (haussier /
// baissier / neutre), du plus ancien au plus récent, tooltip avec la date.
function FondBiasTrail({ snaps }) {
  // snaps : plus anciens → plus récents (≤10), chacun { generated_at, biases }
  // (projection légère ctx->biases ; absente des vieux snapshots → pastille vide).
  if (!Array.isArray(snaps) || snaps.length < 2) return null;
  const rows = FOND_CATS_ALL.map(c => {
    const cells = snaps.map(s => {
      const b = (s.biases && typeof s.biases === 'object') ? s.biases[c.id] : null;
      return { bias: b ? fondBiasNorm(b) : null, date: s.generated_at };
    });
    return { id: c.id, label: c.label, cells };
  }).filter(r => r.cells.some(x => x.bias != null));
  if (!rows.length) return null;

  return (
    <div style={{ borderTop: '1px solid var(--line)', paddingTop: 12, marginTop: 2 }}>
      <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.05em', textTransform: 'uppercase', marginBottom: 9 }}>
        Évolution du biais ({snaps.length} dernières analyses)
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
        {rows.map(r => (
          <div key={r.id} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
            <span style={{ fontSize: 11.5, color: 'var(--fg-2)', fontWeight: 500, width: 118, flexShrink: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.label}</span>
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
              {r.cells.map((cell, i) => {
                if (cell.bias == null) {
                  return <span key={i} title="Biais indisponible pour cette analyse" style={{ width: 11, height: 11, borderRadius: '50%', border: '1px dashed var(--line-2)', display: 'inline-block' }}></span>;
                }
                const s = FOND_BIAS_STYLE[cell.bias];
                const dateMs = cell.date ? Date.parse(cell.date) : null;
                return (
                  <span key={i} title={`${s.label} — ${dateMs ? fondDateTime(dateMs) : ''}`}
                    style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 15, height: 15, borderRadius: 5, background: s.bg, color: s.dot, border: '1px solid var(--line)' }}>
                    {s.arrow}
                  </span>
                );
              })}
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}

// ─── Verdict du biais (v35) ──────────────────────────────────────────
// La colonne `evaluation` (shape v1, écrite par le MOTEUR en fire-and-forget)
// mesure a posteriori la DIRECTION du biais noté vs la variation constatée
// (sources intraday uniquement). AUCUNE simulation de prise de position.
//   evaluation = { v:1, horizonH, source:'intraday',
//                  cats: { [catId]: { bias, conviction, pct, verdict } },
//                  agg: { ok, ko, neutre, na } }
//   verdict ∈ 'juste' | 'faux' | 'neutre' | 'non_evaluable'.
// Tout est GUARDÉ : evaluation null (en attente / weekly / vieux snapshot sans
// evalRefs) → affichage « — » explicatif, jamais de crash.

// Fenêtre d'évaluation de la routine moteur : un daily jamais évalué qui dépasse
// 7 jours ne le sera PLUS jamais (la routine ne sélectionne que now−7j → now−2h).
// L'UI tranche elle-même : au-delà, « Non évalué » honnête au lieu d'une promesse
// « en attente » fausse à vie.
const FOND_EVAL_WINDOW_MS = 7 * 24 * 3600e3;
const FOND_EVAL_EXPIRED_TITLE = "Fenêtre d'évaluation dépassée — aucune analyse n'a été lancée dans les 7 jours suivant celle-ci.";

const FOND_VERDICT_STYLE = {
  juste:  { color: 'var(--green)', glyph: '✓', label: 'Juste' },   // ✓
  faux:   { color: 'var(--red)',   glyph: '✗', label: 'Faux' },    // ✗
  neutre: { color: 'var(--fg-4)',  glyph: '−', label: 'Neutre' },  // −
  non_evaluable: { color: 'var(--fg-4)', glyph: '·', label: 'Non évaluable' },
};
function fondVerdictNorm(v) {
  return FOND_VERDICT_STYLE[v] ? v : 'non_evaluable';
}

// evaluation → { ok, ko, neutre, na, horizonH } bornés | null si shape invalide.
function fondEvalAgg(evaluation) {
  if (!evaluation || typeof evaluation !== 'object' || !evaluation.agg || typeof evaluation.agg !== 'object') return null;
  const int = (x) => { const n = Math.round(Number(x)); return isFinite(n) && n > 0 ? n : 0; };
  return {
    ok: int(evaluation.agg.ok), ko: int(evaluation.agg.ko),
    neutre: int(evaluation.agg.neutre), na: int(evaluation.agg.na),
    horizonH: int(evaluation.horizonH),
  };
}

// Horizon en clair : « 5 h » / « 3 j ».
function fondHorizonFmt(h) {
  const n = Math.round(Number(h));
  if (!isFinite(n) || n <= 0) return '';
  return n >= 48 ? Math.round(n / 24) + ' j' : n + ' h';
}

// Variation signée « +0,42 % » (signe − typographique, cohérent avec fondSignedInt).
function fondPctFmt(p) {
  const n = Number(p);
  if (!isFinite(n)) return '—';
  const s = Math.abs(n).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  return (n > 0 ? '+' : (n < 0 ? '−' : '')) + s + ' %';
}

// Pastille agrégée d'une ligne de l'Historique : « {ok}/{ok+ko} ✓ ».
// Vert si ok>ko, rouge si ko>ok, gris à égalité ; « — » pointillé si en attente
// ou si aucun biais n'était évaluable. Les snapshots WEEKLY ne sont pas évalués
// par la routine (contrat) → pastille seulement si une évaluation existe.
function FondEvalBadge({ row }) {
  const ev = (row.evaluation && typeof row.evaluation === 'object') ? row.evaluation : null;
  if (row.scope !== 'daily' && !ev) return null;
  const agg = fondEvalAgg(ev);
  const base = {
    display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0,
    fontSize: 10, fontWeight: 700, padding: '1px 8px', borderRadius: 999,
    whiteSpace: 'nowrap', cursor: 'help',
  };
  if (!row.evaluated_at || !agg) {
    const ms = row.generated_at ? Date.parse(row.generated_at) : null;
    const expired = Number.isFinite(ms) && (Date.now() - ms > FOND_EVAL_WINDOW_MS);
    if (expired) {
      return (
        <span title={FOND_EVAL_EXPIRED_TITLE}
          style={{ ...base, color: 'var(--fg-4)', border: '1px dashed var(--line-2)', background: 'transparent' }}>Non évalué</span>
      );
    }
    return (
      <span className="num" title="Verdict en attente — la justesse du biais est mesurée automatiquement lors d'une prochaine analyse (entre 2 h et 7 jours après celle-ci)."
        style={{ ...base, color: 'var(--fg-4)', border: '1px dashed var(--line-2)', background: 'transparent' }}>—</span>
    );
  }
  const evaluables = agg.ok + agg.ko;
  if (evaluables === 0) {
    return (
      <span className="num" title="Aucun biais évaluable sur cette analyse (biais neutres au moment de l'analyse, variations sous le seuil ou données indisponibles)."
        style={{ ...base, color: 'var(--fg-4)', border: '1px dashed var(--line-2)', background: 'transparent' }}>—</span>
    );
  }
  const color = agg.ok > agg.ko ? 'var(--green)' : (agg.ko > agg.ok ? 'var(--red)' : 'var(--fg-4)');
  const horizon = fondHorizonFmt(agg.horizonH);
  return (
    <span className="num"
      title={`Biais juste ${agg.ok} fois sur ${evaluables} catégories évaluables${horizon ? `, mesuré ${horizon} après l'analyse` : ''} (direction vs variation constatée, sources intraday — aucune simulation de position).`}
      style={{ ...base, color, border: '1px solid var(--line)', background: 'var(--bg-card)' }}>
      {agg.ok}/{evaluables} {'✓'}
    </span>
  );
}

// Tableau des verdicts par catégorie, affiché en RELECTURE d'un snapshot :
// catégorie · biais noté · variation mesurée · verdict. Daily sans évaluation
// → petite ligne « en attente » ; weekly sans évaluation → rien.
function FondEvalDetail({ evaluation, evaluatedAt, scope, generatedAtMs, mobile }) {
  const ev = (evaluation && typeof evaluation === 'object') ? evaluation : null;
  const cats = (ev && ev.cats && typeof ev.cats === 'object') ? ev.cats : null;
  if (!cats) {
    if (scope !== 'daily') return null;
    const expired = Number.isFinite(generatedAtMs) && (Date.now() - generatedAtMs > FOND_EVAL_WINDOW_MS);
    return (
      <div className="card" style={{ padding: mobile ? '10px 14px' : '11px 16px', fontSize: 11.5, color: 'var(--fg-3)', lineHeight: 1.5 }}>
        {expired
          ? "Biais non évalué — fenêtre d'évaluation dépassée : aucune analyse n'a été lancée dans les 7 jours suivant celle-ci."
          : "Verdict du biais en attente — mesuré automatiquement lors d'une prochaine analyse (entre 2 h et 7 jours après celle-ci)."}
      </div>
    );
  }
  const rows = FOND_CATS_ALL
    // bias null = catégorie NON incluse dans cette analyse (décochée) : le moteur
    // écrit quand même l'entrée (bias:null, non_evaluable) — l'afficher fabriquerait
    // un « Biais noté : Neutre » que l'analyse n'a jamais produit.
    .filter(c => cats[c.id] && typeof cats[c.id] === 'object' && cats[c.id].bias != null)
    .map(c => ({ id: c.id, label: c.label, e: cats[c.id] }));
  if (!rows.length) return null;
  const horizon = fondHorizonFmt(ev.horizonH);
  const cols = mobile ? '1.1fr 0.9fr 0.8fr 0.9fr' : '1.2fr 1fr 0.9fr 1fr';
  const cell = { fontSize: mobile ? 11 : 11.5, display: 'flex', alignItems: 'center', minWidth: 0 };

  return (
    <div className="card lift" style={{ padding: mobile ? '13px 14px' : '15px 18px' }}>
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', marginBottom: 10 }}>
        <span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--fg-3)', letterSpacing: '.05em', textTransform: 'uppercase' }}>Verdict du biais</span>
        {horizon && <span style={{ fontSize: 10.5, color: 'var(--fg-4)' }}>mesuré {horizon} après l'analyse · sources intraday</span>}
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: cols, gap: '7px 10px', alignItems: 'center' }}>
        <span style={{ fontSize: 9.5, fontWeight: 700, color: 'var(--fg-4)', letterSpacing: '.05em', textTransform: 'uppercase' }}>Catégorie</span>
        <span style={{ fontSize: 9.5, fontWeight: 700, color: 'var(--fg-4)', letterSpacing: '.05em', textTransform: 'uppercase' }}>Biais noté</span>
        <span style={{ fontSize: 9.5, fontWeight: 700, color: 'var(--fg-4)', letterSpacing: '.05em', textTransform: 'uppercase' }}>Mesuré</span>
        <span style={{ fontSize: 9.5, fontWeight: 700, color: 'var(--fg-4)', letterSpacing: '.05em', textTransform: 'uppercase' }}>Verdict</span>
        {rows.map(r => {
          const bias = fondBiasNorm(r.e.bias);
          const bs = FOND_BIAS_STYLE[bias];
          const verdict = fondVerdictNorm(r.e.verdict);
          const vs = FOND_VERDICT_STYLE[verdict];
          const pct = (verdict === 'non_evaluable') ? '—' : fondPctFmt(r.e.pct);
          const vTitle = verdict === 'neutre'
            ? 'Variation sous le seuil de neutralité — le marché n’a pas tranché.'
            : (verdict === 'non_evaluable'
              ? 'Non évaluable : biais neutre au moment de l’analyse, ou données de référence indisponibles.'
              : `Direction du biais ${vs.label.toLowerCase()} vs variation constatée.`);
          return (
            <React.Fragment key={r.id}>
              <span style={{ ...cell, fontWeight: 600, color: 'var(--fg)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{r.label}</span>
              <span style={{ ...cell, gap: 5, color: bs.fg }}>
                <span style={{ display: 'inline-flex', color: bs.dot }}>{bs.arrow}</span>{bs.label}
              </span>
              <span className="num" style={{ ...cell, color: 'var(--fg-2)' }}>{pct}</span>
              <span title={vTitle} style={{ ...cell, gap: 5, color: vs.color, fontWeight: 700, cursor: 'help' }}>
                <span aria-hidden="true">{vs.glyph}</span>{vs.label}
              </span>
            </React.Fragment>
          );
        })}
      </div>
      <div style={{ marginTop: 11, paddingTop: 9, borderTop: '1px solid var(--line)', fontSize: 10, color: 'var(--fg-4)', lineHeight: 1.5 }}>
        Mesure : direction du biais vs variation constatée (sources intraday), seuil de neutralité ±0,15-0,30%. Ne simule pas de prises de position.
      </div>
    </div>
  );
}

// Fetch léger DÉDIÉ pour la carte « Fiabilité du biais » (page Fondamental) :
// les analyses Jour ÉVALUÉES des 30 derniers jours. Choix documenté : le panneau
// Historique est un modal chargé à la demande — la carte vit sur la page et ne
// peut pas dépendre de son ouverture ; un select ciblé (id/date/evaluation,
// filtré serveur sur evaluated_at non null, index partiel dédié) est plus simple
// et plus léger que partager l'état du modal. null = indisponible (hors ligne,
// non connecté, migration absente) → la carte ne s'affiche pas.
async function fondFetchEvaluatedDaily() {
  try {
    if (typeof window === 'undefined' || !window.sb) return null;
    if (!(await fondHasSession())) return null;
    const since = new Date(Date.now() - FOND_RELIABILITY_DAYS * 24 * 3600e3).toISOString();
    const res = await window.sb.from('fondamental_snapshots')
      .select('id, generated_at, evaluation')
      .eq('scope', 'daily')
      .not('evaluated_at', 'is', null)
      .gte('generated_at', since)
      .order('generated_at', { ascending: true }) // chronologique : nourrit le fil ✓✗−
      .limit(120);
    if (res.error || !Array.isArray(res.data)) return null;
    return res.data.filter(r => r && r.id && r.evaluation && typeof r.evaluation === 'object');
  } catch (e) { return null; }
}

// Carte « Fiabilité du biais » — sous la grille des catégories (scope Jour).
// Par catégorie : taux de justesse ✓/(✓+✗) sur 30 jours, nombre d'évaluations,
// mini-fil chronologique ✓✗− (pattern FondBiasTrail). Statistiques complètes
// seulement à partir de 3 analyses évaluées ; 1-2 → message d'attente ; 0 →
// rien (la fonctionnalité ne fait pas de bruit tant qu'elle n'a rien mesuré).
function FondReliabilityCard({ refreshKey, mobile }) {
  const [rows, setRows] = React.useState(null);

  React.useEffect(() => {
    let alive = true;
    let timer = null;
    const load = async () => {
      const r = await fondFetchEvaluatedDaily();
      if (alive && r) setRows(r);
    };
    load();
    // L'évaluation moteur est fire-and-forget APRÈS la génération : une relance
    // différée rattrape les verdicts écrits juste après l'affichage du ctx.
    timer = setTimeout(load, 6000);
    return () => { alive = false; if (timer) clearTimeout(timer); };
  }, [refreshKey]);

  if (!Array.isArray(rows) || rows.length === 0) return null;

  if (rows.length < 3) {
    return (
      <React.Fragment>
        <FondSectionTitle>Fiabilité du biais</FondSectionTitle>
        <div className="card" style={{ padding: mobile ? '14px 16px' : '16px 20px', fontSize: 12, color: 'var(--fg-3)', lineHeight: 1.6 }}>
          Pas encore assez d'analyses évaluées — reviens dans quelques jours.
        </div>
      </React.Fragment>
    );
  }

  // Agrégation par catégorie (ordre FOND_CATS_ALL), fil chronologique ≤ 12 cases.
  const stats = FOND_CATS_ALL.map(c => {
    let ok = 0, ko = 0, neutre = 0;
    const cells = [];
    rows.forEach(r => {
      const e = (r.evaluation.cats && typeof r.evaluation.cats === 'object') ? r.evaluation.cats[c.id] : null;
      // Entrée absente OU bias null = catégorie non incluse dans l'analyse ce
      // jour-là (décochée) : le moteur écrit quand même les 5 catégories
      // (bias:null, non_evaluable) — pas de cellule, pas de compte. Une catégorie
      // jamais suivie disparaît via le filtre s.cells.length ci-dessous.
      if (!e || typeof e !== 'object' || e.bias == null) return;
      const v = fondVerdictNorm(e.verdict);
      if (v === 'juste') ok++; else if (v === 'faux') ko++; else if (v === 'neutre') neutre++;
      cells.push({ v, date: r.generated_at });
    });
    return { id: c.id, label: c.label, ok, ko, neutre, cells: cells.slice(-12) };
  }).filter(s => s.cells.length > 0);
  if (!stats.length) return null;

  return (
    <React.Fragment>
      <FondSectionTitle>Fiabilité du biais</FondSectionTitle>
      <div className="card lift" style={{ padding: mobile ? '14px 16px' : '16px 20px' }}>
        <div style={{ fontSize: 11.5, color: 'var(--fg-3)', lineHeight: 1.5, marginBottom: 12 }}>
          Direction du biais Jour vérifiée a posteriori — {FOND_RELIABILITY_DAYS} derniers jours ({rows.length} analyses évaluées).
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? 9 : 7 }}>
          {stats.map(s => {
            const evaluables = s.ok + s.ko;
            const rate = evaluables > 0 ? Math.round((s.ok / evaluables) * 100) : null;
            const rateColor = evaluables === 0 ? 'var(--fg-4)'
              : (s.ok > s.ko ? 'var(--green)' : (s.ko > s.ok ? 'var(--red)' : 'var(--fg-4)'));
            // L'effectif VISIBLE doit être le dénominateur du taux : « 100 % ·
            // 5 éval. » avec 4 neutres laissait croire à 100 % sur 5 mesures.
            const nTxt = evaluables > 0
              ? `${evaluables} tranché${evaluables > 1 ? 's' : ''} · ${s.neutre} neutre${s.neutre > 1 ? 's' : ''}`
              : (s.neutre > 0 ? `${s.neutre} neutre${s.neutre > 1 ? 's' : ''}` : '0 tranché');
            return (
              <div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: mobile ? 'wrap' : 'nowrap' }}>
                <span style={{ fontSize: 11.5, color: 'var(--fg-2)', fontWeight: 500, width: 118, flexShrink: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.label}</span>
                <span className="num"
                  title={evaluables > 0
                    ? `Biais juste ${s.ok} fois sur ${evaluables} verdicts tranchés (${s.neutre} neutres non comptés).`
                    : 'Aucun verdict tranché pour cette catégorie sur la période (biais neutres ou variations sous le seuil).'}
                  style={{ fontSize: 12, fontWeight: 700, color: rateColor, width: 46, flexShrink: 0, cursor: 'help' }}>
                  {rate == null ? '—' : rate + ' %'}
                </span>
                <span className="num" style={{ fontSize: 10.5, color: 'var(--fg-4)', width: 128, flexShrink: 0, whiteSpace: 'nowrap' }}>
                  {nTxt}
                </span>
                <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
                  {s.cells.map((cell, i) => {
                    const dateMs = cell.date ? Date.parse(cell.date) : null;
                    if (cell.v === 'non_evaluable') {
                      return <span key={i} title={`Non évaluable — ${dateMs ? fondDateTime(dateMs) : ''}`}
                        style={{ width: 11, height: 11, borderRadius: '50%', border: '1px dashed var(--line-2)', display: 'inline-block' }}></span>;
                    }
                    const vs = FOND_VERDICT_STYLE[cell.v];
                    return (
                      <span key={i} title={`${vs.label} — ${dateMs ? fondDateTime(dateMs) : ''}`}
                        style={{
                          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                          width: 15, height: 15, borderRadius: 5, fontSize: 9.5, fontWeight: 700,
                          background: 'var(--bg-elev)', color: vs.color, border: '1px solid var(--line)',
                        }}>{vs.glyph}</span>
                    );
                  })}
                </span>
              </div>
            );
          })}
        </div>
        <div style={{ marginTop: 12, paddingTop: 10, borderTop: '1px solid var(--line)', fontSize: 10, color: 'var(--fg-4)', lineHeight: 1.5 }}>
          Mesure : direction du biais vs variation constatée (sources intraday), seuil de neutralité ±0,15-0,30%. Ne simule pas de prises de position.
        </div>
      </div>
    </React.Fragment>
  );
}

// ─── Panneau Historique (modal) : liste + relecture + suppression ─────
function FondHistoryPanel({ scope, onClose, onView, mobile }) {
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);
  const [rows, setRows] = React.useState([]);
  const [confirmDel, setConfirmDel] = React.useState(null); // id en attente de confirmation
  const [delError, setDelError] = React.useState(false);    // dernier delete en échec
  const [viewLoading, setViewLoading] = React.useState(null); // id dont le ctx complet se charge

  React.useEffect(() => {
    let alive = true;
    (async () => {
      const res = await fondFetchSnapshots();
      if (!alive) return;
      setLoading(false);
      setError(res.error);
      setRows(res.rows);
    })();
    return () => { alive = false; };
  }, []);

  // Fermeture à la touche Échap — même convention que les autres modals (legal.jsx).
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose && onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);

  const del = async (id) => {
    setConfirmDel(null);
    setDelError(false);
    const removed = rows.find(r => r.id === id) || null;
    setRows(prev => prev.filter(r => r.id !== id)); // optimiste
    const ok = await fondDeleteSnapshot(id);
    if (!ok) {
      console.warn('Fondamental : suppression du snapshot impossible.');
      // Rollback : la ligne réapparaît à sa place (tri par date desc) + message.
      if (removed) {
        setRows(prev => prev.some(r => r.id === id)
          ? prev
          : prev.concat([removed]).sort((a, b) => (Date.parse(b.generated_at) || 0) - (Date.parse(a.generated_at) || 0)));
      }
      setDelError(true);
    }
  };

  // Relecture : charge le ctx COMPLET à la demande (la liste n'est qu'une projection légère).
  const view = async (r) => {
    if (viewLoading) return;
    setViewLoading(r.id);
    const full = await fondFetchSnapshotCtx(r.id);
    setViewLoading(null);
    if (full && onView) {
      const ms = full.generated_at ? Date.parse(full.generated_at) : null;
      onView({
        id: full.id, scope: full.scope, generatedAtMs: ms, ctx: full.ctx,
        // v35 : verdict du biais (colonne evaluation) → tableau en relecture.
        evaluation: full.evaluation || null, evaluatedAt: full.evaluated_at || null,
      });
    } else {
      console.warn('Fondamental : relecture du snapshot impossible.');
    }
  };

  // Biais : 10 derniers snapshots du scope AFFICHÉ dans la page, plus ancien → plus récent.
  const trailSnaps = rows.filter(r => r.scope === scope).slice(0, 10).reverse();

  return (
    /* Fermeture sur mousedown DU FOND uniquement (e.target === e.currentTarget) :
       une sélection de texte qui se termine sur le fond n'émet pas de mousedown
       sur le backdrop → le modal ne se ferme plus par accident. */
    <div onMouseDown={(e) => { if (e.target === e.currentTarget && onClose) onClose(); }} style={{
      position: 'fixed', inset: 0, zIndex: 200,
      background: 'rgba(15,23,42,0.40)',
      backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      padding: mobile ? 12 : 24, animation: 'fade-in .25s var(--ease)',
    }}>
      <div onClick={e => e.stopPropagation()} style={{
        width: '100%', maxWidth: 640, maxHeight: '88%',
        background: 'var(--bg-card)', borderRadius: 16,
        overflow: 'hidden', display: 'flex', flexDirection: 'column',
        boxShadow: '0 28px 56px rgba(0,0,0,.24)',
        animation: 'fade-up .35s var(--ease)',
      }}>
        {/* En-tête */}
        <div style={{ padding: '18px 22px 13px', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
            <span style={{ display: 'inline-flex', color: 'var(--blue-600)' }}>{FondIco.histo}</span>
            <div>
              <div style={{ fontSize: 15.5, fontWeight: 700, letterSpacing: '-.015em', color: 'var(--fg)' }}>Historique des analyses</div>
              <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 2 }}>Relis une analyse passée ou suis l'évolution du biais.</div>
            </div>
          </div>
          <button onClick={() => onClose && onClose()} className="tap" style={{ width: 30, height: 30, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg-2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{Ico.x}</button>
        </div>

        {/* Corps scrollable */}
        <div className="scroll" style={{ overflow: 'auto', padding: '14px 22px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
          {loading && (
            <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '18px 4px', color: 'var(--fg-3)', fontSize: 12.5 }}>
              <FondSpinner size={16} /> Chargement de l'historique…
            </div>
          )}

          {!loading && error === 'auth' && (
            <div style={{ padding: '22px 4px', textAlign: 'center', fontSize: 12.5, color: 'var(--fg-3)', lineHeight: 1.6 }}>
              Connecte-toi pour sauvegarder et retrouver l'historique de tes analyses.
            </div>
          )}
          {!loading && error && error !== 'auth' && (
            <div style={{ padding: '22px 4px', textAlign: 'center', fontSize: 12.5, color: 'var(--fg-3)', lineHeight: 1.6 }}>
              Historique indisponible pour le moment. Réessaie plus tard.
            </div>
          )}
          {!loading && !error && !rows.length && (
            <div style={{ padding: '22px 4px', textAlign: 'center', fontSize: 12.5, color: 'var(--fg-3)', lineHeight: 1.6 }}>
              Aucune analyse sauvegardée pour l'instant.<br />
              Chaque génération réussie est archivée automatiquement.
            </div>
          )}

          {!loading && rows.length > 0 && (
            <React.Fragment>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
                {rows.map(r => {
                  const ms = r.generated_at ? Date.parse(r.generated_at) : null;
                  const summary = r.summary ? String(r.summary) : '';
                  const short = summary.length > 110 ? summary.slice(0, 110).trimEnd() + '…' : summary;
                  const scopeLabel = r.scope === 'weekly' ? 'Semaine' : 'Jour';
                  return (
                    <div key={r.id} className="tap" onClick={() => view(r)}
                      style={{
                        display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer',
                        padding: '10px 12px', borderRadius: 11, border: '1px solid var(--line)', background: 'var(--bg-elev)',
                        opacity: (viewLoading && viewLoading !== r.id) ? 0.6 : 1,
                      }}>
                      <div style={{ flex: 1, minWidth: 0 }}>
                        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: short ? 4 : 0 }}>
                          <span className="num" style={{ fontSize: 12, fontWeight: 700, color: 'var(--fg)' }}>{ms ? fondDateTime(ms) : '—'}</span>
                          <span className="pill pill-gray" style={{ fontSize: 9.5, padding: '1px 8px' }}>{scopeLabel}</span>
                          <FondEvalBadge row={r} />
                          {viewLoading === r.id && <FondSpinner size={12} />}
                        </div>
                        {short && <div style={{ fontSize: 11.5, color: 'var(--fg-3)', lineHeight: 1.45, overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>{short}</div>}
                      </div>
                      {confirmDel === r.id ? (
                        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
                          <button className="btn tap" onClick={() => del(r.id)} style={{ fontSize: 10.5, padding: '4px 9px', color: 'var(--red-text)' }}>Supprimer</button>
                          <button className="btn tap" onClick={() => setConfirmDel(null)} style={{ fontSize: 10.5, padding: '4px 9px' }}>Non</button>
                        </span>
                      ) : (
                        <button className="tap" title="Supprimer cette analyse"
                          onClick={e => { e.stopPropagation(); setConfirmDel(r.id); }}
                          style={{ width: 26, height: 26, borderRadius: 7, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg-4)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
                          {FondIco.trash}
                        </button>
                      )}
                    </div>
                  );
                })}
              </div>

              {delError && (
                <div style={{ fontSize: 11.5, color: 'var(--red-text)', fontWeight: 500 }}>
                  Suppression impossible — réessaie.
                </div>
              )}

              <FondBiasTrail snaps={trailSnaps} />
            </React.Fragment>
          )}
        </div>
      </div>
    </div>
  );
}

// ─── Corps de page (partagé desktop / mobile) ────────────────────────

// Verrou de génération PAR SCOPE, en PORTÉE MODULE : il survit au démontage de
// la page (changement d'onglet dans app.jsx) et empêche deux runs concurrents.
// { promise, step, stepLabel, onStep } | null. Un remount se RATTACHE à la
// promesse en vol (progression réaffichée) au lieu de relancer l'analyse —
// donc jamais de double buildFundamentalContext ni de snapshots dupliqués.
const fondRunsInFlight = { daily: null, weekly: null };

// Borne d'ancienneté de l'hydratation snapshot : au-delà, un snapshot ne se fait
// pas passer pour l'analyse du jour (on retombe sur l'écran d'accueil).
// v34 : daily resserré de 20 h à 4 h — une analyse du matin ne se présente plus
// comme celle de l'après-midi ; weekly inchangé (5 j).
const FOND_SNAPSHOT_MAX_AGE = { daily: 4 * 3600e3, weekly: 5 * 24 * 3600e3 };
// Au-delà de ce délai, le ctx affiché est signalé « daté » par un bandeau ambré.
// PAR SCOPE (comme FOND_SNAPSHOT_MAX_AGE) : une analyse hebdo est valable
// plusieurs jours par nature — un seuil unique de 2 h faisait réclamer en boucle
// la régénération d'un snapshot weekly que l'hydratation 5 j venait d'autoriser.
const FOND_STALE_BANNER_MS = { daily: 2 * 3600e3, weekly: 24 * 3600e3 };
// En-deçà de 30 min, la régénération redemande confirmation (au-delà : 1 clic).
const FOND_FRESH_CONFIRM_MS = 30 * 60 * 1000;

function FondBody({ mobile = false }) {
  const [scope, setScopeRaw] = React.useState('daily');
  const [categories, setCategories] = React.useState(fondReadCategories);
  // État par scope : { phase, ctx, step, stepLabel }. phase ∈ welcome|running|ready|error.
  const [byScope, setByScope] = React.useState({
    daily:  { phase: 'welcome', ctx: null, step: 0, stepLabel: '' },
    weekly: { phase: 'welcome', ctx: null, step: 0, stepLabel: '' },
  });
  const [confirming, setConfirming] = React.useState(false);
  const [historyOpen, setHistoryOpen] = React.useState(false);
  // Relecture d'un snapshot : { id, scope, generatedAtMs, ctx } | null.
  const [snapView, setSnapView] = React.useState(null);

  const patch = React.useCallback((sc, obj) => {
    setByScope(prev => ({ ...prev, [sc]: { ...prev[sc], ...obj } }));
  }, []);

  // Changer d'onglet quitte la relecture d'un snapshot ET désarme la confirmation
  // de régénération (sinon « Oui, régénérer » relancerait l'AUTRE scope).
  const setScope = React.useCallback((sc) => {
    setSnapView(null);
    setConfirming(false);
    setScopeRaw(sc);
  }, []);

  // Hydratation 1 : cache local (< 30 min) → affichage instantané, aucune génération auto.
  React.useEffect(() => {
    setByScope(prev => {
      const next = { ...prev };
      ['daily', 'weekly'].forEach(sc => {
        const cached = fondReadCache(sc);
        if (cached && cached.ctx) next[sc] = { phase: 'ready', ctx: cached.ctx, step: 0, stepLabel: '' };
      });
      return next;
    });
  }, []);

  // Hydratation 2 (best-effort) : pour les scopes SANS cache frais, on affiche le
  // DERNIER snapshot Supabase (avec son horodatage réel via ctx.generatedAt) —
  // BORNÉ en ancienneté (4 h en daily, 5 j en weekly) : un snapshot plus vieux
  // est ignoré, l'écran d'accueil « Analyser le contexte » reprend sa place.
  // Si un événement FORT est passé depuis sa génération, le snapshot s'affiche
  // quand même mais le bandeau ambré « événement majeur passé » apparaît
  // immédiatement (voir ctxEventOutdated dans le rendu), quel que soit son âge.
  // On ne remplace jamais un run en cours ni un ctx déjà affiché.
  React.useEffect(() => {
    let alive = true;
    (async () => {
      try {
        for (const sc of ['daily', 'weekly']) {
          if (fondReadCache(sc)) continue;
          const row = await fondFetchLatestSnapshot(sc);
          if (!alive || !row || !row.ctx) continue;
          const ms = Number(row.ctx.generatedAt) ||
            (row.generated_at ? Date.parse(row.generated_at) : NaN);
          if (!Number.isFinite(ms) || Date.now() - ms > FOND_SNAPSHOT_MAX_AGE[sc]) continue;
          setByScope(prev => {
            if (prev[sc].phase !== 'welcome') return prev;
            return { ...prev, [sc]: { phase: 'ready', ctx: row.ctx, step: 0, stepLabel: '' } };
          });
        }
      } catch (e) {}
    })();
    return () => { alive = false; };
  }, []);

  // Lance le moteur local (toujours déclenché par un bouton user). Si un run est
  // DÉJÀ en vol pour ce scope (remount de la page ou second clic), on se rattache
  // à sa promesse au lieu d'en lancer un deuxième : une seule génération, un seul
  // snapshot, et la progression réapparaît là où elle en est.
  const run = React.useCallback(async (sc, cats) => {
    setConfirming(false);
    setSnapView(null);
    if (typeof window === 'undefined' || typeof window.buildFundamentalContext !== 'function') {
      patch(sc, { phase: 'error' });
      return;
    }
    const steps = fondSteps();
    let flight = fondRunsInFlight[sc];
    if (!flight) {
      flight = { step: 0, stepLabel: steps[0], onStep: null, promise: null };
      flight.promise = window.buildFundamentalContext({
        scope: sc,
        categories: cats.slice(),
        onStep: (stepIndex, label) => {
          const idx = Math.max(0, Math.min(steps.length - 1, Number(stepIndex) || 0));
          flight.step = idx;
          flight.stepLabel = label || steps[idx];
          if (typeof flight.onStep === 'function') flight.onStep(idx, flight.stepLabel);
        },
      });
      fondRunsInFlight[sc] = flight;
    }
    // Cette instance (re)prend l'affichage de la progression du run en vol.
    flight.onStep = (idx, label) => patch(sc, { step: idx, stepLabel: label });
    patch(sc, { phase: 'running', step: flight.step, stepLabel: flight.stepLabel });
    try {
      const ctx = await flight.promise;
      if (fondRunsInFlight[sc] === flight) fondRunsInFlight[sc] = null;
      if (!ctx || typeof ctx !== 'object') { patch(sc, { phase: 'error' }); return; }
      fondWriteCache(sc, ctx);
      // (Le snapshot Supabase est sauvegardé par le moteur lui-même — voir feSaveSnapshot.)
      patch(sc, { phase: 'ready', ctx, step: steps.length, stepLabel: '' });
    } catch (e) {
      if (fondRunsInFlight[sc] === flight) fondRunsInFlight[sc] = null;
      patch(sc, { phase: 'error' });
    }
  }, [patch]);

  // Hydratation 3 : un run encore EN VOL (page démontée pendant l'analyse puis
  // remontée) → on se rattache à sa promesse et la progression se réaffiche —
  // plus de retour trompeur à l'écran d'accueil en pleine génération.
  React.useEffect(() => {
    ['daily', 'weekly'].forEach(sc => {
      if (fondRunsInFlight[sc]) run(sc, categories);
    });
    // Au montage uniquement : `run` est stable, `categories` n'est lu que si un
    // run est déjà en vol (et n'y change rien).
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const regenerate = React.useCallback((sc, cats) => {
    fondPurgeCache(sc);
    run(sc, cats);
  }, [run]);

  const toggleCat = (id) => {
    setCategories(prev => {
      const on = prev.includes(id);
      if (on && prev.length === 1) return prev; // au moins une catégorie
      const next = FOND_CAT_IDS.filter(x => (x === id ? !on : prev.includes(x)));
      fondWriteCategories(next);
      return next;
    });
  };

  const cur = byScope[scope];
  const running = cur.phase === 'running';
  const hasData = cur.phase === 'ready' && cur.ctx;
  // Âge du contexte affiché : au-delà du seuil PAR SCOPE (2 h daily, 24 h
  // weekly) → bandeau ambré « analyse datée » avec relance en un clic ;
  // < 30 min → la régénération redemande confirmation.
  const ctxAge = (hasData && Number.isFinite(Number(cur.ctx.generatedAt)))
    ? Date.now() - Number(cur.ctx.generatedAt) : null;
  const ctxStale = ctxAge != null && ctxAge > FOND_STALE_BANNER_MS[scope];
  const ctxFresh = ctxAge != null && ctxAge < FOND_FRESH_CONFIRM_MS;
  // Péremption ÉVÉNEMENTIELLE (v34) : un événement d'impact FORT est passé depuis
  // la génération du ctx affiché (cache local OU snapshot hydraté) → bandeau
  // ambré immédiat, quel que soit l'âge (même < 2 h). Défensif : vieux ctx sans
  // timestamps ou moteur absent → false, seul l'âge décide comme avant.
  const ctxEventOutdated = !!(hasData && fondUiCtxOutdatedByEvent(cur.ctx));
  // Détecte un désaccord entre la sélection courante et les catégories analysées.
  const usedIds = hasData && Array.isArray(cur.ctx.categories) ? cur.ctx.categories.map(c => c && c.id).filter(Boolean) : [];
  const selectionChanged = hasData && usedIds.slice().sort().join('|') !== categories.slice().sort().join('|');

  const gap = mobile ? 12 : 16;

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap }}>
      <style>{'@keyframes fond-spin { to { transform: rotate(360deg); } }'}</style>

      {/* Contrôles : onglets Jour/Semaine + catégories */}
      <div style={{
        display: 'flex', alignItems: mobile ? 'flex-start' : 'center',
        justifyContent: 'space-between', gap: 12, flexWrap: 'wrap',
        flexDirection: mobile ? 'column' : 'row',
      }}>
        <FondSegmented options={FOND_SCOPES} value={scope} onChange={setScope} disabled={running} />
        <FondCategoryPicker categories={categories} onToggle={toggleCat} disabled={running} />
      </div>

      {snapView ? (
        /* ─── Relecture d'un snapshot (lecture seule) ─── */
        <React.Fragment>
          <div className="card" style={{
            padding: mobile ? '11px 14px' : '12px 16px', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
            background: 'var(--blue-soft)', border: '1px solid var(--blue-border)',
          }}>
            <span style={{ display: 'inline-flex', color: 'var(--blue-600)', flexShrink: 0 }}>{FondIco.histo}</span>
            <span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--fg)', flex: 1, minWidth: 160, lineHeight: 1.45 }}>
              Analyse du {snapView.generatedAtMs ? fondDateTime(snapView.generatedAtMs) : '—'}
              <span style={{ color: 'var(--fg-3)', fontWeight: 500 }}> · {snapView.scope === 'weekly' ? 'Semaine' : 'Jour'} · lecture seule</span>
            </span>
            <span style={{ display: 'inline-flex', gap: 8, flexWrap: 'wrap' }}>
              <button className="btn tap" onClick={() => setHistoryOpen(true)} style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                <span style={{ display: 'inline-flex' }}>{FondIco.histo}</span> Historique
              </button>
              <button className="btn btn-blue tap" onClick={() => setSnapView(null)} style={{ fontSize: 12 }}>
                Retour au direct
              </button>
            </span>
          </div>
          {/* v35 : verdict du biais mesuré a posteriori (jamais une simulation). */}
          <FondEvalDetail evaluation={snapView.evaluation} evaluatedAt={snapView.evaluatedAt} scope={snapView.scope} generatedAtMs={snapView.generatedAtMs} mobile={mobile} />
          <FondContextView ctx={snapView.ctx} mobile={mobile} />
        </React.Fragment>
      ) : (
        /* ─── Vue « direct » habituelle ─── */
        <React.Fragment>
          {/* Barre d'actions quand un contexte est affiché */}
          {hasData && (
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
                {cur.ctx.generatedAt && (
                  <span style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>Analysé le {fondDateTime(cur.ctx.generatedAt)}</span>
                )}
                {selectionChanged && (
                  <span style={{ fontSize: 11.5, color: 'var(--blue-600)', fontWeight: 500 }}>
                    Sélection modifiée — régénère pour l'appliquer.
                  </span>
                )}
              </div>
              <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
                <button className="btn tap" onClick={() => setHistoryOpen(true)}
                  style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                  <span style={{ display: 'inline-flex' }}>{FondIco.histo}</span> Historique
                </button>
                {confirming ? (
                  /* Confirmation UNIQUEMENT si l'analyse affichée a < 30 min :
                     au-delà, régénérer est le geste attendu → un seul clic. */
                  <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
                    <span style={{ fontSize: 12, color: 'var(--fg-2)', fontWeight: 500 }}>Analyse toute fraîche — relancer quand même ?</span>
                    <button className="btn btn-blue tap" onClick={() => regenerate(scope, categories)} style={{ fontSize: 12 }}>
                      Oui, régénérer
                    </button>
                    <button className="btn tap" onClick={() => setConfirming(false)} style={{ fontSize: 12 }}>Annuler</button>
                  </span>
                ) : (
                  <button className="btn tap"
                    onClick={() => (ctxFresh ? setConfirming(true) : regenerate(scope, categories))}
                    style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                    <span style={{ display: 'inline-flex' }}>{Ico.refresh}</span> Régénérer
                  </button>
                )}
              </div>
            </div>
          )}

          {/* Bandeau ambré « analyse à rafraîchir » : soit un événement MAJEUR est
              passé depuis la génération (v34 — priorité, quel que soit l'âge),
              soit le ctx affiché a plus de 2 h (snapshot hydraté du matin,
              veille…). Dans les deux cas : relance en UN clic. */}
          {hasData && (ctxEventOutdated || ctxStale) && (
            <div className="card" style={{
              padding: mobile ? '12px 14px' : '13px 16px',
              background: 'var(--amber-soft)', border: '1px solid var(--line)',
              display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
            }}>
              <span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--amber-text)', flex: 1, minWidth: 180, lineHeight: 1.5 }}>
                {ctxEventOutdated
                  ? 'Un événement majeur est passé depuis cette analyse — actualise.'
                  : `Analyse datée du ${fondDateTime(cur.ctx.generatedAt)} — relance pour le contexte ${scope === 'weekly' ? 'de la semaine' : 'du jour'}.`}
              </span>
              <button className="btn btn-blue tap" onClick={() => regenerate(scope, categories)} style={{ fontSize: 12.5 }}>
                Actualiser
              </button>
            </div>
          )}

          {/* Corps selon l'état */}
          {cur.phase === 'welcome' && (
            <FondWelcomeCard scope={scope} onAnalyse={() => run(scope, categories)} />
          )}
          {cur.phase === 'running' && (
            <FondProgressCard stepIndex={cur.step} stepLabel={cur.stepLabel} />
          )}
          {cur.phase === 'error' && (
            <FondErrorCard onRetry={() => run(scope, categories)} />
          )}
          {hasData && <FondContextView ctx={cur.ctx} mobile={mobile} />}
          {/* v35 : fiabilité du biais Jour, sous la grille des catégories.
              refreshKey = generatedAt : une nouvelle génération relit les
              verdicts (fetch immédiat + relance différée, l'évaluation moteur
              étant fire-and-forget). Scope Jour uniquement : la routine
              d'évaluation ne couvre que le daily. */}
          {hasData && scope === 'daily' && (
            <FondReliabilityCard refreshKey={Number(cur.ctx.generatedAt) || 0} mobile={mobile} />
          )}
        </React.Fragment>
      )}

      {/* Panneau Historique */}
      {historyOpen && (
        <FondHistoryPanel
          scope={scope}
          mobile={mobile}
          onClose={() => setHistoryOpen(false)}
          onView={(row) => { setSnapView(row); setHistoryOpen(false); }}
        />
      )}

      {/* Disclaimer — toujours visible */}
      <div style={{ textAlign: 'center', marginTop: 4 }}>
        <div style={{ fontSize: 11, color: 'var(--fg-4)' }}>
          Contenu informatif — ne constitue pas un conseil en investissement.
        </div>
      </div>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════
//  PAGE DESKTOP
// ═════════════════════════════════════════════════════════════════════
function FondamentalPage({ state }) {
  return (
    <div className="scroll" style={{ width: '100%', height: '100%', overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
      <PageHeader title="Fondamental" />
      <div style={{ padding: '20px 24px 40px', flex: 1, maxWidth: 1080, width: '100%', margin: '0 auto' }}>
        <FondBody />
      </div>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════
//  PAGE MOBILE
// ═════════════════════════════════════════════════════════════════════
function FondamentalMobile({ state }) {
  return (
    <div style={{ padding: '14px 16px 28px' }}>
      <h1 style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-.025em', margin: '0 0 16px' }}>Fondamental</h1>
      <FondBody mobile />
    </div>
  );
}

Object.assign(window, { FondamentalPage, FondamentalMobile });
