// 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,total?,events:[{title,country,impact,timeFR}]}], unavailable?, partial?, full? } (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, sans bloquer l'analyse, dans la table
// Supabase `fondamental_snapshots` (RLS owner-only, voir supabase-migration-fondamental.sql)
// PAR LE MOTEUR (feSaveSnapshot, design/fondamental-engine.jsx) — l'UI lit (hydratation,
// panneau Historique), supprime, et signale un enregistrement en échec (bouton « Réessayer »,
// voir fondSaveState). 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 ─────────────────────────────────────────────────────────
// Titre de la page : table NAV_LABELS (core.jsx), seule source des noms d'écrans.
function fondPageTitle() {
  const n = (typeof NAV_LABELS !== 'undefined' && NAV_LABELS && NAV_LABELS.fondamental) || null;
  return (n && n.titre) || 'Analyse fondamentale';
}

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.
// Le cache est une donnée locale que rien ne garantit : seule une analyse de
// la forme attendue (objet v2 de CET onglet, catégories et synthèse présentes,
// date d'enregistrement lisible) est affichée. Une entrée abîmée est supprimée
// — sinon elle s'affichait comme une analyse fantôme (« Contexte du jour —
// Risque événementiel modéré », sans catégorie). L'âge est borné des DEUX
// côtés : une date d'enregistrement dans le futur (horloge de l'ordinateur
// déréglée puis corrigée) ne rend pas le cache éternel ; une minute d'avance
// est tolérée. Les titres français (ctx.titlesFr) ne sont pas exigés ici :
// c'est le moteur qui décide de régénérer.
// 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;
    let obj = null;
    try { obj = JSON.parse(raw); } catch (e) { obj = null; }
    const c = obj && obj.ctx;
    const lisible = !!c && typeof c === 'object' && !Array.isArray(c)
      && c.v === 2 && c.scope === scope
      && Array.isArray(c.categories) && typeof c.summary === 'string'
      && Number.isFinite(obj.savedAt);
    const age = lisible ? Date.now() - obj.savedAt : NaN;
    if (!lisible || age < -60000) {
      try { localStorage.removeItem(FOND_LS_CTX(scope)); } catch (e) {}
      return null;
    }
    if (!(age <= FOND_CACHE_TTL)) return null;   // simple expiration : rien à nettoyer
    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) {}
}

// Instant d'une analyse (ms ou ISO) → « samedi 26 septembre à 14:30 », dans le
// fuseau du profil, via les formateurs communs (format-prefs.jsx). L'année
// s'ajoute d'elle-même si elle diffère de l'année en cours. Illisible → « — ».
function fondDateTime(ms) {
  if (ms == null || ms === '') return '—';
  const day = fmtDayLong(ms);
  if (day === '—') return '—';
  return day + ' à ' + tzTimeIn(ms);
}

// Titre d'un jour de la « Semaine à venir » : « Lundi 28 septembre ». La clé
// 'YYYY-MM-DD' est une date civile, formatée sans projection ; le libellé du
// moteur ne sert que de repli (vieux instantané sans date lisible).
function fondDayHeading(d) {
  const long = fmtDayLong(d && d.date);
  if (long !== '—') return long.charAt(0).toUpperCase() + long.slice(1);
  return (d && d.label) || '—';
}

// 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;
}

// Dernière valeur d'une série (formateurs communs, décimales fixes) : taux en %
// à 2 décimales (4,25), indices et or selon l'ordre de grandeur (6 512 · 121,4),
// paires de devises à 4 décimales (1,1712).
function fondSerieVal(v, unit) {
  const n = Number(v);
  if (v == null || !isFinite(n)) return '—';
  if (unit === '%') return fmtNum(n, 2);
  const a = Math.abs(n);
  if (a >= 1000) return fmtNum(n, 0);
  if (a >= 10) return fmtNum(n, 1);
  return fmtPrice(n, 4);
}
// Espace avant l'unité d'une série : fine insécable avant « % », insécable sinon.
function fondUnitSep(unit) {
  return unit === '%' ? '\u202F' : '\u00A0';
}

// 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). Aucune erreur ne casse la page :
// le message brut part dans console.warn('[Tempo] …') et l'écran affiche une
// phrase française (sbErrorMessage). Sans la section 3 de
// supabase-migration-fondamental.sql (colonnes evaluation / evaluated_at), les
// lectures retombent sur les colonnes de base : l'historique reste disponible,
// seuls les verdicts du biais manquent.
// NB : la SAUVEGARDE (lancée sans attente 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. Si elle échoue, le moteur le rend à l'écran (option onSnapshot),
// qui l'affiche et propose de réessayer : voir fondSaveState plus bas.

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; }
}

// Journal technique (jamais affiché) : préfixe commun de l'app.
function fondWarn(what, err) {
  try { console.warn('[Tempo] Fondamental : ' + what, err); } catch (e) {}
}

// Erreur « colonne absente » (42703) sur evaluation / evaluated_at : la
// migration du verdict du biais n'est pas encore exécutée.
function fondIsMissingEvalColumn(err) {
  if (!err) return false;
  const m = String(err.message || '');
  return err.code === '42703' || err.code === 'PGRST204' || /evaluat/i.test(m);
}

// Client absent (script Supabase non chargé) : même message qu'une coupure réseau.
function fondOfflineError() {
  return new TypeError('Failed to fetch');
}

// 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) { fondWarn('lecture de la dernière analyse impossible', res.error); return null; }
    if (!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) { fondWarn('lecture de la dernière analyse impossible', 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).
// → { error: null | 'auth' | 'load', cause, rows }.
async function fondFetchSnapshots() {
  const base = 'id, scope, generated_at, summary:ctx->>summary, biases:ctx->biases';
  const query = (cols) => window.sb.from('fondamental_snapshots')
    .select(cols)
    .order('generated_at', { ascending: false })
    .limit(FOND_SNAPSHOT_FETCH_LIMIT); // les deux scopes (120 daily + 40 weekly)
  try {
    if (typeof window === 'undefined' || !window.sb) return { error: 'load', cause: fondOfflineError(), rows: [] };
    if (!(await fondHasSession())) return { error: 'auth', cause: null, rows: [] };
    // 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.
    let res = await query(base + ', evaluation, evaluated_at');
    if (res.error && fondIsMissingEvalColumn(res.error)) {
      fondWarn('colonnes du verdict absentes (section 3 de supabase-migration-fondamental.sql) — historique sans verdicts', res.error);
      res = await query(base);
    }
    if (res.error) { fondWarn('chargement de l’historique impossible', res.error); return { error: 'load', cause: res.error, rows: [] }; }
    return { error: null, cause: null, rows: Array.isArray(res.data) ? res.data.filter(r => r && r.id) : [] };
  } catch (e) {
    fondWarn('chargement de l’historique impossible', e);
    return { error: 'load', cause: e, rows: [] };
  }
}

// Charge le ctx COMPLET d'un snapshot (relecture)
// → { row: { id, scope, generated_at, ctx, evaluation, evaluated_at } | null, error }.
// evaluation/evaluated_at (v35) alimentent le tableau des verdicts en relecture ;
// colonnes absentes (migration pas encore passée) → relecture sans verdict.
async function fondFetchSnapshotCtx(id) {
  const query = (cols) => window.sb.from('fondamental_snapshots').select(cols).eq('id', id).limit(1);
  try {
    if (!id || typeof window === 'undefined' || !window.sb) return { row: null, error: fondOfflineError() };
    let res = await query('id, scope, generated_at, ctx, evaluation, evaluated_at');
    if (res.error && fondIsMissingEvalColumn(res.error)) {
      fondWarn('colonnes du verdict absentes — relecture sans verdict', res.error);
      res = await query('id, scope, generated_at, ctx');
    }
    if (res.error) { fondWarn('relecture de l’analyse impossible', res.error); return { row: null, error: res.error }; }
    const row = Array.isArray(res.data) ? res.data[0] : null;
    if (!row || !row.ctx || typeof row.ctx !== 'object') {
      fondWarn('relecture : analyse introuvable ou illisible', id);
      return { row: null, error: null };
    }
    return { row, error: null };
  } catch (e) {
    fondWarn('relecture de l’analyse impossible', e);
    return { row: null, error: e };
  }
}

// Supprime un snapshot → null si réussi, sinon l'erreur (affichée via sbErrorMessage).
async function fondDeleteSnapshot(id) {
  try {
    if (!id || typeof window === 'undefined' || !window.sb) return fondOfflineError();
    const res = await window.sb.from('fondamental_snapshots').delete().eq('id', id);
    if (res.error) { fondWarn('suppression de l’analyse impossible', res.error); return res.error; }
    return null;
  } catch (e) {
    fondWarn('suppression de l’analyse impossible', e);
    return e;
  }
}

// ─── Primitives UI ───────────────────────────────────────────────────
function FondSegmented({ options, value, onChange, disabled, ariaLabel }) {
  // Style unique de l'application : SegmentedTabs (core.jsx).
  return <SegmentedTabs options={options} value={value} onChange={onChange} disabled={disabled} kind="choice" ariaLabel={ariaLabel} />;
}

// 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} type="button" onClick={() => !disabled && onToggle(c.id)} disabled={disabled} className="tap tap-44"
            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énementiel faible' },
  moyen:  { bg: 'var(--amber-soft)', fg: 'var(--amber-text)', dot: 'var(--amber)', label: 'Risque événementiel modéré' },
  eleve:  { bg: 'var(--red-soft)',   fg: 'var(--red-text)',   dot: 'var(--red)',   label: 'Risque événementiel é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)';
}

// Nom affiché d'un événement : le même que dans le Calendrier éco. (econTitleFr,
// econ.jsx). Les analyses récentes arrivent déjà traduites (sans effet) ; les
// anciennes, relues de l'historique, portent encore le titre anglais d'origine.
function fondEvTitle(t) {
  const raw = String(t || '').trim();
  if (!raw) return '';
  try { return (typeof econTitleFr === 'function' ? econTitleFr(raw) : raw) || raw; }
  catch (e) { return raw; }
}

// Majuscule initiale d'une date en début de ligne (« Mar. 10:00 », charte §4).
function fondCapFirst(s) {
  const t = String(s || '');
  return t ? t.charAt(0).toUpperCase() + t.slice(1) : t;
}

// Pastille impact d'un catalyseur ('High' | 'Medium'). Libellés communs avec le
// Calendrier éco. (IMPACT_LABEL, econ.jsx) : « Élevé », « Moyen ».
const FOND_IMPACT_LABEL = (typeof IMPACT_LABEL === 'object' && IMPACT_LABEL) ? IMPACT_LABEL : { high: 'Élevé', medium: 'Moyen', low: 'Faible' };
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 = FOND_IMPACT_LABEL[high ? 'high' : 'medium'];
  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…) : un h2, comme
// tout titre de section d'un écran (charte §8), au dessin inchangé.
function FondSectionTitle({ children }) {
  return (
    <h2 style={{ fontSize: 11, fontWeight: 700, color: 'var(--fg-3)', letterSpacing: '.06em', textTransform: 'uppercase', lineHeight: 'inherit', margin: '4px 2px -4px' }}>
      {children}
    </h2>
  );
}

// ─── É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>
      <CardTitle style={{ fontSize: 16, fontWeight: 700, letterSpacing: '-.015em', lineHeight: 'inherit', marginBottom: 7 }}>Contexte fondamental</CardTitle>
      <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'}\u00A0: `}
        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' }}>
      <CardTitle style={{ fontSize: 14.5, lineHeight: 'inherit', marginBottom: 4 }}>Analyse du contexte en cours</CardTitle>
      <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-strong) 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' }}>
      <CardTitle style={{ fontSize: 14.5, letterSpacing: 'normal', lineHeight: 'inherit', marginBottom: 6 }}>Analyse impossible</CardTitle>
      <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-3)', fontWeight: 500 }}> · {unit}</span> : null}
        </span>
        <span className="num" style={{ fontSize: 12, fontWeight: 700, color: 'var(--fg)', whiteSpace: 'nowrap' }}>
          {fondSerieVal(last, unit)}{unit ? <span style={{ fontSize: 10, color: 'var(--fg-3)', fontWeight: 500 }}>{fondUnitSep(unit) + 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 ? '' : `(${fmtNum(delta, 0, true)} 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-3)' }}>au {fmtDayShort(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 || net === 0) ? 'var(--fg-2)' : (net > 0 ? 'var(--green-text)' : 'var(--red-text)') }}>
          {`Fonds nets${market ? ` ${market}` : ''}\u00A0: ${fmtNum(net, 0, true)}`}
        </span>
        {delta != null && <span className="num" style={{ fontSize: 11, color: 'var(--fg-3)' }}>{deltaTxt}</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' }}>{fondCapFirst(c && c.whenFR) || '—'}</span>
              <span style={{ fontSize: 12.5, lineHeight: 1.4, color: 'var(--fg)', minWidth: 0, overflowWrap: 'anywhere' }}>{fondEvTitle(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-3)', 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>
  );
}

// Catalyseurs de la vue repliée : `n` lignes, les impacts élevés d'abord, puis
// les plus proches ; affichées dans l'ordre de la liste (chronologique). Avec
// « les n premiers », deux discours à impact moyen masquaient la publication
// majeure que la synthèse annonce juste au-dessus.
function fondTopCatalysts(list, n) {
  if (!Array.isArray(list) || list.length <= n) return Array.isArray(list) ? list : [];
  const isHigh = (c) => /high/i.test(String((c && c.impact) || ''));
  const rang = list.map((c, i) => i);
  const keep = new Set(rang.filter(i => isHigh(list[i])).concat(rang.filter(i => !isHigh(list[i]))).slice(0, n));
  return list.filter((c, i) => keep.has(i));
}

// ─── 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 catalyseurs (les plus importants, voir
  // fondTopCatalysts). Vue étendue : tout.
  const shownDrivers = open ? drivers : drivers.slice(0, 2);
  const shownCatalysts = open ? catalysts : fondTopCatalysts(catalysts, 2);

  return (
    <div className="card lift" style={{ padding: mobile ? '15px 16px' : '17px 18px', display: 'flex', flexDirection: 'column', gap: 13 }}>
      {/* En-tête cliquable : le titre de la carte (h2) contient le bouton qui
          déplie le détail, et le bouton dit s'il est déplié (aria-expanded). */}
      <h2 style={{ margin: 0, fontSize: 'inherit', fontWeight: 'inherit', lineHeight: 'inherit', letterSpacing: 'inherit' }}>
      <button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open} 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%', color: 'var(--fg)',
      }}>
        <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>
      </h2>

      {/* 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-3)' }}>+ {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-3)' }}>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) : [];
  // Drapeaux du moteur :
  //   unavailable — le calendrier n'a pas répondu : rien n'est mesuré ;
  //   partial     — seule la source de secours a répondu, elle s'arrête à la
  //                 fin de la semaine en cours ;
  //   full        — le calendrier complet a répondu pour toute la période :
  //                 seul ce marqueur POSITIF permet d'affirmer qu'il n'y a
  //                 aucun événement.
  // Les analyses plus anciennes (cache, Historique) n'ont aucun drapeau, alors
  // que leur calendrier ne couvrait qu'une semaine : devant une liste vide
  // elles reçoivent une formule neutre, jamais l'affirmation.
  const unavailable = !!(weekAhead && weekAhead.unavailable);
  const partial = !!(weekAhead && weekAhead.partial);
  const full = !!(weekAhead && weekAhead.full);
  if (!days.length) {
    // Quatre états distincts : une panne, une source incomplète ou une
    // couverture inconnue ne sont pas une semaine calme (charte §7).
    // La période est datée par rapport à l'ANALYSE et non au jour de lecture :
    // la même analyse se relit depuis l'Historique des semaines plus tard.
    return (
      <div className="card" style={{ padding: '26px 20px', textAlign: 'center', fontSize: 12.5, color: 'var(--fg-3)', lineHeight: 1.6 }}>
        {unavailable
          ? 'Calendrier économique indisponible\u00A0: la semaine à venir ne peut pas être affichée. Relance l’analyse dans quelques minutes.'
          : partial
            ? 'Le calendrier de secours s’arrête à la fin de cette semaine\u00A0: la semaine prochaine n’est pas encore disponible.'
            : full
              ? 'Aucun événement à impact élevé ou moyen sur les 7 jours qui suivent cette analyse.'
              : 'Aucun événement connu sur la période couverte par cette analyse.'}
      </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;
          // `total` (moteur récent) : nombre réel d'événements du jour ; la
          // liste est abrégée quand il dépasse le nombre de lignes. Absent
          // des analyses plus anciennes : seul le nombre de lignes est connu.
          const total = Number.isFinite(d.total) ? d.total : 0;
          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)' }}>{fondDayHeading(d)}</span>
                  {events.length > 0 && (
                    <span className="num" style={{ fontSize: 10.5, color: 'var(--fg-3)' }}>
                      {total > events.length
                        ? `${events.length} sur ${total} événements`
                        : `${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, lineHeight: 1.4, color: 'var(--fg)', minWidth: 0, overflowWrap: 'anywhere', flex: 1 }}>{fondEvTitle(ev && ev.title)}</span>
                        {ev && ev.country && (
                          <span className="pill pill-gray" style={{ fontSize: 9.5, padding: '1px 7px', flexShrink: 0 }}>{/^all$/i.test(ev.country) ? 'Monde' : ev.country}</span>
                        )}
                      </div>
                    ))}
                  </div>
                ) : (
                  <div style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>Aucun événement majeur.</div>
                )}
              </div>
            </div>
          );
        })}
      </div>
      {partial && (
        <div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--line)', fontSize: 11.5, color: 'var(--fg-3)', lineHeight: 1.5 }}>
          Le calendrier de secours s’arrête à la fin de cette semaine{'\u00A0'}: la semaine prochaine n’est pas encore disponible.
        </div>
      )}
    </div>
  );
}

// ─── Rendu complet du contexte ───────────────────────────────────────
// `notice` (optionnel) : ligne d'information affichée au pied de la carte
// d'en-tête, sous « Analysé le … » (enregistrement dans l'historique en échec).
function FondContextView({ ctx, mobile, notice }) {
  const cats = Array.isArray(ctx.categories) ? ctx.categories : [];
  // Contextes déjà en cache ou dans l'Historique : anciens libellés du moteur
  // ('news', 'cotations intraday') traduits comme les nouveaux.
  const degraded = (Array.isArray(ctx.degradedSources) ? ctx.degradedSources.filter(Boolean) : [])
    .map(s => ({ news: 'actualités', 'cotations intraday': 'cotations du jour' }[s] || s));
  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 }}>
              {fondEvTitle(pivot.title)}{pivot.whenFR ? <span className="num" style={{ color: 'var(--fg-2)', marginLeft: 8, whiteSpace: 'nowrap' }}>{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\u00A0: ' + degraded.join(', ')}
            </span>
          )}
        </div>
        {notice || null}
      </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.';
// Note de méthode, commune au tableau des verdicts (relecture) et à la carte
// « Fiabilité du biais » : un seul texte, une seule formulation.
const FOND_EVAL_METHOD_NOTE = 'Mesure\u00A0: direction du biais par rapport à la variation constatée (données du jour), seuil de neutralité de ±0,15 à ±0,30\u202F%. Ne simule pas de prises de position.';

// Couleurs de TEXTE (contraste ≥ 4,5:1 en clair comme en sombre) : --fg-4 est
// réservé au décoratif.
const FOND_VERDICT_STYLE = {
  juste:  { color: 'var(--green-text)', glyph: '✓', label: 'Juste' },   // ✓
  faux:   { color: 'var(--red-text)',   glyph: '✗', label: 'Faux' },    // ✗
  neutre: { color: 'var(--fg-3)',       glyph: '−', label: 'Neutre' },  // −
  non_evaluable: { color: 'var(--fg-3)', 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 (formateur commun des durées) : « 5 h », « 2 j 4 h ». '' si absent.
function fondHorizonFmt(h) {
  const n = Math.round(Number(h));
  if (!isFinite(n) || n <= 0) return '';
  return fmtDuration(n * 60);
}

// 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-3)', 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-3)', 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-3)', border: '1px dashed var(--line-2)', background: 'transparent' }}>—</span>
    );
  }
  const color = agg.ok > agg.ko ? 'var(--green-text)' : (agg.ko > agg.ok ? 'var(--red-text)' : 'var(--fg-3)');
  const horizon = fondHorizonFmt(agg.horizonH);
  return (
    <span className="num"
      title={`Biais juste ${agg.ok} fois sur ${evaluables} catégorie${evaluables > 1 ? 's' : ''} évaluable${evaluables > 1 ? 's' : ''}${horizon ? `, mesuré ${horizon} après l’analyse` : ''} (direction du biais par rapport à la variation constatée, données du jour — 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\u00A0: 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-3)' }}>mesuré {horizon} après l’analyse · données du jour</span>}
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: cols, gap: '7px 10px', alignItems: 'center' }}>
        <span style={{ fontSize: 9.5, fontWeight: 700, color: 'var(--fg-3)', letterSpacing: '.05em', textTransform: 'uppercase' }}>Catégorie</span>
        <span style={{ fontSize: 9.5, fontWeight: 700, color: 'var(--fg-3)', letterSpacing: '.05em', textTransform: 'uppercase' }}>Biais noté</span>
        <span style={{ fontSize: 9.5, fontWeight: 700, color: 'var(--fg-3)', letterSpacing: '.05em', textTransform: 'uppercase' }}>Mesuré</span>
        <span style={{ fontSize: 9.5, fontWeight: 700, color: 'var(--fg-3)', 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') ? '—' : fmtPct(r.e.pct, 2, true);
          const vTitle = verdict === 'neutre'
            ? 'Variation sous le seuil de neutralité — le marché n’a pas tranché.'
            : (verdict === 'non_evaluable'
              ? 'Non évaluable\u00A0: biais neutre au moment de l’analyse, ou données de référence indisponibles.'
              : `Direction du biais ${vs.label.toLowerCase()} par rapport à la 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-3)', lineHeight: 1.5 }}>
        {FOND_EVAL_METHOD_NOTE}
      </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) {
      fondWarn(fondIsMissingEvalColumn(res.error)
        ? 'colonnes du verdict absentes — carte « Fiabilité du biais » masquée'
        : 'lecture des verdicts impossible', res.error);
      return null;
    }
    if (!Array.isArray(res.data)) return null;
    return res.data.filter(r => r && r.id && r.evaluation && typeof r.evaluation === 'object');
  } catch (e) { fondWarn('lecture des verdicts impossible', 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-3)'
              : (s.ok > s.ko ? 'var(--green-text)' : (s.ko > s.ok ? 'var(--red-text)' : 'var(--fg-3)'));
            // 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} verdict${evaluables > 1 ? 's' : ''} tranché${evaluables > 1 ? 's' : ''} (${s.neutre} neutre${s.neutre > 1 ? 's' : ''} non compté${s.neutre > 1 ? '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 ? '—' : fmtPct(rate, 0)}
                </span>
                <span className="num" style={{ fontSize: 10.5, color: 'var(--fg-3)', 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-3)', lineHeight: 1.5 }}>
          {FOND_EVAL_METHOD_NOTE}
        </div>
      </div>
    </React.Fragment>
  );
}

// ─── Panneau Historique (modal) : liste + relecture + suppression ─────
// Erreur technique → phrase française (supabase.jsx), jamais le message brut.
function fondErrText(err) {
  try {
    if (err && typeof sbErrorMessage === 'function') return sbErrorMessage(err) || '';
  } catch (e) {}
  return err ? 'Une erreur est survenue. Réessaie dans un instant.' : '';
}

// Comportement commun des modales (useModalBehavior, core.jsx — charte §8) :
// fond sur mousedown uniquement, Échap sur la modale du dessus, focus piégé
// puis rendu au déclencheur. Sous 900 px (mobile), feuille pleine largeur.
function FondHistoryPanel({ scope, onClose, onView, mobile }) {
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);         // null | 'auth' | 'load'
  const [errorText, setErrorText] = React.useState('');
  const [rows, setRows] = React.useState([]);
  const [reloadKey, setReloadKey] = React.useState(0);    // « Réessayer » relance le chargement
  const [confirmDel, setConfirmDel] = React.useState(null); // id en attente de confirmation
  const [deletingId, setDeletingId] = React.useState(null); // suppression en cours
  const [rowError, setRowError] = React.useState(null);     // { id, text } : dernier échec sur une ligne
  const [viewLoading, setViewLoading] = React.useState(null); // id dont le ctx complet se charge
  const busyRef = React.useRef(false); // anti double envoi (suppression, relecture)
  const modal = useModalBehavior({ open: true, onRequestClose: onClose, labelledBy: 'fond-history-title' });

  React.useEffect(() => {
    let alive = true;
    setLoading(true);
    (async () => {
      const res = await fondFetchSnapshots();
      if (!alive) return;
      setLoading(false);
      setError(res.error);
      setErrorText(res.error === 'load' ? fondErrText(res.cause) : '');
      // Une erreur de chargement n'est pas un historique vide : les lignes déjà
      // affichées restent en place.
      if (!res.error) setRows(res.rows);
    })();
    return () => { alive = false; };
  }, [reloadKey]);

  const del = async (r) => {
    if (busyRef.current) return;
    busyRef.current = true;
    setDeletingId(r.id);
    setRowError(null);
    const err = await fondDeleteSnapshot(r.id);
    busyRef.current = false;
    setDeletingId(null);
    setConfirmDel(null);
    if (err) {
      setRowError({ id: r.id, text: 'Suppression impossible. ' + fondErrText(err) });
      return;
    }
    setRows(prev => prev.filter(x => x.id !== r.id));
  };

  // Relecture : charge le ctx COMPLET à la demande (la liste n'est qu'une projection légère).
  const view = async (r) => {
    if (busyRef.current) return;
    busyRef.current = true;
    setRowError(null);
    setViewLoading(r.id);
    const res = await fondFetchSnapshotCtx(r.id);
    busyRef.current = false;
    setViewLoading(null);
    const full = res.row;
    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 {
      setRowError({
        id: r.id,
        text: res.error
          ? 'Impossible d’ouvrir cette analyse. ' + fondErrText(res.error)
          : 'Cette analyse est introuvable ou illisible.',
      });
    }
  };

  // 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();
  const padX = mobile ? 12 : 22;
  const miniBtn = {
    fontSize: 11.5, fontWeight: 600, padding: '5px 11px', borderRadius: 7, cursor: 'pointer',
    fontFamily: 'inherit', whiteSpace: 'nowrap',
  };

  return (
    <div {...modal.backdropProps} style={{
      position: 'fixed', inset: 0, zIndex: 200,
      background: 'rgba(15,23,42,0.40)',
      backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
      display: 'flex', alignItems: mobile ? 'flex-end' : 'center', justifyContent: 'center',
      padding: mobile ? 0 : 24, animation: 'fade-in .25s var(--ease)',
    }}>
      <div {...modal.dialogProps} style={{
        width: '100%', maxWidth: mobile ? 'none' : 640, maxHeight: mobile ? '92dvh' : '88%',
        background: 'var(--bg-card)', borderRadius: mobile ? '16px 16px 0 0' : 16,
        overflow: 'hidden', display: 'flex', flexDirection: 'column', outline: 'none',
        boxShadow: '0 28px 56px rgba(0,0,0,.24)',
        animation: 'fade-up .35s var(--ease)',
      }}>
        {/* En-tête */}
        <div style={{ padding: mobile ? `14px ${padX}px 12px` : `18px ${padX}px 13px`, borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 9, minWidth: 0 }}>
            <span style={{ display: 'inline-flex', color: 'var(--blue-600)', flexShrink: 0 }}>{FondIco.histo}</span>
            <div style={{ minWidth: 0 }}>
              <div id="fond-history-title" 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 type="button" onClick={modal.requestClose} className="tap tap-44" aria-label="Fermer" title="Fermer" 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: mobile ? `12px ${padX}px calc(16px + env(safe-area-inset-bottom))` : `14px ${padX}px 20px`, display: 'flex', flexDirection: 'column', gap: 12 }}>
          {loading && !rows.length && error !== 'load' && (
            <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>
          )}
          {error === 'load' && (
            <div role="alert" style={{ padding: '11px 13px', borderRadius: 10, background: 'var(--amber-soft)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
              <span style={{ fontSize: 12.5, color: 'var(--amber-text)', fontWeight: 500, lineHeight: 1.5, flex: '1 1 220px' }}>
                Impossible de charger l’historique.{errorText ? ' ' + errorText : ''}
              </span>
              <button type="button" className="btn tap" onClick={() => setReloadKey(k => k + 1)} disabled={loading}
                style={{ fontSize: 12, color: 'var(--fg)', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                <span style={{ display: 'inline-flex' }}>{Ico.refresh}</span> {loading ? 'Chargement…' : 'Réessayer'}
              </button>
            </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 analyse réussie est archivée automatiquement.
            </div>
          )}

          {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 when = ms ? fondDateTime(ms) : '—';
                  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';
                  const confirming = confirmDel === r.id;
                  const deleting = deletingId === r.id;
                  return (
                    <div key={r.id} style={{
                      display: 'flex', flexDirection: 'column', gap: 8,
                      padding: '10px 12px', borderRadius: 11, border: '1px solid var(--line)', background: 'var(--bg-elev)',
                      opacity: (viewLoading && viewLoading !== r.id) ? 0.6 : 1,
                    }}>
                      <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
                        <button type="button" className="tap" onClick={() => view(r)} disabled={!!viewLoading || !!deletingId}
                          title="Relire cette analyse"
                          style={{
                            flex: 1, minWidth: 0, display: 'block', textAlign: 'left', cursor: 'pointer',
                            background: 'transparent', border: 'none', padding: 0, margin: 0,
                            fontFamily: 'inherit', color: 'var(--fg)',
                          }}>
                          <span style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: short ? 4 : 0 }}>
                            <span className="num" style={{ fontSize: 12, fontWeight: 700, color: 'var(--fg)' }}>{when}</span>
                            <span className="pill pill-gray" style={{ fontSize: 9.5, padding: '1px 8px' }}>{scopeLabel}</span>
                            <FondEvalBadge row={r} />
                            {viewLoading === r.id && <FondSpinner size={12} />}
                          </span>
                          {short && <span style={{ fontSize: 11.5, color: 'var(--fg-3)', lineHeight: 1.45, overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>{short}</span>}
                        </button>
                        {!confirming && (
                          <button type="button" className="tap" aria-label="Supprimer cette analyse" title="Supprimer cette analyse"
                            onClick={() => { setRowError(null); setConfirmDel(r.id); }} disabled={!!deletingId}
                            style={{ width: 26, height: 26, borderRadius: 7, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg-3)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
                            {FondIco.trash}
                          </button>
                        )}
                      </div>
                      {confirming && (
                        /* Confirmation en ligne (modèle du backtest) : la question
                           nomme l'objet et la conséquence. */
                        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', paddingTop: 8, borderTop: '1px solid var(--line)' }}>
                          <span style={{ fontSize: 11.5, color: 'var(--fg-2)', lineHeight: 1.45, flex: '1 1 220px' }}>
                            {`Supprimer l’analyse du ${when}\u202F? Elle sera définitivement retirée de ton historique.`}
                          </span>
                          <span style={{ display: 'inline-flex', gap: 8, flexShrink: 0 }}>
                            <button type="button" className="tap" onClick={() => del(r)} disabled={deleting}
                              style={{ ...miniBtn, background: 'var(--sell-bg, #dc2626)', border: '1px solid var(--sell-bg, #dc2626)', color: '#fff', opacity: deleting ? 0.55 : 1 }}>
                              {deleting ? 'Suppression…' : 'Supprimer'}
                            </button>
                            <button type="button" className="tap" onClick={() => setConfirmDel(null)} disabled={deleting}
                              style={{ ...miniBtn, background: 'var(--bg-card)', border: '1px solid var(--line)', color: 'var(--fg)' }}>
                              Annuler
                            </button>
                          </span>
                        </div>
                      )}
                      {rowError && rowError.id === r.id && (
                        <div role="alert" style={{ fontSize: 11.5, color: 'var(--red-text)', fontWeight: 500, lineHeight: 1.45 }}>
                          {rowError.text}
                        </div>
                      )}
                    </div>
                  );
                })}
              </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 };

// Enregistrement de l'analyse dans l'historique, PAR SCOPE et en PORTÉE MODULE
// comme le verrou ci-dessus : le moteur enregistre sans attendre, son résultat
// peut donc arriver après un changement de page — il ne doit pas se perdre.
//   unsavedAt   — generatedAt de l'analyse dont l'enregistrement a échoué
//                 (null = rien à signaler). Comparé à l'analyse AFFICHÉE : une
//                 nouvelle analyse ne porte jamais l'avertissement de l'ancienne.
//   retrying    — une nouvelle tentative est en cours (bouton désactivé : la
//                 table n'a pas de contrainte d'unicité, deux tentatives en
//                 parallèle créeraient deux lignes).
//   retryFailed — la dernière tentative manuelle a échoué elle aussi.
// Les pages montées s'abonnent (fondSaveSubs) pour se redessiner.
const fondSaveState = {
  daily:  { unsavedAt: null, retrying: false, retryFailed: false },
  weekly: { unsavedAt: null, retrying: false, retryFailed: false },
};
const fondSaveSubs = new Set();
function fondSaveNotify() {
  fondSaveSubs.forEach(fn => { try { fn(); } catch (e) {} });
}
// Résultat de l'enregistrement fait par le moteur à la fin d'une analyse
// (option onSnapshot) : { ok } | { skipped } | { error }. Rien n'est signalé
// pour { skipped } (non connecté : il n'y a pas d'historique).
function fondSaveReport(sc, res, ctx) {
  const st = fondSaveState[sc];
  if (!st) return;
  const at = Number(ctx && ctx.generatedAt);
  st.unsavedAt = (res && res.error && Number.isFinite(at)) ? at : null;
  st.retryFailed = false;
  fondSaveNotify();
}
// « Réessayer » : nouvel enregistrement de la MÊME analyse, sans la régénérer.
async function fondSaveRetry(sc, ctx) {
  const st = fondSaveState[sc];
  const at = Number(ctx && ctx.generatedAt);
  if (!st || st.retrying || !Number.isFinite(at) || st.unsavedAt !== at) return;
  st.retrying = true;
  fondSaveNotify();
  let res = null;
  try {
    res = (typeof window !== 'undefined' && typeof window.fondSaveSnapshot === 'function')
      ? await window.fondSaveSnapshot(ctx)
      : { error: new Error('moteur absent') };
  } catch (e) { res = { error: e }; }
  st.retrying = false;
  // Entre-temps une autre analyse a pu remplacer celle-ci : on ne touche à
  // l'avertissement que s'il concerne toujours cette analyse.
  if (st.unsavedAt === at) {
    if (res && res.error) st.retryFailed = true;
    else { st.unsavedAt = null; st.retryFailed = false; }
  }
  fondSaveNotify();
}

// 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, refreshError }. phase ∈
  // welcome|running|ready|error. refreshError : la dernière régénération a
  // échoué, l'analyse précédente (ctx) est restée affichée.
  const [byScope, setByScope] = React.useState({
    daily:  { phase: 'welcome', ctx: null, step: 0, stepLabel: '', refreshError: false },
    weekly: { phase: 'welcome', ctx: null, step: 0, stepLabel: '', refreshError: false },
  });
  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);

  // Horloge de la page : les bandeaux « Un événement majeur est passé depuis
  // cette analyse » et « Analyse datée » dépendent de l'heure, pas d'une action.
  // Sans elle, une page laissée ouverte ne les affichait qu'au prochain clic.
  // Nouveau rendu toutes les 30 s, et dès le retour sur l'onglet (les
  // minuteries sont ralenties en arrière-plan). Aucun remontage : les états
  // locaux (cartes dépliées, confirmation, panneau Historique) sont conservés.
  const [, setTick] = React.useState(0);
  React.useEffect(() => {
    const id = setInterval(() => setTick(t => t + 1), 30000);
    const onVisible = () => { if (document.visibilityState === 'visible') setTick(t => t + 1); };
    document.addEventListener('visibilitychange', onVisible);
    return () => { clearInterval(id); document.removeEventListener('visibilitychange', onVisible); };
  }, []);

  // Enregistrement dans l'historique : l'état vit hors du composant
  // (fondSaveState) ; la page s'y abonne pour se redessiner quand il change,
  // et se met à jour dès le montage (résultat arrivé pendant qu'elle était
  // démontée, ou entre le premier rendu et cet effet).
  const [, setSaveTick] = React.useState(0);
  React.useEffect(() => {
    const sub = () => setSaveTick(t => t + 1);
    fondSaveSubs.add(sub);
    sub();
    return () => { fondSaveSubs.delete(sub); };
  }, []);

  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: '', refreshError: false };
      });
      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: '', refreshError: false } };
          });
        }
      } 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.
  // `force` (régénération) : le moteur ignore son cache et relit toutes les
  // sources. L'analyse déjà affichée n'est PAS effacée avant le résultat : si
  // la relecture échoue, elle reste à l'écran avec un bandeau « Réessayer »
  // (charte §7) ; l'écran « Analyse impossible » ne sert que s'il n'y avait rien.
  const run = React.useCallback(async (sc, cats, force) => {
    setConfirming(false);
    setSnapView(null);
    const fail = (err) => {
      try { console.warn('[Tempo] analyse fondamentale : génération impossible (' + ((err && err.message) || err) + ')'); } catch (e) {}
      setByScope(prev => {
        const p = prev[sc];
        return {
          ...prev,
          [sc]: p.ctx
            ? { ...p, phase: 'ready', step: 0, stepLabel: '', refreshError: true }
            : { ...p, phase: 'error', step: 0, stepLabel: '', refreshError: false },
        };
      });
    };
    if (typeof window === 'undefined' || typeof window.buildFundamentalContext !== 'function') {
      fail(new Error('moteur absent'));
      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(),
        force: !!force,
        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);
        },
        // Fin de l'enregistrement dans l'historique (après le retour de
        // l'analyse) : rangé hors du composant, la page peut avoir changé.
        onSnapshot: (res, saved) => fondSaveReport(sc, res, saved),
      });
      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, refreshError: false });
    try {
      const ctx = await flight.promise;
      if (fondRunsInFlight[sc] === flight) fondRunsInFlight[sc] = null;
      if (!ctx || typeof ctx !== 'object') { fail(new Error('contexte vide')); return; }
      fondWriteCache(sc, ctx);
      // (L'analyse est enregistrée dans l'historique par le moteur lui-même —
      // voir feSaveSnapshot ; un échec revient par onSnapshot → fondSaveState.)
      patch(sc, { phase: 'ready', ctx, step: steps.length, stepLabel: '', refreshError: false });
    } catch (e) {
      if (fondRunsInFlight[sc] === flight) fondRunsInFlight[sc] = null;
      fail(e);
    }
  }, [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

  // Régénérer / Actualiser : relecture forcée, sans purger le cache avant — le
  // moteur ne l'écrase qu'en cas de succès (voir `run`).
  const regenerate = React.useCallback((sc, cats) => {
    run(sc, cats, true);
  }, [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.
  // Ces âges dépendent de l'heure : l'horloge de la page (plus haut) les fait
  // recalculer même sans action.
  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];
  // Fraîcheur lue AU CLIC (et non au dernier rendu) : la question « Analyse
  // toute fraîche » ne se pose pas pour une analyse qui a vieilli entre-temps.
  const ctxIsFresh = () => {
    const at = hasData ? Number(cur.ctx.generatedAt) : NaN;
    return Number.isFinite(at) && Date.now() - at < 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('|');

  // Enregistrement dans l'historique en échec pour l'analyse AFFICHÉE : une
  // ligne discrète sous son en-tête, avec « Réessayer » (nouvel enregistrement
  // de la même analyse, sans la régénérer). Sans elle, l'analyse manquait le
  // lendemain dans l'Historique et la fiabilité du biais sans que rien ne l'ait
  // dit (charte §7 : un échec produit un message).
  const saveSt = fondSaveState[scope];
  const saveFailed = !!(hasData && saveSt.unsavedAt != null && saveSt.unsavedAt === Number(cur.ctx.generatedAt));
  const saveNotice = saveFailed ? (
    <div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--line)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
      <span role="status" style={{ fontSize: 12, fontWeight: 500, color: 'var(--amber-text)', flex: 1, minWidth: 180, lineHeight: 1.5 }}>
        {saveSt.retryFailed
          ? 'Cette analyse n’a toujours pas pu être enregistrée dans ton historique. Vérifie ta connexion.'
          : 'Cette analyse n’a pas pu être enregistrée dans ton historique.'}
      </span>
      <button className="btn tap" disabled={saveSt.retrying} onClick={() => fondSaveRetry(scope, cur.ctx)} style={{ fontSize: 12, color: 'var(--fg)' }}>
        {saveSt.retrying ? 'Enregistrement…' : 'Réessayer'}
      </button>
    </div>
  ) : null;

  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} ariaLabel="Période de l’analyse" />
        <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. « Historique » est toujours accessible hors
              génération — écran d'accueil et écran d'erreur compris : les
              analyses passées se relisent sans en lancer une nouvelle.
              « Sélection modifiée » et « Régénérer » n'existent que si une
              analyse est affichée. */}
          {!running && (
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
                {/* L'horodatage « Analysé le … » est affiché une seule fois, dans la carte Contexte. */}
                {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>
                {hasData && (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\u202F?'}</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={() => (ctxIsFresh() ? 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>
          )}

          {/* Régénération échouée : l'analyse précédente reste affichée, avec un
              bandeau « Réessayer » (charte §7). Il remplace le bandeau ambré
              ci-dessous : même geste, et la raison de l'échec prime. */}
          {hasData && cur.refreshError && (
            <div className="card" role="alert" style={{
              padding: mobile ? '12px 14px' : '13px 16px',
              background: 'var(--red-soft)', border: '1px solid var(--line)',
              display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
            }}>
              <span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--red-text)', flex: 1, minWidth: 180, lineHeight: 1.5 }}>
                Impossible d’actualiser l’analyse. Vérifie ta connexion.
              </span>
              <button className="btn tap" onClick={() => regenerate(scope, categories)} style={{ fontSize: 12.5, color: 'var(--fg)' }}>
                Réessayer
              </button>
            </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 && !cur.refreshError && (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} notice={saveNotice} />}
          {/* 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-3)' }}>
          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={fondPageTitle()} />
      <div style={{ ...PAGE_BODY, padding: '20px 24px 40px', flex: 1 }}>
        <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' }}>{fondPageTitle()}</h1>
      <FondBody mobile />
    </div>
  );
}

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