TR4KER+

Le compagnon tout-en-un de tr4ker.net, par la communauté : une recherche qui trouve ce que le site rate (345 000 torrents indexés), le chat partout en colonnes (splittable, ancrable, GIF, upload d'images en glisser-déposer), ratio et crédits en direct, tableau de bord d'accueil, salon vocal — et un mode secours complet quand le site tombe : statut en direct, chat de repli hébergé ailleurs, recherche qui continue. Gratuit, open source, non officiel.

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==UserScript==
// @name         TR4KER+
// @namespace    https://t4x.ekaii.fr
// @version      0.61.0
// @description  Le compagnon tout-en-un de tr4ker.net, par la communauté : une recherche qui trouve ce que le site rate (345 000 torrents indexés), le chat partout en colonnes (splittable, ancrable, GIF, upload d'images en glisser-déposer), ratio et crédits en direct, tableau de bord d'accueil, salon vocal — et un mode secours complet quand le site tombe : statut en direct, chat de repli hébergé ailleurs, recherche qui continue. Gratuit, open source, non officiel.
// @license      MIT
// @author       communauté TR4KER
// @match        https://tr4ker.net/*
// @run-at       document-start
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_setClipboard
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_addValueChangeListener
// @grant        GM_notification
// @grant        GM_addElement
// @connect      t4search.ekaii.fr
// @connect      www.youtube.com
// @connect      youtube.com
// @connect      t4ai.ekaii.fr
// @connect      t4x.ekaii.fr
// @connect      forgejo.ekaii.fr
// @connect      t4health.ekaii.fr
// @connect      t4hub.ekaii.fr
// @connect      voice.ekaii.fr
// -- providers for the optional "bring your own API key" mode. Anything else (a self-hosted
// -- endpoint, another provider) still works: Tampermonkey asks the user to allow that host once,
// -- which is the right prompt to show rather than granting this script @connect *.
// @connect      api.anthropic.com
// @connect      api.openai.com
// @connect      openrouter.ai
// @connect      api.mistral.ai
// @connect      api.groq.com
// @connect      localhost
// @connect      127.0.0.1
// @connect      voice.ekaii.fr
// -- GIF picker: Klipy (default shared key, no signup) + Giphy (optional, needs the member's own key).
// -- static.klipy.com / giphy.com / tenor.com are on tr4ker's server-side image allowlist, so the
// -- inserted [img] renders natively for every viewer (verified against /api/image-domains).
// @connect      api.klipy.com
// @connect      api.giphy.com
// -- community emoticons: the site's own emotes served by the shared Klipy gateway worker (also on
// -- tr4ker's server-side image allowlist), fetched read-only for the "tr4ker" tab of the emoji picker.
// @connect      klipy-api-gateway.tr4ker-klipy-emoj-gateway-customer593.workers.dev
// @noframes
// @homepageURL  https://greasyfork.org/en/scripts
// ==/UserScript==

/*
 * TR4KER+ v0.1 — pure client-side. See docs/PLAN.md.
 * Design: reskin by overriding the site's Material Design 3 CSS custom properties (classname-independent);
 * all net-new UI lives in one shadow-DOM host on <html> so it survives SPA re-renders and bundle drift.
 * v0.1 is read/navigation only (open, download-the-.torrent, copy) — no write actions.
 */
(function () {
  'use strict';

  // ---- single-inject guard -------------------------------------------------
  if (window.__T4X__) { return; }
  const VERSION = '0.61.0';   // ⚠ bump BOTH here and the @version header — the update check compares THIS
  window.__T4X__ = VERSION;

  const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
  const SAFE_BOOT = /[?&]t4x=safe(\b|=)/.test(location.search);

  // ---- settings store ------------------------------------------------------
  const DEFAULTS = {
    enabled: true,
    theme: true,        // reskin
    polish: true,       // structural polish layer
    palette: true,      // command palette
    chat: true,         // global chat drawer
    hud: true,          // status/ratio pill
    ai: false,          // AI assistant dock (t4ai) — OPT-IN, off until the user enables it
    updates: true,      // check the Forgejo manifest for a newer userscript
    statusBanner: true, // discreet banner when tr4ker itself is degraded (source: t4health)
    grouping: true,     // collapse the releases of one work into a single search result
    hotkeys: true,      // single-key actions on a torrent page
    gif: true,          // GIF picker in the chat composer (Klipy; Giphy optional with own key)
    e2e: false,         // opt-in end-to-end encryption for DMs (needs native X25519/Ed25519 WebCrypto; off by default)
    ytPlayer: true,     // YouTube links/fragments become chips → floating in-page mini-player
    ytNative: true,     // also add YouTube chips in the native site chat
    ytMode: 'auto',     // 'auto' (floating player) | 'tab' (open in a new tab)
    phrases: true,      // saved quick-reply phrases in the composers
    emojiRecents: true, // favourites-aware emoji picker (frequency + pinning)
    siteEmojis: true,   // community emoticons tab (Klipy /emochat gateway)
    rxQuick: true,      // favourite quick-reactions on drawer messages
    mutes: true,        // hide messages from muted users (our chat surfaces)
    muteMode: 'stub',   // 'stub' (collapsed reveal line) | 'hide' (fully removed)
    chatHl: true,       // per-user highlight colours in our chat surfaces
    mentionPulse: true, // one-shot pulse when a live message mentions me
    hoverCard: true,    // profile card on hovering a username/avatar in our chat
    siteNav: true,      // enrich the site's own top nav bar with every link (editable + persistent)
    siteComposer: true, // add GIF / upload / format buttons to the site's native chat composer
    homeDash: true,     // home-page dashboard: inline chat + service status graph + daily/weekly recap
    commSide: true,     // /communication: resizable left sidebar (channels + tickets + DMs)
    commSideW: null,    // saved sidebar width in px; null = site default
    chanSplit: true,    // /communication: per-channel button to open it as an extra TR4KER+ chat column
    chatPush: true,     // docked chat pushes the page content aside instead of covering it
    bus: true,          // T4CO inter-script bus (phase 2): serve search/status to companion scripts
    ideas: true,        // community suggestion board (polls / voting / pinning), self-hosted on t4x.ekaii.fr
    inbox: true,        // Signaux: persistent inbox of mentions / keywords / replies-to-me
    inboxReplies: true, // also capture replies to my own messages
    archiveSearch: true,// shoutbox infinie: search the durable public-channel archive (t4x BFF)
    dmCard: true,       // rich toast + "MP" tab for new private messages
    extChat: true,      // external community chat (t4health shoutbox) as an extra channel — survives tr4ker outages
    extPseudo: null,    // pseudo for the external chat when the tr4ker username is unknown (offline)
    offline: true,      // fallback interface (status, external chat, own search) when tr4ker is down
    hideRedundant: true, // on the home page, hide the site's own (now redundant) global-stats strip + tile-grid menu
    pillPos: null,      // {r,b} right/bottom offset when the user has dragged the pill; null = auto
    pillMode: 'float',  // 'float' (draggable bar) | 'rail' (edge tabs, PMS-style) | 'mini' (collapsed button)
    railTop: null,      // rail mode: preferred top offset in px; null = 40% of the viewport
    accent: 'indigo',   // indigo | cyan | amber | rose
    tone: 'default',    // dark surface tone: 'default' (deep ink) | 'slate' (softer, nuanced greys — the 3rd theme)
    scrollbarColor: 'auto', // 'auto' = theme (surface-container-highest) | any CSS color for custom scrollbars (page + our panels)
    resultHover: true,  // hover a search result -> floating vignette with full torrent info + actions (no page load)
    ratioAlert: true,   // warn when the ratio crosses below a floor
    ratioFloor: 1.0,    // the floor (bonus-inclusive ratio)
    flAlert: true,      // notify when global freeleech turns ON/OFF
    mentionNotify: true,// system notification (GM_notification) on mention/keyword
    mentionSound: false,// short beep on mention/keyword
    quietStart: '',     // "23:00" — start of quiet hours (no notif/sound); '' = off
    quietEnd: '',       // "08:00"
    soundStyle: 'ping', // mention beep voice: ping | soft | double
    alertSolo: true,    // multi-tab: exactly one tab beeps / notifies per event
    statsBuffer: true,  // show the ratio "buffer" (marge avant le plancher) figure
    siteMinRatio: 0.5,  // site ratio floor (from the rules); a pref so a rule change is a one-click fix
    chatFontScale: 1,   // chat text scale 0.85–1.7
    chatDensity: 'cozy',// cozy | compact
    lightbox: true,     // in-page image viewer instead of window.open
    statsPanel: true,   // "Mes stats" panel from the pill (buffer + periods + 30-day chart)
    statsPeriod: '7d',  // 24h | 7d | 30d
    meSpark: true,      // 30-day activity tile on the home dashboard ME strip
  };
  // GM storage is primary; mirror to localStorage so prefs survive even if GM storage is unavailable
  // (sandbox/container/private-mode quirks). Read GM first, fall back to the localStorage mirror.
  const store = {
    get(k, d) {
      try { const v = GM_getValue(k, undefined); if (v !== undefined) return v; } catch (e) {}
      try { const ls = localStorage.getItem('t4x:' + k); if (ls != null) return JSON.parse(ls); } catch (e) {}
      return d;
    },
    set(k, v) {
      try { GM_setValue(k, v); } catch (e) {}
      try { localStorage.setItem('t4x:' + k, JSON.stringify(v)); } catch (e) {}
    },
  };
  function prefs() { return Object.assign({}, DEFAULTS, store.get('t4x.prefs', {})); }
  function setPref(k, v) { const p = prefs(); p[k] = v; store.set('t4x.prefs', p); return p; }
  // v0.18 — the AI dock becomes opt-in. setPref() persists the WHOLE merged object, so anyone who
  // ever touched a setting already has `ai:true` frozen in storage and would not see the new default.
  // One-shot migration: turn it off once, then never touch the user's choice again.
  function migrateOptInAi() {
    if (store.get('t4x.mig.ai-optin', false)) return;
    const raw = store.get('t4x.prefs', null);
    if (raw && raw.ai === true) { raw.ai = false; store.set('t4x.prefs', raw); }
    store.set('t4x.mig.ai-optin', true);
  }
  migrateOptInAi();
  let P = prefs();

  const log = (...a) => { try { console.debug('%cTR4KER+', 'color:#93a5ff', ...a); } catch (e) {} };

  // ==========================================================================
  // DESIGN TOKENS — "Encre & Signal"  (override the site's M3 custom properties)
  // ==========================================================================
  // Accent groups: each is a complete, AA-checked primary/secondary/gradient set for dark+light.
  const ACCENTS = {
    indigo: { d: { primary: '#93a5ff', onp: '#101638', sec: '#2b3050', onsec: '#dfe3ff', grad: 'linear-gradient(135deg,#93a5ff,#67e8f9)' },
              l: { primary: '#4256c5', onp: '#ffffff', sec: '#dfe4ff', onsec: '#1a2050', grad: 'linear-gradient(135deg,#4256c5,#0891b2)' } },
    cyan:   { d: { primary: '#5fd0e6', onp: '#00363f', sec: '#123b44', onsec: '#c9f2fb', grad: 'linear-gradient(135deg,#5fd0e6,#93a5ff)' },
              l: { primary: '#0e7490', onp: '#ffffff', sec: '#cbeef6', onsec: '#04333f', grad: 'linear-gradient(135deg,#0e7490,#4256c5)' } },
    amber:  { d: { primary: '#f5c869', onp: '#3a2c00', sec: '#3f3413', onsec: '#ffe6a8', grad: 'linear-gradient(135deg,#f5c869,#fb7185)' },
              l: { primary: '#8a6100', onp: '#ffffff', sec: '#ffe7ad', onsec: '#2b1f00', grad: 'linear-gradient(135deg,#8a6100,#b45309)' } },
    rose:   { d: { primary: '#ffb0c2', onp: '#57061f', sec: '#54263a', onsec: '#ffd9e2', grad: 'linear-gradient(135deg,#ffb0c2,#93a5ff)' },
              l: { primary: '#b0224f', onp: '#ffffff', sec: '#ffd9e2', onsec: '#3f0021', grad: 'linear-gradient(135deg,#b0224f,#4256c5)' } },
  };

  function themeCSS() {
    const a = ACCENTS[P.accent] || ACCENTS.indigo;
    const dark = `
      --surface:#0f1117 !important; --surface-dim:#0a0c10 !important;
      --surface-container-low:#151823 !important; --surface-container:#1a1e2b !important;
      --surface-container-high:#232838 !important; --surface-container-highest:#2d3345 !important;
      --surface-bright:#343b4f !important;
      --primary:${a.d.primary} !important; --on-primary:${a.d.onp} !important; --on-primary-fixed:#dfe4ff !important;
      --on-surface:#e6e8f2 !important; --on-surface-variant:#b8bdd1 !important;
      --outline-variant:#3a4056 !important; --inverse-surface:#e6e8f2 !important;
      --secondary-container:${a.d.sec} !important; --on-secondary-container:${a.d.onsec} !important;
      --error:#ffb4ab !important; --ann-color:#ffd28a !important; --notif:${a.d.primary} !important;
      --t4x-seed:#4ade80; --t4x-leech:#fb7185; --t4x-fl:#fbbf24; --t4x-grad:${a.d.grad};`;
    const light = `
      --surface:#f6f7fb !important; --surface-dim:#e8eaf2 !important;
      --surface-container-low:#eef0f7 !important; --surface-container:#e7eaf4 !important;
      --surface-container-high:#dfe3f0 !important; --surface-container-highest:#d7dcec !important;
      --surface-bright:#ffffff !important;
      --primary:${a.l.primary} !important; --on-primary:${a.l.onp} !important; --on-primary-fixed:#1a1c1c !important;
      --on-surface:#171a26 !important; --on-surface-variant:#454b61 !important;
      --outline-variant:#d3d7e6 !important; --inverse-surface:#2a2a2a !important;
      --secondary-container:${a.l.sec} !important; --on-secondary-container:${a.l.onsec} !important;
      --error:#ba1a1a !important; --ann-color:#8a6100 !important; --notif:${a.l.primary} !important;
      --t4x-seed:#16a34a; --t4x-leech:#e11d48; --t4x-fl:#b45309; --t4x-grad:${a.l.grad};`;
    // "Ardoise" — a 3rd, softer theme: dark base but nuanced greys instead of near-black. Overrides ONLY the
    // surface/on-surface/outline scale (accents keep the chosen colour set). Applied on top of dark via the
    // data-t4x-tone attribute so the site's own data-theme=dark rules stay valid.
    const slate = `
      --surface:#1a1d24 !important; --surface-dim:#15181e !important;
      --surface-container-low:#20242c !important; --surface-container:#262b34 !important;
      --surface-container-high:#30363f !important; --surface-container-highest:#3a414c !important;
      --surface-bright:#454c58 !important;
      --on-surface:#e2e6ef !important; --on-surface-variant:#aeb4c4 !important;
      --outline-variant:#454c5a !important;`;
    return `:root{${dark}} :root[data-theme=dark]{${dark}} [data-theme=light]{${light}} [data-t4x-tone=slate]:not([data-theme=light]){${slate}}`;
  }

  // Structural polish — stable primitives only (element/attribute/aria), no hashed classnames.
  const POLISH_CSS = `
    html{ -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility; }
    *{ scrollbar-width:thin; scrollbar-color:var(--t4x-sb,var(--surface-container-highest)) transparent; }
    ::-webkit-scrollbar{ width:10px; height:10px; }
    ::-webkit-scrollbar-thumb{ background:var(--t4x-sb,var(--surface-container-highest)); border-radius:999px; border:2px solid transparent; background-clip:content-box; }
    ::-webkit-scrollbar-thumb:hover{ background:var(--t4x-sb-hover,var(--outline-variant)); }
    :focus-visible{ outline:2px solid var(--primary) !important; outline-offset:2px; border-radius:6px; }
    ::selection{ background:color-mix(in srgb, var(--primary) 32%, transparent); }
    td, th{ font-variant-numeric:tabular-nums; }
    a{ text-underline-offset:2px; }
  `;

  // run cb once <html> exists (at true document-start even documentElement can be null)
  function whenHtml(cb) {
    if (document.documentElement) { cb(); return; }
    try {
      const mo = new MutationObserver(() => { if (document.documentElement) { mo.disconnect(); cb(); } });
      mo.observe(document, { childList: true, subtree: true });
    } catch (e) { const t = setInterval(() => { if (document.documentElement) { clearInterval(t); cb(); } }, 5); }
  }

  function keepLast(styleEl, marker) {
    styleEl.setAttribute('data-t4x', marker);
    const place = () => {
      const head = document.head || document.documentElement;
      if (!head) return;
      if (styleEl.parentNode !== head || head.lastElementChild !== styleEl) {
        try { head.appendChild(styleEl); } catch (e) {}
      }
    };
    place();
    const target = document.head || document.documentElement;
    if (target) {
      try {
        let raf = 0;
        const mo = new MutationObserver(() => {
          if (!styleEl.__t4xLive || raf) return;
          raf = requestAnimationFrame(() => { raf = 0; if (styleEl.__t4xLive) place(); });
        });
        mo.observe(target, { childList: true });
        styleEl.__t4xMo = mo;
      } catch (e) {}
    }
    styleEl.__t4xLive = true;
  }

  let themeStyle = null;
  // Custom scrollbar colour → --t4x-sb on <html>; inherits into the shadow panels too. 'auto' = unset (falls
  // back to the theme's --surface-container-highest via the var() default in POLISH_CSS/SHELL_CSS).
  function applyScrollbar() {
    try {
      const el = document.documentElement; if (!el) return;
      const c = P.scrollbarColor;
      if (!c || c === 'auto') { el.style.removeProperty('--t4x-sb'); el.style.removeProperty('--t4x-sb-hover'); }
      else { el.style.setProperty('--t4x-sb', c); el.style.setProperty('--t4x-sb-hover', c); }
    } catch (e) {}
  }
  // "Ardoise" (slate) tone → data-t4x-tone on <html>; the slate override in themeCSS keys off it.
  function applyTone() {
    try {
      const el = document.documentElement; if (!el) return;
      if (P.tone === 'slate') { if (el.getAttribute('data-t4x-tone') !== 'slate') el.setAttribute('data-t4x-tone', 'slate'); }
      else if (el.hasAttribute('data-t4x-tone')) el.removeAttribute('data-t4x-tone');
    } catch (e) {}
  }
  function applyTheme() {
    applyScrollbar(); applyTone();   // must run even with the reskin off (custom scrollbars use the always-on shadow sheet)
    if (!P.theme && !P.polish) { removeTheme(); return; }
    let css = '';
    if (P.theme) css += themeCSS();
    if (P.polish) css += '\n' + POLISH_CSS;
    if (!themeStyle) { themeStyle = document.createElement('style'); themeStyle.textContent = css; keepLast(themeStyle, 'theme'); }
    else themeStyle.textContent = css;
  }
  function removeTheme() { if (themeStyle) { themeStyle.__t4xLive = false; themeStyle.remove(); themeStyle = null; } }

  // ==========================================================================
  // SHADOW HOST — one root for all overlays; isolated from the site's CSS.
  // ==========================================================================
  let host = null, root = null;
  function ensureHost() {
    if (host && document.documentElement.contains(host)) return root;
    // React detached our host but the NODE still exists with its shadow root + every overlay inside →
    // re-attach the same node rather than recreating an empty one (which would orphan pal/chat/pill/etc.)
    if (host && host.shadowRoot) {
      try { document.documentElement.appendChild(host); root = host.shadowRoot; return root; } catch (e) {}
    }
    host = document.getElementById('t4x-root');
    if (!host) {
      host = document.createElement('div');
      host.id = 't4x-root';
      // z-index ABOVE the home-dashboard expand modal (.t4x-cmbox, z 2147483051, in light-DOM homeMount):
      // that modal resolves against the ROOT stacking context and would otherwise paint over every shadow
      // overlay (lightbox, stats panel, YT player…). Raising the host makes intra-shadow z the single authority.
      host.style.cssText = 'all:initial; position:fixed; inset:0 auto auto 0; width:0; height:0; z-index:2147483200;';
      document.documentElement.appendChild(host);
    }
    root = host.shadowRoot || host.attachShadow({ mode: 'open' });
    if (!root.querySelector('style[data-t4x-shell]')) {
      const s = document.createElement('style');
      s.setAttribute('data-t4x-shell', '');
      s.textContent = SHELL_CSS();
      root.appendChild(s);
    }
    return root;
  }
  // Re-anchor host if React ever detaches it.
  function watchHost() {
    try {
      const mo = new MutationObserver(() => {
        if (!document.documentElement.contains(host)) ensureHost();
      });
      mo.observe(document.documentElement, { childList: true });
    } catch (e) {}
  }

  // shadow stylesheet reads the site's live M3 tokens so overlays match theme + reskin.
  function SHELL_CSS() {
    return `
    :host{ all:initial;
      /* readability-critical glass tokens live here (always injected, regardless of the reskin toggle) and
         derive from the site's live --surface/--on-surface, so panels track whatever theme is actually active
         — reskin ON (our overridden --surface) or OFF (the site's own light/dark). Single source of truth. */
      --t4x-glass: color-mix(in srgb, var(--surface,#0f1117) 86%, transparent);
      --t4x-hair: color-mix(in srgb, var(--on-surface,#93a5ff) 14%, transparent); }
    *{ box-sizing:border-box; font-family:'Geist Variable',Inter,system-ui,sans-serif; }
    /* scrollbars inside OUR panels — document ::-webkit-scrollbar rules never cross the shadow boundary, so
       without these our drawers/panels showed the browser's default (white in dark). Driven by --t4x-sb
       (inherited from <html>), so the theme + the user's custom colour both reach here. */
    *{ scrollbar-width:thin; scrollbar-color:var(--t4x-sb,var(--surface-container-highest,#2d3345)) transparent; }
    ::-webkit-scrollbar{ width:10px; height:10px; }
    ::-webkit-scrollbar-thumb{ background:var(--t4x-sb,var(--surface-container-highest,#2d3345)); border-radius:999px; border:2px solid transparent; background-clip:content-box; }
    ::-webkit-scrollbar-thumb:hover{ background:var(--t4x-sb-hover,var(--outline-variant,#3a4056)); }
    .veil{ position:fixed; inset:0; background:rgba(5,7,12,.55); -webkit-backdrop-filter:blur(2px); backdrop-filter:blur(2px); z-index:2147483005;
      opacity:0; transition:opacity .16s ease; pointer-events:none; }
    .veil.on{ opacity:1; pointer-events:auto; }
    .pal{ position:fixed; left:50%; top:14vh; z-index:2147483006; transform:translateX(-50%) translateY(8px) scale(.985);
      width:min(680px,92vw); max-height:70vh; display:flex; flex-direction:column; opacity:0;
      transition:opacity .18s ease, transform .18s cubic-bezier(.3,0,0,1); pointer-events:none;
      background:var(--t4x-glass, rgba(15,17,23,.85)); border:1px solid var(--t4x-hair, rgba(147,165,255,.14));
      border-radius:20px; box-shadow:0 24px 64px rgba(0,0,0,.5); overflow:hidden;
      -webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%); color:var(--on-surface,#e6e8f2); }
    .pal.on{ opacity:1; transform:translateX(-50%) translateY(0) scale(1); pointer-events:auto; }
    .pal header{ display:flex; align-items:center; gap:10px; padding:14px 16px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .pal .glyph{ width:20px; height:20px; border-radius:6px; background:var(--t4x-grad,linear-gradient(135deg,#93a5ff,#67e8f9)); flex:0 0 auto; }
    .pal input{ flex:1; background:transparent; border:0; outline:0; color:var(--on-surface,#e6e8f2);
      font-size:16px; line-height:24px; padding:2px 0; }
    .pal input::placeholder{ color:var(--on-surface-variant,#b8bdd1); }
    .pal .hintk{ font:12px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1);
      border:1px solid var(--outline-variant,#3a4056); border-radius:6px; padding:4px 6px; }
    .list{ overflow-y:auto; padding:6px; }
    .grp{ font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--on-surface-variant,#b8bdd1);
      padding:10px 12px 4px; }
    .row{ display:flex; align-items:center; gap:10px; padding:9px 12px; border-radius:12px; cursor:pointer;
      color:var(--on-surface,#e6e8f2); }
    .row .ic{ font-family:'Material Symbols Outlined'; font-size:20px; width:24px; text-align:center;
      color:var(--on-surface-variant,#b8bdd1); flex:0 0 auto; }
    .row .main{ flex:1; min-width:0; }
    .row .title{ font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    .row .sub{ font-size:12px; color:var(--on-surface-variant,#b8bdd1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    .row .chips{ display:flex; gap:6px; flex:0 0 auto; }
    .chip{ font:11px/1 'JetBrains Mono',monospace; padding:4px 7px; border-radius:999px;
      background:var(--surface-container-high,#232838); color:var(--on-surface-variant,#b8bdd1); }
    .chip.seed{ color:var(--t4x-seed,#4ade80); } .chip.leech{ color:var(--t4x-leech,#fb7185); }
    .chip.fl{ color:#0f1117; background:var(--t4x-fl,#fbbf24); font-weight:600; }
    .row[aria-selected="true"]{ background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff); }
    .row[aria-selected="true"] .ic, .row[aria-selected="true"] .sub{ color:var(--on-secondary-container,#dfe3ff); }
    .empty{ padding:26px 16px; text-align:center; color:var(--on-surface-variant,#b8bdd1); font-size:13px; }
    .pal footer{ display:flex; gap:14px; padding:8px 14px; border-top:1px solid var(--outline-variant,#3a4056);
      font-size:11px; color:var(--on-surface-variant,#b8bdd1); }
    .pal footer b{ font-family:'JetBrains Mono',monospace; font-weight:500; color:var(--on-surface,#e6e8f2); }
    .spin{ width:14px; height:14px; border-radius:50%; border:2px solid var(--outline-variant,#3a4056);
      border-top-color:var(--primary,#93a5ff); animation:t4spin .7s linear infinite; }
    @keyframes t4spin{ to{ transform:rotate(360deg); } }
    /* toasts sit above EVERYTHING (z 2147483010) and are purely informational — neither the stack
       nor the toasts themselves may ever eat a click on the page underneath. */
    .toasts{ position:fixed; right:18px; bottom:130px; z-index:2147483100; display:flex; flex-direction:column; gap:10px; align-items:flex-end;
      pointer-events:none; }
    .toast{ pointer-events:none; background:var(--t4x-glass,rgba(15,17,23,.85)); border:1px solid var(--t4x-hair,rgba(147,165,255,.14));
      color:var(--on-surface,#e6e8f2); border-radius:14px; padding:12px 14px; font-size:13px; max-width:340px;
      box-shadow:0 12px 32px rgba(0,0,0,.4); -webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%);
      transform:translateY(8px); opacity:0; transition:opacity .2s ease, transform .2s cubic-bezier(.3,0,0,1); }
    .toast.on{ transform:translateY(0); opacity:1; }
    .toast.act{ pointer-events:auto; display:flex; align-items:center; gap:12px; }
    .toast.act>div{ flex:1; min-width:0; }
    .t4x-toast-act{ all:unset; cursor:pointer; flex:0 0 auto; font-size:12px; font-weight:600; color:var(--primary,#93a5ff);
      padding:5px 10px; border-radius:999px; border:1px solid color-mix(in srgb,var(--primary,#93a5ff) 45%,transparent); }
    .t4x-toast-act:hover{ background:color-mix(in srgb,var(--primary,#93a5ff) 16%,transparent); }
    .t4x-toast-act:focus-visible{ outline:2px solid var(--primary,#93a5ff); outline-offset:2px; }
    .toast.dmcard{ pointer-events:auto; display:block; max-width:340px; }
    .t4x-dmav{ width:34px; height:34px; border-radius:9px; overflow:hidden; flex:0 0 auto; background:var(--surface-container-high,#232838); display:flex; align-items:center; justify-content:center; font:600 13px/1 sans-serif; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-dmav img{ width:100%; height:100%; object-fit:cover; }
    .t4x-dmkick{ font:700 10px/1 'JetBrains Mono',monospace; letter-spacing:.08em; color:var(--primary,#93a5ff); margin-bottom:2px; }
    /* version grouping (v0.19): the expander chip and the unfolded sibling releases */
    .chip.t4x-vers{ cursor:pointer; border-style:dashed; }
    .chip.t4x-vers:hover{ background:var(--secondary-container,#2b3050); }
    .t4x-versbox{ margin:-2px 10px 6px 44px; border-left:2px solid var(--outline-variant,#3a4056); }
    .t4x-versrow{ display:flex; gap:8px; align-items:baseline; padding:5px 10px; font-size:12px; cursor:pointer;
      color:var(--on-surface-variant,#b8bdd1); border-radius:0 8px 8px 0; }
    .t4x-versrow:hover{ background:var(--surface-container,#1a1e2b); color:var(--on-surface,#e6e8f2); }
    .t4x-versmeta{ margin-left:auto; flex:0 0 auto; font:11px/1 'JetBrains Mono',monospace; opacity:.8; }
    .wm{ position:absolute; right:12px; top:12px; font:600 11px/1 'JetBrains Mono',monospace;
      color:var(--on-surface-variant,#b8bdd1); opacity:.7; }
    @media (prefers-reduced-motion: reduce){ .pal,.veil,.toast{ transition:opacity .12s linear !important; transform:none !important; } }
    `;
  }

  // ==========================================================================
  // SPA route awareness — patch history so the palette can navigate & features react.
  // ==========================================================================
  function patchHistory() {
    if (history.__t4xPatched) return; history.__t4xPatched = true;
    const fire = () => { try { window.dispatchEvent(new Event('t4x:locationchange')); } catch (e) {} };
    for (const m of ['pushState', 'replaceState']) {
      const orig = history[m];
      history[m] = function () { const r = orig.apply(this, arguments); fire(); return r; };
    }
    window.addEventListener('popstate', fire);
  }
  function navigate(path) {
    path = String(path || '');
    // legacy recents stored the wrong plural route; the torrent detail page is /torrent/<slug> (singular)
    path = path.replace(/^\/torrents\/([^/?#]+)/, '/torrent/$1');
    // tr4ker's router ignores synthetic popstate, so a pushState nudge silently no-ops → do a real navigation.
    // (Going to a torrent/wiki page from the palette is a page change anyway; a full load is expected + reliable.)
    location.assign(path);
  }

  // ==========================================================================
  // API client — same-origin, credentialed, single-flight + short TTL cache.
  // ==========================================================================
  const inflight = new Map();
  const cache = new Map();
  // Persist the apiGet cache to IndexedDB (SWR): instant first paint from disk + serve-stale on network
  // error (offline resilience) for the member's OWN data. DMs and auth are NEVER persisted (privacy);
  // the tracker session COOKIE is never stored at rest (that stays the browser's, account-takeover risk).
  const API_PERSIST_DENY = /\/api\/(conversations|auth)(\/|\?|$)/i;
  const persistable = (path) => !API_PERSIST_DENY.test(path);
  let _apiPersistT = null, _apiPersistDirty = false;
  let _apiDownUntil = 0;   // circuit breaker: after a network failure, serve stale-first for 30 s (instant paint during an outage)
  function flushApiCache() {
    try { const ent = []; for (const [k, v] of cache) { if (persistable(k)) ent.push([k, v]); } ent.sort((a, b) => (b[1].t || 0) - (a[1].t || 0)); const obj = {}; for (const [k, v] of ent.slice(0, 400)) obj[k] = v; idbSet('api-cache', { at: Date.now(), map: obj }); } catch (e) {}   // keep NEWEST 400
    _apiPersistDirty = false;
  }
  function persistApiCache() {
    _apiPersistDirty = true;
    if (_apiPersistT) return;   // coalesce writes within the debounce window; pagehide/hidden flushes the tail
    _apiPersistT = setTimeout(() => { _apiPersistT = null; flushApiCache(); }, 1500);
  }
  try {
    const _flush = () => { if (_apiPersistDirty) { if (_apiPersistT) { clearTimeout(_apiPersistT); _apiPersistT = null; } flushApiCache(); } };
    window.addEventListener('pagehide', _flush);
    document.addEventListener('visibilitychange', () => { if (document.hidden) _flush(); });
  } catch (e) {}
  let _seedPromise = null;
  function seedApiCache() {
    if (_seedPromise) return _seedPromise;
    _seedPromise = idbGet('api-cache').then((d) => { if (d && d.map) { for (const k in d.map) if (!cache.has(k)) cache.set(k, d.map[k]); } }).catch(() => {});
    return _seedPromise;
  }
  async function apiGet(path, { ttl = 15000, timeout = 5000 } = {}) {
    let c = cache.get(path);
    if (c && Date.now() - c.t < ttl) return c.v;
    await seedApiCache();                                   // bring the disk cache in before deciding
    c = cache.get(path);
    if (c && Date.now() - c.t < ttl) return c.v;
    if (c && Date.now() < _apiDownUntil) return c.v;        // site down: serve stale instantly (network retried after the window)
    if (inflight.has(path)) return inflight.get(path);
    const ctl = new AbortController();
    const to = setTimeout(() => ctl.abort(), timeout);
    const p = fetch(path, { credentials: 'include', headers: { accept: 'application/json' }, signal: ctl.signal })
      .then(async (r) => {
        if (!r.ok) throw new Error('HTTP ' + r.status);
        const j = await r.json();
        _apiDownUntil = 0;
        cache.set(path, { t: Date.now(), v: j }); if (cache.size > 300) { const _k0 = cache.keys().next().value; if (_k0 !== undefined) cache.delete(_k0); }   // perf: bound the in-memory cache
        if (persistable(path)) persistApiCache();
        return j;
      })
      .catch((e) => { if (!/^HTTP /.test((e && e.message) || '')) _apiDownUntil = Date.now() + 30000; const st = cache.get(path); if (st) return st.v; throw e; })   // network error → trip breaker + serve stale (offline)
      .finally(() => { clearTimeout(to); inflight.delete(path); });
    inflight.set(path, p);
    return p;
  }
  // never cache identity
  async function me() { return apiGet('/api/me', { ttl: 30000 }); }
  try { if (window.__T4X_EXPOSE__) { window.__t4xApi = { get: apiGet, seed: seedApiCache, size: () => cache.size, persistable }; } } catch (e) {}

  // ===== E2E bundle (generated by build-bundle.js from e2e/*.js; audited) — isolated-world only =====
  const T4E = (function () {
  'use strict';
  const subtle = (globalThis.crypto || require('crypto').webcrypto).subtle;
  const G = (globalThis.crypto || require('crypto').webcrypto);
  const te = new TextEncoder(), td = new TextDecoder();
  const V = 't4e.v1';

  // ---- encodings ----
  const bytesToHex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, '0')).join('');
  const hexToBytes = (h) => new Uint8Array(h.match(/../g).map((x) => parseInt(x, 16)));
  function bytesToB64(b) { const u = new Uint8Array(b); let s = ''; for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s); }
  function b64ToBytes(s) { const bin = atob(s); const u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u; }
  const b64ToUrl = (s) => s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  const urlToB64 = (s) => s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - s.length % 4) % 4);
  const b64urlToBytes = (s) => b64ToBytes(urlToB64(s));
  function randomBytes(n) { const b = new Uint8Array(n); G.getRandomValues(b); return b; }
  async function sha256Hex(data) { const d = typeof data === 'string' ? te.encode(data) : data; return bytesToHex(await subtle.digest('SHA-256', d)); }

  // ---- AES-GCM ----
  async function gcmEncryptBytes(key, bytes, aadStr) {
    const iv = randomBytes(12); const params = { name: 'AES-GCM', iv };
    if (aadStr) params.additionalData = te.encode(aadStr);
    const ct = await subtle.encrypt(params, key, bytes);
    return { iv: bytesToB64(iv), ct: bytesToB64(ct) };
  }
  async function gcmDecryptBytes(key, ivB64, ctB64, aadStr) {
    const params = { name: 'AES-GCM', iv: b64ToBytes(ivB64) };
    if (aadStr) params.additionalData = te.encode(aadStr);
    return new Uint8Array(await subtle.decrypt(params, key, b64ToBytes(ctB64)));
  }

  // ---- length-hiding bucket padding (4-byte BE length || bytes || random filler) ----
  const MSG_BUCKETS = [256, 1024, 4096, 16384, 65536];
  function padBucket(u8, buckets) {
    buckets = buckets || MSG_BUCKETS;
    const need = u8.length + 4;
    let size = buckets.find((b) => b >= need);
    if (size === undefined) { const top = buckets[buckets.length - 1]; size = Math.ceil(need / top) * top; }
    const out = new Uint8Array(size);
    new DataView(out.buffer).setUint32(0, u8.length);
    out.set(u8, 4);
    for (let off = need; off < size; off += 65536) G.getRandomValues(out.subarray(off, Math.min(off + 65536, size)));
    return out;
  }
  function unpadBucket(u8) {
    const len = new DataView(u8.buffer, u8.byteOffset, 4).getUint32(0);
    if (len > u8.length - 4) throw new Error('bad padding');
    return u8.subarray(4, 4 + len);
  }

  // ---- browser capability ----
  // The whole scheme needs native X25519 + Ed25519 WebCrypto (Chrome ~2025+, Firefox ~2024+; Node 18+).
  // Older browsers throw "Unrecognized name" on generateKey. Callers MUST feature-detect and degrade
  // gracefully (disable E2E, fall back to plaintext/image) rather than crash or fake privacy.
  let _supported = null;
  async function isSupported() {
    if (_supported !== null) return _supported;
    try {
      await subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);
      await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
      _supported = true;
    } catch (e) { _supported = false; }
    return _supported;
  }

  // ---- identity ----
  async function generateIdentity() {
    const x = await subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);
    const ed = await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
    return {
      xPriv: x.privateKey, edPriv: ed.privateKey,
      xPubB64: bytesToB64(await subtle.exportKey('raw', x.publicKey)),
      edPubB64: bytesToB64(await subtle.exportKey('raw', ed.publicKey)),
      bundle: { x: bytesToB64(await subtle.exportKey('pkcs8', x.privateKey)), e: bytesToB64(await subtle.exportKey('pkcs8', ed.privateKey)),
                xp: bytesToB64(await subtle.exportKey('raw', x.publicKey)), ep: bytesToB64(await subtle.exportKey('raw', ed.publicKey)) },
    };
  }
  async function importIdentity(bundle) {
    return {
      xPriv: await subtle.importKey('pkcs8', b64ToBytes(bundle.x), { name: 'X25519' }, false, ['deriveBits']),
      edPriv: await subtle.importKey('pkcs8', b64ToBytes(bundle.e), { name: 'Ed25519' }, false, ['sign']),
      xPubB64: bundle.xp, edPubB64: bundle.ep, bundle,
    };
  }
  const importXPub = (b64) => subtle.importKey('raw', b64ToBytes(b64), { name: 'X25519' }, false, []);
  const importEdPub = (b64) => subtle.importKey('raw', b64ToBytes(b64), { name: 'Ed25519' }, false, ['verify']);

  const ED_PKCS8_PREFIX = hexToBytes('302e020100300506032b657004220420');
  async function edKeypairFromSeed(seed32) {
    const pkcs8 = new Uint8Array(ED_PKCS8_PREFIX.length + 32);
    pkcs8.set(ED_PKCS8_PREFIX); pkcs8.set(new Uint8Array(seed32), ED_PKCS8_PREFIX.length);
    const priv = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
    const jwk = await subtle.exportKey('jwk', priv);
    return { priv, pubB64: bytesToB64(b64urlToBytes(jwk.x)) };
  }
  async function edSign(edPriv, str) { return bytesToB64(await subtle.sign('Ed25519', edPriv, te.encode(str))); }
  async function edVerify(edPubB64, sigB64, str) { try { return await subtle.verify('Ed25519', await importEdPub(edPubB64), b64ToBytes(sigB64), te.encode(str)); } catch (e) { return false; } }
  async function fingerprint(xPubB64, edPubB64) { const h = await sha256Hex(`${V}.fp|${xPubB64}|${edPubB64}`); return h.match(/.{4}/g).join(' '); }   // full 256-bit OOB anchor

  // ---- sealed box: anonymous ECIES to a recipient X25519 pubkey ----
  async function sealedBoxEncrypt(recipientXPubB64, obj) {
    const eph = await subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);
    const ephPubB64 = bytesToB64(await subtle.exportKey('raw', eph.publicKey));
    const shared = await subtle.deriveBits({ name: 'X25519', public: await importXPub(recipientXPubB64) }, eph.privateKey, 256);
    const hk = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
    const key = await subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: b64ToBytes(ephPubB64), info: te.encode(`${V}.sealedbox`) }, hk, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
    const { iv, ct } = await gcmEncryptBytes(key, padBucket(te.encode(JSON.stringify(obj)), MSG_BUCKETS));
    return { ephPub: ephPubB64, iv, ct };
  }
  async function sealedBoxOpen(myXPriv, box) {
    const shared = await subtle.deriveBits({ name: 'X25519', public: await importXPub(box.ephPub) }, myXPriv, 256);
    const hk = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
    const key = await subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: b64ToBytes(box.ephPub), info: te.encode(`${V}.sealedbox`) }, hk, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
    return JSON.parse(td.decode(unpadBucket(await gcmDecryptBytes(key, box.iv, box.ct))));
  }
  function packSealedBox(box) {
    const eph = b64ToBytes(box.ephPub), iv = b64ToBytes(box.iv), ct = b64ToBytes(box.ct);
    const raw = new Uint8Array(eph.length + iv.length + ct.length);
    raw.set(eph, 0); raw.set(iv, eph.length); raw.set(ct, eph.length + iv.length);
    return padBucket(raw, MSG_BUCKETS);
  }
  function unpackSealedBox(bytes) {
    const raw = unpadBucket(new Uint8Array(bytes));
    if (raw.length < 44) throw new Error('short sealed box');
    return { ephPub: bytesToB64(raw.subarray(0, 32)), iv: bytesToB64(raw.subarray(32, 44)), ct: bytesToB64(raw.subarray(44)) };
  }

  // ---- tr4ker identity: self-signed profile tag (advertise pubkeys), TOFU-parsed ----
  // IDENTITY IS BOUND TO tr4ker's NUMERIC ACCOUNT ID, never the display username: ids are unique and
  // rename-proof, which sidesteps username canonicalization collisions (case / Unicode NFKC, e.g. the
  // Kelvin-sign homoglyph) that a username binding suffered (adversarial review MAJOR). The caller MUST
  // pass tr4ker's own account id (from /api/me for self, /api/users/<name>.id for a peer), not free text.
  const acct = (id) => String(id == null ? '' : id).trim();
  function tagSigData(accountId, xPubB64, edPubB64) { return `${V}.profile|${accountId}|${xPubB64}|${edPubB64}`; }
  async function makeProfileTag(identity, accountId) {
    const id = acct(accountId); if (!/^[0-9]{1,20}$/.test(id)) throw new Error('accountId must be a numeric tr4ker id');
    const sig = await edSign(identity.edPriv, tagSigData(id, identity.xPubB64, identity.edPubB64));
    return `t4e.k1.${b64ToUrl(identity.xPubB64)}.${b64ToUrl(identity.edPubB64)}.${b64ToUrl(sig)}`;
  }
  // Parse + VERIFY a tag against the claimed numeric account id. Returns {xPubB64, edPubB64, fp} or null.
  async function parseProfileTag(tagStr, accountId) {
    try {
      const id = acct(accountId); if (!/^[0-9]{1,20}$/.test(id)) return null;   // reject empty / non-canonical id
      const m = String(tagStr || '').match(/t4e\.k1\.([A-Za-z0-9_-]{40,50})\.([A-Za-z0-9_-]{40,50})\.([A-Za-z0-9_-]{80,100})(?![A-Za-z0-9_-])/);
      if (!m) return null;
      const xPub = b64urlToBytes(m[1]), edPub = b64urlToBytes(m[2]);
      if (xPub.length !== 32 || edPub.length !== 32) return null;               // canonical 32-byte curve keys only
      const xPubB64 = bytesToB64(xPub), edPubB64 = bytesToB64(edPub), sig = bytesToB64(b64urlToBytes(m[3]));
      if (!(await edVerify(edPubB64, sig, tagSigData(id, xPubB64, edPubB64)))) return null;   // self-sig must hold
      return { xPubB64, edPubB64, fp: await fingerprint(xPubB64, edPubB64) };
    } catch (e) { return null; }
  }

  return {
    V, isSupported, bytesToHex, hexToBytes, bytesToB64, b64ToBytes, b64ToUrl, urlToB64, randomBytes, sha256Hex,
    gcmEncryptBytes, gcmDecryptBytes, MSG_BUCKETS, padBucket, unpadBucket,
    generateIdentity, importIdentity, importXPub, importEdPub, edKeypairFromSeed, edSign, edVerify, fingerprint,
    sealedBoxEncrypt, sealedBoxOpen, packSealedBox, unpackSealedBox,
    makeProfileTag, parseProfileTag,
  };
  })();
  const T4EDM = (function () {
  'use strict';
  // NOTE (m6, metadata): DM_MARK is a plaintext marker, so the host CAN see which messages are E2E DMs
  // (and thus which accounts run the addon) — a targeting signal, honest alongside the who/when/size
  // metadata that a host always sees. A marker is required for client-side detection; hiding it fully is
  // out of scope for this phase.
  const DM_MARK = 't4edm1:';
  const V = 't4e.v1';
  const MAX_WIRE_B64 = 300000;   // ~220 KB decoded cap before we touch attacker bytes (m7 DoS guard)

  // convId is bound into the signature; it MUST NOT contain the '|' delimiter (m3 aliasing). On tr4ker it
  // is the numeric conversation id, but we enforce it so a host-influenced id can never alias the tuple.
  function safeConvId(convId) { const s = String(convId); if (s.indexOf('|') !== -1) throw new Error('convId must not contain "|"'); return s; }
  function dmSigData(F, convId, counter, recipXPubB64, bodyHashHex) {
    return `${V}.dm|${safeConvId(convId)}|${counter}|${recipXPubB64}|${bodyHashHex}`;
  }

  // F = the foundation module (window.T4E / require('./foundation.js'))
  async function sealDm(F, identity, recipXPubB64, convId, counter, body) {
    if (!identity || !recipXPubB64 || convId == null) throw new Error('sealDm args');
    if (!Number.isSafeInteger(counter) || counter < 0) throw new Error('counter must be a non-negative integer');
    const bodyHash = await F.sha256Hex(String(body));
    const sig = await F.edSign(identity.edPriv, dmSigData(F, convId, counter, recipXPubB64, bodyHash));
    const payload = { s: identity.edPubB64, b: String(body), c: counter, sig };
    const box = await F.sealedBoxEncrypt(recipXPubB64, payload);
    return DM_MARK + F.bytesToB64(F.packSealedBox(box));
  }

  // A DM wire is the WHOLE message body (marker anchored at the start, ignoring leading whitespace); a
  // marker embedded mid-text is NOT treated as a DM (m4: no smuggled-prefix cross-client discrepancy).
  function isDm(wire) { return typeof wire === 'string' && wire.replace(/^\s+/, '').indexOf(DM_MARK) === 0; }

  // expectedSenderEdPubB64 = the DIRECTORY-pinned Ed25519 key of the account the SITE attributes this
  // message to. It is MANDATORY and the check FAILS CLOSED (M1): a sealed box is anonymous encryption, so
  // without this binding anyone (incl. the host) could mint a DM from a key of their choosing. The UI must
  // attribute from the returned senderEdPub / the pinned key, never from the host's label.
  async function openDm(F, identity, wire, convId, expectedSenderEdPubB64) {
    if (!expectedSenderEdPubB64 || typeof expectedSenderEdPubB64 !== 'string') throw new Error('expectedSenderEdPubB64 required');
    if (!isDm(wire)) return null;
    const s = wire.replace(/^\s+/, '');
    const b64 = s.slice(DM_MARK.length).trim();
    if (!b64 || b64.length > MAX_WIRE_B64 || !/^[A-Za-z0-9+/=]+$/.test(b64)) throw new Error('malformed dm payload');
    const box = F.unpackSealedBox(F.b64ToBytes(b64));
    const payload = await F.sealedBoxOpen(identity.xPriv, box);        // only the recipient's xPriv succeeds
    if (!payload || typeof payload.b !== 'string' || typeof payload.s !== 'string' || !payload.sig) throw new Error('malformed dm');
    if (!Number.isSafeInteger(payload.c) || payload.c < 0) throw new Error('bad counter');   // m2: keep counter safely comparable
    if (payload.s !== expectedSenderEdPubB64) throw new Error('sender key mismatch');
    const bodyHash = await F.sha256Hex(payload.b);
    if (!(await F.edVerify(payload.s, payload.sig, dmSigData(F, convId, payload.c, identity.xPubB64, bodyHash)))) throw new Error('bad signature');
    return { senderEdPub: payload.s, body: payload.b, counter: payload.c };
  }

  // m5: stateful strict-monotonic replay/reorder guard (per conversation). The caller MUST run each
  // opened DM's counter through check() and drop it on false. Stateless openDm cannot do this itself.
  function createReplayGuard() {
    const last = new Map();
    return {
      check(convId, counter) {
        if (!Number.isSafeInteger(counter) || counter < 0) return false;
        const k = String(convId), prev = last.has(k) ? last.get(k) : -1;
        if (counter <= prev) return false;   // replay or reorder-to-old
        last.set(k, counter); return true;
      },
      seen: last,
    };
  }

  return { DM_MARK, isDm, sealDm, openDm, dmSigData, createReplayGuard };
  })();
  const T4EID = (function () {
  'use strict';
  const KEY_BUNDLE = 't4e.id.bundle';   // { x, e, xp, ep }
  const KEY_PINS = 't4e.id.pins';       // { "<accountId>": { xPubB64, edPubB64, fp, at, verified } }

  function createIdentity(F, io) {
    if (!F || !io || typeof io.getKV !== 'function' || typeof io.setKV !== 'function') throw new Error('createIdentity(F, {getKV,setKV})');
    let _id = null;

    async function ensureIdentity() {
      if (_id) return _id;
      let bundle = null; try { bundle = io.getKV(KEY_BUNDLE, null); } catch (e) {}
      if (bundle && bundle.x && bundle.e && bundle.xp && bundle.ep) {
        try { _id = await F.importIdentity(bundle); return _id; } catch (e) { /* corrupt -> regenerate */ }
      }
      const gen = await F.generateIdentity();
      io.setKV(KEY_BUNDLE, gen.bundle);
      _id = gen; return _id;
    }

    async function myTag(accountId) {
      const id = await ensureIdentity();
      return F.makeProfileTag(id, accountId);
    }
    async function myFingerprint() {
      const id = await ensureIdentity();
      return F.fingerprint(id.xPubB64, id.edPubB64);
    }

    function _pins() { try { return io.getKV(KEY_PINS, {}) || {}; } catch (e) { return {}; } }
    function getPinned(accountId) { const p = _pins()[String(accountId)]; return p || null; }
    function forgetPin(accountId) { const p = _pins(); delete p[String(accountId)]; io.setKV(KEY_PINS, p); }

    // Parse+verify a peer tag, then TOFU-pin. Returns:
    //   {ok:false}                                        tag missing/invalid/forged (self-sig or id mismatch)
    //   {ok:true, first:true, peer}                       first sighting -> pinned
    //   {ok:true, changed:false, peer}                    matches the existing pin
    //   {ok:true, changed:true, peer, previous}           KEY CHANGED -> caller MUST warn + require re-verify
    async function resolvePeer(accountId, tagStr) {
      const parsed = await F.parseProfileTag(tagStr, accountId);
      if (!parsed) return { ok: false };
      const key = String(accountId), pins = _pins(), prev = pins[key];
      const peer = { accountId: key, xPubB64: parsed.xPubB64, edPubB64: parsed.edPubB64, fp: parsed.fp };
      if (!prev) { pins[key] = { xPubB64: peer.xPubB64, edPubB64: peer.edPubB64, fp: peer.fp, at: (io.now ? io.now() : 0), verified: false }; io.setKV(KEY_PINS, pins); return { ok: true, first: true, peer }; }
      if (prev.xPubB64 === peer.xPubB64 && prev.edPubB64 === peer.edPubB64) return { ok: true, changed: false, peer };
      // key rotation / possible substitution: do NOT auto-replace; surface for the user to decide
      return { ok: true, changed: true, peer, previous: { xPubB64: prev.xPubB64, edPubB64: prev.edPubB64, fp: prev.fp } };
    }
    // Explicit accept of a changed/new key (after the user re-verifies OOB). Marks verified when asked.
    function acceptPeer(accountId, peer, verified) {
      const pins = _pins(); pins[String(accountId)] = { xPubB64: peer.xPubB64, edPubB64: peer.edPubB64, fp: peer.fp, at: (io.now ? io.now() : 0), verified: !!verified }; io.setKV(KEY_PINS, pins);
    }

    return { ensureIdentity, myTag, myFingerprint, resolvePeer, acceptPeer, getPinned, forgetPin };
  }
  return { createIdentity };
  })();
  const T4EIMG = (function () {
  'use strict';

  function wrap(ctx, text, maxTextW) {
    const out = [];
    for (const para of String(text).split('\n')) {
      if (!para) { out.push(''); continue; }
      let cur = '';
      for (const word of para.split(/\s+/)) {
        // hard-break a single word longer than the line
        let w = word;
        while (ctx.measureText(w).width > maxTextW && w.length > 1) {
          let i = 1; while (i < w.length && ctx.measureText(w.slice(0, i + 1)).width <= maxTextW) i++;
          out.push((cur ? cur + ' ' : '') + w.slice(0, i)); cur = ''; w = w.slice(i);
        }
        const test = cur ? cur + ' ' + w : w;
        if (ctx.measureText(test).width > maxTextW && cur) { out.push(cur); cur = w; } else cur = test;
      }
      out.push(cur);
    }
    return out.length ? out : [''];
  }

  // Returns a PNG data URL. opts: {maxWidth, fontPx, color, outline}
  function renderTextToImage(text, opts) {
    opts = opts || {};
    const dpr = Math.min(3, (typeof window !== 'undefined' && window.devicePixelRatio) || 1);
    const maxWidth = Math.max(80, Math.min(560, opts.maxWidth || 360));
    const fontPx = Math.max(11, Math.min(22, opts.fontPx || 14));
    const pad = 8, lineH = Math.round(fontPx * 1.42);
    const font = `${fontPx}px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`;
    const meas = document.createElement('canvas').getContext('2d'); meas.font = font;
    const lines = wrap(meas, text, maxWidth - pad * 2);
    const textW = Math.max(1, ...lines.map((l) => meas.measureText(l).width));
    const width = Math.ceil(Math.min(maxWidth, textW + pad * 2));
    const height = Math.ceil(lines.length * lineH + pad * 2);
    const c = document.createElement('canvas'); c.width = Math.ceil(width * dpr); c.height = Math.ceil(height * dpr);
    const cx = c.getContext('2d'); cx.scale(dpr, dpr);
    cx.font = font; cx.textBaseline = 'top';
    // transparent background (nothing painted); outline for cross-theme legibility, then fill
    cx.lineJoin = 'round'; cx.miterLimit = 2; cx.lineWidth = 3; cx.strokeStyle = opts.outline || 'rgba(0,0,0,.55)';
    cx.fillStyle = opts.color || '#f2f3f7';
    lines.forEach((l, i) => { const y = pad + i * lineH; if (l) { cx.strokeText(l, pad, y); cx.fillText(l, pad, y); } });
    return { dataUrl: c.toDataURL('image/png'), width, height, lines: lines.length };
  }

  return { renderTextToImage, wrap };
  })();

  try { if (window.__T4X_EXPOSE__) { window.__t4xE2E = { F: T4E, DM: T4EDM, ID: T4EID, IMG: T4EIMG }; } } catch (e) {}

  // ===== E2E DM manager (Phase 2 wiring): opt-in, feature-gated, DM conversations only ================
  // Ciphertext rides the site's own DM body; identity keys live ONLY in the GM store (Tampermonkey isolated
  // world), NEVER on the page window nor in page-readable localStorage. Peers are TOFU-pinned from our BFF
  // key directory (tr4ker never sees keys). A CHANGED peer key FAILS CLOSED (never silent plaintext) and
  // prompts an out-of-band re-verify. No native X25519/Ed25519 WebCrypto -> E2E stays OFF (graceful).
  const E2E_DIR = 'https://t4x.ekaii.fr/bff/e2e';
  // BLOCKER-fix: a prior build routed identity KV through store.set, which mirrors every key to page-readable
  // localStorage 't4x:'+k -> the host page could read the private-key bundle. One-shot purge of any leaked
  // bundle/pins, then use a GM-ONLY io for identity storage.
  (function e2ePurgeLeakedKeys() {
    try { const ls = window.localStorage; if (!ls) return; ['t4x:t4e.id.bundle', 't4x:t4e.id.pins'].forEach((k) => { if (ls.getItem(k) != null) ls.removeItem(k); }); } catch (e) {}
  })();
  const _e2eGmIo = {
    getKV: (k, d) => { let v; try { v = GM_getValue(k, undefined); } catch (e) {} return v === undefined ? d : v; },
    setKV: (k, v) => { try { GM_setValue(k, v); } catch (e) {} },
    now: () => Date.now(),
  };
  const e2eId = T4EID.createIdentity(T4E, _e2eGmIo);
  let _e2eSup = null;
  const _e2ePromptCool = {};   // per-peer backoff for repeated changed-key prompts (habituation guard)
  function e2eActive() { return pref0('e2e', false) && _e2eSup === true; }
  function isKnownChannel(conv) { return (chat.channels || []).some((c) => c.id === conv); }
  // RF1-fix: the host controls /api/channels, so a fresh conv is a DM iff it is not a known channel — BUT once
  // a conv has an E2E peer or is established, the host must NOT be able to reclassify it into the plaintext path
  // by later listing it as a channel. e2ePeer/e2eEstablished are our own GM state and cannot be unset by the host.
  function e2eIsDmConv(conv) { return conv != null && !isExt(conv) && (e2ePeer(conv) != null || e2eEstablished(conv) || !isKnownChannel(conv)); }
  function e2ePubState() { return _e2eGmIo.getKV('t4x.e2e.published', null) === chat.myId && !_e2eGmIo.getKV('t4x.e2e.pubfail', 0); }
  async function e2eInit() {
    try { _e2eSup = await T4E.isSupported(); } catch (e) { _e2eSup = false; }
    if (!e2eActive()) return;
    try {
      await e2eId.ensureIdentity();
      if (chat.myId && _e2eGmIo.getKV('t4x.e2e.published', null) !== chat.myId) await e2ePublishTag();
    } catch (e) {
      // MINOR-8-fix: a failed tag publish means NO peer can seal to us — surface it, never swallow.
      _e2eGmIo.setKV('t4x.e2e.pubfail', 1);
      try { toast('E2E : publication de ta clé échouée — les MP chiffrés entrants sont indisponibles tant que ça n’a pas réussi.', 8000); } catch (_) {}
    }
  }
  function e2eDirGet(id) { return new Promise((res, rej) => { GM_xmlhttpRequest({ method: 'GET', url: E2E_DIR + '/tag?id=' + encodeURIComponent(id), timeout: 10000, onload: (r) => { try { res(JSON.parse(r.responseText)); } catch (e) { rej(e); } }, onerror: () => rej(new Error('net')), ontimeout: () => rej(new Error('to')) }); }); }
  async function e2ePublishTag() {
    if (!chat.myId) return; const tag = await e2eId.myTag(chat.myId);
    await new Promise((res, rej) => GM_xmlhttpRequest({ method: 'POST', url: E2E_DIR + '/tag', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ tag }), timeout: 10000, onload: (r) => (r.status < 400 ? res() : rej(new Error('HTTP ' + r.status))), onerror: () => rej(new Error('net')), ontimeout: () => rej(new Error('to')) }));
    _e2eGmIo.setKV('t4x.e2e.published', chat.myId); _e2eGmIo.setKV('t4x.e2e.pubfail', 0);
  }
  async function e2eResolve(peerId) { try { const d = await e2eDirGet(peerId); if (!d || !d.found) return { none: true }; return await e2eId.resolvePeer(peerId, d.tag); } catch (e) { return { none: true }; } }
  // PERF (E2E decrypt path): rendering a 50-message DM history fired one cross-origin directory GET per message
  // for the SAME sender. Memoize resolve results for the DECRYPT path only, and ONLY a clean success (ok + not
  // changed) — a rotation, not-found, or error always re-resolves fresh so a real key change is never masked.
  // e2eSeal (the SEND path) deliberately stays on the un-cached e2eResolve. Signature verification (openDm) and
  // the replay guard still run per message.
  // v0.53.1 SECURITY: a cached resolve can lag a real key rotation by up to the TTL. e2eOpen therefore ALSO
  // fail-closes when the key that actually verified (r.peer) differs from the CURRENT pin (see e2eOpen), and
  // e2eDropResolve() invalidates the entry the instant the trusted key set changes (accept/forget/confirm/rotate)
  // so a legitimate post-rotation message resolves to the new key immediately instead of erroring for the TTL.
  const _e2eResolveCache = new Map();
  const _e2eResolveInflight = new Map();
  const E2E_RESOLVE_TTL = 60000;
  function e2eDropResolve(id) { try { _e2eResolveCache.delete(String(id)); _e2eResolveInflight.delete(String(id)); } catch (e) {} }
  async function e2eResolveCached(peerId) {
    const k = String(peerId);
    const hit = _e2eResolveCache.get(k);
    if (hit && (Date.now() - hit.t < E2E_RESOLVE_TTL)) return hit.r;
    let p = _e2eResolveInflight.get(k);   // coalesce the burst of identical GETs a fresh 50-message pane fires
    if (!p) {
      p = e2eResolve(peerId).then((r) => {
        if (r && r.ok && !r.changed && r.peer) { _e2eResolveCache.set(k, { t: Date.now(), r: r }); if (_e2eResolveCache.size > 200) { const f = _e2eResolveCache.keys().next().value; if (f !== undefined) _e2eResolveCache.delete(f); } }
        else _e2eResolveCache.delete(k);   // never cache a rotation / none / error: always re-surface a key change
        return r;
      }).finally(() => { _e2eResolveInflight.delete(k); });
      _e2eResolveInflight.set(k, p);
    }
    return p;
  }
  // first-seen TOFU only. Never repoint an existing conv->peer mapping from a host-controlled sender_id.
  function e2eLearnPeer(conv, senderId) {
    if (!e2eIsDmConv(conv) || senderId == null || senderId === chat.myId) return;
    const m = _e2eGmIo.getKV('t4x.e2e.peers', {}) || {};
    if (m[String(conv)] == null) { m[String(conv)] = senderId; const ks = Object.keys(m); if (ks.length > 300) delete m[ks[0]]; _e2eGmIo.setKV('t4x.e2e.peers', m); }   // RF6: cap host-supplied conv ids (LRU-ish)
  }
  function e2ePeer(conv) { return (_e2eGmIo.getKV('t4x.e2e.peers', {}) || {})[String(conv)]; }
  function e2eNextCounter(conv) { const m = _e2eGmIo.getKV('t4x.e2e.ctr', {}) || {}; const c = m[String(conv)] || 0; m[String(conv)] = c + 1; const ks = Object.keys(m); if (ks.length > 1000) delete m[ks[0]]; _e2eGmIo.setKV('t4x.e2e.ctr', m); return c; }
  function e2eEstablished(conv) { return !!(_e2eGmIo.getKV('t4x.e2e.estab', {}) || {})[String(conv)]; }
  function e2eSetEstablished(conv) { const m = _e2eGmIo.getKV('t4x.e2e.estab', {}) || {}; if (!m[String(conv)]) { m[String(conv)] = 1; const ks = Object.keys(m); if (ks.length > 1000) delete m[ks[0]]; _e2eGmIo.setKV('t4x.e2e.estab', m); } }
  // Per-conversation security level (choose it per chat, à la e2e-chat). 'auto' = chiffré quand possible (défaut
  // quand les MP chiffrés sont activés) ; 'clear' = ce fil reste volontairement en clair même si le E2E est actif.
  // The GLOBAL `e2e` pref is the master switch (publishes your key); this only refines it per conversation.
  function e2eConvMode(conv) { return (_e2eGmIo.getKV('t4x.e2e.convmode', {}) || {})[String(conv)] || 'auto'; }
  function e2eSetConvMode(conv, m) { const x = _e2eGmIo.getKV('t4x.e2e.convmode', {}) || {}; x[String(conv)] = m; const ks = Object.keys(x); if (ks.length > 1000) delete x[ks[0]]; _e2eGmIo.setKV('t4x.e2e.convmode', x); }
  function e2eWantEncrypt(conv) { return e2eActive() && e2eIsDmConv(conv) && e2eConvMode(conv) !== 'clear'; }
  function e2eClearOk(conv) { const v = (_e2eGmIo.getKV('t4x.e2e.clearok', {}) || {})[String(conv)]; return v === 1 ? 'clear' : v || null; }   // remembered no-key fallback: 'image' | 'clear' | null (legacy 1 -> clear)
  function e2eSetClearOk(conv, mode) { const m = _e2eGmIo.getKV('t4x.e2e.clearok', {}) || {}; m[String(conv)] = mode || 'clear'; const ks = Object.keys(m); if (ks.length > 1000) delete m[ks[0]]; _e2eGmIo.setKV('t4x.e2e.clearok', m); }
  // RF3-fix: an explicit per-peer CONFIRMATION of a specific key, distinct from the pin merely EXISTING. Set only
  // by user action in e2eVerifyPrompt. e2eSeal refuses to seal to a key that is not confirmed, so a key that got
  // TOFU-pinned as a side effect of rendering a host-initiated inbound message can never be sealed to silently.
  function e2eConfirmedKey(peerId) { return (_e2eGmIo.getKV('t4x.e2e.confirmed', {}) || {})[String(peerId)]; }
  function e2eSetConfirmed(peerId, xPubB64) { const m = _e2eGmIo.getKV('t4x.e2e.confirmed', {}) || {}; m[String(peerId)] = xPubB64; const ks = Object.keys(m); if (ks.length > 1000) delete m[ks[0]]; _e2eGmIo.setKV('t4x.e2e.confirmed', m); e2eDropResolve(peerId); }
  // M5-fix: remember our OWN deliberately-cleartext sends so a host cannot fabricate "your own words" as an
  // unmarked plaintext line in an established E2E conversation (host-injected own-plaintext gets the marker).
  function e2eCacheMineClear(conv, body) { try { const m = _e2eGmIo.getKV('t4x.e2e.mineclear', {}) || {}; m[String(conv) + '|' + String(body)] = 1; const ks = Object.keys(m); if (ks.length > 300) delete m[ks[0]]; _e2eGmIo.setKV('t4x.e2e.mineclear', m); } catch (e) {} }
  function e2eIsMineClear(conv, body) { return !!(_e2eGmIo.getKV('t4x.e2e.mineclear', {}) || {})[String(conv) + '|' + String(body)]; }
  // BLOCKER-2-fix: PERSISTED per-(conv,sender) replay guard. A counter is idempotent for re-render iff its
  // msgId matches the one first seen; a DIFFERENT msgId reusing a counter is a replay/substitution (rejected).
  // GM-persisted so it survives reload; bounded per (conv,sender) to the most recent 3000 counters. IDs must
  // be digits (NIT-9: no '|' tuple aliasing).
  function e2eGuardCheck(conv, senderId, counter, msgId) {
    if (!Number.isSafeInteger(counter) || counter < 0) return false;
    if (!/^\d+$/.test(String(conv)) || !/^\d+$/.test(String(senderId))) return false;
    const store = _e2eGmIo.getKV('t4x.e2e.rctr', {}) || {};
    const ck = String(conv) + '|' + String(senderId);
    const rec = store[ck] || { hw: -1, floor: -1, seen: {} };
    if (rec.floor == null) rec.floor = -1;
    const mid = String(msgId);   // R3-fix: FULL msgId as the idempotency discriminator (a lossy 64-char prefix let two host-crafted ids collide and re-open replay)
    if (mid.length > 200) return false;   // sanity DoS guard on a host-controlled id (never truncate for comparison)
    const prev = rec.seen[counter];
    if (prev !== undefined) return prev === mid;   // known counter: idempotent iff same msg, else replay/substitution
    if (counter <= rec.floor) return false;        // RF5-fix: below the eviction floor -> treat as replay, never fresh
    rec.seen[counter] = mid; if (counter > rec.hw) rec.hw = counter;
    const ks = Object.keys(rec.seen);
    if (ks.length > 3000) { const drop = ks.map(Number).sort((a, b) => a - b).slice(0, ks.length - 3000); drop.forEach((k) => { delete rec.seen[k]; }); rec.floor = Math.max(rec.floor, drop[drop.length - 1]); }
    store[ck] = rec;
    const sks = Object.keys(store); if (sks.length > 1000) delete store[sks[0]];   // R3-fix: bound top-level (conv|sender) record count (host could mint many)
    _e2eGmIo.setKV('t4x.e2e.rctr', store);
    return true;
  }
  function e2eForgetConv(conv) {   // NIT-10: prune all per-conv state together
    const c = String(conv);
    ['t4x.e2e.peers', 't4x.e2e.ctr', 't4x.e2e.estab', 't4x.e2e.clearok'].forEach((key) => { const m = _e2eGmIo.getKV(key, {}) || {}; if (m[c] != null) { delete m[c]; _e2eGmIo.setKV(key, m); } });
    const rc = _e2eGmIo.getKV('t4x.e2e.rctr', {}) || {}; let ch = false; Object.keys(rc).forEach((k) => { if (k === c || k.indexOf(c + '|') === 0) { delete rc[k]; ch = true; } }); if (ch) _e2eGmIo.setKV('t4x.e2e.rctr', rc);
  }
  // MAJOR-4-fix: cache OWN plaintext keyed by the exact wire. A sealed box is anonymous ECIES to the PEER, so
  // we cannot open our own sent message; the echoed body equals the wire we sent, so we re-display from cache.
  function e2eCacheMine(wire, body) { try { const mc = _e2eGmIo.getKV('t4x.e2e.mine', {}) || {}; mc[wire] = String(body); const wk = Object.keys(mc); if (wk.length > 500) delete mc[wk[0]]; _e2eGmIo.setKV('t4x.e2e.mine', mc); } catch (e) {} }
  function e2eMine(wire) { return (_e2eGmIo.getKV('t4x.e2e.mine', {}) || {})[wire]; }
  // Seal an outgoing DM. Discriminated result so the caller FAILS CLOSED:
  //   { wire }                            -> send this ciphertext
  //   { nokey:true }                      -> never-pinned peer, no directory key: explicit-confirm plaintext only
  //   { first, peerId, peer }             -> first contact with a resolvable peer: confirm fingerprint, then send
  //   { changed, peerId, peer, previous } -> key rotation: do NOT send; OOB re-verify
  //   { error:true }                      -> crypto failure OR directory-down-with-a-pin: do NOT send (never downgrade)
  async function e2eSeal(conv, body) {
    const peerId = e2ePeer(conv); if (peerId == null) return { nokey: true };
    const pin = e2eId.getPinned(peerId);
    let r = null; try { r = await e2eResolve(peerId); } catch (e) { r = { none: true }; }
    if (r && r.ok && r.changed) { e2eDropResolve(peerId); return { changed: true, peerId: peerId, peer: r.peer, previous: r.previous }; }   // rotation -> fail closed + drop any stale decrypt-cache entry
    // BLOCKER-1-fix: prefer a fresh directory key, else the existing pin. A directory that is unreachable or
    // returns not-found NEVER downgrades a pinned peer to plaintext; only a peer that was NEVER pinned and has
    // no directory key is a genuine no-key. Directory-down-with-a-pin still encrypts to the pin.
    const peerObj = (r && r.ok && r.peer) ? r.peer : (pin ? { xPubB64: pin.xPubB64, edPubB64: pin.edPubB64, fp: pin.fp } : null);
    if (!peerObj) return { nokey: true };
    const xpub = peerObj.xPubB64;
    // RF3-fix: OUTGOING requires an explicit confirmation of THIS exact key. A pin that merely EXISTS (e.g.
    // TOFU-pinned as a side effect of a host-initiated inbound message) is NOT sufficient — prompt first. This
    // covers both true first contact and a reply to a conversation the host spoke into first.
    if (e2eConfirmedKey(peerId) !== xpub) return { first: true, peerId: peerId, peer: peerObj };
    try {
      const id = await e2eId.ensureIdentity();
      const wire = await T4EDM.sealDm(T4E, id, xpub, String(conv), e2eNextCounter(conv), body);
      e2eCacheMine(wire, body); e2eSetEstablished(conv);
      return { wire: wire };
    } catch (e) { return { error: true }; }
  }
  // Open an incoming DM -> {text, verified, fp, senderId} | {error} | null (not an e2e message)
  async function e2eOpen(conv, senderId, body, msgId) {
    if (!T4EDM.isDm(body)) return null;
    if (senderId != null && senderId !== chat.myId) e2eLearnPeer(conv, senderId);   // TOFU-pin the peer on first sight (history or live)
    // MAJOR-3-fix: bind the ciphertext's sender to the conversation's pinned peer. The host controls sender_id
    // and the display name; a genuine sealed box from another account must NOT render as the pinned peer.
    const peerId = e2ePeer(conv);
    if (peerId != null && String(senderId) !== String(peerId)) return { error: 'expéditeur inattendu' };
    try {
      const r = await e2eResolveCached(senderId);   // decrypt path: memoized per sender (fail-closed on stale, see e2eResolveCached)
      if (!r || r.none || !r.ok || !r.peer) return { error: 'clé expéditeur inconnue' };
      if (r.changed) return { error: 'clé de l’expéditeur a changé — non vérifié' };
      const id = await e2eId.ensureIdentity();
      const got = await T4EDM.openDm(T4E, id, body, String(conv), r.peer.edPubB64);
      if (!e2eGuardCheck(conv, senderId, got.counter, msgId)) return { error: 'message rejoué' };
      // v0.53.1 HIGH-fix: r may be a cached resolve up to E2E_RESOLVE_TTL old. openDm verified the signature
      // against r.peer, but the trust display must reflect the CURRENT pinned key. If the peer rotated after this
      // resolve was cached (pin now differs from the key that actually verified), fail CLOSED — never render a
      // rotated-away (possibly compromised) key as authentic, and never anchor the badge/fingerprint to a newer
      // pin than the key the message was actually verified against.
      const pin = e2eId.getPinned(senderId);
      if (pin && (pin.edPubB64 !== r.peer.edPubB64 || pin.xPubB64 !== r.peer.xPubB64)) { e2eDropResolve(senderId); return { error: 'clé de l’expéditeur a changé — non vérifié' }; }
      e2eSetEstablished(conv);
      return { text: got.body, verified: !!(pin && pin.verified), fp: pin ? pin.fp : null, senderId: String(senderId) };
    } catch (e) { return { error: 'déchiffrement impossible' }; }
  }
  function e2eDmApplies(m, conv) { return e2eActive() && e2eIsDmConv(conv) && T4EDM.isDm(m.body); }
  // M5-fix: ANY plaintext line inside an established E2E conv is host-injectable — including one the host labels
  // as the local user (to fabricate "your own words"). Mark all of them EXCEPT our own genuine cleartext sends.
  function e2eUnauthApplies(m, conv) {
    if (!(e2eActive() && e2eIsDmConv(conv) && e2eEstablished(conv) && !T4EDM.isDm(m.body))) return false;
    if (m.sender_id === chat.myId && e2eIsMineClear(conv, m.body)) return false;   // a message we deliberately sent in clear
    return true;
  }
  // Render a DM body inside an E2E conversation: own message (from the wire cache), peer ciphertext (decrypt +
  // bind + identity-anchored badge), or host-injected plaintext in an established conv (explicit unauth marker).
  function e2eRenderBody(bd, m, conv) {
    if (m.sender_id === chat.myId) {   // MAJOR-4: our own sent message (sealed to the peer, not openable by us)
      bd.replaceChildren();
      const badge = document.createElement('span'); badge.className = 't4x-e2e-badge'; badge.textContent = '🔒'; badge.title = 'Ton message, chiffré de bout en bout';
      bd.append(badge, document.createTextNode(' '));
      const mine = e2eMine(m.body); const inner = document.createElement('span');
      if (mine != null) renderBody(inner, mine, chat.myName); else inner.textContent = '(message chiffré envoyé)';
      bd.appendChild(inner); return;
    }
    bd.textContent = '🔒 …';
    e2eOpen(conv, m.sender_id, m.body, m.id).then((r) => {
      bd.replaceChildren();
      if (!r) { renderBody(bd, m.body, chat.myName); return; }
      if (r.error) { const e = document.createElement('span'); e.className = 't4x-e2e-err'; e.textContent = '🔓 ' + r.error; bd.appendChild(e); return; }
      const badge = document.createElement('span'); badge.className = 't4x-e2e-badge'; badge.textContent = r.verified ? '🔒✓' : '🔒';
      badge.title = (r.verified ? 'Chiffré de bout en bout, clé vérifiée' : 'Chiffré de bout en bout (clé épinglée, non vérifiée)') + ' — compte #' + r.senderId + (r.fp ? ' · empreinte ' + r.fp : '');
      bd.append(badge, document.createTextNode(' '));
      const inner = document.createElement('span'); renderBody(inner, r.text, chat.myName); bd.appendChild(inner);
    }).catch(() => { bd.replaceChildren(); const e = document.createElement('span'); e.className = 't4x-e2e-err'; e.textContent = '🔓 erreur'; bd.appendChild(e); });
  }
  // MAJOR-5-fix: a plaintext (non-ciphertext) message inside an ESTABLISHED E2E conversation is NOT
  // authenticated — the host could have injected it. Mark it explicitly rather than rendering it as normal.
  function e2eRenderUnauth(bd, m) {
    bd.replaceChildren();
    const w = document.createElement('span'); w.className = 't4x-e2e-err'; w.textContent = '🔓 non chiffré — expéditeur non authentifié : '; bd.appendChild(w);
    const inner = document.createElement('span'); renderBody(inner, m.body, chat.myName); bd.appendChild(inner);
  }
  // Explicit one-time-per-conversation confirm before EVER sending in clear to a never-pinned peer (BLOCKER-1:
  // no silent plaintext). onYes fires only on accept; the choice is remembered per conversation.
  // R3-fix (dialog forgery): render every security-decision dialog into a dedicated CLOSED shadow root whose node
  // references never leave this isolated-world closure — a malicious host cannot read the shown fingerprint,
  // pre-fill the inputs, or reach the buttons. Combined with the isTrusted gate on each handler, a page-dispatched
  // synthetic click cannot forge a confirmation. (Open root only under __T4X_EXPOSE__, a test-only flag never set in prod.)
  const _e2eAttachShadow = Element.prototype.attachShadow;
  const E2E_DIALOG_CSS = '.t4x-e2e-verify{position:fixed;inset:0;z-index:2147483300;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.55);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}.t4x-e2e-vbox{max-width:440px;width:calc(100% - 40px);background:var(--surface-container-high,#1a1d26);border:1px solid var(--outline-variant,rgba(147,165,255,.2));border-radius:16px;padding:18px 20px;box-shadow:0 24px 60px rgba(0,0,0,.5);color:var(--on-surface,#e6e9f2)}.t4x-e2e-vh{font-size:15px;font-weight:700;color:var(--error,#ffb4ab);margin-bottom:8px}.t4x-e2e-vp{font-size:12.5px;line-height:1.5;color:var(--on-surface-variant,#aab2c5);margin-bottom:12px}.t4x-e2e-vfp{font-size:11.5px;color:var(--on-surface,#e6e9f2);margin-bottom:14px;display:flex;flex-direction:column;gap:5px}.t4x-e2e-vfp code{font-family:ui-monospace,monospace;background:var(--surface-container,#141722);padding:2px 6px;border-radius:6px;word-break:break-all}.t4x-e2e-vbtns{display:flex;gap:8px;justify-content:flex-end}.t4x-e2e-vcancel,.t4x-e2e-vaccept{all:unset;cursor:pointer;font-size:12.5px;padding:8px 14px;border-radius:10px}.t4x-e2e-vcancel{color:var(--on-surface-variant,#aab2c5);border:1px solid var(--outline-variant,rgba(147,165,255,.2))}.t4x-e2e-vaccept{background:var(--error,#ffb4ab);color:#3b0a08;font-weight:600}.t4x-e2e-vcancel:hover{color:var(--on-surface,#e6e9f2)}.t4x-e2e-vaccept:hover{filter:brightness(1.08)}.t4x-e2e-vcmp{width:100%;box-sizing:border-box;margin-bottom:12px;font:inherit;font-size:12px;padding:8px 10px;border-radius:9px;background:var(--surface-container,#141722);color:var(--on-surface,#e6e9f2);border:1px solid var(--outline-variant,rgba(255,255,255,.15))}.t4x-e2e-voob{display:flex;align-items:flex-start;gap:7px;font-size:11.5px;color:var(--on-surface-variant,#aab2c5);margin-bottom:10px;cursor:pointer;line-height:1.4}.t4x-e2e-voob input{margin-top:2px}.t4x-e2e-arming{opacity:.45;cursor:progress;pointer-events:none}';
  const E2E_DWELL_MS = 700;   // R4-fix: confirm/accept buttons arm after a short dwell (breaks pointer-events:none clickjacking lures)
  function e2eArm(btn) { btn.disabled = true; btn.classList.add('t4x-e2e-arming'); setTimeout(() => { try { btn.disabled = false; btn.classList.remove('t4x-e2e-arming'); } catch (e) {} }, E2E_DWELL_MS); }
  let _e2ePromptSR = null, _e2ePromptHost = null;
  function e2ePromptRoot() {
    if (_e2ePromptSR && _e2ePromptHost && document.documentElement.contains(_e2ePromptHost)) return _e2ePromptSR;
    const hostEl = document.createElement('div'); hostEl.style.cssText = 'all:initial';
    const mode = window.__T4X_EXPOSE__ ? 'open' : 'closed';
    let sr; try { sr = _e2eAttachShadow.call(hostEl, { mode: mode }); } catch (e) { sr = hostEl.attachShadow({ mode: mode }); }
    const st = document.createElement('style'); st.textContent = E2E_DIALOG_CSS; sr.appendChild(st);
    document.documentElement.appendChild(hostEl);
    _e2ePromptSR = sr; _e2ePromptHost = hostEl; return sr;
  }
  const _e2eTrusted = (e) => !!(window.__T4X_EXPOSE__ || (e && e.isTrusted === true));   // real user gesture (synthetic host clicks rejected in prod)
  // Backward-compat "invisible screenshot": render the message text to a transparent PNG (T4EIMG) and upload it
  // to a PUBLIC third-party image host (ImgBB, via the BFF /upload proxy), then send the [img] URL — so a
  // NON-addon recipient still SEES the words while tr4ker's DB never holds indexable TEXT (only a URL).
  // HONEST CEILING (stated in the dialog): the pixels live on a public third-party host reachable by anyone with
  // the link and kept durably, and are OCR-able. This is confidentiality-of-text-FROM-tr4ker only, NOT E2E, and
  // exposure-wise it is BROADER than plaintext (which only tr4ker sees). done(true|false) = whether it was sent.
  async function e2eSendAsImage(conv, body, replyId, done) {
    try {
      const im = T4EIMG.renderTextToImage(String(body), { maxWidth: 380 });
      const url = await uploadImage(im.dataUrl);
      if (!/^https:\/\/i\.ibb\.co\/[A-Za-z0-9\/._-]+$/.test(url)) { toast('E2E : URL image inattendue — message NON envoyé.', 7000); done(false); return; }   // F3: fail closed on an unexpected host response (no BBCode breakout)
      const wire = '[img]' + url + '[/img]';
      const f = { type: 'msg.send', conv_id: conv, body: wire }; if (replyId) f.parent_id = replyId;
      const okk = sendFrame(f); if (okk) e2eCacheMineClear(conv, wire);   // cache AFTER a real send, conv-bound (F2)
      done(okk);
    } catch (e) { toast('E2E : envoi en image impossible (' + ((e && e.message) || '?') + ') — message NON envoyé.', 7000); done(false); }
  }
  // No-key peer dialog: E2E impossible (recipient has no TR4KER+ key). Two honest fallbacks — IMAGE (recommended:
  // server stores only pixels, still OCR-able + host-visible, NOT E2E) and CLEAR (plaintext). onImage()/onClear().
  function e2eNoKeyConfirm(conv, onImage, onClear) {
    let unesc = null;
    const wrap = document.createElement('div'); wrap.className = 't4x-e2e-verify';
    const box = document.createElement('div'); box.className = 't4x-e2e-vbox';
    const h = document.createElement('div'); h.className = 't4x-e2e-vh'; h.textContent = '🖼️ Correspondant sans clé TR4KER+';
    const p = document.createElement('div'); p.className = 't4x-e2e-vp'; p.textContent = 'Ce correspondant n’a pas (encore) l’extension : impossible de chiffrer. « En image » : tr4ker n’aura pas le texte indexable, MAIS l’image part sur un hébergeur d’images tiers PUBLIC (accessible à quiconque a le lien, conservée durablement) et reste lisible par OCR — ce n’est PAS du chiffrement E2E, et c’est plus exposé qu’un envoi en clair. « En clair » : seul tr4ker voit le message. À toi de choisir.';
    const btns = document.createElement('div'); btns.className = 't4x-e2e-vbtns';
    const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 't4x-e2e-vcancel'; cancel.textContent = 'Annuler';
    const clear = document.createElement('button'); clear.type = 'button'; clear.className = 't4x-e2e-vcancel'; clear.textContent = 'En clair';
    const img = document.createElement('button'); img.type = 'button'; img.className = 't4x-e2e-vaccept'; img.textContent = 'Envoyer en image';
    const done = (choice) => { try { wrap.remove(); } catch (e) {} if (unesc) { try { unesc(); } catch (e) {} } if (choice) { e2eSetClearOk(conv, choice); try { (choice === 'image' ? onImage : onClear)(); } catch (e) {} } };
    cancel.addEventListener('click', (e) => { if (!_e2eTrusted(e)) return; done(null); });
    clear.addEventListener('click', (e) => { if (!_e2eTrusted(e)) return; done('clear'); });
    img.addEventListener('click', (e) => { if (!_e2eTrusted(e)) return; done('image'); });
    wrap.addEventListener('click', (e) => { if (e.target === wrap && _e2eTrusted(e)) done(null); });
    btns.append(cancel, clear, img); box.append(h, p, btns); wrap.appendChild(box); e2ePromptRoot().appendChild(wrap); e2eArm(clear); e2eArm(img);
    unesc = escPush(() => done(null));
  }
  // Changed-key / first-contact verify UI.
  // RF3-fix: accepting CONFIRMS this key for OUTGOING sealing (e2eSetConfirmed) — a bare pin is never enough.
  // RF4-fix: "vérifiée" (🔒✓) requires an explicit "compared out of band" checkbox AND a FULL fingerprint match
  //          retyped by the user (no shown-tail shortcut). Accept-without-verify pins + confirms but stays 🔒.
  function e2eVerifyPrompt(res, onDecision) {
    const first = !!res.first; let unesc = null;
    const nfp = (((res.peer && res.peer.fp) || '').toLowerCase().replace(/\s+/g, ''));
    const wrap = document.createElement('div'); wrap.className = 't4x-e2e-verify';
    const box = document.createElement('div'); box.className = 't4x-e2e-vbox';
    const h = document.createElement('div'); h.className = 't4x-e2e-vh'; h.textContent = first ? '🔑 Première clé de ce correspondant' : '⚠ Clé du correspondant modifiée';
    const p = document.createElement('div'); p.className = 't4x-e2e-vp'; p.textContent = first
      ? ('Tu vas chiffrer pour le compte #' + res.peerId + '. « Épingler et envoyer » chiffre vers cette clé sans la marquer vérifiée. Pour la marquer VÉRIFIÉE : compare l’empreinte ci-dessous avec celle que le correspondant te donne par un AUTRE canal, coche la case, puis retape-la entièrement.')
      : 'La clé publiée par ce membre a changé. Cela peut être un nouvel appareil — ou une interception. Compare la nouvelle empreinte hors-ligne (autre canal) avant d’accepter.';
    const fps = document.createElement('div'); fps.className = 't4x-e2e-vfp';
    if (!first) { const oldfp = document.createElement('div'); oldfp.append(document.createTextNode('Ancienne : '), Object.assign(document.createElement('code'), { textContent: (res.previous && res.previous.fp) || '—' })); fps.appendChild(oldfp); }
    const newfp = document.createElement('div'); newfp.append(document.createTextNode(first ? 'Empreinte : ' : 'Nouvelle : '), Object.assign(document.createElement('code'), { textContent: (res.peer && res.peer.fp) || '—' })); fps.appendChild(newfp);
    const oob = document.createElement('label'); oob.className = 't4x-e2e-voob';
    const chk = document.createElement('input'); chk.type = 'checkbox';
    oob.append(chk, document.createTextNode(' J’ai comparé cette empreinte hors-ligne (autre canal que TR4KER)'));
    const cmp = document.createElement('input'); cmp.type = 'text'; cmp.className = 't4x-e2e-vcmp'; cmp.placeholder = 'Retape l’empreinte complète pour marquer VÉRIFIÉE (facultatif)';
    const btns = document.createElement('div'); btns.className = 't4x-e2e-vbtns';
    const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 't4x-e2e-vcancel'; cancel.textContent = 'Annuler';
    const accept = document.createElement('button'); accept.type = 'button'; accept.className = 't4x-e2e-vaccept'; accept.textContent = first ? 'Épingler et envoyer' : 'Accepter la nouvelle clé';
    const finish = (accepted) => { try { wrap.remove(); } catch (e) {} if (unesc) { try { unesc(); } catch (e) {} } try { onDecision(accepted); } catch (e) {} };
    const bail = () => { if (first) { try { e2eId.forgetPin(res.peerId); } catch (e) {} try { e2eDropResolve(res.peerId); } catch (e) {} } finish(false); };   // cancel first-contact drops the auto-pin (+ its stale decrypt-cache entry)
    cancel.addEventListener('click', (e) => { if (!_e2eTrusted(e)) return; bail(); });
    accept.addEventListener('click', (e) => {
      if (!_e2eTrusted(e)) return;   // R3-fix: only a real user gesture may confirm/verify (blocks host synthetic clicks)
      const typed = cmp.value.toLowerCase().replace(/\s+/g, '');
      const verified = chk.checked && nfp.length > 0 && typed === nfp;   // explicit affirmation + FULL match, no shown-tail shortcut
      try { e2eId.acceptPeer(res.peerId, res.peer, verified); } catch (e2) {}
      try { e2eSetConfirmed(res.peerId, res.peer && res.peer.xPubB64); } catch (e2) {}   // this exact key is now usable for OUTGOING
      finish(true);
    });
    btns.append(cancel, accept); box.append(h, p, fps, oob, cmp, btns); wrap.appendChild(box);
    wrap.addEventListener('click', (e) => { if (e.target === wrap && _e2eTrusted(e)) bail(); });
    e2ePromptRoot().appendChild(wrap); e2eArm(accept);
    unesc = escPush(bail);
  }
  try { if (window.__T4X_EXPOSE__) { window.__t4xE2Emgr = { init: e2eInit, seal: e2eSeal, open: e2eOpen, learn: e2eLearnPeer, resolve: e2eResolve, guard: e2eGuardCheck, established: e2eEstablished, unauthApplies: e2eUnauthApplies, forget: e2eForgetConv, noKeyConfirm: e2eNoKeyConfirm, sendAsImage: e2eSendAsImage, clearOk: e2eClearOk, mine: e2eMine, confirmedKey: e2eConfirmedKey, setConfirmed: e2eSetConfirmed, isDmConv: e2eIsDmConv, convMode: e2eConvMode, setConvMode: e2eSetConvMode, wantEncrypt: e2eWantEncrypt, id: e2eId, verifyPrompt: e2eVerifyPrompt, promptRoot: () => _e2ePromptSR, pubOk: e2ePubState, sup: () => _e2eSup, setSup: (v) => { _e2eSup = v; } }; } } catch (e) {}

  // ---- Meilisearch (self-hosted, full 345k catalogue, instant + typo-tolerant) ----
  const MEILI_URL = 'https://t4search.ekaii.fr';
  const MEILI_KEY = '6fc1913be46eb01c7ca8ade9eb3f7e03824667e9f1313040557afe5b9232d2bb'; // search-only tenant key
  let meiliCooldown = 0;
  function meiliSearch(q, opts) {
    opts = opts || {};
    if (Date.now() < meiliCooldown) return Promise.resolve(null);
    return new Promise((res) => {
      let done = false;
      const finish = (v) => { if (!done) { done = true; res(v); } };
      const to = setTimeout(() => finish(null), 4000);
      const body = { q: q, limit: opts.limit || 8, attributesToRetrieve: ['title', 'slug', 'seeders', 'leechers', 'grabs', 'size_bytes', 'category_code', 'category_label', 'year', 'group_key'] };
      if (opts.filter) body.filter = opts.filter;
      if (opts.sort) body.sort = opts.sort;
      // version grouping: one line per work, the other releases behind a "N versions" expander
      if (opts.distinct) body.distinct = 'group_key';
      try {
        GM_xmlhttpRequest({
          method: 'POST', url: MEILI_URL + '/indexes/catalogue/search',
          headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + MEILI_KEY },
          data: JSON.stringify(body),
          onload: (r) => { clearTimeout(to); try { const j = JSON.parse(r.responseText); finish(Array.isArray(j.hits) ? j.hits : null); } catch (e) { finish(null); } },
          onerror: () => { clearTimeout(to); meiliCooldown = Date.now() + 60000; finish(null); },
          ontimeout: () => { clearTimeout(to); finish(null); },
          timeout: 4500,
        });
      } catch (e) { finish(null); }
    });
  }
  function mapMeiliHit(h) {
    return { slug: h.slug, name: h.title, size_bytes: h.size_bytes, seeders: h.seeders,
             parent_cat_name: h.category_label, parent_cat_slug: (h.category_label || '').toLowerCase(),
             year: h.year, group_key: h.group_key, _meili: true };
  }
  // Every other release of the same work. Only meaningful for a real group_key — the indexer
  // gives ungroupable rows a unique "!"-prefixed key precisely so they can never be collapsed.
  async function meiliVersions(groupKey, limit) {
    if (!groupKey || groupKey.charAt(0) === '!') return [];
    const hits = await meiliSearch('', { limit: limit || 12, filter: 'group_key = ' + JSON.stringify(groupKey), sort: ['seeders:desc'] });
    return (hits || []).map(mapMeiliHit);
  }
  // ---- OFFLINE catalogue hot-pack (Topic C) : recherche instantanée + repli quand Meili/site tombe ---
  // ~7,6 Mo gz (top 100k torrents), mis en cache IndexedDB (marche hors-ligne), décompressé via
  // DecompressionStream, cherché par indexOf sur une colonne de titres en minuscules. Chargé à la demande.
  const PACK_URL = 'https://t4hub.ekaii.fr/public/pack-hot.json.gz';
  let _pack = null, _packReq = null;
  let _idbDb = null, _idbReq = null;
  function idbOpen() {
    if (_idbDb) return Promise.resolve(_idbDb);
    if (_idbReq) return _idbReq;
    _idbReq = new Promise((res, rej) => { let r; try { r = indexedDB.open('t4xcache', 1); } catch (e) { return rej(e); } r.onupgradeneeded = () => { try { r.result.createObjectStore('kv'); } catch (e) {} }; r.onsuccess = () => { const db = r.result; try { db.onclose = () => { _idbDb = null; }; } catch (e) {} _idbDb = db; res(db); }; r.onerror = () => rej(r.error); });
    _idbReq.catch(() => {}).then(() => { _idbReq = null; });
    return _idbReq;
  }
  // v0.53.1: a memoized connection the engine kills WITHOUT firing 'close' (e.g. WebKit tab suspension) would
  // poison every later op. On a transaction-creation throw, drop the dead handle and retry once via a fresh open.
  function idbStore(mode) {
    return idbOpen().then((db) => {
      try { return db.transaction('kv', mode).objectStore('kv'); }
      catch (e) { _idbDb = null; return idbOpen().then((db2) => db2.transaction('kv', mode).objectStore('kv')); }
    });
  }
  function idbGet(k) { return idbStore('readonly').then((os) => new Promise((res) => { try { const t = os.get(k); t.onsuccess = () => res(t.result); t.onerror = () => res(null); } catch (e) { res(null); } })).catch(() => null); }
  function idbSet(k, v) { return idbStore('readwrite').then((os) => new Promise((res) => { try { const t = os.put(v, k); t.onsuccess = () => res(true); t.onerror = () => res(false); } catch (e) { res(false); } })).catch(() => false); }
  async function decompressGz(buf) { const ds = new DecompressionStream('gzip'); const stream = new Response(buf).body.pipeThrough(ds); return new Response(stream).text(); }
  function parsePack(text) { const a = String(text || '').split('\x00'); if (a.length < 3) return null; try { return { header: JSON.parse(a[0]), idx: a[1] ? a[1].split('\n') : [], recs: a[2] ? a[2].split('\n') : [] }; } catch (e) { return null; } }
  function fetchPackBytes() { return new Promise((res, rej) => { GM_xmlhttpRequest({ method: 'GET', url: PACK_URL, responseType: 'arraybuffer', timeout: 30000, onload: (r) => { if (r.status >= 400) return rej(new Error('HTTP ' + r.status)); res(r.response); }, onerror: () => rej(new Error('injoignable')), ontimeout: () => rej(new Error('délai')) }); }); }
  function loadPack() {
    if (_pack) return Promise.resolve(_pack);
    if (_packReq) return _packReq;
    _packReq = (async () => {
      let cached = null; try { cached = await idbGet('pack-hot'); } catch (e) {}
      const fresh = cached && cached.buf && (Date.now() - (cached.at || 0) < 864e5);
      if (fresh) { const p = parsePack(await decompressGz(cached.buf)); if (p) { _pack = p; return p; } }
      try {
        const buf = await fetchPackBytes(); const p = parsePack(await decompressGz(buf));
        if (p) { _pack = p; idbSet('pack-hot', { buf, at: Date.now() }); return p; }
      } catch (e) {
        if (cached && cached.buf) { const p = parsePack(await decompressGz(cached.buf)); if (p) { _pack = p; return p; } }   // offline: stale cache
        throw e;
      }
      throw new Error('pack illisible');
    })();
    _packReq.catch(() => { _packReq = null; });
    return _packReq;
  }
  function packSearchSync(q, limit) {
    if (!_pack) return []; const qq = String(q || '').toLowerCase().trim(); if (!qq) return [];
    const out = [], idx = _pack.idx, recs = _pack.recs, cap = limit || 20;
    for (let i = 0; i < idx.length && out.length < cap; i++) {
      if (idx[i].indexOf(qq) >= 0) { const r = recs[i].split('\t'); out.push({ name: r[0], size_bytes: +r[1] || 0, seeders: +r[2] || 0, parent_cat_name: r[3] || '', infohash: r[4] || '', _pack: true }); }
    }
    out.sort((a, b) => b.seeders - a.seeders);
    return out;
  }
  async function packSearch(q, limit) { try { await loadPack(); } catch (e) { return []; } return packSearchSync(q, limit); }
  // cache the gz bytes to disk BEFORE any outage (no decompression, no memory cost); loadPack() inflates
  // from IDB only when the pack actually serves. Lets full-offline search work even if never used before.
  async function warmPack() {
    try {
      const cached = await idbGet('pack-hot');
      if (cached && cached.buf && (Date.now() - (cached.at || 0) < 864e5)) return;
      idbSet('pack-hot', { buf: await fetchPackBytes(), at: Date.now() });
    } catch (e) {}
  }
  try { if (window.__T4X_EXPOSE__) { window.__t4xPack = { load: loadPack, search: packSearch, searchSync: packSearchSync, parse: parsePack, _set: (p) => { _pack = p; } }; } } catch (e) {}

  async function getTorrents(q, facets) {
    const o = { limit: 8, distinct: pref0('grouping', true) };
    if (facets && facets.filter) o.filter = facets.filter;
    if (facets && facets.sort) o.sort = facets.sort;
    const hits = await meiliSearch(q, o);
    if (hits) return hits.map(mapMeiliHit);
    if (Date.now() < _apiDownUntil || navigator.onLine === false) { const ph = await packSearch(q, 8); if (ph.length) return ph; }   // site down: straight to the offline pack
    // API fallback has no facet support; still returns relevance-ordered results.
    try { const j = await apiGet('/api/torrents?q=' + encodeURIComponent(q), { ttl: 20000, timeout: 6000 }); const arr = pickArray(j, ['torrents', 'items', 'data']).slice(0, 8); if (arr.length) return arr; }
    catch (e) {}
    // last resort: the offline hot-pack (instant, works with no network / site + Meili both down)
    return await packSearch(q, 8);
  }
  // magnet for an offline pack hit (no site slug available)
  function packMagnet(h) { return 'magnet:?xt=urn:btih:' + encodeURIComponent(h.infohash || '') + '&dn=' + encodeURIComponent(h.name || ''); }

  function fmtSize(b) {
    if (b == null) return '';
    const u = ['o', 'Ko', 'Mo', 'Go', 'To', 'Po', 'Eo']; let i = 0, n = Number(b);
    while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
    return (n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)) + ' ' + u[i];
  }
  function pickArray(j, keys) {
    if (Array.isArray(j)) return j;
    for (const k of keys) if (j && Array.isArray(j[k])) return j[k];
    // fall back to first array-valued property
    if (j && typeof j === 'object') for (const v of Object.values(j)) if (Array.isArray(v)) return v;
    return [];
  }

  // ==========================================================================
  // TOASTS
  // ==========================================================================
  let toastWrap = null;
  // toast(msg, ms, {action:{label, fn}}) — a plain toast is informational and pointer-events:none (never
  // eats a page click); an ACTION toast opts into pointer-events and carries a button, pauses its dismiss
  // on hover/focus, and returns { dismiss, el } so callers (undo, confirm) can close it programmatically.
  function toast(msg, ms = 3800, opts) {
    ensureHost();
    if (!toastWrap) { toastWrap = document.createElement('div'); toastWrap.className = 'toasts'; root.appendChild(toastWrap); }
    const t = document.createElement('div'); t.className = 'toast';
    const line = document.createElement('div'); line.textContent = msg; t.appendChild(line);
    let timer = null, dead = false;
    const kill = () => { if (dead) return; dead = true; clearTimeout(timer); t.classList.remove('on'); setTimeout(() => { try { t.remove(); } catch (e) {} }, 260); };
    const arm = (d) => { clearTimeout(timer); timer = setTimeout(kill, d); };
    if (opts && opts.action && opts.action.label) {
      t.classList.add('act');
      const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-toast-act'; b.textContent = opts.action.label;
      b.addEventListener('click', () => { try { opts.action.fn && opts.action.fn(); } catch (e) {} kill(); });
      t.appendChild(b);
      t.addEventListener('mouseenter', () => clearTimeout(timer));
      t.addEventListener('mouseleave', () => arm(1800));
      t.addEventListener('focusin', () => clearTimeout(timer));
      t.addEventListener('focusout', () => arm(1800));
    }
    toastWrap.appendChild(t);
    requestAnimationFrame(() => t.classList.add('on'));
    arm(ms);
    return { dismiss: kill, el: t };
  }

  // ==========================================================================
  // OVERLAY INERTNESS — class-of-bug guard
  // --------------------------------------------------------------------------
  // An `opacity:0` overlay is INVISIBLE BUT STILL HIT-TESTABLE. Every fixed-position panel we
  // leave in the DOM while "closed" therefore silently swallows page clicks, and any button
  // inside it (e.g. the voice panel's "Rejoindre", parked over the pill in the bottom-right
  // corner) re-fires on every click there — that is the "je ne peux plus rien cliquer / ça
  // rouvre le vocal à l'infini" bug. Toggling pointer-events by hand at each call site is
  // fragile: one forgotten path brings the whole page down.
  //
  // So instead of trusting call sites, we sweep: any direct child of our shadow root that is
  // position:fixed and computes to invisible (opacity 0 / visibility hidden / display none)
  // is forced inert. Panels explicitly mark themselves `data-t4open="1"` while opening so a
  // sweep landing mid-transition can never blank a panel that is on its way in.
  // Only ever writes `none` or `''` (never `auto`) so CSS stays the source of truth.
  // ==========================================================================
  function markOpen(el, on) {
    if (!el) return;
    if (on) { el.dataset.t4open = '1'; el.style.pointerEvents = ''; el.__t4pe = 'auto'; }
    else { delete el.dataset.t4open; }
    scheduleSweep();
  }
  function inertSweep() {
    if (!root) return;
    // Two-phase: read every computed style first, then apply pointer-events writes — interleaving a
    // style write between getComputedStyle reads would force a layout reflush on each subsequent read.
    const writes = [];
    for (const el of root.children) {
      if (!el.style || el.tagName === 'STYLE') continue;
      if (el.dataset && el.dataset.t4open === '1') {
        if (el.__t4pe !== 'auto') writes.push([el, 'auto']);
        continue;
      }
      let cs; try { cs = getComputedStyle(el); } catch (e) { continue; }
      if (cs.position !== 'fixed') continue;
      const dead = cs.visibility === 'hidden' || cs.display === 'none' || parseFloat(cs.opacity) === 0;
      const want = dead ? 'none' : 'auto';
      if (el.__t4pe !== want) writes.push([el, want]);
    }
    for (const [el, want] of writes) { el.style.pointerEvents = want === 'none' ? 'none' : ''; el.__t4pe = want; }
  }
  let _sweepT = null;
  function scheduleSweep() {
    // sweep once the CSS transition has settled (opacity only reaches 0 at the end of the fade)
    if (_sweepT) clearTimeout(_sweepT);
    _sweepT = setTimeout(() => { _sweepT = null; inertSweep(); }, 320);
    requestAnimationFrame(inertSweep);
  }
  // Panic hatch: close every panel and force everything inert. Bound to Ctrl/⌘+Shift+X and
  // exposed in the Tampermonkey menu, so a stuck UI is always one keystroke from recoverable.
  function closeAllOverlays(quiet) {
    const safe = (f) => { try { f(); } catch (e) {} };
    safe(closePalette); safe(closeDrawer); safe(closeAi); safe(closeRequests); safe(closeVoice); safe(closeIdeas); safe(closeLightbox); safe(closeYt); safe(closeSignals); safe(closeStatsPanel);
    safe(() => { if (launcherEl) { launcherEl.remove(); launcherEl = null; } });
    safe(() => { if (settingsEl) settingsEl(); });
    safe(inertSweep);
    setTimeout(inertSweep, 340);
    if (!quiet) toast('Panneaux TR4KER+ fermés — l’interface est débloquée.');
  }

  // ---- shared Escape stack -------------------------------------------------
  // One capture-phase keydown, bound once, closes only the TOPMOST registered overlay. New shadow
  // overlays (lightbox, YouTube player, …) push a closer and pop it on close, so Escape never has to
  // fight other listeners and always closes the last thing opened. Returns an unregister fn.
  const _escStack = [];
  let _escBound = false;
  function ensureEscBind() {
    if (_escBound) return; _escBound = true;
    document.addEventListener('keydown', (e) => {
      if (e.key !== 'Escape' || !_escStack.length) return;
      const fn = _escStack[_escStack.length - 1];
      e.stopPropagation();
      try { fn(); } catch (_) {}
    }, true);
  }
  function escPush(fn) { ensureEscBind(); _escStack.push(fn); return () => { const i = _escStack.indexOf(fn); if (i >= 0) _escStack.splice(i, 1); }; }

  // ==========================================================================
  // UPDATE CHECK — is a newer TR4KER+ published?
  // --------------------------------------------------------------------------
  // Tampermonkey only polls @updateURL on its own schedule (once a day by default, and never at
  // all if the user disabled auto-update), so a fix can sit unseen for a long time. We poll a tiny
  // manifest ourselves — version + notes, a few hundred bytes, at most once every 6 h — and, when
  // something newer is out, surface it: pill button, palette entry, launcher entry, one toast.
  // Clicking it opens the raw .user.js, which Tampermonkey intercepts with its install/update page.
  // ==========================================================================
  const UPDATE_MANIFEST = 'https://forgejo.ekaii.fr/tr4ker/tr4ker-plus/raw/branch/main/version.json';
  const UPDATE_SCRIPT = 'https://forgejo.ekaii.fr/tr4ker/tr4ker-plus/raw/branch/main/tr4ker-plus.user.js';
  const UPDATE_EVERY = 6 * 3600 * 1000;
  const updateState = { available: false, latest: null, notes: '', checkedAt: 0, checking: false };

  // numeric-segment compare; missing segments read as 0 so "0.18" < "0.18.1"
  function verCmp(a, b) {
    const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
    const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
    for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
      const d = (pa[i] || 0) - (pb[i] || 0);
      if (d) return d < 0 ? -1 : 1;
    }
    return 0;
  }
  function applyUpdateState() {
    if (pillUpBtn) {
      pillUpBtn.style.display = updateState.available ? '' : 'none';
      pillUpBtn.title = updateState.available ? ('TR4KER+ v' + updateState.latest + ' disponible — cliquer pour mettre à jour') : 'Mise à jour disponible';
    }
  }
  function openUpdate() {
    // Tampermonkey intercepts any navigation to a *.user.js and shows its install/update screen
    try { window.open(UPDATE_SCRIPT, '_blank', 'noreferrer'); } catch (e) { location.href = UPDATE_SCRIPT; }
  }
  function checkUpdate(manual) {
    if (updateState.checking) return;
    if (!manual && !pref0('updates', true)) return;
    const last = store.get('t4x.update', { at: 0, latest: null, notes: '' });
    if (!manual && Date.now() - (last.at || 0) < UPDATE_EVERY) {
      // still inside the cache window — reuse the last verdict, don't hit the network
      if (last.latest && verCmp(last.latest, VERSION) > 0) { updateState.available = true; updateState.latest = last.latest; updateState.notes = last.notes || ''; applyUpdateState(); }
      return;
    }
    updateState.checking = true;
    try {
      GM_xmlhttpRequest({
        method: 'GET', url: UPDATE_MANIFEST + '?t=' + Date.now(),
        headers: { 'Accept': 'application/json' }, timeout: 8000,
        onload: (r) => {
          updateState.checking = false;
          let j = null; try { j = JSON.parse(r.responseText || '{}'); } catch (e) {}
          if (!j || !j.version) { if (manual) toast('Impossible de lire le manifeste de version.'); return; }
          updateState.checkedAt = Date.now();
          store.set('t4x.update', { at: Date.now(), latest: j.version, notes: j.notes || '' });
          if (verCmp(j.version, VERSION) > 0) {
            const first = !updateState.available;
            updateState.available = true; updateState.latest = j.version; updateState.notes = j.notes || '';
            applyUpdateState();
            if (first || manual) toast('TR4KER+ v' + j.version + ' est dispo (tu es en ' + VERSION + ')' + (j.notes ? ' — ' + j.notes : '') + '. Clique l’icône ⭳ de la pastille pour l’installer.', 9000);
          } else {
            updateState.available = false; updateState.latest = j.version; applyUpdateState();
            if (manual) toast('TR4KER+ est à jour (v' + VERSION + ').');
          }
        },
        onerror: () => { updateState.checking = false; if (manual) toast('Vérification impossible (Forgejo injoignable).'); },
        ontimeout: () => { updateState.checking = false; if (manual) toast('Vérification : délai dépassé.'); },
      });
    } catch (e) { updateState.checking = false; if (manual) toast('GM_xmlhttpRequest indisponible.'); }
  }

  // ==========================================================================
  // SERVICE STATUS — "c'est moi ou c'est le site ?"
  // --------------------------------------------------------------------------
  // t4health already measures tr4ker from the outside every minute (state, latency, p50/p95,
  // 24 h uptime, flapping) and exposes /api/status. We add the half it cannot see: the latency
  // YOUR browser gets on the real API, and the state of the TR4KER+ backends themselves. A
  // discreet banner appears only when something is actually degraded; the full picture lives in
  // a panel, so the answer to "is it me?" is one click away instead of a chat thread.
  // ==========================================================================
  const HEALTH_URL = 'https://t4health.ekaii.fr';
  const T4X_SERVICES = [
    { key: 'search', label: 'Recherche TR4KER+', url: () => MEILI_URL + '/health' },
    { key: 'ai', label: 'Assistant IA', url: () => T4AI_URL + '/healthz' },
    { key: 'voice', label: 'Salon vocal', url: () => VOICE_URL + '/token/health' },
  ];
  const health = { data: null, at: 0, mine: {}, apiMs: null, banner: null, panel: null, timer: null };

  function fetchHealth() {
    if (health.at && health.data && Date.now() - health.at < 20000) return Promise.resolve(health.data);   // perf: collapse the boot burst + 45s/120s poller overlap
    return new Promise((res) => {
      let done = false; const fin = (v) => { if (!done) { done = true; res(v); } };
      const to = setTimeout(() => fin(null), 6000);
      try {
        GM_xmlhttpRequest({
          method: 'GET', url: HEALTH_URL + '/api/status', timeout: 6000,
          headers: { 'Accept': 'application/json' },
          onload: (r) => { clearTimeout(to); try { fin(JSON.parse(r.responseText)); } catch (e) { fin(null); } },
          onerror: () => { clearTimeout(to); fin(null); }, ontimeout: () => { clearTimeout(to); fin(null); },
        });
      } catch (e) { fin(null); }
    });
  }
  // measure what THIS browser gets, which is the whole point: t4health can be green while the
  // member's own connection to the site is not.
  async function probeOwnApi() {
    const t0 = performance.now();
    try {
      // /api/me, not some liveness path: it is authenticated and goes through the same stack the
      // member actually uses, so a green result here really means "the site works for you".
      const r = await fetch('/api/me', { credentials: 'include', cache: 'no-store' });
      health.apiMs = Math.round(performance.now() - t0);
      return { ok: r.ok, ms: health.apiMs, status: r.status };
    } catch (e) {
      health.apiMs = null;
      return { ok: false, ms: null, status: 0 };
    }
  }
  function probeService(s) {
    const t0 = performance.now();
    return new Promise((res) => {
      let done = false; const fin = (v) => { if (!done) { done = true; res(v); } };
      const to = setTimeout(() => fin({ key: s.key, label: s.label, ok: false, ms: null }), 6000);
      try {
        GM_xmlhttpRequest({
          method: 'GET', url: s.url(), timeout: 6000,
          onload: (r) => { clearTimeout(to); fin({ key: s.key, label: s.label, ok: r.status >= 200 && r.status < 400, ms: Math.round(performance.now() - t0), status: r.status }); },
          onerror: () => { clearTimeout(to); fin({ key: s.key, label: s.label, ok: false, ms: null }); },
          ontimeout: () => { clearTimeout(to); fin({ key: s.key, label: s.label, ok: false, ms: null }); },
        });
      } catch (e) { fin({ key: s.key, label: s.label, ok: false, ms: null }); }
    });
  }

  async function refreshHealth(full) {
    const d = await fetchHealth();
    if (d) { health.data = d; health.at = Date.now(); }
    updateBanner();
    if (full) {
      const [own, ...svcs] = await Promise.all([probeOwnApi()].concat(T4X_SERVICES.map(probeService)));
      health.own = own;
      health.mine = {};
      for (const s of svcs) health.mine[s.key] = s;
    }
    if (health.panel && health.panel.open) renderHealthPanel();
  }

  const STATE_FR = { up: 'opérationnel', warn: 'dégradé', down: 'hors service', unknown: 'inconnu' };
  const STATE_COL = { up: 'var(--t4x-seed,#4ade80)', warn: 'var(--ann-color,#ffd28a)', down: 'var(--error,#ffb4ab)' };

  function updateBanner() {
    if (!pref0('statusBanner', true)) { if (health.banner) health.banner.style.display = 'none'; return; }
    const d = health.data;
    const bad = d && d.overall && d.overall !== 'up';
    if (!bad) { if (health.banner) health.banner.style.display = 'none'; return; }
    if (!health.banner) {
      ensureHost();
      const b = ceReq('div', 'position:fixed;top:0;left:0;right:0;z-index:2147483004;display:flex;align-items:center;gap:10px;padding:7px 14px;font-size:12.5px;background:color-mix(in srgb, var(--surface,#0f1117) 97%, transparent);border-bottom:1px solid var(--t4x-hair,rgba(147,165,255,.18));color:var(--on-surface,#e6e9f2)');
      b.className = 't4x-statusbar';
      const dot = ceReq('span', 'width:8px;height:8px;border-radius:50%;flex:0 0 auto');
      const txt = ceReq('span', 'flex:1');
      const more = ceReq('button', 'all:unset;cursor:pointer;font-size:11px;padding:3px 9px;border-radius:999px;border:1px solid var(--t4x-hair,rgba(147,165,255,.25))', 'Détails');
      more.addEventListener('click', openHealth);
      const x = ceReq('button', 'all:unset;cursor:pointer;opacity:.6;padding:0 4px', '✕');
      x.title = 'Masquer jusqu’au prochain changement';
      x.addEventListener('click', () => { health.dismissed = (health.data || {}).overall; b.style.display = 'none'; });
      b.append(dot, txt, more, x);
      root.appendChild(b);
      health.banner = b; health.bannerDot = dot; health.bannerTxt = txt;
    }
    if (health.dismissed === d.overall) { health.banner.style.display = 'none'; return; }
    health.bannerDot.style.background = STATE_COL[d.overall] || STATE_COL.warn;
    const names = Object.values(d.services || {}).filter(s => s && s.state && s.state !== 'up').map(s => s.label || '').filter(Boolean);
    health.bannerTxt.textContent = 'TR4KER ' + (d.overall_label || STATE_FR[d.overall] || d.overall)
      + (names.length ? ' — ' + names.join(', ') : '') + (d.reason ? ' · ' + d.reason : '');
    health.banner.style.display = '';
    markOpen(health.banner, true);
  }

  function buildHealthPanel() {
    ensureHost();
    const veil = ceReq('div', 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:2147483000;opacity:0;transition:opacity .15s;pointer-events:none');
    veil.className = 't4x-hveil';
    const panel = ceReq('div', 'position:fixed;z-index:2147483001;top:8vh;left:50%;transform:translateX(-50%) scale(.98);width:min(600px,94vw);max-height:80vh;display:flex;flex-direction:column;background:var(--t4x-glass,rgba(15,17,23,.9));-webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%);color:var(--on-surface,#e6e9f2);border:1px solid var(--t4x-hair,rgba(147,165,255,.14));border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.5);opacity:0;transition:opacity .15s,transform .15s;overflow:hidden');
    panel.className = 't4x-hpanel';
    const head = ceReq('div', 'display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--outline-variant,rgba(255,255,255,.1))');
    head.appendChild(ceReq('div', 'font-weight:700;flex:1', 'État des services'));
    const rf = ceReq('button', 'font:inherit;font-size:11px;padding:6px 10px;border-radius:999px;cursor:pointer;border:1px solid var(--outline-variant,rgba(255,255,255,.15));background:transparent;color:var(--on-surface,#cdd3e0)', 'Retester');
    rf.addEventListener('click', () => { rf.textContent = '…'; refreshHealth(true).then(() => { rf.textContent = 'Retester'; }); });
    const x = ceReq('button', 'font:inherit;padding:6px 9px;border-radius:8px;cursor:pointer;border:1px solid var(--outline-variant,rgba(255,255,255,.15));background:transparent;color:var(--on-surface,#cdd3e0)', '✕');
    x.addEventListener('click', closeHealth);
    head.append(rf, x);
    const body = ceReq('div', 'overflow-y:auto;flex:1;padding:6px 0');
    panel.append(head, body);
    root.append(veil, panel);
    veil.addEventListener('click', closeHealth);
    document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && health.panel && health.panel.open) { e.preventDefault(); closeHealth(); } }, true);
    health.panel = { veil, panel, body, open: false };
  }
  function hRow(label, state, detail, sub) {
    const row = ceReq('div', 'display:flex;align-items:center;gap:10px;padding:9px 16px');
    const dot = ceReq('span', 'width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:' + (STATE_COL[state] || 'var(--outline-variant,#3a4056)'));
    const main = ceReq('div', 'flex:1;min-width:0');
    main.appendChild(ceReq('div', 'font-size:13px', label));
    if (sub) main.appendChild(ceReq('div', 'font-size:11px;opacity:.6', sub));
    const val = ceReq('div', 'font:12px/1 "JetBrains Mono",monospace;opacity:.85;flex:0 0 auto;text-align:right', detail || '');
    row.append(dot, main, val);
    return row;
  }
  function renderHealthPanel() {
    const b = health.panel.body; b.replaceChildren();
    const d = health.data;
    b.appendChild(ceReq('div', 'padding:8px 16px 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55', 'TR4KER — mesuré de l’extérieur (t4health)'));
    if (!d) b.appendChild(ceReq('div', 'padding:10px 16px;opacity:.6;font-size:12px', 'Barre de santé injoignable.'));
    else {
      for (const [k, s] of Object.entries(d.services || {})) {
        if (!s || typeof s !== 'object') continue;
        const p = s.pct || {};
        const bits = [];
        if (s.latency_ms != null) bits.push(s.latency_ms + ' ms');
        if (p.p95) bits.push('p95 ' + p.p95);
        b.appendChild(hRow(s.label || k, s.state, bits.join(' · '),
          [s.uptime_24h != null ? ('dispo 24 h ' + Math.round(s.uptime_24h * 100) / 100 + ' %') : '',
           s.flapping ? 'instable' : ''].filter(Boolean).join(' — ')));
      }
    }
    b.appendChild(ceReq('div', 'padding:14px 16px 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55', 'Depuis TON navigateur'));
    const own = health.own;
    b.appendChild(hRow('API tr4ker vue d’ici', own ? (own.ok ? (own.ms > 1200 ? 'warn' : 'up') : 'down') : 'unknown',
      own && own.ms != null ? own.ms + ' ms' : '—',
      own && !own.ok ? 'la requête a échoué depuis ce navigateur' :
        (own && d && d.services && d.services.api && d.services.api.latency_ms != null && own.ms != null && own.ms > d.services.api.latency_ms * 3
          ? 'nettement plus lent qu’en mesure externe — problème côté réseau/FAI probable' : '')));
    b.appendChild(ceReq('div', 'padding:14px 16px 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55', 'Services TR4KER+'));
    for (const s of T4X_SERVICES) {
      const r = health.mine[s.key];
      b.appendChild(hRow(s.label, r ? (r.ok ? 'up' : 'down') : 'unknown', r && r.ms != null ? r.ms + ' ms' : '—',
        r && !r.ok ? 'injoignable' : ''));
    }
    const m = aiMedian();
    if (m) b.appendChild(hRow('Temps de réponse de l’IA', 'up', secs(m), 'médiane de tes dernières questions'));
    const chg = dashCacheGet('sitechanges');
    if (chg && (chg.events || []).length) {
      b.appendChild(ceReq('div', 'padding:14px 16px 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55', 'Ce que le site change (interprété)'));
      const nn = chg.events.filter((e) => e.severity !== 'info');
      for (const ev of (nn.length ? nn : chg.events).slice(0, 3)) {
        b.appendChild(hRow(ev.title, ev.severity === 'major' ? 'warn' : 'unknown', '', ev.interp || ''));
      }
      const lnk = ceReq('div', 'padding:2px 16px 0;font-size:11px', '');
      const a = document.createElement('a'); a.textContent = 'Toutes les nouveautés interprétées →';
      a.href = HEALTH_URL + '/changes'; a.target = '_blank'; a.rel = 'noreferrer';
      a.style.cssText = 'color:var(--primary,#93a5ff);text-decoration:none';
      lnk.appendChild(a); b.appendChild(lnk);
    }
    b.appendChild(ceReq('div', 'padding:12px 16px 14px;font-size:11px;opacity:.5',
      'Mesures communautaires (t4health.ekaii.fr) — non officielles.'));
  }
  function openHealth() {
    if (!health.panel) buildHealthPanel();
    health.panel.open = true;
    markOpen(health.panel.veil, true); markOpen(health.panel.panel, true);
    requestAnimationFrame(() => {
      health.panel.veil.style.opacity = '1'; health.panel.panel.style.opacity = '1';
      health.panel.panel.style.transform = 'translateX(-50%) scale(1)';
    });
fetchSiteChanges().then(() => { try { if (health.panel && health.panel.open) renderHealthPanel(); } catch (e) {} });
    renderHealthPanel();
    refreshHealth(true);
  }
  function closeHealth() {
    if (!health.panel) return;
    health.panel.open = false;
    health.panel.veil.style.opacity = '0'; health.panel.panel.style.opacity = '0';
    health.panel.panel.style.transform = 'translateX(-50%) scale(.98)';
    markOpen(health.panel.veil, false); markOpen(health.panel.panel, false);
  }

  // ==========================================================================
  // DEMANDES overlay — sort-by-recent + "mes demandes" filter (suggestions: gaudruche, John654987)
  // Robust shadow-DOM panel over /api/requests (no React-DOM mutation). Reachable from ⌘K anywhere.
  // ==========================================================================
  const reqState = { sort: 'recent', mine: false, data: null };
  let reqPanel = null, reqListEl = null;
  function tParse(ts) { return typeof ts === 'number' ? ts : (Date.parse(ts) || 0); }
  function ago(ts) {
    const t = tParse(ts); if (!t) return '';
    const s = Math.max(0, (Date.now() - t) / 1000);
    if (s < 90) return 'à l’instant';
    const m = s / 60; if (m < 60) return 'il y a ' + Math.round(m) + ' min';
    const h = m / 60; if (h < 24) return 'il y a ' + Math.round(h) + ' h';
    const d = h / 24; if (d < 31) return 'il y a ' + Math.round(d) + ' j';
    return 'il y a ' + Math.round(d / 30) + ' mois';
  }
  function isMine(r) {
    const uid = chat.myId; const uname = (chat.myName || '').toLowerCase();
    if (uid != null && (r.requester_id === uid || (r.requester && r.requester.id === uid) || r.user_id === uid || r.created_by === uid)) return true;
    const rn = (r.requester_username || (r.requester && r.requester.username) || r.username || '').toLowerCase();
    return !!uname && rn === uname;
  }
  function reqBounty(r) { return Number(r.total_bounty != null ? r.total_bounty : (r.bounty_credits || 0)) || 0; }
  function ceReq(tag, css, txt) { const e = document.createElement(tag); if (css) e.style.cssText = css; if (txt != null) e.textContent = txt; return e; }
  async function ensureIdentity() {
    if (chat.myId != null) return;
    try { const m = await me(); chat.myId = m.id; chat.myName = m.username; chat.myRole = m.role || chat.myRole; if (m.username) store.set('t4x.myname', m.username); } catch (e) {}
    try { e2eInit(); } catch (e) {}   // E2E: detect curve support + (if opted-in) ensure identity & publish tag
  }
  function renderRequests() {
    if (!reqListEl) return;
    let list = (reqState.data || []).slice();
    if (reqState.mine) list = list.filter(isMine);
    list.sort(reqState.sort === 'recent'
      ? (a, b) => tParse(b.created_at) - tParse(a.created_at)
      : (a, b) => reqBounty(b) - reqBounty(a));
    reqListEl.replaceChildren();
    if (!list.length) { reqListEl.appendChild(ceReq('div', 'padding:24px;text-align:center;opacity:.6', reqState.mine ? 'Tu n’as aucune demande en cours.' : 'Aucune demande.')); return; }
    for (const r of list) {
      const row = ceReq('div', 'display:flex;gap:10px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--outline-variant,rgba(255,255,255,.06))'); row.className='t4x-reqrow';
      const main = ceReq('div', 'flex:1;min-width:0');
      const title = ceReq('div', 'font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis', r.title || '(sans titre)');
      const sub = ceReq('div', 'font-size:11px;opacity:.6;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis',
        [r.category_name || r.category_label || r.category, ago(r.created_at), r.status].filter(Boolean).join(' · '));
      main.append(title, sub);
      const b = reqBounty(r);
      const bounty = ceReq('span', 'flex:0 0 auto;font-size:12px;font-weight:700;color:var(--primary,#93a5ff)', b ? Intl.NumberFormat('fr-FR', { notation: 'compact' }).format(b) : '');
      row.append(main, bounty);
      if (r.linked_torrent_slug) {
        row.style.cursor = 'pointer'; row.title = 'Ouvrir le torrent lié';
        row.addEventListener('click', () => { closeRequests(); navigate('/torrent/' + r.linked_torrent_slug); });
      }
      reqListEl.appendChild(row);
    }
  }
  function buildRequests() {
    ensureHost();
    const veil = ceReq('div', 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:2147483000;opacity:0;transition:opacity .15s;pointer-events:none'); veil.className='t4x-reqveil';
    const panel = ceReq('div', 'position:fixed;z-index:2147483001;top:8vh;left:50%;transform:translateX(-50%) scale(.98);width:min(640px,94vw);max-height:82vh;display:flex;flex-direction:column;background:var(--t4x-glass,rgba(15,17,23,.9));-webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%);color:var(--on-surface,#e6e9f2);border:1px solid var(--t4x-hair,rgba(147,165,255,.14));border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.5);opacity:0;transition:opacity .15s,transform .15s;overflow:hidden'); panel.className='t4x-reqpanel';
    const head = ceReq('div', 'display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--outline-variant,rgba(255,255,255,.1))');
    head.appendChild(ceReq('div', 'font-weight:700', 'Demandes'));
    const spacer = ceReq('div', 'flex:1'); head.appendChild(spacer);
    const chipCss = (on) => 'font:inherit;font-size:11px;line-height:1;padding:6px 10px;border-radius:999px;cursor:pointer;border:1px solid ' + (on ? 'var(--primary,#93a5ff)' : 'var(--outline-variant,rgba(255,255,255,.15))') + ';background:' + (on ? 'var(--primary,#93a5ff)' : 'transparent') + ';color:' + (on ? 'var(--on-primary,#0b0d13)' : 'var(--on-surface,#cdd3e0)');
    const chips = {};
    const mkc = (key, label, apply) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.addEventListener('click', () => { apply(); syncChips(); renderRequests(); }); chips[key] = b; head.appendChild(b); return b; };
    mkc('recent', 'Récentes', () => reqState.sort = 'recent');
    mkc('bounty', 'Récompense', () => reqState.sort = 'bounty');
    mkc('mine', 'Mes demandes', () => reqState.mine = !reqState.mine);
    const x = ceReq('button', 'font:inherit;margin-left:4px;padding:6px 9px;border-radius:8px;cursor:pointer;border:1px solid var(--outline-variant,rgba(255,255,255,.15));background:transparent;color:var(--on-surface,#cdd3e0)', '✕');
    x.addEventListener('click', closeRequests); head.appendChild(x);
    function syncChips() {
      chips.recent.style.cssText = chipCss(reqState.sort === 'recent');
      chips.bounty.style.cssText = chipCss(reqState.sort === 'bounty');
      chips.mine.style.cssText = chipCss(reqState.mine);
    }
    reqPanel = { veil, panel, syncChips };
    reqListEl = ceReq('div', 'overflow-y:auto;flex:1'); reqListEl.className='t4x-reqlist';
    panel.append(head, reqListEl);
    root.append(veil, panel);
    veil.addEventListener('click', closeRequests);
    document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqPanel && reqPanel.veil.style.opacity === '1') { e.preventDefault(); closeRequests(); } }, true);
    syncChips();
  }
  async function openRequests(preset) {
    if (!P.palette) return; // gated with the palette feature flag
    if (preset === 'mine') { reqState.mine = true; reqState.sort = 'recent'; }
    else if (preset === 'recent') { reqState.mine = false; reqState.sort = 'recent'; }
    if (!reqPanel) buildRequests();
    reqPanel.syncChips();
    markOpen(reqPanel.veil, true); markOpen(reqPanel.panel, true);
    requestAnimationFrame(() => { reqPanel.veil.style.opacity = '1'; reqPanel.veil.style.pointerEvents = 'auto'; reqPanel.panel.style.opacity = '1'; reqPanel.panel.style.pointerEvents = 'auto'; reqPanel.panel.style.transform = 'translateX(-50%) scale(1)'; });
    reqListEl.replaceChildren(ceReq('div', 'padding:24px;text-align:center;opacity:.6', 'Chargement…'));
    await ensureIdentity();
    try { const j = await apiGet('/api/requests?limit=100', { ttl: 30000, timeout: 8000 }); reqState.data = pickArray(j, ['requests', 'items', 'data']); }
    catch (e) { reqListEl.replaceChildren(ceReq('div', 'padding:24px;text-align:center;opacity:.6', 'Impossible de charger les demandes.')); return; }
    renderRequests();
  }
  function closeRequests() {
    if (!reqPanel) return;
    reqPanel.veil.style.opacity = '0'; reqPanel.veil.style.pointerEvents = 'none'; reqPanel.panel.style.opacity = '0'; reqPanel.panel.style.pointerEvents = 'none'; reqPanel.panel.style.transform = 'translateX(-50%) scale(.98)';
    markOpen(reqPanel.veil, false); markOpen(reqPanel.panel, false);
  }

  // ==========================================================================
  // VOICE — community rooms via self-hosted LiveKit SFU (voice.ekaii.fr). Task #9.
  // Runs the LiveKit client in a voice.ekaii.fr companion WINDOW (window.open) — tr4ker CSP blocks both an
  // in-page WebSocket (connect-src) and a cross-origin iframe (frame-src). See services/voice/token /embed.
  // ==========================================================================
  const VOICE_URL = 'https://voice.ekaii.fr';
  const VOICE_ROOMS = ['salon-general', 'cinema', 'series', 'detente'];
  const voice = { win: null, connected: false, connecting: false, room: null, muted: false, participants: [], count: 0, poll: null, listener: false, panel: null, opened: false, listEl: null, statusEl: null };
  // tr4ker's CSP blocks BOTH an in-page WebSocket (connect-src) AND a cross-origin iframe (frame-src→'self').
  // A separate top-level window (window.open) is NOT subject to the opener's CSP, so the LiveKit client runs
  // freely in a small companion window on voice.ekaii.fr; state/commands travel over postMessage.
  function setupVoiceListener() {
    if (voice.listener) return; voice.listener = true;
    window.addEventListener('message', (e) => {
      if (e.origin !== VOICE_URL) return; const d = e.data; if (!d || !d.__t4voice) return; onVoiceMessage(d);
    });
  }
  function onVoiceMessage(d) {
    if (d.type === 'state') {
      voice.connecting = false; voice.connected = !!d.connected; voice.room = d.room || voice.room; voice.muted = !!d.muted;
      voice.participants = Array.isArray(d.participants) ? d.participants : []; voice.count = d.count || (voice.connected ? 1 : 0);
      renderVoiceState(); updateParticipants();
    } else if (d.type === 'left') {
      voice.connected = false; voice.connecting = false; voice.participants = []; voice.count = 0; renderVoiceState(); updateParticipants();
    } else if (d.type === 'error') {
      voice.connecting = false; voice.connected = false; renderVoiceState(); updateParticipants();
      toast('Voix : ' + (d.message || 'échec') + ' (bêta).', 6000);
    }
  }
  function watchVoiceWin() {
    if (voice.poll) return;
    voice.poll = setInterval(() => {
      if (!voice.win || voice.win.closed) {
        voice.win = null; voice.connected = false; voice.connecting = false; voice.participants = []; voice.count = 0;
        clearInterval(voice.poll); voice.poll = null; renderVoiceState(); updateParticipants();
      }
    }, 1500);
  }
  function updateParticipants() {
    if (!voice.listEl) return;
    voice.listEl.replaceChildren();
    const rows = [];
    if (voice.connected) {
      rows.push({ name: 'toi', me: true });
      for (const n of (voice.participants || [])) rows.push({ name: typeof n === 'string' ? n : (n && n.name) || '?' });
    }
    if (!rows.length) { voice.listEl.appendChild(ceReq('div', 'padding:14px;opacity:.6;text-align:center', voice.connecting ? 'Connexion…' : 'Personne pour l’instant.')); return; }
    for (const p of rows) {
      const row = ceReq('div', 'display:flex;align-items:center;gap:8px;padding:7px 12px');
      const dot = ceReq('span', 'width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--t4x-seed,#4ade80)');
      const nm = ceReq('span', 'flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis', p.name + (p.me ? ' (toi)' : ''));
      row.append(dot, nm); voice.listEl.appendChild(row);
    }
  }
  let _lastJoin = 0;
  async function joinVoice(room) {
    if (voice.win && !voice.win.closed) { try { voice.win.focus(); } catch (e) {} return; }
    // cooldown: a stray/repeated click must never be able to spawn a stack of popups
    if (Date.now() - _lastJoin < 2000) return;
    _lastJoin = Date.now();
    setupVoiceListener();
    await ensureIdentity();
    const name = chat.myName || 'invité';
    const url = VOICE_URL + '/embed?room=' + encodeURIComponent(room) + '&name=' + encodeURIComponent(name);
    const w = window.open(url, 't4x-voice', 'width=400,height=560,menubar=no,toolbar=no,location=no,status=no,resizable=yes');
    if (!w) { toast('Autorise les pop-ups pour ouvrir le salon vocal.', 6000); return; }
    voice.win = w; voice.room = room; voice.connecting = true; renderVoiceState(); updateParticipants(); watchVoiceWin();
  }
  function leaveVoice() {
    try { if (voice.win && !voice.win.closed) { voice.win.postMessage({ __t4cmd: 1, cmd: 'leave' }, VOICE_URL); voice.win.close(); } } catch (e) {}
    voice.win = null; voice.connected = false; voice.connecting = false; voice.room = null; voice.participants = []; voice.count = 0;
    renderVoiceState(); updateParticipants();
  }
  function toggleMute() {
    if (!voice.connected || !voice.win) return;
    try { voice.win.postMessage({ __t4cmd: 1, cmd: 'mute' }, VOICE_URL); } catch (e) {} // popup echoes new muted state back
  }
  function renderVoiceState() {
    // persistent indicator on the pill mic button, even when the panel is closed (mic may still be live)
    if (pillVoiceBtn) {
      const on = voice.connected;
      pillVoiceBtn.style.boxShadow = on ? '0 0 0 2px var(--t4x-seed,#4ade80)' : '';
      pillVoiceBtn.title = on ? ('Salon vocal « ' + (voice.room || '') + ' » — micro ' + (voice.muted ? 'coupé' : 'actif')) : 'Salon vocal';
    }
    if (!voice.statusEl) return;
    const s = voice.statusEl; const on = voice.connected;
    s.joinBtn.style.display = on ? 'none' : '';
    s.joinBtn.disabled = voice.connecting; s.joinBtn.textContent = voice.connecting ? 'Connexion…' : 'Rejoindre';
    s.leaveBtn.style.display = on ? '' : 'none';
    s.muteBtn.style.display = on ? '' : 'none';
    s.muteBtn.textContent = voice.muted ? 'Activer le micro' : 'Couper le micro';
    s.roomSel.disabled = on || voice.connecting;
    s.label.textContent = voice.connecting ? 'Connexion…' : on ? ('Dans « ' + voice.room + ' »') : 'Choisis un salon';
  }
  function buildVoice() {
    ensureHost();
    // NO full-screen veil: the voice panel is a small corner widget, not a modal. A veil over the
    // whole page for a 340px panel is what made the site feel frozen, and it had to be dismissed
    // before anything else could be clicked. Click-outside still closes it (listener below).
    // Parked ABOVE the pill (bottom:136px) — at bottom:20px it sat exactly on top of the pill's
    // buttons, so its own "Rejoindre" caught clicks aimed at the pill.
    const panel = ceReq('div', 'position:fixed;z-index:2147483003;bottom:136px;right:18px;width:min(340px,92vw);display:flex;flex-direction:column;background:var(--t4x-glass,rgba(15,17,23,.9));-webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%);color:var(--on-surface,#e6e9f2);border:1px solid var(--t4x-hair,rgba(147,165,255,.14));border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.5);opacity:0;visibility:hidden;transform:translateY(8px);transition:opacity .15s,transform .15s,visibility .15s;overflow:hidden'); panel.className = 't4x-voicepanel';
    const head = ceReq('div', 'display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid var(--outline-variant,rgba(255,255,255,.1))');
    head.appendChild(ceReq('div', 'font-weight:700;flex:1', 'Salon vocal'));
    const x = ceReq('button', 'font:inherit;padding:5px 8px;border-radius:8px;cursor:pointer;border:1px solid var(--outline-variant,rgba(255,255,255,.15));background:transparent;color:var(--on-surface,#cdd3e0)', '✕');
    x.addEventListener('click', closeVoice); head.appendChild(x);
    const body = ceReq('div', 'padding:12px 14px;display:flex;flex-direction:column;gap:10px');
    const label = ceReq('div', 'font-size:12px;opacity:.7', 'Choisis un salon');
    const roomSel = document.createElement('select'); roomSel.style.cssText = 'font:inherit;padding:8px;border-radius:8px;background:var(--surface-container-high,#1a1d26);color:var(--on-surface,#e6e9f2);border:1px solid var(--outline-variant,rgba(255,255,255,.15))';
    VOICE_ROOMS.forEach(r => { const o = document.createElement('option'); o.value = r; o.textContent = '# ' + r; roomSel.appendChild(o); });
    const btnRow = ceReq('div', 'display:flex;gap:8px');
    const bcss = (accent) => 'font:inherit;flex:1;padding:9px;border-radius:9px;cursor:pointer;border:1px solid ' + (accent ? 'var(--primary,#93a5ff)' : 'var(--outline-variant,rgba(255,255,255,.18))') + ';background:' + (accent ? 'var(--primary,#93a5ff)' : 'transparent') + ';color:' + (accent ? 'var(--on-primary,#0b0d13)' : 'var(--on-surface,#cdd3e0)');
    const joinBtn = ceReq('button', bcss(true), 'Rejoindre'); joinBtn.addEventListener('click', () => joinVoice(roomSel.value));
    const leaveBtn = ceReq('button', bcss(false), 'Quitter'); leaveBtn.addEventListener('click', leaveVoice);
    const muteBtn = ceReq('button', bcss(false), 'Couper le micro'); muteBtn.addEventListener('click', toggleMute);
    btnRow.append(joinBtn, leaveBtn, muteBtn);
    voice.listEl = ceReq('div', 'max-height:200px;overflow-y:auto;border-top:1px solid var(--outline-variant,rgba(255,255,255,.08));margin:0 -14px -12px;padding:6px 2px');
    body.append(label, roomSel, btnRow);
    panel.append(head, body, voice.listEl);
    root.append(panel);
    document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && voice.opened) { e.preventDefault(); closeVoice(); } }, true);
    // click-outside dismiss, without a veil: composed path lets us see through the shadow boundary
    document.addEventListener('pointerdown', (e) => {
      if (!voice.opened) return;
      const path = (e.composedPath && e.composedPath()) || [];
      if (path.indexOf(panel) !== -1) return;
      if (pillEl && path.indexOf(pillEl) !== -1) return;   // the pill's own mic button toggles it
      closeVoice();
    }, true);
    voice.panel = { panel };
    voice.statusEl = { label, roomSel, joinBtn, leaveBtn, muteBtn };
    renderVoiceState(); updateParticipants();
  }
  function openVoice() {
    if (!pref0('voice', true)) { toast('Salon vocal désactivé dans les réglages.'); return; }
    if (!voice.panel) buildVoice();
    if (voice.opened) return;
    voice.opened = true;
    const p = voice.panel.panel;
    markOpen(p, true);
    p.style.visibility = 'visible';
    requestAnimationFrame(() => { p.style.opacity = '1'; p.style.transform = 'translateY(0)'; });
  }
  function closeVoice() {
    if (!voice.panel) return;
    voice.opened = false;
    const p = voice.panel.panel;
    p.style.opacity = '0'; p.style.visibility = 'hidden'; p.style.transform = 'translateY(8px)';
    markOpen(p, false);
  }
  function toggleVoice() { voice.opened ? closeVoice() : openVoice(); }

  // ==========================================================================
  // COMMAND PALETTE + universal search
  // ==========================================================================
  // routes verified from the rendered logged-in nav (2026-07-30)
  const ROUTES = [
    { t: 'Accueil', p: '/', ic: 'home' },
    { t: 'Torrents', p: '/torrents', ic: 'movie' },
    { t: 'Wiki', p: '/wiki', ic: 'menu_book' },
    { t: 'Communauté (chat)', p: '/communication', ic: 'forum' },
    { t: 'Migrations', p: '/migrations', ic: 'move_up' },
    { t: 'Exclusivités', p: '/exclusivite', ic: 'star' },
    { t: 'Boutique', p: '/shop', ic: 'storefront' },
    { t: 'Uploader un torrent', p: '/upload', ic: 'upload' },
    { t: 'Mes uploads', p: '/my-uploads', ic: 'inventory_2' },
    { t: 'Succès / badges', p: '/achievements', ic: 'trophy' },
    { t: 'Mon compte', p: '/mon-compte', ic: 'account_circle' },
    { t: 'Paramètres', p: '/settings', ic: 'settings' },
  ];

  function actions() {
    return [
      // AI entries only surface when the user opted in (v0.18)
      ...(pref0('ai', false) ? [
        { t: 'Demander à l’assistant TR4KER+', ic: 'auto_awesome', run: openAi },
        { t: 'Expliquer cette page', ic: 'help', run: () => askAi('Explique-moi cette page et ce que je peux y faire.', true) },
      ] : []),
      ...(updateState.available ? [{ t: 'Mettre à jour TR4KER+ (v' + updateState.latest + ' dispo)', ic: 'system_update_alt', run: openUpdate }] : []),
      { t: 'État des services — c’est moi ou c’est le site ?', ic: 'monitor_heart', run: openHealth },
      ...(pref0('statsPanel', true) ? [{ t: 'Mes stats — ratio, marge, activité', ic: 'insights', run: openStatsPanel }] : []),
      { t: 'Classement L4DDER — top contributeurs', ic: 'leaderboard', run: openLadderBoard },
      ...(pref0('inbox', true) ? [{ t: 'Signaux : mentions à relire' + (inboxUnread() ? ' (' + inboxUnread() + ')' : ''), ic: 'notifications', run: openSignals }] : []),
      ...(pref0('ideas', true) ? [{ t: 'Suggestions TR4KER+ — proposer & voter', ic: 'lightbulb', run: openIdeas }] : []),
      { t: 'Demandes : les plus récentes', ic: 'schedule', run: () => openRequests('recent') },
      { t: 'Demandes : les miennes', ic: 'person', run: () => openRequests('mine') },
      { t: 'Assistant d’upload (IA) — doublon, NFO, métadonnées', ic: 'upload_file', run: openUploadAssist },
      { t: 'Ouvrir le chat', ic: 'forum', run: openDrawer },
      ...(pref0('phrases', true) ? [{ t: 'Réponses rapides : gérer', ic: 'quickreply', run: () => openPhraseManager(false) }] : []),
      { t: 'Salon vocal (bêta)', ic: 'mic', run: openVoice },
      ...(pref0('chat', true) ? [
        { t: 'Chat : texte plus grand', ic: 'text_increase', run: () => { P = setPref('chatFontScale', normScale(pref0('chatFontScale', 1) + 0.05)); applyChatReading(); toast('Texte du chat : ' + Math.round(normScale(pref0('chatFontScale', 1)) * 100) + '%'); } },
        { t: 'Chat : texte plus petit', ic: 'text_decrease', run: () => { P = setPref('chatFontScale', normScale(pref0('chatFontScale', 1) - 0.05)); applyChatReading(); toast('Texte du chat : ' + Math.round(normScale(pref0('chatFontScale', 1)) * 100) + '%'); } },
        { t: 'Chat : densité compacte / confortable', ic: 'density_medium', run: () => { const n = pref0('chatDensity', 'cozy') === 'compact' ? 'cozy' : 'compact'; P = setPref('chatDensity', n); applyChatReading(); toast('Densité : ' + (n === 'compact' ? 'compacte' : 'confortable')); } },
      ] : []),
      { t: 'Basculer thème clair/sombre', ic: 'contrast', run: toggleSiteTheme },
      { t: 'Copier ma passkey', ic: 'key', run: copyPasskey },
      { t: 'Copier ma clé API (tr4k_)', ic: 'vpn_key', run: copyApiKey },
      { t: 'TR4KER+ : changer d’accent', ic: 'palette', run: cycleAccent },
      { t: 'TR4KER+ : réglages', ic: 'tune', run: openSettings },
      { t: 'TR4KER+ : recharger le thème', ic: 'refresh', run: () => { P = prefs(); applyTheme(); toast('Thème rechargé'); } },
    ];
  }

  function toggleSiteTheme() {
    const el = document.documentElement;
    const cur = el.getAttribute('data-theme') || 'dark';
    const next = cur === 'light' ? 'dark' : 'light';
    el.setAttribute('data-theme', next);
    P = setPref('siteTheme', next);   // remember the light/dark choice across reloads
  }
  // re-apply the saved light/dark choice (the site resets data-theme on every reload / SPA nav)
  function applySiteTheme() {
    const t = P.siteTheme;
    if ((t === 'light' || t === 'dark') && document.documentElement) {
      if (document.documentElement.getAttribute('data-theme') !== t) document.documentElement.setAttribute('data-theme', t);
    }
    applyTone();   // the SPA can reset root attributes on nav — keep the slate tone pinned
  }
  async function copyPasskey() {
    try { const m = await me(); if (m && m.passkey) { GM_setClipboard(m.passkey); toast('Passkey copiée'); } else toast('Passkey introuvable'); }
    catch (e) { toast('Erreur: ' + e.message); }
  }
  async function copyApiKey() {
    try { const m = await me(); if (m && m.api_key) { GM_setClipboard(m.api_key); toast('Clé API copiée'); } else toast('Clé API introuvable'); }
    catch (e) { toast('Erreur: ' + e.message); }
  }
  function cycleAccent() {
    const order = ['indigo', 'cyan', 'amber', 'rose'];
    const next = order[(order.indexOf(P.accent) + 1) % order.length];
    P = setPref('accent', next); applyTheme(); toast('Accent : ' + next);
  }

  // frecency for empty-state recents
  function frec() { return store.get('t4x.frecency', {}); }
  function bump(id, label, path) {
    let f = frec(); const e = f[id] || { c: 0, label, path }; e.c++; e.last = Date.now(); e.label = label; e.path = path;
    f[id] = e;
    // cap at write time so the map can't grow unbounded over months of browsing (keep 60 most-recent)
    const keys = Object.keys(f);
    if (keys.length > 60) { const keep = keys.map((k) => [k, f[k].last || 0]).sort((a, b) => b[1] - a[1]).slice(0, 60); const nf = {}; for (const [k] of keep) nf[k] = f[k]; f = nf; }
    store.set('t4x.frecency', f);
  }
  function recents(n) {
    const f = frec();
    return Object.entries(f).map(([id, e]) => ({ id, ...e }))
      .sort((a, b) => (b.last || 0) - (a.last || 0)).slice(0, n);
  }

  let pal = null, palInput = null, palList = null, palSpin = null;
  let selectable = [], activeIdx = -1, searchSeq = 0, palOpen = false, debTimer = null;
  // search facets (Shrex's suggestion: filter by period / recency). Persist in-memory across opens.
  let palYear = '', palSort = ''; // palYear: '' | '2026' | '2025' | '2024' | 'old' ; palSort: '' | 'recent' | 'seeders'
  function facetOpts() {
    const o = {};
    if (palYear === 'old') o.filter = 'year < 2024';
    else if (palYear) o.filter = 'year = ' + palYear;
    if (palSort === 'recent') o.sort = ['pub_date_ts:desc'];
    else if (palSort === 'seeders') o.sort = ['seeders:desc'];
    return o;
  }

  function buildPalette() {
    ensureHost();
    const veil = document.createElement('div'); veil.className = 'veil';
    pal = document.createElement('div'); pal.className = 'pal'; pal.setAttribute('role', 'dialog'); pal.setAttribute('aria-modal', 'true'); pal.setAttribute('aria-label', 'Palette de commandes TR4KER+');
    const header = document.createElement('header');
    const glyph = document.createElement('div'); glyph.className = 'glyph';
    palInput = document.createElement('input'); palInput.type = 'text'; palInput.placeholder = 'Rechercher torrents, wiki, membres — ou une action…';
    palInput.setAttribute('role', 'combobox'); palInput.setAttribute('aria-expanded', 'true'); palInput.setAttribute('aria-autocomplete', 'list');
    palInput.spellcheck = false; palInput.autocapitalize = 'off'; palInput.autocomplete = 'off';
    palSpin = document.createElement('div'); palSpin.style.cssText = 'width:16px;flex:0 0 auto;display:flex;align-items:center;justify-content:center;';
    const esc = document.createElement('span'); esc.className = 'hintk'; esc.textContent = 'esc';
    header.append(glyph, palInput, palSpin, esc);
    const facetBar = buildFacetBar();
    palList = document.createElement('div'); palList.className = 'list'; palList.setAttribute('role', 'listbox');
    const footer = document.createElement('footer');
    footer.innerHTML = '<span><b>↵</b> ouvrir</span><span><b>⌘↵</b> télécharger</span><span><b>↑↓</b> naviguer</span>';
    const wm = document.createElement('div'); wm.className = 'wm'; wm.textContent = 'T4+';
    pal.append(header, facetBar, palList, footer, wm);
    root.append(veil, pal);
    pal.__veil = veil;

    veil.addEventListener('click', closePalette);
    palInput.addEventListener('input', onInput);
    palInput.addEventListener('keydown', onKey);
  }

  // Facet bar under the search input — period filter + recency sort (Meili year/pub_date_ts, both indexed).
  let facetChips = [];
  function buildFacetBar() {
    const bar = document.createElement('div'); bar.className = 't4x-facets';
    bar.style.cssText = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid var(--outline-variant,rgba(255,255,255,.08));';
    const lbl = document.createElement('span'); lbl.textContent = 'Période'; lbl.style.cssText = 'font-size:10px;letter-spacing:.06em;text-transform:uppercase;opacity:.55;margin-right:2px;'; bar.appendChild(lbl);
    facetChips = [];
    const mkChip = (label, kind, val) => {
      const b = document.createElement('button'); b.type = 'button'; b.textContent = label;
      b.style.cssText = 'font:inherit;font-size:11px;line-height:1;padding:5px 9px;border-radius:999px;cursor:pointer;border:1px solid var(--outline-variant,rgba(255,255,255,.15));background:transparent;color:var(--on-surface,#cdd3e0);transition:background .12s,color .12s;';
      b.__kind = kind; b.__val = val; facetChips.push(b);
      b.addEventListener('click', () => {
        if (kind === 'year') palYear = (palYear === val ? '' : val);
        else palSort = (palSort === val ? '' : val);
        paintFacets();
        const q = palInput.value.trim(); if (q.length >= 2) { palSpin.innerHTML = '<div class="spin"></div>'; search(q); }
      });
      return b;
    };
    [['Tous', ''], ['2026', '2026'], ['2025', '2025'], ['2024', '2024'], ['+ ancien', 'old']]
      .forEach(([l, v]) => bar.appendChild(mkChip(l, 'year', v)));
    const sep = document.createElement('span'); sep.textContent = 'Tri'; sep.style.cssText = 'font-size:10px;letter-spacing:.06em;text-transform:uppercase;opacity:.55;margin:0 2px 0 8px;'; bar.appendChild(sep);
    [['Pertinence', ''], ['Récent', 'recent'], ['Seeders', 'seeders']]
      .forEach(([l, v]) => bar.appendChild(mkChip(l, 'sort', v)));
    paintFacets();
    return bar;
  }
  function paintFacets() {
    facetChips.forEach(b => {
      const active = (b.__kind === 'year' ? palYear : palSort) === b.__val;
      b.style.background = active ? 'var(--primary,#93a5ff)' : 'transparent';
      b.style.color = active ? 'var(--on-primary,#0b0d13)' : 'var(--on-surface,#cdd3e0)';
      b.style.borderColor = active ? 'var(--primary,#93a5ff)' : 'var(--outline-variant,rgba(255,255,255,.15))';
    });
  }

  // set of slugs the member is already seeding — so search results can flag "déjà en seed" and you
  // don't re-download what you already have. Cached; /api/me/torrents is paginated so we sweep pages.
  let _seedSlugs = null, _seedAt = 0;
  async function seedSlugs() {
    if (_seedSlugs && Date.now() - _seedAt < 300000) return _seedSlugs;
    const set = new Set();
    try {
      for (let page = 1; page <= 6; page++) {
        const j = await apiGet('/api/me/torrents?limit=100&page=' + page, { ttl: 300000 });
        const arr = pickArray(j, ['torrents', 'data']);
        for (const t of arr) { const sl = t.slug || t.torrent_slug || (t.torrent && t.torrent.slug); if (sl) set.add(sl); }
        if (arr.length < 100) break;
      }
    } catch (e) {}
    _seedSlugs = set; _seedAt = Date.now(); return set;
  }
  function openPalette() {
    if (!P.palette) return;
    try { closeIdeas(); } catch (e) {}   // the board panel is opaque and above the palette — don't leave a hidden focused input under it (M19)
    if (!pal) buildPalette();
    seedSlugs();   // warm the "en seed" set so chips render on the first result paint
    palOpen = true;
    pal.__veil.classList.add('on'); pal.classList.add('on');
    markOpen(pal, true); markOpen(pal.__veil, true);
    palInput.value = ''; render('');
    setTimeout(() => palInput.focus(), 10);
  }
  function closePalette() {
    palOpen = false; searchSeq++;   // invalidate any in-flight search paint
    hideVignette();
    if (pal) { pal.classList.remove('on'); pal.__veil.classList.remove('on'); markOpen(pal, false); markOpen(pal.__veil, false); }
  }
  function togglePalette() { palOpen ? closePalette() : openPalette(); }

  function onInput() {
    const q = palInput.value.trim();
    render(q);
    clearTimeout(debTimer);
    if (q.length >= 2) { palSpin.innerHTML = '<div class="spin"></div>'; debTimer = setTimeout(() => search(q), 160); }
    else { palSpin.innerHTML = ''; searchSeq++; }   // dropping below 2 chars invalidates in-flight results
  }

  function onKey(e) {
    if (e.key === 'Escape') { e.preventDefault(); closePalette(); return; }
    if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
    else if (e.key === 'Enter') {
      e.preventDefault();
      const it = selectable[activeIdx];
      if (it) it.__act ? it.__act(e) : activate(it, e);
    }
  }
  function move(d) {
    if (!selectable.length) return;
    activeIdx = (activeIdx + d + selectable.length) % selectable.length;
    selectable.forEach((it, i) => it.el.setAttribute('aria-selected', i === activeIdx ? 'true' : 'false'));
    const el = selectable[activeIdx].el; el.scrollIntoView({ block: 'nearest' });
    palInput.setAttribute('aria-activedescendant', el.id || '');
    maybeVigForActive();   // keyboard nav also opens the vignette for the highlighted torrent
  }

  function activate(it, e) {
    if (it.kind === 'action') { closePalette(); try { it.run(); } catch (err) { toast('Erreur: ' + err.message); } return; }
    if (it.kind === 'route') { bump('r:' + it.p, it.t, it.p); closePalette(); navigate(it.p); return; }
    if (it.kind === 'torrent') {
      if ((it._pack || !it.slug) && it.infohash) {   // offline hot-pack hit: no site page, offer the magnet
        const mag = packMagnet(it); closePalette();
        try { GM_setClipboard ? GM_setClipboard(mag) : null; } catch (er) {}
        try { location.href = mag; } catch (er) {}
        toast('Lien magnet copié (résultat hors-ligne).'); return;
      }
      const path = '/torrent/' + it.slug;
      if ((IS_MAC ? e.metaKey : e.ctrlKey)) { downloadTorrent(it.slug, it.name); return; }
      bump('t:' + it.slug, it.name, path); closePalette(); navigate(path); return;
    }
    if (it.kind === 'wiki') { const path = '/wiki/' + it.slug; bump('w:' + it.slug, it.title, path); closePalette(); navigate(path); return; }
    if (it.kind === 'user') { const path = '/user/' + encodeURIComponent(it.name); closePalette(); navigate(path); return; }   // real profile route is /user/<name>; /u/<name> hits the SPA catch-all → home
    if (it.kind === 'recent') { closePalette(); navigate(it.path); return; }
  }

  async function downloadTorrent(slug, name) {
    try {
      toast('Téléchargement du .torrent…', 2000);
      const r = await fetch('/api/torrents/' + encodeURIComponent(slug) + '/download', { credentials: 'include' });
      if (!r.ok) throw new Error('HTTP ' + r.status);
      const blob = await r.blob();
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a'); a.href = url; a.download = (name || slug).replace(/[\\/:*?"<>|]+/g, '_') + '.torrent';
      document.documentElement.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 4000);
      toast('.torrent téléchargé');
    } catch (e) { toast('Échec du téléchargement: ' + e.message); }
  }

  // ---- rendering -----------------------------------------------------------
  let rowSeq = 0;
  function mkRow(sel) {
    const el = document.createElement('div'); el.className = 'row'; el.id = 't4row-' + (rowSeq++);
    el.setAttribute('role', 'option'); el.setAttribute('aria-selected', 'false');
    if (sel) {
      // mouseenter fires once on row entry (not per pointer pixel like mousemove); delta-update only the two
      // affected rows instead of rewriting aria-selected across the whole list on every mouse move.
      el.addEventListener('mouseenter', () => { const ni = selectable.indexOf(sel); if (ni === activeIdx) return; const prev = selectable[activeIdx]; if (prev && prev.el) prev.el.setAttribute('aria-selected', 'false'); activeIdx = ni; el.setAttribute('aria-selected', 'true'); });
      el.addEventListener('click', (e) => { activeIdx = selectable.indexOf(sel); activate(sel, e); });
      sel.el = el;
    }
    return el;
  }
  function icon(name) { const s = document.createElement('span'); s.className = 'ic'; s.textContent = name; return s; }
  function group(label) { const g = document.createElement('div'); g.className = 'grp'; g.textContent = label; return g; }

  function fuzzy(q, s) { // simple subsequence score
    q = q.toLowerCase(); s = (s || '').toLowerCase(); let i = 0, sc = 0, run = 0;
    for (let j = 0; j < s.length && i < q.length; j++) { if (s[j] === q[i]) { i++; run++; sc += run; } else run = 0; }
    return i === q.length ? sc + (s.startsWith(q) ? 50 : 0) : -1;
  }

  function itemKey(it) { return (it.kind || '') + '|' + (it.slug || it.path || it.p || it.name || it.t || it.title || ''); }
  function setActive(i) { if (i < 0 || i >= selectable.length) return; activeIdx = i; selectable.forEach((it, j) => it.el.setAttribute('aria-selected', j === i ? 'true' : 'false')); const el = selectable[i].el; el.scrollIntoView({ block: 'nearest' }); palInput.setAttribute('aria-activedescendant', el.id || ''); }
  function render(q, remote) {
    const prevKey = (activeIdx >= 0 && selectable[activeIdx]) ? itemKey(selectable[activeIdx]) : null;   // keep the user's highlight across progressive repaints
    selectable = []; activeIdx = -1;
    const frag = document.createElement('div');

    // Actions + routes (local, instant)
    const acts = actions().map(a => ({ kind: 'action', ...a }));
    const routes = ROUTES.map(r => ({ kind: 'route', ...r }));
    if (!q) {
      const rec = recents(6);
      if (rec.length) {
        frag.appendChild(group('Récents'));
        rec.forEach(r => { const it = { kind: 'recent', path: r.path, label: r.label }; const el = mkRow(it);
          el.append(icon('history'), mkMain(r.label, r.path)); frag.appendChild(el); selectable.push(it); });
      }
      frag.appendChild(group('Aller à'));
      routes.forEach(r => addSimple(frag, r, r.ic, r.t));
      frag.appendChild(group('Actions'));
      acts.forEach(a => addSimple(frag, a, a.ic, a.t));
    } else {
      const matchedR = routes.map(r => ({ r, s: fuzzy(q, r.t) })).filter(x => x.s >= 0).sort((a, b) => b.s - a.s).map(x => x.r);
      const matchedA = acts.map(a => ({ a, s: fuzzy(q, a.t) })).filter(x => x.s >= 0).sort((a, b) => b.s - a.s).map(x => x.a);
      if (matchedR.length) { frag.appendChild(group('Aller à')); matchedR.forEach(r => addSimple(frag, r, r.ic, r.t)); }
      if (matchedA.length) { frag.appendChild(group('Actions')); matchedA.forEach(a => addSimple(frag, a, a.ic, a.t)); }
      // remote results
      if (remote) {
        if (remote.torrents && remote.torrents.length) {
          frag.appendChild(group('Torrents')); remote.torrents.forEach(t => addTorrent(frag, t));
        }
        if (remote.wiki && remote.wiki.length) {
          frag.appendChild(group('Wiki')); remote.wiki.forEach(w => addWiki(frag, w));
        }
        if (remote.users && remote.users.length) {
          frag.appendChild(group('Membres')); remote.users.forEach(u => addUser(frag, u));
        }
      }
      if (!selectable.length) {
        const e = document.createElement('div'); e.className = 'empty';
        e.textContent = remote ? 'Aucun résultat.' : 'Recherche…';
        frag.appendChild(e);
      }
    }
    palList.replaceChildren(frag);
    if (selectable.length) { const i = prevKey ? selectable.findIndex((it) => itemKey(it) === prevKey) : -1; setActive(i < 0 ? 0 : i); }
  }

  function mkMain(title, sub) {
    const m = document.createElement('div'); m.className = 'main';
    const t = document.createElement('div'); t.className = 'title'; t.textContent = title; m.appendChild(t);
    if (sub) { const s = document.createElement('div'); s.className = 'sub'; s.textContent = sub; m.appendChild(s); }
    return m;
  }
  function addSimple(frag, obj, ic, title) {
    const it = Object.assign({}, obj); const el = mkRow(it);
    el.append(icon(ic), mkMain(title)); frag.appendChild(el); selectable.push(it);
  }
  function addTorrent(frag, t) {
    const it = { kind: 'torrent', slug: t.slug, name: t.name, infohash: t.infohash, _pack: t._pack };
    const el = mkRow(it);
    const main = mkMain(t.name, [t.parent_cat_name || t.parent_cat_slug, t.year].filter(Boolean).join(' · '));
    const chips = document.createElement('div'); chips.className = 'chips';
    if (_seedSlugs && t.slug && _seedSlugs.has(t.slug)) { const c = document.createElement('span'); c.className = 'chip seed'; c.textContent = '✓ en seed'; c.title = 'Tu seedes déjà ce torrent'; chips.appendChild(c); }
    if (t.is_freeleech) { const c = document.createElement('span'); c.className = 'chip fl'; c.textContent = 'FL'; chips.appendChild(c); }
    if (t.seeders != null) { const c = document.createElement('span'); c.className = 'chip seed'; c.textContent = '▲' + t.seeders; chips.appendChild(c); }
    if (t.leechers != null) { const c = document.createElement('span'); c.className = 'chip leech'; c.textContent = '▼' + t.leechers; chips.appendChild(c); }
    if (t.size_bytes != null) { const c = document.createElement('span'); c.className = 'chip'; c.textContent = fmtSize(t.size_bytes); chips.appendChild(c); }
    // version grouping: results are collapsed per work, so offer to unfold the other releases.
    // The count is unknown until asked (Meili's distinct doesn't report it), so the chip queries
    // on click and disappears when the work turns out to be a one-off.
    if (t.group_key && t.group_key.charAt(0) !== '!' && !t._version) {
      const c = document.createElement('span'); c.className = 'chip t4x-vers'; c.textContent = '⋯ versions';
      c.title = 'Voir les autres versions de ce titre';
      c.addEventListener('click', async (ev) => {
        ev.preventDefault(); ev.stopPropagation();
        if (c.dataset.busy) return; c.dataset.busy = '1'; c.textContent = '…';
        const all = await meiliVersions(t.group_key, 12);
        const others = all.filter(o => o.slug !== t.slug);
        if (!others.length) { c.textContent = 'seule version'; c.style.opacity = '.5'; return; }
        c.textContent = others.length + ' autre' + (others.length > 1 ? 's' : '');
        const holder = document.createElement('div'); holder.className = 't4x-versbox';
        for (const o of others) {
          const sub = document.createElement('div'); sub.className = 't4x-versrow';
          sub.textContent = o.name;
          const meta = document.createElement('span'); meta.className = 't4x-versmeta';
          meta.textContent = '▲' + (o.seeders || 0) + ' · ' + fmtSize(o.size_bytes);
          sub.appendChild(meta);
          sub.addEventListener('click', (e2) => { e2.stopPropagation(); closePalette(); navigate('/torrent/' + o.slug); });
          holder.appendChild(sub);
        }
        el.insertAdjacentElement('afterend', holder);
        c.style.display = 'none';
      });
      chips.appendChild(c);
    }
    el.append(icon(catIcon(t)), main, chips); frag.appendChild(el); selectable.push(it);
    it._t = t;
    if (pref0('resultHover', true)) wireVignette(el, it);
  }
  function catIcon(t) {
    const s = (t.parent_cat_slug || t.cat_slug || '') + (t.sub_cat_slug || '');
    if (/film|movie/.test(s)) return 'movie'; if (/serie|tv/.test(s)) return 'live_tv';
    if (/livre|book|bd|comic|manga/.test(s)) return 'menu_book'; if (/audio|music|musi/.test(s)) return 'music_note';
    if (/game|jeu/.test(s)) return 'sports_esports'; return 'download';
  }

  // ============================================================================
  // VIGNETTE — hover (or arrow-select) a torrent result and a floating card opens beside the palette with the
  // full torrent: poster, synopsis + description/NFO, live stats, and one-tap actions (favori, merci, .torrent,
  // commentaires, ouvrir) — everything from the search engine, no page load. Paints instantly from the list item,
  // then enriches from /api/torrents/<slug> (+ comment count), both cached. Kill-switch: pref `resultHover`.
  const VIG_CSS = `
    .t4x-vig{ position:fixed; z-index:2147483007; width:344px; max-width:92vw; display:flex; flex-direction:column;
      background:var(--surface-container-high,#1a1d26); color:var(--on-surface,#e6e8f2);
      border:1px solid var(--t4x-hair,rgba(147,165,255,.16)); border-radius:16px; overflow-y:auto; overscroll-behavior:contain;
      box-shadow:0 24px 70px rgba(0,0,0,.55); opacity:0; transform:translateX(-6px); pointer-events:none;
      transition:opacity .13s ease, transform .13s ease; }
    .t4x-vig.on{ opacity:1; transform:none; pointer-events:auto; }
    .t4x-vig-top{ display:flex; gap:11px; padding:13px 14px 10px; }
    .t4x-vig-poster{ width:64px; height:92px; flex:0 0 auto; border-radius:9px; overflow:hidden; background:var(--surface-container,#141722);
      display:flex; align-items:center; justify-content:center; }
    .t4x-vig-poster img{ width:100%; height:100%; object-fit:cover; display:block; }
    .t4x-vig-poster.ph .ic{ font-family:'Material Symbols Outlined'; font-size:30px; color:var(--on-surface-variant,#8b93a7); }
    .t4x-vig-head{ min-width:0; flex:1; }
    .t4x-vig-title{ font-size:13.5px; font-weight:650; line-height:1.3; margin-bottom:4px; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
    .t4x-vig-meta{ font-size:11.5px; color:var(--on-surface-variant,#aab2c5); }
    .t4x-vig-stats{ display:flex; flex-wrap:wrap; gap:5px; padding:0 14px 8px; }
    .t4x-vig-chip{ font:11px/1 'JetBrains Mono',monospace; padding:4px 7px; border-radius:999px; background:var(--surface-container,#141722); color:var(--on-surface-variant,#aab2c5); }
    .t4x-vig-chip.seed{ color:var(--t4x-seed,#4ade80); } .t4x-vig-chip.leech{ color:var(--t4x-leech,#fb7185); }
    .t4x-vig-chip.fl{ background:var(--t4x-fl,#fbbf24); color:#1a1200; font-weight:700; }
    .t4x-vig-up{ font-size:11px; color:var(--on-surface-variant,#aab2c5); padding:0 14px 6px; }
    .t4x-vig-syn{ font-size:12px; line-height:1.45; color:var(--on-surface,#d6dbe8); padding:0 14px 8px; display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden; }
    .t4x-vig-desc{ font-size:11.5px; line-height:1.5; color:var(--on-surface-variant,#b8bdd1); padding:8px 14px; margin:0 4px; max-height:168px; overflow:auto; overscroll-behavior:contain; border-top:1px solid var(--outline-variant,#2a3040); }
    .t4x-vig-desc img{ max-width:100%; border-radius:6px; } .t4x-vig-desc a{ color:var(--primary,#93a5ff); }
    .t4x-vig-load{ font-size:11.5px; color:var(--on-surface-variant,#8b93a7); padding:6px 14px 10px; font-style:italic; }
    .t4x-vig-acts{ display:flex; flex-wrap:wrap; gap:6px; padding:10px 12px 12px; border-top:1px solid var(--outline-variant,#2a3040); }
    .t4x-vig-btn{ all:unset; cursor:pointer; display:inline-flex; align-items:center; gap:5px; font-size:12px; padding:7px 10px; border-radius:10px;
      background:var(--surface-container,#141722); color:var(--on-surface-variant,#c2c8da); transition:background .12s,color .12s,transform .1s; }
    .t4x-vig-btn .ic{ font-family:'Material Symbols Outlined'; font-size:17px; }
    .t4x-vig-btn:hover{ background:color-mix(in srgb,var(--primary,#93a5ff) 18%,transparent); color:var(--primary,#93a5ff); }
    .t4x-vig-btn:active{ transform:scale(.94); }
    .t4x-vig-btn.on{ background:color-mix(in srgb,var(--primary,#93a5ff) 24%,transparent); color:var(--primary,#93a5ff); }
    .t4x-vig-btn.busy{ opacity:.5; cursor:default; }
  `;
  let _vigCard = null, _vigTimer = null, _vigHideTimer = null, _vigSlug = null, _vigSeq = 0, _vigCssAdded = false;
  const _vigCache = new Map();   // slug -> detail torrent object (+ ._commTotal)
  function ensureVigCSS() { if (_vigCssAdded) return; ensureHost(); const s = document.createElement('style'); s.textContent = VIG_CSS; root.appendChild(s); _vigCssAdded = true; }
  function wireVignette(rowEl, it) {
    rowEl.addEventListener('mouseenter', () => { clearTimeout(_vigHideTimer); clearTimeout(_vigTimer); _vigTimer = setTimeout(() => showVignette(it, rowEl), 180); });
    rowEl.addEventListener('mouseleave', () => { clearTimeout(_vigTimer); scheduleVigHide(); });
  }
  function scheduleVigHide() { clearTimeout(_vigHideTimer); _vigHideTimer = setTimeout(hideVignette, 260); }
  function hideVignette() { clearTimeout(_vigTimer); if (_vigCard) _vigCard.classList.remove('on'); _vigSlug = null; }
  function buildVigCard() {
    _vigCard = document.createElement('div'); _vigCard.className = 't4x-vig';
    _vigCard.addEventListener('mouseenter', () => clearTimeout(_vigHideTimer));
    _vigCard.addEventListener('mouseleave', scheduleVigHide);
    root.appendChild(_vigCard);
  }
  function positionVig(anchorEl) {
    const card = _vigCard; if (!card) return;
    const W = 344, M = 10;
    card.style.maxHeight = Math.min(window.innerHeight - 2 * M, 600) + 'px';
    // horizontal anchor: beside the palette when it's open, otherwise beside the hovered row (native results)
    const hb = (palOpen && pal && pal.classList.contains('on')) ? pal.getBoundingClientRect() : (anchorEl ? anchorEl.getBoundingClientRect() : { left: M, right: M + 300 });
    let left = hb.right + M;
    if (left + W > window.innerWidth - M) left = hb.left - W - M;   // flip to the other side
    if (left < M) left = Math.max(M, window.innerWidth - W - M);
    card.style.left = left + 'px';
    const ar = anchorEl ? anchorEl.getBoundingClientRect() : hb;
    let top = Math.min(Math.max(M, ar.top - 8), window.innerHeight - M - card.offsetHeight);
    card.style.top = Math.max(M, isFinite(top) ? top : M) + 'px';
  }
  function vigChip(text, cls) { const c = document.createElement('span'); c.className = 't4x-vig-chip' + (cls ? ' ' + cls : ''); c.textContent = text; return c; }
  function vigBtn(icon, label, fn, opts) {
    const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-vig-btn' + (opts && opts.on ? ' on' : '');
    const s = document.createElement('span'); s.className = 'ic'; s.textContent = icon; b.appendChild(s);
    if (label) { const l = document.createElement('span'); l.textContent = label; b.appendChild(l); }
    if (opts && opts.title) b.title = opts.title;
    b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); fn(b); });
    return b;
  }
  function paintVig(t, detail) {
    const card = _vigCard; if (!card) return;
    card.replaceChildren();
    const poster = document.createElement('div'); poster.className = 't4x-vig-poster';
    const purl = t.poster_url || t.classic_cover_url;
    if (purl) { const img = document.createElement('img'); img.src = purl; img.loading = 'lazy'; img.alt = ''; img.addEventListener('error', () => { poster.classList.add('ph'); const ic = document.createElement('span'); ic.className = 'ic'; ic.textContent = catIcon(t); img.remove(); poster.appendChild(ic); }); poster.appendChild(img); }
    else { poster.classList.add('ph'); const ic = document.createElement('span'); ic.className = 'ic'; ic.textContent = catIcon(t); poster.appendChild(ic); }
    const head = document.createElement('div'); head.className = 't4x-vig-head';
    const title = document.createElement('div'); title.className = 't4x-vig-title'; title.textContent = t.name || t.slug;
    const meta = document.createElement('div'); meta.className = 't4x-vig-meta';
    meta.textContent = [t.cat_name || t.parent_cat_name, t.sub_cat_name, t.year].filter(Boolean).join(' · ');
    head.append(title, meta);
    const top = document.createElement('div'); top.className = 't4x-vig-top'; top.append(poster, head); card.appendChild(top);
    const stats = document.createElement('div'); stats.className = 't4x-vig-stats';
    if (t.is_freeleech) stats.appendChild(vigChip('FL', 'fl'));
    if (t.seeders != null) stats.appendChild(vigChip('▲ ' + t.seeders, 'seed'));
    if (t.leechers != null) stats.appendChild(vigChip('▼ ' + t.leechers, 'leech'));
    if (t.size_bytes != null) stats.appendChild(vigChip(fmtSize(t.size_bytes)));
    if (t.times_completed != null) stats.appendChild(vigChip('✓ ' + t.times_completed));
    if (t.views != null) stats.appendChild(vigChip('👁 ' + t.views));
    if (t.file_count != null && t.file_count > 1) stats.appendChild(vigChip(t.file_count + ' fichiers'));
    if (stats.children.length) card.appendChild(stats);
    if (t.uploader && !t.is_anonymous) { const up = document.createElement('div'); up.className = 't4x-vig-up'; up.textContent = 'par ' + t.uploader; card.appendChild(up); }
    const syn = t.synopsis && String(t.synopsis).trim();
    if (syn) { const sd = document.createElement('div'); sd.className = 't4x-vig-syn'; sd.textContent = syn; card.appendChild(sd); }
    if (detail) {
      const src = detail.description || detail.classic_description || detail.extra_info || detail.nfo || '';
      if (src && String(src).trim()) { const dd = document.createElement('div'); dd.className = 't4x-vig-desc'; renderBody(dd, String(src).slice(0, 1400), chat.myName); card.appendChild(dd); }
    } else { const load = document.createElement('div'); load.className = 't4x-vig-load'; load.textContent = 'Chargement des détails…'; card.appendChild(load); }
    card.appendChild(buildVigActions(t, detail));
  }
  function buildVigActions(t, detail) {
    const row = document.createElement('div'); row.className = 't4x-vig-acts'; const slug = t.slug;
    const favOn = !!(detail && detail.is_favorited);
    row.appendChild(vigBtn('favorite', 'Favori', (b) => vigPost('/favorite', slug, b, 'Ajouté aux favoris', 'Retiré des favoris'), { on: favOn, title: 'Ajouter / retirer des favoris' }));
    row.appendChild(vigBtn('volunteer_activism', 'Merci', (b) => vigPost('/thanks', slug, b, 'Merci envoyé à l’uploadeur', 'Merci'), { title: 'Remercier l’uploadeur' }));
    const cnt = detail && detail._commTotal;
    row.appendChild(vigBtn('mode_comment', (cnt != null ? String(cnt) : 'Commentaires'), () => { closePalette(); navigate('/torrent/' + slug + '#comments'); }, { title: 'Voir / écrire un commentaire' }));
    row.appendChild(vigBtn('download', '.torrent', () => downloadTorrent(slug, t.name), { title: 'Télécharger le .torrent' }));
    row.appendChild(vigBtn('open_in_new', 'Ouvrir', () => { closePalette(); navigate('/torrent/' + slug); }, { title: 'Ouvrir la fiche complète' }));
    return row;
  }
  async function vigPost(path, slug, btn, okMsg, altMsg) {
    if (btn.dataset.busy) return; btn.dataset.busy = '1'; btn.classList.add('busy');
    try {
      const r = await fetch('/api/torrents/' + encodeURIComponent(slug) + path, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' });
      if (!r.ok) throw new Error('HTTP ' + r.status);
      const on = btn.classList.toggle('on'); toast(on ? okMsg : (altMsg || okMsg));
      const d = _vigCache.get(slug); if (d && path === '/favorite') d.is_favorited = on;
    } catch (e) { toast('Action impossible : ' + e.message); }
    finally { btn.dataset.busy = ''; btn.classList.remove('busy'); }
  }
  async function showVignette(it, anchorEl) {
    if (!it || it.kind !== 'torrent' || !it.slug || it._pack) return;
    if (!palOpen && !it._native) return;   // palette path needs the palette open; native path is allowed anywhere
    if (it.slug === _vigSlug && _vigCard && _vigCard.classList.contains('on')) { positionVig(anchorEl); return; }   // already showing this one
    ensureVigCSS(); if (!_vigCard) buildVigCard();
    _vigSlug = it.slug; const mySeq = ++_vigSeq;
    paintVig(it._t || { slug: it.slug, name: it.name }, null);
    _vigCard.classList.add('on'); requestAnimationFrame(() => positionVig(anchorEl));
    let detail = _vigCache.get(it.slug);
    if (!detail) { try { const j = await apiGet('/api/torrents/' + encodeURIComponent(it.slug), { ttl: 120000, timeout: 7000 }); detail = j.torrent || j; if (detail) _vigCache.set(it.slug, detail); } catch (e) {} }
    if (mySeq !== _vigSeq) return;
    if (detail && detail._commTotal == null) {
      try { const cj = await apiGet('/api/torrents/' + encodeURIComponent(it.slug) + '/comments', { ttl: 120000, timeout: 6000 }); detail._commTotal = (cj && (cj.total != null ? cj.total : (cj.comments ? cj.comments.length : 0))) || 0; }
      catch (e) { detail._commTotal = null; }
    }
    if (mySeq !== _vigSeq) return;
    if (detail) { paintVig(Object.assign({}, it._t || {}, detail), detail); requestAnimationFrame(() => positionVig(anchorEl)); }
  }
  function maybeVigForActive() {
    if (!pref0('resultHover', true)) return;
    const it = selectable[activeIdx]; clearTimeout(_vigTimer);
    if (it && it.kind === 'torrent' && it.slug && !it._pack && it.el) _vigTimer = setTimeout(() => showVignette(it, it.el), 150);
    else scheduleVigHide();
  }
  // NATIVE result rows (the site's own /torrents, /search, /browse lists): hovering a torrent link opens the
  // SAME vignette — so "everything from the search engine" works on the site's pages too, not just our palette.
  let _nativeVigWired = false;
  function wireNativeVignette() {
    if (_nativeVigWired) return; _nativeVigWired = true;
    const findLink = (t) => (t && t.closest) ? t.closest('a[href*="/torrent/"]') : null;
    document.addEventListener('mouseover', (e) => {
      if (!pref0('resultHover', true)) return;
      try { if (location.pathname.indexOf('/torrent/') === 0) return; } catch (er) {}   // don't self-vignette on a detail page
      const a = findLink(e.target); if (!a || a.closest('#t4x-root')) return;   // ignore our own shadow-host links
      const m = (a.getAttribute('href') || '').match(/\/torrent\/([^/?#]+)/); if (!m) return;
      const slug = m[1]; if (!slug || slug.length < 4 || slug === _vigSlug) return;
      const row = a.closest('li, tr, article, [class*="row"], [class*="card"], [class*="item"]') || a;
      clearTimeout(_vigTimer); clearTimeout(_vigHideTimer);
      const name = (a.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 200);
      _vigTimer = setTimeout(() => showVignette({ kind: 'torrent', slug, name, _native: true, _t: { slug, name } }, row), 200);
    }, true);
    document.addEventListener('mouseout', (e) => {
      const a = findLink(e.target); if (a && !a.closest('#t4x-root')) { clearTimeout(_vigTimer); scheduleVigHide(); }
    }, true);
  }
  function addWiki(frag, w) {
    const slug = w.slug || w.article_slug; const title = w.title || w.heading || slug;
    const it = { kind: 'wiki', slug, title };
    const el = mkRow(it); el.append(icon('menu_book'), mkMain(title, w.category_name || w.excerpt || '')); frag.appendChild(el); selectable.push(it);
  }
  function addUser(frag, u) {
    const name = u.username || u.name || u.handle; if (!name) return;
    const it = { kind: 'user', name };
    const el = mkRow(it); el.append(icon('account_circle'), mkMain(name, u.role || '')); frag.appendChild(el); selectable.push(it);
  }

  async function search(q) {
    const seq = ++searchSeq;
    const out = { torrents: [], wiki: [], users: [] };
    // progressive: paint each section as it resolves so one slow endpoint never blocks the rest
    const paint = () => { if (seq === searchSeq && palOpen) render(q, out); };
    const jobs = [
      getTorrents(q, facetOpts()).then(t => { out.torrents = t; paint(); }).catch(() => {}),
      apiGet('/api/wiki/search?q=' + encodeURIComponent(q), { ttl: 60000, timeout: 4000 })
        .then(j => { out.wiki = pickArray(j, ['results', 'articles', 'items', 'data']).slice(0, 5); paint(); }).catch(() => {}),
      apiGet('/api/users/search?q=' + encodeURIComponent(q), { ttl: 60000, timeout: 4000 })
        .then(j => { out.users = pickArray(j, ['results', 'users', 'items', 'data']).slice(0, 5); paint(); }).catch(() => {}),
    ];
    await Promise.allSettled(jobs);
    if (seq !== searchSeq || !palOpen) return; // stale
    palSpin.innerHTML = '';
    render(q, out);
  }

  // ==========================================================================
  // WEBSOCKET MANAGER — tee the SPA's socket (no 2nd connection); own-socket fallback.
  // ==========================================================================
  const NativeWS = window.WebSocket;
  const wsListeners = new Set();
  let teedWS = null, ownWS = null, ownBackoff = 1, ownWanted = false;
  function onFrame(cb) { wsListeners.add(cb); return () => wsListeners.delete(cb); }
  function emitFrame(f, own) { for (const cb of wsListeners) { try { cb(f, own); } catch (e) {} } }
  function handleRaw(data, own) {
    let f; try { f = JSON.parse(data); } catch (e) { return; }
    try { const w = (window.__T4X_WS__ = window.__T4X_WS__ || { n: 0, types: {} }); w.n++; w.types[f.type] = (w.types[f.type] || 0) + 1; } catch (e) {}
    if (f.type === 'ping') { if (own && ownWS && ownWS.readyState === 1) { try { ownWS.send('{"type":"pong"}'); } catch (e) {} } return; }
    if (f.type === 'notification') { try { if (f.title || f.body) toast((f.title ? f.title + ' — ' : '') + (f.body || ''), 6000); } catch (e) {} }
    emitFrame(f, own);
  }
  function patchWS() {
    if (window.WebSocket && window.WebSocket.__t4x) return;
    function T4WS(url, protocols) {
      const s = protocols === undefined ? new NativeWS(url) : new NativeWS(url, protocols);
      try {
        if (String(url).indexOf('/api/ws') !== -1) {
          teedWS = s;
          // SPA socket is now the source of truth → tear down any redundant own socket to avoid duplicate frames
          ownWanted = false; if (ownWS) { try { ownWS.close(); } catch (e) {} ownWS = null; }
          s.addEventListener('message', (ev) => handleRaw(ev.data, false));
          s.addEventListener('close', () => { if (teedWS === s) teedWS = null; });
        }
      } catch (e) {}
      return s; // SPA receives a genuine native socket
    }
    try {
      T4WS.prototype = NativeWS.prototype;
      ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'].forEach(k => { T4WS[k] = NativeWS[k]; });
      T4WS.__t4x = true;
      window.WebSocket = T4WS;
    } catch (e) {}
  }
  function liveSocket() {
    if (teedWS && teedWS.readyState === 1) return teedWS;
    if (ownWS && ownWS.readyState === 1) return ownWS;
    return null;
  }
  function ensureOwnSocket() {
    ownWanted = true;
    if (teedWS && teedWS.readyState <= 1) return;             // SPA socket present or connecting — use it, don't race a 2nd
    if (ownWS && (ownWS.readyState === 0 || ownWS.readyState === 1)) return;
    try {
      ownWS = new NativeWS('wss://' + location.host + '/api/ws');
      ownWS.addEventListener('open', () => { ownBackoff = 1; emitFrame({ type: '_ws_open', own: true }, true); });
      ownWS.addEventListener('message', (ev) => handleRaw(ev.data, true));
      ownWS.addEventListener('close', () => {
        ownWS = null;
        if (ownWanted && !(teedWS && teedWS.readyState === 1)) {
          setTimeout(ensureOwnSocket, ownBackoff * 1000 + Math.random() * 500);
          ownBackoff = Math.min(ownBackoff * 2, 30);
        }
      });
    } catch (e) {}
  }
  function sendFrame(obj) {
    const s = liveSocket();
    if (!s) { ensureOwnSocket(); toast('Connexion au chat en cours…'); return false; }
    try { s.send(JSON.stringify(obj)); return true; } catch (e) { toast('Envoi impossible: ' + e.message); return false; }
  }

  // ==========================================================================
  // CHAT DRAWER — global, every page (WebSocket + REST history/backfill)
  // ==========================================================================
  const CHANNELS_FALLBACK = [
    { id: 1, slug: 'general', name: 'Général' }, { id: 2, slug: 'aide', name: 'Aide' },
    { id: 23, slug: 'hors-sujet', name: 'Hors-Sujet' }, { id: 34, slug: 'wiki', name: 'Wiki' },
    { id: 44, slug: 'suggestions', name: 'Suggestions' }, { id: 129, slug: 'migrations', name: 'Migrations' },
    { id: 92, slug: 'petites-annonces', name: 'Petites Annonces', write_roles: ['moderator', 'admin'] },
  ];
  // v0.31 — the drawer became a WORKSPACE: several channel panes side by side (split), a persisted
  // layout (mode dock-left/right/float + size + position + open panes) restored across page loads,
  // and T4CO-aware docking so it never collides with a companion sidebar (OmniChat).
  const chat = {
    open: false, inline: false, channels: null, active: 1, myRole: 'user', myName: null, myId: null,
    panes: [], focus: 0, lastId: {}, unread: {},
    el: null, tabsEl: null, colsEl: null,
    // focused-pane aliases kept in sync for older call sites (home dashboard, settings live-apply)
    listEl: null, composerEl: null, inputEl: null, typingEl: null,
  };
  function focusedPane() { return chat.panes[chat.focus] || chat.panes[0] || null; }
  function syncPaneAliases() {
    const p = focusedPane();
    chat.listEl = p ? p.listEl : null; chat.composerEl = p ? p.composerEl : null;
    chat.inputEl = p ? p.inputEl : null; chat.typingEl = p ? p.typingEl : null;
    if (p) chat.active = p.conv;
  }
  // persisted workspace layout — its own store key (changes often; keeps prefs export clean)
  function chatLay() { return Object.assign({ open: false, convs: [1], focus: 0, mode: 'right', w: 420, h: 560, x: 80, y: 70, grid: false }, store.get('t4x.chatlay', {}) || {}); }
  function layMode() { return chatLay().mode || 'right'; }
  function saveLay(patch) {
    const l = Object.assign(chatLay(), patch || {});
    if (chat.panes.length) {
      // only CHANNEL columns are restored on reload; DM/ticket columns are reopened from the Messagerie panel
      const chanConvs = chat.panes.filter((p) => (p.kind || 'channel') === 'channel').map((p) => p.conv);
      if (chanConvs.length) l.convs = chanConvs;
      l.focus = Math.min(chat.focus, Math.max(0, chanConvs.length - 1));
    }
    if (!chat.inline) l.open = !!chat.open;   // inline (home dashboard) must not clobber the persisted open state
    store.set('t4x.chatlay', l); return l;
  }
  const roleColor = (r) => r === 'admin' ? 'var(--error,#ffb4ab)' : r === 'moderator' ? 'var(--ann-color,#ffd28a)'
    : r === 'uploader' ? 'var(--t4x-seed,#4ade80)' : 'var(--primary,#93a5ff)';

  function chatCSS() {
    return `
    .draw{ position:fixed; top:0; right:0; height:100vh; width:min(var(--t4x-chatw,420px),94vw); display:flex; flex-direction:column;
      background:var(--t4x-glass,rgba(15,17,23,.9)); border-left:1px solid var(--t4x-hair,rgba(147,165,255,.14));
      box-shadow:-24px 0 64px rgba(0,0,0,.45); -webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%); color:var(--on-surface,#e6e8f2);
      transform:translateX(102%); transition:transform .22s cubic-bezier(.3,0,0,1); z-index:2147483001; }
    .draw.on{ transform:translateX(0); }
    .draw{ isolation:isolate; }
    .draw.dockL{ right:auto; left:0; border-left:none; border-right:1px solid var(--t4x-hair,rgba(147,165,255,.14));
      box-shadow:24px 0 64px rgba(0,0,0,.45); transform:translateX(-102%); }
    .draw.dockL.on{ transform:translateX(0); }
    .draw.float{ top:min(var(--t4x-chaty,70px),calc(100vh - 240px)); left:min(var(--t4x-chatx,80px),calc(100vw - 280px)); right:auto;
      height:min(var(--t4x-chath,560px),96vh); width:min(var(--t4x-chatw,420px),96vw);
      border:1px solid var(--t4x-hair,rgba(147,165,255,.18)); border-radius:16px; box-shadow:0 28px 80px rgba(0,0,0,.55);
      transform:none; transition:none; resize:both; overflow:hidden; min-width:320px; min-height:280px; display:none; }
    .draw.float.on{ display:flex; }
    .draw.float .dh{ cursor:grab; user-select:none; }
    .draw.float.dragging{ opacity:.92; }
    .draw.float.dragging .dh{ cursor:grabbing; }
    .draw .dh{ display:flex; align-items:center; gap:4px; padding:10px 14px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .draw .dh b{ font-size:14px; flex:1; }
    .draw .dh .x{ cursor:pointer; font-family:'Material Symbols Outlined'; color:var(--on-surface-variant,#b8bdd1); margin-left:4px; }
    .draw .hb{ all:unset; cursor:pointer; width:26px; height:26px; border-radius:7px; display:flex; align-items:center; justify-content:center;
      color:var(--on-surface-variant,#b8bdd1); flex:0 0 auto; }
    .draw .hb:hover{ background:var(--surface-container-high,#232838); color:var(--on-surface,#e6e8f2); }
    .draw .hb.act{ color:var(--primary,#93a5ff); background:var(--secondary-container,#2b3050); }
    .draw .hb .ic{ font-family:'Material Symbols Outlined'; font-size:17px; }
    .draw .wgrip{ position:absolute; top:0; bottom:0; width:6px; cursor:ew-resize; z-index:4; background:transparent; }
    .draw:not(.dockL) .wgrip{ left:0; }
    .draw.dockL .wgrip{ left:auto; right:0; }
    .draw.float .wgrip{ display:none; }
    .draw .wgrip:hover{ background:color-mix(in oklab, var(--primary,#93a5ff) 35%, transparent); }
    .cols{ flex:1; display:flex; min-height:0; }
    /* Panorama: wrap the columns into a grid (half-height rows) so 5-8 channels are visible at once — for mods/admins. */
    .cols.grid{ display:grid; grid-template-columns:repeat(var(--t4x-gridcols,3),minmax(0,1fr)); grid-auto-rows:minmax(200px,1fr); overflow:auto; overscroll-behavior:contain; }
    .pane{ flex:1 1 0; min-width:0; display:flex; flex-direction:column; border-left:1px solid var(--t4x-hair,rgba(147,165,255,.14)); }
    .pane:first-child{ border-left:none; }
    .cols.grid .pane{ border-left:1px solid var(--t4x-hair,rgba(147,165,255,.14)); border-top:1px solid var(--t4x-hair,rgba(147,165,255,.14)); min-height:0; }
    /* Per-column header: a dropdown picks THIS column's channel (the old horizontal list was impractical). Always shown. */
    .pane .ph{ display:flex; align-items:center; gap:3px; padding:5px 6px; border-bottom:1px solid var(--outline-variant,#3a4056); font-size:12px; }
    .t4x-pchan{ flex:1; min-width:0; font:inherit; font-size:12px; background:var(--surface-container,#1a1e2b); color:var(--on-surface,#e6e8f2);
      border:1px solid var(--outline-variant,#3a4056); border-radius:8px; padding:4px 6px; cursor:pointer; -webkit-appearance:menulist; appearance:menulist; }
    .t4x-pchan:focus{ outline:2px solid var(--primary,#93a5ff); outline-offset:-1px; }
    .pane.foc .t4x-pchan{ border-color:color-mix(in srgb,var(--primary,#93a5ff) 55%,transparent); color:var(--primary,#93a5ff); }
    .pane .ph .t4x-pmove, .pane .ph .t4x-pclose{ display:none; }
    .cols.multi .pane .ph .t4x-pmove, .cols.multi .pane .ph .t4x-pclose, .cols.grid .pane .ph .t4x-pmove, .cols.grid .pane .ph .t4x-pclose{ display:inline-flex; }
    .cols.multi .pane.foc, .cols.grid .pane.foc{ box-shadow:inset 0 2px 0 var(--primary,#93a5ff); }
    .tab.add{ background:transparent; border:1px dashed var(--outline-variant,#3a4056); color:var(--on-surface-variant,#b8bdd1); font-weight:600; }
    .tab.add:hover{ color:var(--primary,#93a5ff); border-color:color-mix(in srgb,var(--primary,#93a5ff) 55%,transparent); }
    .tabs{ display:flex; gap:4px; padding:8px 10px; overflow-x:auto; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .tab{ padding:6px 10px; border-radius:999px; font-size:12px; white-space:nowrap; cursor:pointer; color:var(--on-surface-variant,#b8bdd1);
      background:var(--surface-container,#1a1e2b); position:relative; }
    .tab.act{ background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff); }
    .tab.oth{ box-shadow:inset 0 0 0 1px color-mix(in oklab, var(--primary,#93a5ff) 55%, transparent); }
    .tab .ub{ position:absolute; top:-3px; right:-3px; min-width:16px; height:16px; border-radius:999px; background:var(--primary,#93a5ff);
      color:var(--on-primary,#101638); font:600 10px/16px 'JetBrains Mono',monospace; text-align:center; padding:0 3px; }
    .msgs{ flex:1; overflow-y:auto; padding:10px 12px; display:flex; flex-direction:column; gap:2px; }
    .m{ display:flex; gap:var(--t4x-mgap,8px); padding:var(--t4x-mpad,5px 6px); border-radius:var(--t4x-mrad,10px); }
    .m:hover{ background:var(--surface-container,#1a1e2b); }
    .m .av{ width:var(--t4x-av,28px); height:var(--t4x-av,28px); border-radius:8px; flex:0 0 auto; background:var(--surface-container-high,#232838);
      display:flex; align-items:center; justify-content:center; font:600 12px/1 'Geist Variable',sans-serif; color:var(--on-surface-variant,#b8bdd1); overflow:hidden; }
    .m .av img{ width:100%; height:100%; object-fit:cover; }
    .m .mc{ flex:1; min-width:0; }
    .m .mh{ display:flex; align-items:baseline; gap:6px; }
    .m .who{ font-size:calc(13px*var(--t4x-fscale,1)); font-weight:600; }
    .m .tm{ font-family:'JetBrains Mono',monospace; font-size:calc(11px*var(--t4x-fscale,1)); line-height:1; color:var(--on-surface-variant,#b8bdd1); }
    .m .bd{ font-size:calc(13px*var(--t4x-fscale,1)); line-height:var(--t4x-lh,1.4); word-wrap:break-word; overflow-wrap:anywhere; white-space:pre-wrap; }
    .m .pq{ font-size:calc(11px*var(--t4x-fscale,1)); color:var(--on-surface-variant,#b8bdd1); border-left:2px solid var(--outline-variant,#3a4056);
      padding-left:6px; margin:1px 0 2px; opacity:.85; max-height:2.4em; overflow:hidden; }
    .m .acts{ opacity:0; display:flex; gap:4px; align-items:center; transition:opacity .12s; }
    .m:hover .acts, .m:focus-within .acts{ opacity:1; }
    @media (hover:none){ .m .acts{ opacity:1; } }
    .m .acts button{ all:unset; cursor:pointer; display:inline-flex; align-items:center; justify-content:center;
      min-width:20px; height:20px; border-radius:6px; color:var(--on-surface-variant,#b8bdd1); transition:color .12s,background .12s; }
    .m .acts button:hover{ color:var(--on-surface,#e6e8f2); background:var(--surface-container-high,#232838); }
    .m .acts button:focus-visible{ outline:2px solid var(--primary,#93a5ff); outline-offset:1px; }
    .m .acts button.busy{ opacity:.45; pointer-events:none; }
    .m .acts .ic{ font-family:'Material Symbols Outlined'; font-size:16px; }
    .m .acts .rxq{ font-size:15px; line-height:1; }
    .rxs{ display:flex; flex-wrap:wrap; gap:4px; margin-top:3px; }
    .rx{ display:inline-flex; gap:3px; align-items:center; font-size:calc(11px*var(--t4x-fscale,1)); padding:2px 6px; border-radius:999px;
      background:var(--surface-container-high,#232838); }
    .rx.mine{ background:color-mix(in srgb,var(--primary,#93a5ff) 22%,transparent); color:var(--on-surface,#e6e8f2); }
    .t4x-rxpick{ position:fixed; z-index:2147483044; display:grid; grid-template-columns:repeat(8,minmax(0,1fr)); gap:2px;
      padding:7px; border-radius:12px; background:var(--surface-container-high,#232838); border:1px solid var(--outline-variant,#3a4056);
      box-shadow:0 12px 32px rgba(0,0,0,.4); max-width:300px; max-height:170px; overflow:auto; }
    /* per-user highlight + mention pulse (rail unified on .mc; mention wins over highlight) */
    .m.u-hl{ background:var(--t4x-uhl-bg); border-radius:var(--t4x-mrad,10px); }
    .m.u-hl:not(.ment-hit) .mc{ box-shadow:-3px 0 0 var(--t4x-uhl-edge); }
    .m.u-hl .av{ box-shadow:0 0 0 2px var(--t4x-uhl-edge); }
    @keyframes t4x-mentpulse{ 0%,100%{ background:transparent; } 50%{ background:color-mix(in srgb,var(--primary,#93a5ff) 22%,transparent); } }
    .m.ment-new{ animation:t4x-mentpulse .8s ease-in-out 3; }
    @media (prefers-reduced-motion: reduce){ .m.ment-new{ animation:none; } }
    /* mute stub + highlight popover */
    .m.mrun{ padding:2px 6px; }
    .t4x-mrunhead{ all:unset; cursor:pointer; font-size:11.5px; color:var(--on-surface-variant,#b8bdd1); font-style:italic; }
    .t4x-mrunhead:hover{ color:var(--on-surface,#e6e8f2); }
    .t4x-hlpop{ position:fixed; z-index:2147483044; display:flex; flex-wrap:wrap; gap:6px; align-items:center; width:190px;
      padding:9px; border-radius:12px; background:var(--surface-container-high,#232838); border:1px solid var(--outline-variant,#3a4056); box-shadow:0 12px 32px rgba(0,0,0,.4); }
    .t4x-hlsw{ display:grid; grid-template-columns:repeat(4,1fr); gap:6px; width:100%; }
    .t4x-hlc{ all:unset; cursor:pointer; width:28px; height:22px; border-radius:6px; border:1px solid var(--outline-variant,#3a4056); }
    .t4x-hlc:hover{ outline:2px solid var(--primary,#93a5ff); outline-offset:1px; }
    .t4x-hlrm{ all:unset; cursor:pointer; font-size:12px; color:var(--error,#ffb4ab); }
    /* profile hover card */
    .t4x-ucard{ position:fixed; z-index:2147483020; width:min(300px,92vw); padding:12px 14px; border-radius:14px; color:var(--on-surface,#e6e8f2);
      background:var(--t4x-glass,rgba(15,17,23,.92)); border:1px solid var(--t4x-hair,rgba(147,165,255,.16)); box-shadow:0 20px 60px rgba(0,0,0,.5);
      -webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%); }
    .t4x-uchead{ display:flex; align-items:center; gap:10px; }
    .t4x-uchead .av{ width:34px; height:34px; border-radius:9px; overflow:hidden; flex:0 0 auto; background:var(--surface-container-high,#232838); display:flex; align-items:center; justify-content:center; font:600 13px/1 sans-serif; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-uchead .av img{ width:100%; height:100%; object-fit:cover; }
    .t4x-ucname{ font-size:14px; font-weight:600; }
    .t4x-ucsub{ font-size:11.5px; color:var(--on-surface-variant,#b8bdd1); margin-top:6px; }
    .t4x-ucstat{ font-size:12px; color:var(--on-surface-variant,#b8bdd1); margin-top:8px; }
    .t4x-ucgrid{ display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-top:9px; }
    .t4x-uck{ font-size:10px; letter-spacing:.05em; text-transform:uppercase; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-ucv{ font:600 13px/1.2 'JetBrains Mono',monospace; }
    /* L4DDER (classement contribution) */
    .t4x-ucladder, .t4x-ucstats{ margin-top:11px; padding-top:10px; border-top:1px solid var(--t4x-hair,rgba(147,165,255,.16)); }
    .t4x-ucltop{ display:flex; align-items:center; gap:8px; }
    .t4x-ucllbl{ font:700 9.5px/1 'JetBrains Mono',monospace; letter-spacing:.14em; color:var(--primary,#93a5ff); }
    .t4x-uclrank{ font:700 13px/1 'JetBrains Mono',monospace; }
    .t4x-uclscore{ margin-left:auto; font:600 11px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-uclsub{ font-size:11px; color:var(--on-surface-variant,#b8bdd1); margin-top:5px; }
    .t4x-uclbars{ display:flex; flex-direction:column; gap:4px; margin-top:7px; }
    .t4x-uclbar{ display:flex; align-items:center; gap:7px; }
    .t4x-uclbk{ font:600 9px/1 'JetBrains Mono',monospace; letter-spacing:.04em; text-transform:uppercase; color:var(--on-surface-variant,#b8bdd1); width:26px; flex:0 0 auto; }
    .t4x-uclbt{ flex:1; height:4px; border-radius:2px; background:var(--surface-container-high,rgba(147,165,255,.14)); overflow:hidden; }
    .t4x-uclbf{ display:block; height:100%; border-radius:2px; background:linear-gradient(90deg,var(--primary,#93a5ff),var(--t4x-seed,#4ade80)); }
    .typing{ font-size:11px; color:var(--on-surface-variant,#b8bdd1); padding:2px 12px; height:16px; }
    .comp{ border-top:1px solid var(--outline-variant,#3a4056); padding:8px 10px; }
    .comp .rc{ font-size:11px; color:var(--on-surface-variant,#b8bdd1); padding:2px 4px 6px; display:flex; gap:6px; align-items:center; }
    .comp .rc .x{ cursor:pointer; }
    .comp textarea{ width:100%; resize:none; background:var(--surface-container,#1a1e2b); color:var(--on-surface,#e6e8f2);
      border:1px solid var(--outline-variant,#3a4056); border-radius:12px; padding:9px 12px; font:14px/1.4 'Geist Variable',sans-serif; outline:none; }
    .comp textarea:focus{ border-color:var(--primary,#93a5ff); }
    .comp .ro{ font-size:12px; color:var(--on-surface-variant,#b8bdd1); text-align:center; padding:8px; }
    /* status/HUD pill */
    .pill{ position:fixed; right:18px; bottom:80px; display:flex; align-items:center; gap:10px; z-index:2147483000;
      background:var(--t4x-glass,rgba(15,17,23,.85)); border:1px solid var(--t4x-hair,rgba(147,165,255,.14)); border-radius:999px;
      padding:7px 10px 7px 14px; box-shadow:0 12px 32px rgba(0,0,0,.4); -webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%);
      color:var(--on-surface,#e6e8f2); font-size:12px; user-select:none; }
    .pill .stat{ display:flex; flex-direction:column; line-height:1.15; }
    .pill .stat .k{ font-size:9px; letter-spacing:.06em; text-transform:uppercase; color:var(--on-surface-variant,#b8bdd1); }
    .pill .stat .v{ font:600 13px/1 'JetBrains Mono',monospace; }
    .pill .sep{ width:1px; height:22px; background:var(--outline-variant,#3a4056); }
    .pill .t4x-grip{ display:flex; align-items:center; justify-content:center; margin:-2px 0 -2px -6px; width:16px; cursor:grab; touch-action:none;
      color:var(--on-surface-variant,#b8bdd1); opacity:.5; transition:opacity .12s; }
    .pill .t4x-grip:hover{ opacity:.95; }
    .pill .t4x-grip .ic{ font-family:'Material Symbols Outlined'; font-size:18px; }
    .pill.t4x-dragging{ cursor:grabbing; box-shadow:0 18px 44px rgba(0,0,0,.55); }
    .pill.t4x-dragging .t4x-grip{ cursor:grabbing; opacity:1; }
    .pill button{ all:unset; cursor:pointer; width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center;
      background:var(--surface-container-high,#232838); color:var(--on-surface,#e6e8f2); position:relative; }
    .pill button:hover{ background:var(--secondary-container,#2b3050); }
    .pill button .ic{ font-family:'Material Symbols Outlined'; font-size:19px; }
    .pill .badge{ position:absolute; top:-4px; right:-4px; min-width:16px; height:16px; border-radius:999px; background:var(--primary,#93a5ff);
      color:var(--on-primary,#101638); font:600 10px/16px 'JetBrains Mono',monospace; text-align:center; padding:0 3px; }
    .pill .fl{ color:#0f1117; background:var(--t4x-fl,#fbbf24); border-radius:999px; padding:2px 7px; font:600 10px/1.4 'JetBrains Mono',monospace; }
    /* Edge/Chromium: isolate the pill on its own composite layer (backdrop-filter paint-order bug) */
    .pill{ transform:translateZ(0); z-index:2147483004; }
    /* --- pill mode: RAIL (edge tabs, PimpMyShoutbox-style; vertically draggable, collision-avoiding) --- */
    .pill.rail{ flex-direction:column; align-items:stretch; gap:8px; border-radius:16px 0 0 16px; border-right:none;
      padding:9px 7px 9px 8px; }
    .pill.rail .stat,.pill.rail .sep,.pill.rail .fl{ display:none; }
    .pill.rail .t4x-grip{ width:auto; margin:0; justify-content:center; cursor:grab; }
    .pill.rail button{ width:36px; height:36px; border-radius:11px; }
    /* --- pill mode: MINI (collapsed to one button; click to unfold temporarily) --- */
    .pill.mini:not(.open) .stat,.pill.mini:not(.open) .sep,.pill.mini:not(.open) .fl,
    .pill.mini:not(.open) button:not(.t4x-minibtn),.pill.mini:not(.open) .t4x-grip{ display:none; }
    .pill.mini{ padding:6px; }
    .pill.mini.open{ padding:7px 10px 7px 14px; }
    .pill .t4x-minibtn{ display:none; }
    .pill.mini .t4x-minibtn{ display:flex; }
    /* narrow viewports: drop the stat columns so the 5 action buttons never overflow the screen */
    @media (max-width:560px){ .pill{ right:12px; bottom:16px; gap:6px; padding:6px 8px; max-width:calc(100vw - 24px); flex-wrap:wrap; justify-content:flex-end; }
      .pill .stat, .pill .sep{ display:none; } .pill button{ width:38px; height:38px; }
      /* keep the corner panels clear of the pill on narrow screens (inline styles → !important) */
      .t4x-voicepanel{ bottom:80px !important; right:12px !important; }
      .t4x-launcher{ bottom:80px !important; right:12px !important; } }
    @media (prefers-reduced-motion: reduce){ .draw{ transition:none !important; } }
    `;
  }
  let chatStyleAdded = false;
  function ensureChatCSS() { if (chatStyleAdded) return; ensureHost(); const s = document.createElement('style'); s.textContent = chatCSS(); root.appendChild(s); chatStyleAdded = true; }
  // reading comfort: one text-scale token + a compact density (avatar/gap/pad/radius/line-height tokens),
  // set on chat.el so every surface that reuses that node (drawer, split, float, home dash, offline) inherits.
  function normScale(v) { let s = String(v == null ? '' : v).trim().replace('%', '').replace(',', '.'); let n = parseFloat(s); if (isNaN(n)) return 1; if (n > 8) n /= 100; return Math.max(0.85, Math.min(1.7, Math.round(n * 100) / 100)); }
  function applyChatReading() {
    if (!chat.el) return;
    const sc = normScale(pref0('chatFontScale', 1)); const compact = pref0('chatDensity', 'cozy') === 'compact';
    const s = chat.el.style;
    if (sc === 1) s.removeProperty('--t4x-fscale'); else s.setProperty('--t4x-fscale', String(sc));
    if (compact) { s.setProperty('--t4x-mgap', '5px'); s.setProperty('--t4x-mpad', '2px 6px'); s.setProperty('--t4x-mrad', '7px'); s.setProperty('--t4x-av', '20px'); s.setProperty('--t4x-lh', '1.35'); }
    else { ['--t4x-mgap', '--t4x-mpad', '--t4x-mrad', '--t4x-av', '--t4x-lh'].forEach((k) => s.removeProperty(k)); }
  }

  async function loadChannels() {
    if (chat.channels) return ensureExtChannel(chat.channels);
    try { const j = await apiGet('/api/channels', { ttl: 3600000 }); chat.channels = pickArray(j, ['channels']); chat._channelsReal = chat.channels.length > 0; }
    catch (e) { chat.channels = CHANNELS_FALLBACK.slice(); chat._channelsReal = false; }   // tr4ker down → fallback list still works (but DM detection must treat it as unknown)
    if (!chat.channels.length) { chat.channels = CHANNELS_FALLBACK.slice(); chat._channelsReal = false; }
    return ensureExtChannel(chat.channels);
  }
  function ensureExtChannel(list) {
    const i = list.findIndex((c) => c.id === EXT_CONV);
    if (pref0('extChat', true)) { if (i === -1) list.push({ id: EXT_CONV, slug: 'tr4ker-plus', name: 'TR4KER+ (externe)', ext: true }); }
    else if (i !== -1) list.splice(i, 1);
    return list;
  }

  function chatHBtn(icon, title, fn) {
    const s = document.createElement('button'); s.className = 'hb'; s.type = 'button'; s.title = title;
    const ic = document.createElement('span'); ic.className = 'ic'; ic.textContent = icon; s.appendChild(ic);
    s.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
    return s;
  }
  function buildDrawer() {
    ensureChatCSS();
    const el = document.createElement('div'); el.className = 'draw'; el.setAttribute('role', 'complementary'); el.setAttribute('aria-label', 'Chat TR4KER+');
    const dh = document.createElement('div'); dh.className = 'dh';
    const b = document.createElement('b'); b.textContent = 'Chat';
    let bsig = null;
    if (pref0('inbox', true)) { bsig = chatHBtn('notifications', 'Signaux — mentions à relire', openSignals); const dot = document.createElement('span'); dot.className = 'dot'; dot.style.cssText = 'position:absolute;top:2px;right:2px;width:7px;height:7px;border-radius:50%;background:var(--primary,#93a5ff);display:none'; bsig.style.position = 'relative'; bsig.appendChild(dot); sigDrawerDot = dot; setTimeout(refreshSigBadge, 0); }
    chat._btnMsg = chatHBtn('mail', 'Messagerie — messages privés et tickets, partout', () => openConvList());
    chat._btnSplit = chatHBtn('splitscreen_right', 'Ajouter une colonne — plusieurs canaux côte à côte', () => splitPane());
    chat._btnGrid = chatHBtn('grid_view', 'Vue panoramique — tous les canaux en grille (modérateurs)', () => toggleGrid());
    chat._btnL = chatHBtn('dock_to_left', 'Ancrer à gauche', () => setChatMode('left'));
    chat._btnF = chatHBtn('picture_in_picture_alt', 'Fenêtre flottante (déplaçable, redimensionnable)', () => setChatMode('float'));
    chat._btnR = chatHBtn('dock_to_right', 'Ancrer à droite', () => setChatMode('right'));
    const bsearch = pref0('archiveSearch', true) ? chatHBtn('search', 'Rechercher dans l’historique (shoutbox infinie)', openArchiveSearch) : null;
    const bstats = chatHBtn('insights', 'Mon activité (mes statistiques)', openMyStats);
    const x = document.createElement('span'); x.className = 'x'; x.textContent = 'close'; x.title = 'Fermer (Échap)'; x.addEventListener('click', closeDrawer);
    dh.append(b, chat._btnMsg, chat._btnSplit, chat._btnGrid, chat._btnL, chat._btnF, chat._btnR);
    if (bsearch) dh.append(bsearch);
    dh.append(bstats);
    if (bsig) dh.append(bsig);
    dh.append(x);
    const tabs = document.createElement('div'); tabs.className = 'tabs';
    const cols = document.createElement('div'); cols.className = 'cols';
    const wg = document.createElement('div'); wg.className = 'wgrip'; wg.title = 'Largeur du chat';
    el.append(dh, tabs, cols, wg);
    root.appendChild(el);
    chat.el = el; chat.tabsEl = tabs; chat.colsEl = cols;
    xtraStyle(); applyChatReading(); wireHoverCard();
    el.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDrawer(); }, true);
    wireChatHeaderDrag(dh);
    wireChatWidthDrag(wg);
    // persist float-mode resizes (the browser-native corner handle writes inline width/height)
    try {
      new ResizeObserver(t4xDebounce(() => {
        if (!chat.open || chat.inline || layMode() !== 'float' || !chat.el) return;
        const r = chat.el.getBoundingClientRect();
        if (r.width > 60 && r.height > 60) saveLay({ w: Math.round(r.width), h: Math.round(r.height) });
      }, 400)).observe(el);
    } catch (e) {}
    // another T4CO app moved its dock (e.g. OmniChat resized) → re-seat ourselves
    try { document.addEventListener('t4co:layout', (e) => { if (!e || !e.detail || e.detail.app !== 'tr4kerplus') applyChatLayout(); }); } catch (e) {}
    // window resize: recompute the docked page-push (its 65% clamp is width-relative) — once, guarded
    if (!chat._resizeWired) { chat._resizeWired = true; try { window.addEventListener('resize', t4xDebounce(() => { if (chat.open && !chat.inline) applyChatLayout(); }, 180)); } catch (e) {} }
  }
  function wireChatHeaderDrag(dh) {
    dh.addEventListener('pointerdown', (e) => {
      if (chat.inline || layMode() !== 'float' || !chat.el) return;
      if (e.target && (e.target.closest('.hb') || e.target.closest('.x'))) return;
      try { e.preventDefault(); } catch (_) {}
      const r = chat.el.getBoundingClientRect(); const sx = e.clientX - r.left, sy = e.clientY - r.top;
      chat.el.classList.add('dragging');
      const mv = (ev) => {
        const nx = Math.max(0, Math.min(ev.clientX - sx, window.innerWidth - 140));
        const ny = Math.max(0, Math.min(ev.clientY - sy, window.innerHeight - 80));
        chat.el.style.setProperty('--t4x-chatx', nx + 'px'); chat.el.style.setProperty('--t4x-chaty', ny + 'px');
        chat.__x = nx; chat.__y = ny;
      };
      const up = () => {
        document.removeEventListener('pointermove', mv, true); document.removeEventListener('pointerup', up, true);
        chat.el.classList.remove('dragging');
        if (typeof chat.__x === 'number') saveLay({ x: Math.round(chat.__x), y: Math.round(chat.__y) });
      };
      document.addEventListener('pointermove', mv, true); document.addEventListener('pointerup', up, true);
    });
  }
  function wireChatWidthDrag(wg) {
    wg.addEventListener('pointerdown', (e) => {
      if (chat.inline || layMode() === 'float' || !chat.el) return;
      try { e.preventDefault(); e.stopPropagation(); } catch (_) {}
      const sx = e.clientX, sw = chat.el.getBoundingClientRect().width, left = layMode() === 'left';
      const mv = (ev) => {
        const d = ev.clientX - sx;
        const w = Math.max(320, Math.min(left ? sw + d : sw - d, Math.round(window.innerWidth * 0.94)));
        chat.el.style.setProperty('--t4x-chatw', w + 'px'); chat.__w = w;
      };
      const up = () => {
        document.removeEventListener('pointermove', mv, true); document.removeEventListener('pointerup', up, true);
        if (typeof chat.__w === 'number') { saveLay({ w: Math.round(chat.__w) }); applyChatLayout(); }
      };
      document.addEventListener('pointermove', mv, true); document.addEventListener('pointerup', up, true);
    });
  }

  let _tabsQ = false;
  function renderTabsSoon() { if (_tabsQ) return; _tabsQ = true; requestAnimationFrame(() => { _tabsQ = false; renderTabs(); }); }   // perf: coalesce the per-background-message tab rebuild
  function renderTabs() {
    if (!chat.tabsEl) return;
    chat.tabsEl.replaceChildren();
    const foc = focusedPane();
    const shown = new Set(chat.panes.map((p) => p.conv));
    for (const c of (chat.channels || [])) {
      const isAct = !!(foc && c.id === foc.conv);
      const t = document.createElement('div'); t.className = 'tab' + (isAct ? ' act' : (shown.has(c.id) ? ' oth' : ''));
      t.textContent = c.name;
      t.title = (!isAct && shown.has(c.id)) ? 'Déjà ouvert dans une colonne — clic : y aller' : 'Afficher #' + (c.slug || c.name) + ' dans la colonne active';
      t.addEventListener('click', () => {
        const q = chat.panes.find((x) => x.conv === c.id);
        if (q && q !== foc) focusPane(q); else selectChannel(c.id);
      });
      if (chat.unread[c.id]) { const ub = document.createElement('span'); ub.className = 'ub'; ub.textContent = chat.unread[c.id] > 99 ? '99+' : chat.unread[c.id]; t.appendChild(ub); }
      chat.tabsEl.appendChild(t);
    }
    const add = document.createElement('div'); add.className = 'tab add'; add.textContent = '+ colonne';
    add.title = 'Ouvrir un canal de plus dans une nouvelle colonne'; add.addEventListener('click', () => splitPane());
    chat.tabsEl.appendChild(add);
  }

  function renderPaneComposer(p) {
    if (!p || !p.composerEl) return;
    const c = (chat.channels || []).find(x => x.id === p.conv) || {};
    p.composerEl.replaceChildren();
    const canWrite = !(c.write_roles && c.write_roles.length && !c.write_roles.includes(chat.myRole));
    if (!canWrite) { const ro = document.createElement('div'); ro.className = 'ro'; ro.textContent = 'Salon en lecture seule (réservé au staff).'; p.composerEl.appendChild(ro); return; }
    if (p.replyTo) {
      const rc = document.createElement('div'); rc.className = 'rc';
      const label = document.createElement('span'); label.textContent = '↳ ' + (p.replyTo.sender || '') + ' : ' + String(p.replyTo.body || '').slice(0, 60);
      const x = document.createElement('span'); x.className = 'x'; x.textContent = '✕'; x.title = 'Annuler'; x.addEventListener('click', () => { p.replyTo = null; renderPaneComposer(p); });
      label.style.flex = '1'; label.style.overflow = 'hidden'; label.style.whiteSpace = 'nowrap'; label.style.textOverflow = 'ellipsis';
      rc.append(label, x); p.composerEl.appendChild(rc);
    }
    const ph = p.kind === 'dm' ? ('Message à ' + (p.peerName || '') + (e2eWantEncrypt(p.conv) ? ' 🔒' : '') + '…')
      : p.kind === 'ticket' ? 'Répondre au ticket…'
      : ('Message dans #' + (c.slug || '') + '…');
    const ta = document.createElement('textarea'); ta.rows = 1; ta.placeholder = ph; ta.setAttribute('aria-label', 'Écrire un message');
    ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px'; onTyping(p); });
    ta.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); const v = ta.value.trim(); if (v) sendMessage(v, p); } // clears only on success (renderPaneComposer)
    });
    ta.addEventListener('focus', () => focusPane(p));
    // glass formatting toolbar — icon buttons; image = full-auto upload (pick / paste / drop)
    emojiPop = null;
    const bar = document.createElement('div'); bar.className = 't4x-cbar';
    const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.style.display = 'none';
    fileInput.addEventListener('change', () => { const f = fileInput.files && fileInput.files[0]; fileInput.value = ''; if (f) handleImageFile(f, ta); });
    const iconBtn = (icon, title, fn) => { const b = document.createElement('button'); b.type = 'button'; b.title = title; b.className = 't4x-cbtn'; const s = document.createElement('span'); s.className = 'ic'; s.textContent = icon; b.appendChild(s); b.addEventListener('click', fn); bar.appendChild(b); return b; };
    iconBtn('mood', 'Emoji', () => toggleEmoji(ta, bar));
    if (pref0('phrases', true)) iconBtn('quickreply', 'Réponses rapides', () => openPhrasePanel(ta, bar));
    p._imgBtn = iconBtn('image', 'Envoyer une image (ou colle / glisse-dépose)', () => { if (!uploading) fileInput.click(); });
    if (pref0('gif', true)) iconBtn('gif_box', 'GIF animé', () => openGifPanel(ta, bar));
    iconBtn('visibility_off', 'Spoiler', () => wrapSel(ta, '[spoiler]', '[/spoiler]'));
    iconBtn('format_bold', 'Gras', () => wrapSel(ta, '**', '**'));
    iconBtn('link', 'Lien', () => wrapSel(ta, 'https://', ''));
    p._upStatus = document.createElement('div'); p._upStatus.className = 't4x-upstatus'; p._upStatus.style.display = 'none'; bar.appendChild(p._upStatus);
    // paste an image straight into the composer → auto-upload
    ta.addEventListener('paste', (e) => { const items = (e.clipboardData && e.clipboardData.items) || []; for (const it of items) { if (it.type && it.type.indexOf('image') === 0) { const f = it.getAsFile(); if (f) { e.preventDefault(); handleImageFile(f, ta); return; } } } });
    p.composerEl.append(bar, fileInput, ta); p.inputEl = ta;
    if (p === focusedPane()) syncPaneAliases();
    // drag-drop image (wired once on the persistent per-pane composer container)
    if (!p._dropWired) {
      p._dropWired = true;
      const stop = (e) => { e.preventDefault(); e.stopPropagation(); };
      ['dragenter', 'dragover'].forEach((ev) => p.composerEl.addEventListener(ev, (e) => { stop(e); p.composerEl.classList.add('t4x-drop'); }));
      ['dragleave', 'drop'].forEach((ev) => p.composerEl.addEventListener(ev, (e) => { stop(e); p.composerEl.classList.remove('t4x-drop'); }));
      p.composerEl.addEventListener('drop', (e) => { const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) handleImageFile(f, p.inputEl); });
    }
  }
  // legacy wrapper (settings live-apply + home dashboard): re-render every pane's composer
  function renderComposer() { chat.panes.forEach((p) => renderPaneComposer(p)); }

  function fmtTime(ts) { try { const d = new Date(ts); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); } catch (e) { return ''; } }
  function initials(n) { return String(n || '?').trim().slice(0, 2).toUpperCase(); }

  // ---- chat rich-body renderer (Omnichat-inspired: [spoiler], **bold**/[b], autolink, @mention) --------
  // Strictly DOM-built — never assigns innerHTML with user content, so it can't inject markup.
  const URL_RE = /\bhttps?:\/\/[^\s<>()]+/gi;
  function escRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
  function appendText(parent, s) { if (s) parent.appendChild(document.createTextNode(s)); }
  const MQ_REDUCE = (function () { try { return matchMedia('(prefers-reduced-motion: reduce)'); } catch (e) { return { matches: false }; } })();
  const MQ_NOHOVER = (function () { try { return matchMedia('(hover:none)'); } catch (e) { return { matches: false }; } })();
  let _mentRe = null, _mentFor = null;   // perf: rebuild the mention regex only when the viewer's name changes
  function appendMention(parent, s, myName) {
    if (!myName) { appendText(parent, s); return; }
    if (_mentFor !== myName) { _mentFor = myName; _mentRe = new RegExp('(^|[^\\w@])(@?' + escRe(myName) + ')(?![\\w])', 'gi'); }
    const re = _mentRe; re.lastIndex = 0;
    let last = 0, m;
    while ((m = re.exec(s))) { appendText(parent, s.slice(last, m.index) + m[1]); const sp = document.createElement('span'); sp.className = 't4x-ment'; sp.textContent = m[2]; parent.appendChild(sp); last = m.index + m[0].length; }
    appendText(parent, s.slice(last));
  }
  // hoisted out of the per-message render path (renderBody/appendStyled run for every chat line)
  const RE_BOLD = /\*\*([^*\n]+)\*\*|\[b\]([\s\S]*?)\[\/b\]/gi;
  const RE_BLOCK = /\[spoiler\]([\s\S]*?)\[\/spoiler\]|\[img\]\s*([^\[\]\s]+?)\s*\[\/img\]/gi;
  function appendStyled(parent, s, myName) {
    const BOLD = RE_BOLD; BOLD.lastIndex = 0; let last = 0, m;
    while ((m = BOLD.exec(s))) { appendMention(parent, s.slice(last, m.index), myName); const b = document.createElement('strong'); appendMention(b, m[1] || m[2] || '', myName); parent.appendChild(b); last = m.index + m[0].length; }
    appendMention(parent, s.slice(last), myName);
  }
  function appendLinkified(parent, s, myName) {
    let last = 0, m; URL_RE.lastIndex = 0;
    while ((m = URL_RE.exec(s))) {
      appendStyled(parent, s.slice(last, m.index), myName);
      const a = document.createElement('a'); a.href = m[0]; a.textContent = m[0]; a.target = '_blank'; a.rel = 'noreferrer noopener'; a.className = 't4x-lnk';
      parent.appendChild(a); last = m.index + m[0].length;
    }
    appendStyled(parent, s.slice(last), myName);
  }
  // Image-domain allowlist. Rendering an arbitrary URL as <img> turns any chat message into an IP/UA
  // beacon — a real de-anonymisation vector on the EXTERNAL channel (t4health does not validate message
  // text). Only render an <img> for hosts on the site's own allowlist (+ the emote gateway + ibb); anything
  // else becomes a plain link. Fail closed while the list is still loading.
  let _imgDomains = null;
  const IMG_FALLBACK_DOMAINS = ['giphy.com', 'tenor.com', 'static.klipy.com', 'ibb.co', 'imgbb.com', 'i.imgur.com', 'imgur.com', 'github.com', 'githubusercontent.com', 'ytimg.com', 'redd.it', 'reddit.com', 'risibank.fr'];
  function loadImgDomains() {
    apiGet('/api/image-domains', { ttl: 3600000 }).then((j) => {
      let arr = [];
      if (Array.isArray(j)) arr = j; else if (j && typeof j === 'object') { const inner = j.domains || j.image_domains || j.allowed; arr = Array.isArray(inner) ? inner : Object.keys(j).filter((k) => j[k]); }
      _imgDomains = (arr.length ? arr : IMG_FALLBACK_DOMAINS).map((x) => String(x).toLowerCase().replace(/^\*\./, '').replace(/^https?:\/\//, '').split('/')[0]);
    }).catch(() => { _imgDomains = IMG_FALLBACK_DOMAINS.slice(); });
  }
  function imgAllowed(url) {
    let h; try { h = new URL(url).hostname.toLowerCase(); } catch (e) { return false; }
    if (isEmoteUrl(url) || h === 'i.ibb.co' || h === 'ibb.co') return true;
    if (!_imgDomains) return false;   // fail closed until the allowlist is loaded
    return _imgDomains.some((d) => d && (h === d || h.endsWith('.' + d)));
  }
  function appendImage(parent, url) {
    if (!/^https?:\/\//i.test(url)) { appendText(parent, '[img]' + url + '[/img]'); return; }
    if (!imgAllowed(url)) { const a = document.createElement('a'); a.href = url; a.textContent = url; a.target = '_blank'; a.rel = 'noreferrer noopener'; a.className = 't4x-lnk'; parent.appendChild(a); return; }
    // community emoticons (Klipy /emochat) render inline-sized, not as a 260px block image (display correction)
    if (pref0('siteEmojis', true) && isEmoteUrl(url)) {
      const em = document.createElement('img'); em.src = url; em.className = 't4x-emoj'; em.loading = 'lazy'; em.decoding = 'async'; em.referrerPolicy = 'no-referrer'; em.alt = '';
      em.onerror = () => { try { em.remove(); } catch (e) {} };
      parent.appendChild(em); return;
    }
    const im = document.createElement('img'); im.src = url; im.loading = 'lazy'; im.referrerPolicy = 'no-referrer';
    im.className = 't4x-cimg'; im.title = 'Ouvrir en grand'; im.tabIndex = 0; im.setAttribute('role', 'button'); im.setAttribute('aria-label', 'Agrandir l’image');
    const openIt = () => { if (pref0('lightbox', true)) openLightbox(url); else window.open(url, '_blank', 'noreferrer'); };
    im.addEventListener('click', openIt);
    im.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openIt(); } });
    im.onerror = () => { const a = document.createElement('a'); a.href = url; a.textContent = url; a.target = '_blank'; a.rel = 'noreferrer noopener'; a.className = 't4x-lnk'; if (im.parentNode) im.replaceWith(a); };
    parent.appendChild(im);
  }
  function renderBody(container, text, myName) {
    container.replaceChildren();
    text = String(text == null ? '' : text);
    // block-level tags: [spoiler]…[/spoiler] and [img]url[/img]. Everything else → inline (bold/link/mention).
    const BLOCK = RE_BLOCK; BLOCK.lastIndex = 0;
    let last = 0, m;
    while ((m = BLOCK.exec(text))) {
      appendLinkified(container, text.slice(last, m.index), myName);
      if (m[1] !== undefined) {
        const sp = document.createElement('span'); sp.className = 't4x-spoil'; sp.title = 'Cliquer pour révéler';
        appendLinkified(sp, m[1], myName);
        sp.addEventListener('click', () => sp.classList.add('on'));
        container.appendChild(sp);
      } else {
        appendImage(container, m[2]);
      }
      last = m.index + m[0].length;
    }
    appendLinkified(container, text.slice(last), myName);
    // bare image URLs (…​.png/.jpg/.gif/.webp) → render inline too, for messages without the [img] tag
    // (handled by appendLinkified's autolink; we additionally upgrade image links below)
    container.querySelectorAll && container.querySelectorAll('a.t4x-lnk').forEach((a) => {
      if (isEmoteUrl(a.href) || /\.(png|jpe?g|gif|webp|avif)(\?|#|$)/i.test(a.href)) { const holder = document.createElement('span'); a.replaceWith(holder); appendImage(holder, a.href); }
    });
    // jumbo: a message that is only 1–6 site emotes (and whitespace) renders large
    try { const im = container.querySelectorAll('img.t4x-emoj'); if (im.length >= 1 && im.length <= 6 && !container.textContent.replace(/\s+/g, '')) container.classList.add('t4x-jumbo'); } catch (e) {}
    try { ytScan(container); } catch (e) {}   // YouTube links/fragments → play chips
  }
  // test-only hook: exposed solely when a harness sets window.__T4X_EXPOSE__ (never set in Tampermonkey).
  try { if (window.__T4X_EXPOSE__) { window.__t4xRB = renderBody; window.__t4xGif = (ta, bar) => openGifPanel(ta, bar);
    window.__t4xGifPin = { toggle: gifTogglePin, load: loadGifPins, isPinned: gifIsPinned };
    window.__t4xToast = toast; window.__t4xEsc = { push: escPush, stack: _escStack }; window.__t4xMkMsg = mkMsg;
    window.__t4xBeep = playBeep; window.__t4xNotify = notifyMention; window.__t4xBuffer = { buf: t4xBuffer, def: t4xFloorDeficit, phrase: bufferPhrase };
    window.__t4xLightbox = { open: openLightbox, close: closeLightbox }; window.__t4xReading = applyChatReading; } } catch (e) {}
  // watchlist: does an incoming body mention me or a saved keyword?
  let _kwRaw = null, _kwArr = [];   // perf: reparse chatKeywords only when the pref string changes
  function mentionHit(body) {
    const b = String(body || '').toLowerCase();
    if (chat.myName && b.includes('@' + chat.myName.toLowerCase())) return chat.myName;
    const raw = String(pref0('chatKeywords', '') || ''); if (raw !== _kwRaw) { _kwRaw = raw; _kwArr = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean); }
    for (const k of _kwArr) if (k && b.includes(k)) return k;
    return null;
  }
  // ---- in-page image viewer (lightbox) — shadow-scoped, keyboard-operable, both themes -------------
  let _lbEl = null, _lbUnesc = null, _lbPrevFocus = null, _lbCss = false;
  function ensureLightboxCSS() {
    if (_lbCss) return; _lbCss = true; ensureHost();
    const s = document.createElement('style'); s.textContent = `
    .t4x-lb{ position:fixed; inset:0; z-index:2147483046; display:flex; align-items:center; justify-content:center;
      background:rgba(5,7,12,.62); -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px); opacity:0; transition:opacity .15s ease; }
    :host-context([data-theme=light]) .t4x-lb, :host([data-theme=light]) .t4x-lb{ background:rgba(232,235,242,.66); }
    .t4x-lb.on{ opacity:1; }
    .t4x-lbfig{ position:relative; max-width:96vw; max-height:88vh; display:flex; flex-direction:column; align-items:center; }
    .t4x-lbimg{ max-width:96vw; max-height:88vh; object-fit:contain; border-radius:14px; border:1px solid var(--t4x-hair,rgba(147,165,255,.2)); cursor:zoom-in; transform:scale(.96); transition:transform .18s cubic-bezier(.3,0,0,1); }
    .t4x-lb.on .t4x-lbimg{ transform:scale(1); }
    .t4x-lbimg.zoom{ max-width:none; max-height:none; cursor:zoom-out; border-radius:0; }
    .t4x-lbctl{ position:absolute; top:12px; right:12px; display:flex; gap:8px; }
    .t4x-lbb{ all:unset; cursor:pointer; display:inline-flex; align-items:center; gap:5px; font-size:12px; color:var(--on-surface,#e6e8f2);
      background:var(--t4x-glass,rgba(15,17,23,.7)); border:1px solid var(--t4x-hair,rgba(147,165,255,.18)); border-radius:999px; padding:6px 11px; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); }
    .t4x-lbb:hover{ color:var(--primary,#93a5ff); }
    .t4x-lbb:focus-visible{ outline:2px solid var(--primary,#93a5ff); outline-offset:2px; }
    .t4x-lbb .ic{ font-family:'Material Symbols Outlined'; font-size:17px; }
    .t4x-lbcap{ margin-top:10px; font:11px/1.4 'JetBrains Mono',monospace; color:var(--on-surface,#e6e8f2); background:var(--t4x-glass,rgba(15,17,23,.7));
      border:1px solid var(--t4x-hair,rgba(147,165,255,.18)); border-radius:999px; padding:5px 12px; max-width:96vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
    @media (prefers-reduced-motion: reduce){ .t4x-lb,.t4x-lbimg{ transition:none !important; transform:none !important; } }`;
    root.appendChild(s);
  }
  function closeLightbox() {
    if (!_lbEl) return; if (_lbUnesc) { _lbUnesc(); _lbUnesc = null; }
    const el = _lbEl; _lbEl = null; el.classList.remove('on'); markOpen(el, false);
    const pf = _lbPrevFocus; _lbPrevFocus = null;
    setTimeout(() => { try { el.remove(); } catch (e) {} try { if (pf && pf.focus) pf.focus(); } catch (e) {} }, 200);
  }
  function openLightbox(url) {
    ensureHost(); ensureLightboxCSS(); if (_lbEl) closeLightbox();
    _lbPrevFocus = (root && root.activeElement) || document.activeElement || null;
    const el = document.createElement('div'); el.className = 't4x-lb'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Image');
    const fig = document.createElement('div'); fig.className = 't4x-lbfig';
    const img = document.createElement('img'); img.className = 't4x-lbimg'; img.src = url; img.alt = ''; img.referrerPolicy = 'no-referrer';
    let zoom = false; img.addEventListener('click', (e) => { e.stopPropagation(); zoom = !zoom; img.classList.toggle('zoom', zoom); });
    const ctl = document.createElement('div'); ctl.className = 't4x-lbctl';
    const mkb = (icon, label, fn) => { const b = document.createElement('button'); b.className = 't4x-lbb'; b.type = 'button'; b.innerHTML = '<span class="ic">' + icon + '</span>'; if (label) { const s = document.createElement('span'); s.textContent = label; b.appendChild(s); } b.addEventListener('click', (e) => { e.stopPropagation(); fn(); }); return b; };
    const cap = document.createElement('div'); cap.className = 't4x-lbcap';
    try { const uo = new URL(url); cap.textContent = uo.host + '/' + (uo.pathname.split('/').pop() || ''); } catch (e) { cap.textContent = url.slice(0, 80); }
    ctl.append(mkb('open_in_new', 'Ouvrir', () => window.open(url, '_blank', 'noreferrer')),
               mkb('link', 'Copier', () => { try { GM_setClipboard(url); toast('Lien copié'); } catch (e) {} }),
               mkb('close', '', closeLightbox));
    img.addEventListener('load', () => { if (img.naturalWidth) cap.textContent += ' · ' + img.naturalWidth + '×' + img.naturalHeight; });
    img.addEventListener('error', () => { const d = document.createElement('div'); d.style.cssText = 'color:var(--on-surface,#e6e8f2);font-size:14px;padding:40px'; d.textContent = 'Image indisponible'; try { img.replaceWith(d); } catch (e) {} });
    fig.append(img, ctl, cap); el.appendChild(fig); el.addEventListener('click', closeLightbox);
    root.appendChild(el); requestAnimationFrame(() => { el.classList.add('on'); markOpen(el, true); });
    _lbUnesc = escPush(closeLightbox);
    _lbEl = el; try { ctl.lastChild.focus(); } catch (e) {}
  }
  let _lbNativeWired = false;
  function wireLightboxNative() {
    if (_lbNativeWired) return; _lbNativeWired = true;
    document.addEventListener('click', (e) => {
      if (e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
      if (!location.pathname.startsWith('/communication')) return;
      // NATIVE shoutbox: clicking a member's avatar/head opens their TR4KER+ PROFILE (ladder + stats), NOT the
      // avatar picture. The site renders each message avatar as <button class="_msgAvatar…" title="<pseudo>">;
      // the title (semantic class, survives CSS-module rehashing) gives the author reliably.
      if (pref0('hoverCard', true) && e.target && e.target.closest) {
        const avBtn = e.target.closest('button[class*="msgAvatar" i]');
        const nm = avBtn && (avBtn.getAttribute('title') || avBtn.getAttribute('aria-label') || '').trim();
        if (nm && nm.length <= 40) { e.preventDefault(); e.stopImmediatePropagation(); try { openMemberProfile(nm); } catch (_) {} return; }
      }
      if (!pref0('lightbox', true) || e.defaultPrevented) return;
      const img = e.target; if (!img || img.tagName !== 'IMG') return;
      if (img.closest('#t4x-root')) return;   // our shadow handles its own images
      if (img.closest('[id^="tm-t4-"],#t9p,.t9-col')) return;
      if (img.closest('.t4x-gifpanel,#t4x-composer-tools')) return;   // our pickers: the tile's own handler inserts+sends
      if (img.closest('[class*="msgAvatar" i],[class*="Avatar" i]')) return;   // avatars handled above (or left to the site) — never lightbox a PP
      const fo = img.closest('[data-t4co-app]'); if (fo && fo.getAttribute('data-t4co-app') !== 'tr4kerplus') return;
      if (img.classList && img.classList.contains('t4x-emoj')) return;
      const src = img.currentSrc || img.src; if (!/^https?:\/\//i.test(src)) return;
      if ((img.naturalWidth || img.width || 0) < 64) return;   // skip avatars / emotes
      e.preventDefault(); e.stopImmediatePropagation(); openLightbox(src);
    }, true);   // CAPTURE phase: run BEFORE the site's React-delegated click (its open-URL modal was a 2nd overlay on GIFs);
                // stopImmediatePropagation → exactly ONE viewer. The e.defaultPrevented guard still defers to an earlier handler (M21/PMS).
  }
  // ==========================================================================
  // YOUTUBE — links/fragments → play chip → floating in-page mini-player.
  // GM_addElement escapes the page CSP frame-src (PMS precedent); a runtime self-check falls back to a
  // thumbnail+link card if the iframe never loads (P-6 untestable headless). oEmbed titles via GM_xhr
  // (corrected /oembed path — PMS's URL was broken). ytMode:'tab' opens a tab instead of the floating window.
  // ==========================================================================
  const YT_ID = '[A-Za-z0-9_-]{11}';
  let _ytRe = null;
  function ytParse(s) {
    s = String(s || '');
    _ytRe = _ytRe || new RegExp('(?:youtube\\.com/watch\\?(?:[^\\s]*&)?v=|youtu\\.be/|youtube\\.com/shorts/|youtube\\.com/live/|youtube\\.com/embed/|music\\.youtube\\.com/watch\\?v=|(?:^|[\\s(])watch\\?v=|(?:^|[\\s(])shorts/)(' + YT_ID + ')(?![A-Za-z0-9_-])');
    let m = s.match(_ytRe);
    if (!m) return null;
    const id = m[1];
    let start = 0; const t = s.match(/[?&#](?:t|start|time_continue)=([0-9hms]+)/i);
    if (t) { const v = t[1]; if (/^\d+$/.test(v)) start = +v; else { const h = (v.match(/(\d+)h/) || [])[1] || 0, mn = (v.match(/(\d+)m/) || [])[1] || 0, sc = (v.match(/(\d+)s/) || [])[1] || 0; start = (+h) * 3600 + (+mn) * 60 + (+sc); } }
    return { id, start };
  }
  function ytTitleCache() { return store.get('t4x.ytTitles', {}) || {}; }
  function ytTitle(id, cb) {
    const c = ytTitleCache(); if (c[id]) { cb(c[id]); return; }
    gifFetchJson('https://www.youtube.com/oembed?url=' + encodeURIComponent('https://www.youtube.com/watch?v=' + id) + '&format=json')
      .then((r) => { const t = r.ok && r.body && r.body.title; if (t) { const cc = ytTitleCache(); const ks = Object.keys(cc); if (ks.length > 200) delete cc[ks[0]]; cc[id] = t; store.set('t4x.ytTitles', cc); cb(t); } }).catch(() => {});
  }
  function ytChip(id, start) {
    const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-ytchip'; b.dataset.t4xYtChip = id;
    const ic = document.createElement('span'); ic.className = 'ic material-symbols-outlined'; ic.textContent = 'play_arrow';
    const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = 'YouTube · ' + id;
    b.append(ic, lbl); b.title = 'Lire la vidéo YouTube';
    ytTitle(id, (t) => { lbl.textContent = 'YouTube · ' + t; b.title = t; b.setAttribute('aria-label', 'Lire ' + t); });
    b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (pref0('ytMode', 'auto') === 'tab') window.open('https://www.youtube.com/watch?v=' + id + (start ? '&t=' + start : ''), '_blank', 'noreferrer'); else openYtPlayer(id, start); });
    return b;
  }
  function ytScan(container) {
    if (!pref0('ytPlayer', true)) return;
    const seen = new Set();
    // full-URL anchors → append a chip after the link
    container.querySelectorAll && container.querySelectorAll('a.t4x-lnk').forEach((a) => { const p = ytParse(a.href); if (p && !seen.has(p.id)) { seen.add(p.id); a.after(ytChip(p.id, p.start)); } });
    // bare fragments in text nodes (the site strips full URLs, leaving watch?v=… / shorts/…)
    const walk = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
    const hits = []; let n;
    while ((n = walk.nextNode())) { if (n.parentNode && n.parentNode.closest && n.parentNode.closest('a,button')) continue; if (/(?:watch\?v=|shorts\/)[A-Za-z0-9_-]{11}/.test(n.nodeValue)) hits.push(n); }
    hits.forEach((tn) => { const p = ytParse(tn.nodeValue); if (p && !seen.has(p.id)) { seen.add(p.id); tn.after(ytChip(p.id, p.start)); } });
  }
  let _yt = null, _ytCss = false;
  function ensureYtCSS() {
    if (_ytCss) return; _ytCss = true; ensureHost();
    const s = document.createElement('style'); s.textContent = `
    .t4x-ytwin{ position:fixed; z-index:2147483003; width:480px; height:300px; min-width:300px; min-height:200px; max-width:92vw; max-height:76vh;
      background:var(--surface-container,#141722); border:1px solid var(--t4x-hair,rgba(147,165,255,.2)); border-radius:14px; overflow:hidden; resize:both;
      box-shadow:0 28px 80px rgba(0,0,0,.6); display:flex; flex-direction:column; }
    .t4x-ythead{ display:flex; align-items:center; gap:6px; padding:7px 10px; cursor:grab; user-select:none; background:var(--surface-container-high,#232838); }
    .t4x-ythead.drag{ cursor:grabbing; }
    .t4x-ythead b{ flex:1; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--on-surface,#e6e8f2); }
    .t4x-ythead button{ all:unset; cursor:pointer; width:24px; height:24px; border-radius:6px; display:flex; align-items:center; justify-content:center; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-ythead button:hover{ background:var(--surface-container,#141722); color:var(--on-surface,#e6e8f2); }
    .t4x-ythead .ic{ font-family:'Material Symbols Outlined'; font-size:18px; }
    .t4x-ytbody{ flex:1; position:relative; background:#000; min-height:0; }
    .t4x-ytbody iframe{ position:absolute; inset:0; width:100%; height:100%; border:0; }
    .t4x-ytwin.mini{ height:auto !important; resize:none; }
    .t4x-ytwin.mini .t4x-ytbody{ display:none; }
    .t4x-ytcard{ position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; padding:16px; text-align:center; color:var(--on-surface,#e6e8f2); }
    .t4x-ytcard img{ max-width:100%; max-height:60%; border-radius:8px; }
    .t4x-ytcard a{ color:var(--primary,#93a5ff); }` + YT_CHIP_CSS;
    root.appendChild(s);
  }
  // chip styles live in their OWN const so they can be injected into BOTH the shadow sheet (drawer chips)
  // AND the light-DOM sheet via ensureSiteCSS (native /communication chips) — a shadow sheet never styles
  // light-DOM nodes (M16). `--t4x-yt` is a dedicated token re-stepped per theme (not the error red — m7b).
  const YT_CHIP_CSS = `
    :root{ --t4x-yt:#e0554e; } :root[data-theme=light],[data-theme=light]{ --t4x-yt:#c0392b; }
    .t4x-ytchip{ all:unset; cursor:pointer; display:inline-flex; align-items:center; gap:5px; vertical-align:middle; margin:2px 3px; padding:3px 9px 3px 6px; border-radius:999px;
      background:color-mix(in srgb,var(--t4x-yt,#e0554e) 16%,transparent); border:1px solid color-mix(in srgb,var(--t4x-yt,#e0554e) 40%,transparent); color:var(--on-surface,#e6e8f2); font-size:12px; max-width:260px; }
    .t4x-ytchip:hover{ background:color-mix(in srgb,var(--t4x-yt,#e0554e) 28%,transparent); }
    .t4x-ytchip .ic{ font-family:'Material Symbols Outlined'; font-size:16px; color:var(--t4x-yt,#e0554e); flex:0 0 auto; }
    .t4x-ytchip .lbl{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }`;
  function closeYt() { try { if (_yt && _yt.win && !_yt.win.closed) _yt.win.close(); } catch (e) {} _yt = null; }
  // tr4ker's CSP (frame-src 'self') blocks any in-page YouTube iframe — even via GM_addElement (confirmed
  // live 2026-08-19), exactly like the voice feature. So the player is a small COMPANION WINDOW served from
  // OUR origin (t4x.ekaii.fr/yt), which embeds youtube-nocookie freely. The chip click is the user gesture
  // that lets window.open through. ytMode:'tab' opens a full tab instead.
  const YT_EMBED = 'https://t4x.ekaii.fr/yt';
  function openYtPlayer(id, start) {
    if (pref0('ytMode', 'auto') === 'tab') { window.open('https://www.youtube.com/watch?v=' + id + (start ? '&t=' + start : ''), '_blank', 'noreferrer'); return; }
    const url = YT_EMBED + '?v=' + encodeURIComponent(id) + (start ? '&t=' + start : '');
    try {
      const w = Math.min(760, (screen.availWidth || 1280) - 40), h = Math.round(w * 9 / 16) + 8;
      const win = window.open(url, 't4xyt_' + id, 'width=' + w + ',height=' + h + ',menubar=no,toolbar=no,location=no,status=no');
      if (win) { _yt = { win }; try { win.focus(); } catch (e) {} }
      else { toast('Autorise les pop-ups pour le lecteur, ou ouvre dans un onglet.'); window.open(url, '_blank', 'noreferrer'); }
    } catch (e) { try { window.open('https://www.youtube.com/watch?v=' + id, '_blank', 'noreferrer'); } catch (e2) {} }
  }
  let _ytNativeWired = false;
  function ensureYtChips() {
    if (!pref0('ytPlayer', true) || !pref0('ytNative', true)) return;
    if (!location.pathname.startsWith('/communication')) return;
    const ta = findComposerTA(); if (!ta) return;
    const scroller = ta.closest('div[class*="page"]') || document.querySelector('main') || document.body;
    const rows = scroller.querySelectorAll('[data-msg-id]:not([data-t4x-yt])');
    let count = 0;
    rows.forEach((row) => {
      if (count > 200) return; count++;
      row.setAttribute('data-t4x-yt', '1');
      if (row.closest('[id^="tm-t4-"]') || (row.closest('[data-t4co-app]') && row.closest('[data-t4co-app]').getAttribute('data-t4co-app') !== 'tr4kerplus')) return;
      if (row.querySelector('[data-tm-youtube-play-link],[data-tm-yt],[data-t4x-yt-chip]')) return;
      const txt = row.textContent || ''; const p = ytParse(txt); if (!p) return;
      const bd = row.querySelector('div,span,p') || row; ensureYtCSS(); bd.appendChild(ytChip(p.id, p.start));
    });
  }
  try { if (window.__T4X_EXPOSE__) { window.__t4xYT = { parse: ytParse, chip: ytChip, open: openYtPlayer, scan: ytScan }; } } catch (e) {}

  function xtraStyle() {
    if (chat._xtra || !root) return; chat._xtra = true;
    const st = document.createElement('style');
    st.textContent = xtraCSSText();
    root.appendChild(st);
  }
  function xtraCSSText() {
    return '.t4x-spoil{background:var(--on-surface,#c8cede);color:transparent;border-radius:4px;padding:0 3px;cursor:pointer;filter:blur(.35em);transition:filter .12s,color .12s,background .12s}.t4x-spoil.on{filter:none;color:inherit;background:var(--surface-container-high,rgba(255,255,255,.06))}.t4x-ment{background:color-mix(in srgb,var(--primary,#93a5ff) 26%,transparent);color:var(--primary,#93a5ff);border-radius:4px;padding:0 3px;font-weight:600}.t4x-lnk{color:var(--primary,#93a5ff);text-decoration:underline}.m.ment-hit .mc{box-shadow:-3px 0 0 var(--primary,#93a5ff)}.t4x-cbar{display:flex;align-items:center;gap:3px;padding:5px 6px;margin-bottom:7px;border-radius:13px;background:var(--t4x-glass,rgba(15,17,23,.5));border:1px solid var(--t4x-hair,rgba(147,165,255,.12));-webkit-backdrop-filter:blur(12px) saturate(150%); backdrop-filter:blur(12px) saturate(150%);box-shadow:0 3px 14px rgba(0,0,0,.18)}.t4x-cbtn{all:unset;cursor:pointer;width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--on-surface-variant,#aab2c5);transition:background .13s,color .13s,transform .13s}.t4x-cbtn .ic{font-family:"Material Symbols Outlined";font-size:19px}.t4x-cbtn:hover{background:color-mix(in srgb,var(--primary,#93a5ff) 16%,transparent);color:var(--primary,#93a5ff)}.t4x-cbtn:active{transform:scale(.9)}.t4x-cbtn.busy{opacity:.45;cursor:default}.t4x-upstatus{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--on-surface-variant,#aab2c5);margin-left:auto;padding-right:6px}.t4x-drop{outline:2px dashed var(--primary,#93a5ff);outline-offset:3px;border-radius:12px}.t4x-cimg{max-width:100%;max-height:260px;border-radius:10px;display:block;margin:5px 0;cursor:zoom-in;border:1px solid var(--t4x-hair,rgba(147,165,255,.12))}.t4x-gifpanel{position:absolute;left:0;right:0;bottom:100%;margin-bottom:8px;z-index:2147483041;padding:9px;border-radius:16px;background:var(--surface-container-high,#1a1d26);-webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%);border:1px solid var(--outline-variant,rgba(147,165,255,.18));box-shadow:0 -16px 44px rgba(0,0,0,.5)}.t4x-gifhead{display:flex;gap:6px;margin-bottom:7px}.t4x-gifq{flex:1;min-width:0;font:inherit;font-size:13px;padding:9px 12px;border-radius:11px;background:var(--surface-container,#141722);color:var(--on-surface,#e6e9f2);border:1px solid var(--outline-variant,rgba(255,255,255,.15))}.t4x-gifq:focus{outline:2px solid var(--primary,#93a5ff);outline-offset:-1px}.t4x-gifx{all:unset;cursor:pointer;width:34px;display:flex;align-items:center;justify-content:center;border-radius:10px;color:var(--on-surface-variant,#aab2c5)}.t4x-gifx .ic{font-family:"Material Symbols Outlined";font-size:20px}.t4x-gifx:hover{background:color-mix(in srgb,var(--primary,#93a5ff) 16%,transparent);color:var(--primary,#93a5ff)}.t4x-gifchips{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px}.t4x-gifchip{all:unset;cursor:pointer;font-size:11px;padding:4px 10px;border-radius:999px;background:var(--surface-container,#141722);color:var(--on-surface-variant,#aab2c5);border:1px solid var(--outline-variant,rgba(255,255,255,.1));text-transform:capitalize}.t4x-gifchip:hover{color:var(--on-surface,#e6e8f2)}.t4x-gifchip.on{background:color-mix(in srgb,var(--primary,#93a5ff) 20%,transparent);color:var(--primary,#93a5ff);border-color:transparent}.t4x-gifmasonry{columns:92px 6;column-gap:6px}.t4x-gifscroll{max-height:210px;overflow-y:auto;overscroll-behavior:contain}.t4x-giftile{all:unset;cursor:pointer;display:block;width:100%;break-inside:avoid;margin:0 0 6px;border-radius:10px;overflow:hidden;background:var(--surface-container,#141722);border:1px solid transparent;transition:border-color .12s,transform .1s}.t4x-giftile:hover{border-color:var(--primary,#93a5ff)}.t4x-giftile:active{transform:scale(.98)}.t4x-giftile img{width:100%;display:block}.t4x-giftile.pin{position:relative}.t4x-giftile.pin::after{content:"";position:absolute;top:4px;right:4px;width:7px;height:7px;border-radius:50%;background:var(--primary,#93a5ff);box-shadow:0 0 0 2px var(--surface-container,#141722)}.t4x-giffav{margin-bottom:7px}.t4x-giffavg{max-height:none}.t4x-gifskel{break-inside:avoid;margin:0 0 6px;border-radius:10px;background:linear-gradient(100deg,var(--surface-container,#141722) 30%,var(--surface-container-high,#232838) 50%,var(--surface-container,#141722) 70%);background-size:200% 100%;animation:t4xgifsh 1.1s linear infinite}.t4x-gifstatus{font-size:11px;color:var(--on-surface-variant,#aab2c5);text-align:center;padding:8px 0 2px}.t4x-giffoot{font-size:10px;color:var(--on-surface-variant,#aab2c5);opacity:.6;text-align:right;padding-top:6px}@keyframes t4xgifsh{to{background-position:-200% 0}}.t4x-e2e-badge{font-size:.9em;opacity:.85;cursor:default;margin-right:1px}.t4x-e2e-err{color:var(--error,#ffb4ab);font-style:italic;font-size:.95em}.t4x-e2e-verify{position:fixed;inset:0;z-index:2147483300;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.55);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}.t4x-e2e-vbox{max-width:440px;width:calc(100% - 40px);background:var(--surface-container-high,#1a1d26);border:1px solid var(--outline-variant,rgba(147,165,255,.2));border-radius:16px;padding:18px 20px;box-shadow:0 24px 60px rgba(0,0,0,.5)}.t4x-e2e-vh{font-size:15px;font-weight:700;color:var(--error,#ffb4ab);margin-bottom:8px}.t4x-e2e-vp{font-size:12.5px;line-height:1.5;color:var(--on-surface-variant,#aab2c5);margin-bottom:12px}.t4x-e2e-vfp{font-size:11.5px;color:var(--on-surface,#e6e9f2);margin-bottom:14px;display:flex;flex-direction:column;gap:5px}.t4x-e2e-vfp code{font-family:ui-monospace,monospace;background:var(--surface-container,#141722);padding:2px 6px;border-radius:6px;word-break:break-all}.t4x-e2e-vbtns{display:flex;gap:8px;justify-content:flex-end}.t4x-e2e-vcancel,.t4x-e2e-vaccept{all:unset;cursor:pointer;font-size:12.5px;padding:8px 14px;border-radius:10px}.t4x-e2e-vcancel{color:var(--on-surface-variant,#aab2c5);border:1px solid var(--outline-variant,rgba(147,165,255,.2))}.t4x-e2e-vaccept{background:var(--error,#ffb4ab);color:#3b0a08;font-weight:600}.t4x-e2e-vcancel:hover{color:var(--on-surface,#e6e9f2)}.t4x-e2e-vaccept:hover{filter:brightness(1.08)}.t4x-e2e-vcmp{width:100%;box-sizing:border-box;margin-bottom:12px;font:inherit;font-size:12px;padding:8px 10px;border-radius:9px;background:var(--surface-container,#141722);color:var(--on-surface,#e6e9f2);border:1px solid var(--outline-variant,rgba(255,255,255,.15))}.t4x-e2e-voob{display:flex;align-items:flex-start;gap:7px;font-size:11.5px;color:var(--on-surface-variant,#aab2c5);margin-bottom:10px;cursor:pointer;line-height:1.4}.t4x-e2e-voob input{margin-top:2px}' + PHRASE_CSS + EMOJI_CSS;
  }
  const PHRASE_CSS = '.t4x-phrpanel .t4x-phrlist{max-height:230px;overflow-y:auto;overscroll-behavior:contain;display:flex;flex-direction:column;gap:2px}.t4x-phrrow{all:unset;cursor:pointer;display:flex;align-items:center;gap:7px;padding:8px 10px;border-radius:9px;font-size:13px;color:var(--on-surface,#e6e8f2)}.t4x-phrrow:hover,.t4x-phrrow[aria-selected=true]{background:var(--secondary-container,#2b3050);color:var(--on-secondary-container,#dfe3ff)}.t4x-phrpin{color:var(--primary,#93a5ff);font-size:12px;flex:0 0 auto}.t4x-phrtext{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.t4x-phrfoot{padding-top:7px;display:flex}.t4x-phrfoot .t4x-gifchip{flex:1;text-align:center;justify-content:center}';
  const EMOJI_CSS = '.t4x-emopanel{padding:9px}.t4x-emotabs{display:flex;gap:6px;margin-bottom:8px}.t4x-emotab{all:unset;cursor:pointer;font-size:12px;padding:5px 11px;border-radius:999px;color:var(--on-surface-variant,#aab2c5);border:1px solid var(--outline-variant,rgba(255,255,255,.12))}.t4x-emotab.on{background:color-mix(in srgb,var(--primary,#93a5ff) 20%,transparent);color:var(--primary,#93a5ff);border-color:transparent}.t4x-emobody{max-height:232px;overflow-y:auto;overscroll-behavior:contain}.t4x-emofav{display:grid;grid-template-columns:repeat(8,1fr);gap:2px;margin-bottom:2px}.t4x-emohr{height:1px;background:var(--outline-variant,#3a4056);margin:4px 0 6px;opacity:.6}.t4x-emogrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(34px,1fr));gap:2px}.t4x-emob{all:unset;cursor:pointer;font-size:18px;text-align:center;padding:4px;border-radius:6px;position:relative}.t4x-emob:hover{background:var(--secondary-container,#2b3050)}.t4x-emob.pin::after{content:"";position:absolute;top:2px;right:2px;width:5px;height:5px;border-radius:50%;background:var(--primary,#93a5ff)}.t4x-emotile{all:unset;cursor:pointer;padding:3px;border-radius:8px;display:flex;align-items:center;justify-content:center;background:var(--surface-container,#141722);position:relative}.t4x-emotile:hover{background:var(--secondary-container,#2b3050)}.t4x-emotile.pin::after{content:"";position:absolute;top:2px;right:2px;width:5px;height:5px;border-radius:50%;background:var(--primary,#93a5ff)}.t4x-emotile img{width:28px;height:28px;object-fit:contain}.t4x-emoj{height:1.5em;width:auto;vertical-align:-.35em;display:inline-block;border:0!important;margin:0 1px;border-radius:0!important;cursor:default;max-height:1.5em}.t4x-jumbo .t4x-emoj{height:2.6em;max-height:2.6em}';
  function wrapSel(ta, open, close) {
    const s = ta.selectionStart || 0, e = ta.selectionEnd || 0, v = ta.value;
    ta.value = v.slice(0, s) + open + v.slice(s, e) + close + v.slice(e);
    const pos = s + open.length + (e - s);
    ta.focus(); ta.setSelectionRange(pos, pos);
    ta.dispatchEvent(new Event('input', { bubbles: true }));
  }
  const EMOJIS = [
    '😀 😃 😄 😁 😆 😅 🤣 😂 🙂 🙃 😉 😊 😇 🥰 😍 🤩 😘 😗 😚 😙 🥲 😋 😛 😜 🤪 😝 🤑 🤗 🤭 🤫 🤔 🤐 🤨 😐 😑 😶 😏 😒 🙄 😬 🤥 😌 😔 😪 🤤 😴 😷 🤒 🤕 🤢 🤮 🤧 🥵 🥶 🥴 😵 🤯 🤠 🥳 🥸 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 😈 👿 💀 ☠️ 🤡 👻 👽 👾 🤖 💩',
    '👋 🤚 ✋ 🖖 👌 🤌 🤏 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 🖕 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 👐 🤲 🤝 🙏 💪 🫶 🫡 🤷 🤦 ✍️ 💅 👀 👁️ 🧠 🦾 🗿',
    '❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❤️‍🔥 ❤️‍🩹 💕 💞 💓 💗 💖 💘 💝 💟 💌 💋',
    '📱 💻 🖥️ ⌨️ 🖱️ 💾 💿 📷 🎥 📺 📻 🎙️ ⏰ ⏳ 💡 🔦 🔋 🔌 📡 🛰️ 🔒 🔓 🔑 🛠️ 🔨 🧲 💣 🔮 💊 🧬 🔬 🔭 📦 ✉️ 📄 📝 ✏️ 📌 📎 ✂️ 🗑️ 💰 💸 💳 💎 ⚖️ 🧭 🗺️ 📚 📖 🏷️',
    '✅ ❌ ❎ ➕ ➖ ➗ ✖️ 💯 🔥 ⚡ ⭐ 🌟 ✨ 💫 💥 💢 💦 💨 💤 ❗ ❓ ‼️ ⁉️ ⚠️ 🚫 🔞 ♻️ ⭕ 🆗 🆕 🆒 🆓 🆙 🔴 🟠 🟡 🟢 🔵 🟣 ⚫ ⚪ 🔺 🔻 🔶 🔷 ➡️ ⬅️ ⬆️ ⬇️ 🔀 🔁 ▶️ ⏸️ ⏭️ 🔊 🔇 🔔 🔕 ♾️ 🚩 🏁 🏴‍☠️',
    '🎉 🎊 🎈 🎁 ⚽ 🏀 🏈 🎾 🏐 🎱 🏓 🥊 🎯 🎳 🎮 🕹️ 🎲 ♟️ 🧩 🎭 🎨 🎬 🎤 🎧 🎼 🎵 🎶 🎹 🥁 🎷 🎺 🎸 🎻 🎪 🏆 🥇 🥈 🥉 🏅 🚴 🏋️ 🧗 🏄 🏊 🛹 🎣 ⛺ 🚀 ✈️ 🚗 🏎️ 🚂 ⛵ 🚁 🛸',
    '🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐨 🐯 🦁 🐮 🐷 🐸 🐵 🐔 🐧 🐦 🦆 🦅 🦉 🐺 🐴 🦄 🐝 🦋 🐌 🐞 🕷️ 🐢 🐍 🐙 🦀 🐠 🐬 🐳 🦈 🐘 🦒 🐐 🌱 🌿 🍀 🌵 🌴 🌳 🍁 🍄 🌸 🌻 🌞 🌙 🌈 ☀️ ⛅ ☁️ 🌧️ ⛈️ ❄️ ⛄ 🌊 💧 🌍 🌋',
    '🍏 🍎 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🥑 🥦 🥕 🌽 🌶️ 🥔 🥐 🍞 🥖 🧀 🥚 🍳 🥓 🍗 🌭 🍔 🍟 🍕 🌮 🥗 🍝 🍜 🍣 🍤 🍙 🥟 🍦 🍩 🍪 🎂 🍰 🧁 🍫 🍬 🍭 🍿 ☕ 🍵 🧃 🥤 🍺 🍻 🥂 🍷 🍸 🍾',
  ].join(' ').split(' ');
  // ---- emoji frecency (unicode): shared with reactions (#11). key = the emoji char; value {c,r,last,pin} ----
  let _emoC = null, _rxFavC = null;   // perf: memoize emofrec + reactionFavorites (hot in mkMsg/buildReactActions)
  function loadEmofrec() { if (_emoC) return _emoC; const raw = store.get('t4x.emofrec', null); _emoC = (raw && raw.items && typeof raw.items === 'object') ? raw.items : {}; return _emoC; }
  function saveEmofrec(items) { _emoC = items; _rxFavC = null; store.set('t4x.emofrec', { v: 1, items }); }
  function emoBump(key, kind) {
    if (!key) return; const items = loadEmofrec(); const r = items[key] || { c: 0, r: 0, last: 0, pin: 0 };
    if (kind === 'rx') r.r++; else r.c++; r.last = Date.now(); items[key] = r;
    let keys = Object.keys(items);
    if (keys.length > 64) { const ev = keys.filter((k) => !items[k].pin).sort((a, b) => (items[a].c + items[a].r) - (items[b].c + items[b].r) || items[a].last - items[b].last); while (Object.keys(items).length > 64 && ev.length) delete items[ev.shift()]; }
    saveEmofrec(items);
  }
  function emoPin(key) { const items = loadEmofrec(); const r = items[key] || { c: 0, r: 0, last: 0, pin: 0 }; r.pin = r.pin ? 0 : Date.now(); items[key] = r; saveEmofrec(items); }
  function emoFavorites(n) { const items = loadEmofrec(); return Object.keys(items).filter((k) => !k.startsWith('klipy:')).map((k) => ({ k, v: items[k] })).sort((a, b) => (b.v.pin - a.v.pin) || (b.v.c - a.v.c) || (b.v.r - a.v.r) || (b.v.last - a.v.last)).slice(0, n).map((x) => x.k); }
  // ---- site emoticons (Klipy /emochat gateway): manifest cache-then-refresh + recents ----
  const EMOTE_GATEWAY = 'https://klipy-api-gateway.tr4ker-klipy-emoj-gateway-customer593.workers.dev';
  function isEmoteUrl(url) { try { const u = new URL(url); return u.origin === EMOTE_GATEWAY && u.pathname.startsWith('/emochat/'); } catch (e) { return false; } }
  function validEmote(v) {
    if (!v || typeof v !== 'object') return null;
    const id = String(v.id || '').trim(), filename = String(v.filename || '').trim(), format = String(v.format || '').toLowerCase(), url = String(v.url || '').trim();
    if (!id || !filename || !/^[^/\\\u0000-\u001f]+\.(?:png|gif)$/i.test(filename) || !['png', 'gif'].includes(format)) return null;
    try { const p = new URL(url); if (p.origin !== EMOTE_GATEWAY || !p.pathname.startsWith('/emochat/') || decodeURIComponent(p.pathname.slice('/emochat/'.length)) !== filename) return null; } catch (e) { return null; }
    return { id, filename, format, url };
  }
  let _emoteReq = null;
  function fetchEmotes() {
    const cached = store.get('t4x.emojiManifest', null);
    const fresh = cached && Array.isArray(cached.items) && (Date.now() - (cached.at || 0) < 7 * 864e5);
    if (fresh) return Promise.resolve(cached.items);
    if (_emoteReq) return _emoteReq;
    _emoteReq = gifFetchJson(EMOTE_GATEWAY + '/emochat').then((r) => {
      _emoteReq = null;
      const items = (r.ok && r.body && Array.isArray(r.body.items)) ? r.body.items.map(validEmote).filter(Boolean).slice(0, 500) : null;
      if (items && items.length) { store.set('t4x.emojiManifest', { at: Date.now(), items }); return items; }
      if (cached && Array.isArray(cached.items)) return cached.items;   // stale-if-error
      throw new Error('manifeste indisponible');
    }).catch((e) => { _emoteReq = null; if (cached && Array.isArray(cached.items)) return cached.items; throw e; });
    return _emoteReq;
  }
  function emoteRecents() { const a = store.get('t4x.emojiRecent', []); return Array.isArray(a) ? a.filter(isEmoteUrl).slice(0, 12) : []; }
  function pushEmoteRecent(url) { let a = emoteRecents().filter((u) => u !== url); a.unshift(url); store.set('t4x.emojiRecent', a.slice(0, 12)); }
  const emoteKey = (it) => 'klipy:' + (it.id || it.url);
  // pinned + most-used emotes (frecency shared with the emoji picker: emofrec `klipy:` keys, pin > count > last)
  function emoteFavorites(items, n) {
    const fr = loadEmofrec(); const byId = {}; items.forEach((it) => { byId[emoteKey(it)] = it; });
    return Object.keys(fr).filter((k) => k.startsWith('klipy:') && byId[k]).map((k) => ({ it: byId[k], v: fr[k] }))
      .sort((a, b) => (b.v.pin - a.v.pin) || (b.v.c - a.v.c) || (b.v.last - a.v.last)).slice(0, n).map((x) => x.it);
  }

  // ---- Émotes COMMUNAUTAIRES (ajoutées par les membres, hébergées BFF t4x, classées par usage) ------
  const EMOTES_URL = 'https://t4x.ekaii.fr/emotes/api';
  const commKey = (e) => 'comm:' + e.id;
  function emotesList(sort, q) {
    const u = EMOTES_URL + '/list?sort=' + encodeURIComponent(sort || 'uses') + (q ? '&q=' + encodeURIComponent(q) : '');
    return new Promise((res, rej) => { GM_xmlhttpRequest({ method: 'GET', url: u, timeout: 12000, onload: (r) => { try { res(JSON.parse(r.responseText)); } catch (e) { rej(new Error('réponse invalide')); } }, onerror: () => rej(new Error('injoignable')), ontimeout: () => rej(new Error('délai')) }); });
  }
  function emotesPost(path, body) {
    return new Promise((res, rej) => { GM_xmlhttpRequest({ method: 'POST', url: EMOTES_URL + path, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(body), timeout: 15000, onload: (r) => { let d = null; try { d = JSON.parse(r.responseText); } catch (e) {} if (r.status >= 400) return rej(new Error((d && d.error) || ('HTTP ' + r.status))); res(d || {}); }, onerror: () => rej(new Error('injoignable')), ontimeout: () => rej(new Error('délai')) }); });
  }
  function emotesUse(id) { try { emotesPost('/use', { id }).catch(() => {}); } catch (e) {} }

  let emojiPop = null;
  function toggleEmoji(ta, bar) {
    if (emojiPop) { emojiPop.remove(); emojiPop = null; return; }
    closeGifPanel(); closePhrasePanel();
    ensureSiteCSS();
    const host = bar.parentNode || chat.composerEl; if (!host) return;
    if (host.style) host.style.position = 'relative';
    const panel = document.createElement('div'); panel.className = 't4x-gifpanel t4x-emopanel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Emojis');
    const siteOn = pref0('siteEmojis', true);
    const tabsRow = document.createElement('div'); tabsRow.className = 't4x-emotabs';
    const body = document.createElement('div'); body.className = 't4x-emobody';
    const insert = (text, key, kind) => { insertAtCursor(ta, text); if (key) emoBump(key, kind || 'ins'); };
    const renderStd = () => {
      body.replaceChildren();
      const frec = loadEmofrec();   // read once, not per-button (466 buttons now)
      const favs = emoFavorites(8);
      if (favs.length) { const fr = document.createElement('div'); fr.className = 't4x-emofav'; favs.forEach((em) => fr.appendChild(emoBtn(em, frec))); body.appendChild(fr); const hr = document.createElement('div'); hr.className = 't4x-emohr'; body.appendChild(hr); }
      const grid = document.createElement('div'); grid.className = 't4x-emogrid';
      EMOJIS.forEach((em) => grid.appendChild(emoBtn(em, frec)));
      body.appendChild(grid);
      const hint = document.createElement('div'); hint.className = 't4x-gifstatus'; hint.textContent = 'clic droit : épingler'; body.appendChild(hint);
    };
    const emoBtn = (em, frec) => {
      const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-emob'; b.textContent = em; b.title = em; b.setAttribute('aria-label', em);
      if (frec && frec[em] && frec[em].pin) b.classList.add('pin');
      b.addEventListener('click', () => { insert(em, em, 'ins'); if (emojiPop) { emojiPop.remove(); emojiPop = null; } try { ta.focus(); } catch (e) {} });
      b.addEventListener('contextmenu', (e) => { e.preventDefault(); emoPin(em); renderStd(); });
      b.addEventListener('keydown', (e) => { if (e.shiftKey && e.key === 'Enter') { e.preventDefault(); emoPin(em); renderStd(); } });
      return b;
    };
    const renderSite = () => {
      body.replaceChildren();
      const st = document.createElement('div'); st.className = 't4x-gifstatus'; st.textContent = 'Chargement des émoticônes…'; body.appendChild(st);
      const paint = (items) => {
        body.replaceChildren();
        const favs = emoteFavorites(items, 12);
        if (favs.length) { const rr = document.createElement('div'); rr.className = 't4x-emofav'; favs.forEach((it) => rr.appendChild(emoteTile(it))); body.appendChild(rr); const hint = document.createElement('div'); hint.className = 't4x-gifstatus'; hint.style.padding = '1px 0'; hint.textContent = 'Fréquents · clic droit : épingler'; body.appendChild(hint); const hr = document.createElement('div'); hr.className = 't4x-emohr'; body.appendChild(hr); }
        const search = document.createElement('input'); search.className = 't4x-gifq'; search.placeholder = 'Filtrer…'; search.style.marginBottom = '7px'; body.appendChild(search);
        const grid = document.createElement('div'); grid.className = 't4x-emogrid'; body.appendChild(grid);
        const fill = (q) => { grid.replaceChildren(); const qq = q ? q.toLowerCase() : ''; const list = items.filter((it) => !qq || it.filename.toLowerCase().includes(qq)); if (!list.length) { const e = document.createElement('div'); e.className = 't4x-gifstatus'; e.textContent = 'Aucune émoticône.'; grid.appendChild(e); return; } list.forEach((it) => grid.appendChild(emoteTile(it))); };
        let deb = null; search.addEventListener('input', () => { clearTimeout(deb); deb = setTimeout(() => fill(search.value.trim()), 100); });
        fill('');
      };
      fetchEmotes().then(paint).catch(() => { body.replaceChildren(); const e = document.createElement('div'); e.className = 't4x-gifstatus'; e.textContent = 'Émoticônes du site indisponibles (passerelle hors ligne).'; const retry = document.createElement('button'); retry.className = 't4x-gifchip'; retry.textContent = 'Réessayer'; retry.style.margin = '8px auto 0'; retry.style.display = 'block'; retry.addEventListener('click', renderSite); body.append(e, retry); tabTo('std'); });
    };
    const emoteTile = (it) => {
      const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-emotile'; b.title = (it.filename || 'émoticône') + ' — clic droit : épingler'; b.setAttribute('aria-label', it.filename || 'émoticône');
      const fr = loadEmofrec(); if (fr[emoteKey(it)] && fr[emoteKey(it)].pin) b.classList.add('pin');
      const im = document.createElement('img'); im.src = it.url; im.loading = 'lazy'; im.decoding = 'async'; im.referrerPolicy = 'no-referrer'; im.alt = '';
      im.addEventListener('error', () => b.remove()); b.appendChild(im);
      b.addEventListener('click', (e) => { const keep = e.shiftKey; insert(it.url + ' ', emoteKey(it), 'ins'); pushEmoteRecent(it.url); if (!keep && emojiPop) { emojiPop.remove(); emojiPop = null; } try { ta.focus(); } catch (er) {} });
      b.addEventListener('contextmenu', (e) => { e.preventDefault(); emoPin(emoteKey(it)); renderSite(); });   // right-click pin
      return b;
    };
    // ---- COMMUNAUTÉ : émotes ajoutées par les membres, triées par usage (défaut), filtrables ----------
    let commSort = 'uses';
    const commTile = (e) => {
      const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-emotile'; b.title = ':' + e.name + ': · par ' + (e.by || '?') + ' · clic droit : épingler'; b.setAttribute('aria-label', e.name);
      const fr = loadEmofrec(); if (fr[commKey(e)] && fr[commKey(e)].pin) b.classList.add('pin');
      const im = document.createElement('img'); im.src = e.url; im.loading = 'lazy'; im.decoding = 'async'; im.referrerPolicy = 'no-referrer'; im.alt = e.name;
      im.addEventListener('error', () => b.remove()); b.appendChild(im);
      b.addEventListener('click', (ev) => { const keep = ev.shiftKey; const pane = paneOfTa(ta); insert((pane && isExt(pane.conv)) ? e.url + ' ' : '[img]' + e.url + '[/img] ', commKey(e), 'ins'); pushEmoteRecent(e.url); emotesUse(e.id); if (!keep && emojiPop) { emojiPop.remove(); emojiPop = null; } try { ta.focus(); } catch (er) {} });
      b.addEventListener('contextmenu', (ev) => { ev.preventDefault(); emoPin(commKey(e)); renderCommunity(); });
      return b;
    };
    const renderCommunity = () => {
      body.replaceChildren();
      const chipsBar = document.createElement('div'); chipsBar.className = 't4x-gifchips'; chipsBar.style.marginBottom = '7px';
      [['uses', 'Populaires'], ['recent', 'Récents'], ['az', 'A-Z']].forEach(([v, l]) => { const c = document.createElement('button'); c.type = 'button'; c.className = 't4x-gifchip' + (commSort === v ? ' on' : ''); c.textContent = l; c.addEventListener('click', () => { commSort = v; renderCommunity(); }); chipsBar.appendChild(c); });
      const add = document.createElement('button'); add.type = 'button'; add.className = 't4x-gifchip'; add.style.marginLeft = 'auto'; add.textContent = '+ Ajouter'; add.addEventListener('click', startAddEmote); chipsBar.appendChild(add);
      body.appendChild(chipsBar);
      const search = document.createElement('input'); search.className = 't4x-gifq'; search.placeholder = 'Filtrer les émotes…'; search.style.marginBottom = '7px'; body.appendChild(search);
      const grid = document.createElement('div'); grid.className = 't4x-emogrid'; body.appendChild(grid);
      const status = document.createElement('div'); status.className = 't4x-gifstatus'; status.textContent = 'Chargement…'; body.appendChild(status);
      let deb = null;
      const load = (q) => { emotesList(commSort, q).then((d) => { grid.replaceChildren(); status.textContent = ''; const l = (d && d.emotes) || []; if (!l.length) { status.textContent = q ? 'Aucune émote.' : 'Aucune émote communautaire pour l’instant. Ajoute la première !'; return; } l.forEach((e) => grid.appendChild(commTile(e))); }).catch((e) => { grid.replaceChildren(); status.textContent = 'Émotes indisponibles (' + e.message + ').'; }); };
      search.addEventListener('input', () => { clearTimeout(deb); deb = setTimeout(() => load(search.value.trim()), 180); });
      load('');
    };
    const startAddEmote = () => {
      const nm = (chat.myName || store.get('t4x.myname', '') || '').trim();
      if (!nm) { toast('Connecte-toi à tr4ker pour ajouter une émote.'); return; }
      const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.style.display = 'none'; document.body.appendChild(inp);
      inp.addEventListener('change', async () => {
        const f = inp.files && inp.files[0]; inp.remove(); if (!f) return;
        if (!/^image\//.test(f.type || '')) { toast('Choisis une image.'); return; }
        toast('Envoi de l’image…', 3000);
        try { const dataUrl = await readAsDataURL(f); const url = await uploadImage(dataUrl); showEmoteNameForm(url); }
        catch (e) { toast('Échec de l’envoi : ' + e.message); }
      });
      inp.click();
    };
    const showEmoteNameForm = (url) => {
      body.replaceChildren();
      const wrap = document.createElement('div'); wrap.style.cssText = 'padding:10px;text-align:center';
      const prev = document.createElement('img'); prev.src = url; prev.style.cssText = 'height:48px;width:auto;max-width:100%;border-radius:8px;display:block;margin:0 auto 10px'; wrap.appendChild(prev);
      const inp = document.createElement('input'); inp.className = 't4x-gifq'; inp.placeholder = 'nom (ex : pepe_love)'; inp.maxLength = 32; inp.style.marginBottom = '8px'; wrap.appendChild(inp);
      const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:6px;justify-content:center';
      const pub = document.createElement('button'); pub.type = 'button'; pub.className = 't4x-gifchip'; pub.textContent = 'Publier';
      const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 't4x-gifchip'; cancel.textContent = 'Annuler'; cancel.addEventListener('click', renderCommunity);
      row.append(pub, cancel); wrap.appendChild(row);
      const err = document.createElement('div'); err.className = 't4x-gifstatus'; wrap.appendChild(err);
      body.appendChild(wrap); setTimeout(() => { try { inp.focus(); } catch (e) {} }, 20);
      const doPub = async () => { const nm = inp.value.trim(); if (!/^[a-z0-9][a-z0-9_-]{1,31}$/i.test(nm)) { err.textContent = '2-32 caractères : lettres, chiffres, _ ou -'; return; } pub.disabled = true; err.textContent = 'Publication…'; try { await emotesPost('/add', { name: nm, url, username: (chat.myName || store.get('t4x.myname', '')), install: installId() }); toast('Émote « ' + nm + ' » ajoutée !'); commSort = 'recent'; renderCommunity(); } catch (e) { err.textContent = e.message; pub.disabled = false; } };
      pub.addEventListener('click', doPub); inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') doPub(); });
    };
    let curTab = pref0('emojiTab', 'std'); if (curTab === 'site' && !siteOn) curTab = 'std'; if (curTab !== 'site' && curTab !== 'comm') curTab = 'std';
    const renderTab = (t) => { (t === 'site' ? renderSite : t === 'comm' ? renderCommunity : renderStd)(); };
    const tabTo = (t) => { curTab = t; P = setPref('emojiTab', t); tabsRow.querySelectorAll('.t4x-emotab').forEach((x) => x.classList.toggle('on', x.dataset.t === t)); renderTab(t); };
    const mkTab = (t, label) => { const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-emotab' + (curTab === t ? ' on' : ''); b.dataset.t = t; b.textContent = label; b.addEventListener('click', () => tabTo(t)); return b; };
    tabsRow.appendChild(mkTab('std', 'Emoji'));
    if (siteOn) tabsRow.appendChild(mkTab('site', 'tr4ker'));
    tabsRow.appendChild(mkTab('comm', 'Communauté'));
    panel.append(tabsRow, body);
    if (siteOn && tabsRow.children.length < 2) curTab = 'std';
    renderTab(curTab);
    host.appendChild(panel);
    emojiPop = panel;
    const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); if (emojiPop) { emojiPop.remove(); emojiPop = null; } document.removeEventListener('keydown', onKey, true); document.removeEventListener('pointerdown', onOut, true); } };
    const onOut = (e) => { const p = (e.composedPath && e.composedPath()) || []; if (p.indexOf(panel) !== -1 || (bar && p.indexOf(bar) !== -1)) return; if (emojiPop) { emojiPop.remove(); emojiPop = null; } document.removeEventListener('keydown', onKey, true); document.removeEventListener('pointerdown', onOut, true); };
    document.addEventListener('keydown', onKey, true);
    setTimeout(() => document.addEventListener('pointerdown', onOut, true), 0);
  }
  // ---- image auto-upload: pick / paste / drop → self-hosted store → insert [img]url[/img], zero friction ----
  const UPLOAD_URL = 'https://t4x.ekaii.fr/upload';
  let uploading = false;
  function uploadImage(dataUrl) {
    return new Promise((res, rej) => {
      GM_xmlhttpRequest({
        method: 'POST', url: UPLOAD_URL, headers: { 'Content-Type': 'application/json' },
        data: JSON.stringify({ image: dataUrl }), timeout: 30000,
        onload: (r) => { try { const j = JSON.parse(r.responseText); j.url ? res(j.url) : rej(new Error(j.error || 'échec')); } catch (e) { rej(new Error('réponse invalide')); } },
        onerror: () => rej(new Error('serveur injoignable')), ontimeout: () => rej(new Error('délai dépassé')),
      });
    });
  }
  function readAsDataURL(file) { return new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => rej(new Error('lecture')); fr.readAsDataURL(file); }); }
  function insertAtCursor(ta, text) {
    if (!ta) return; const s = ta.selectionStart != null ? ta.selectionStart : ta.value.length, e = ta.selectionEnd != null ? ta.selectionEnd : ta.value.length, v = ta.value;
    ta.value = v.slice(0, s) + text + v.slice(e); const pos = s + text.length; try { ta.setSelectionRange(pos, pos); } catch (_) {}
    ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px';
    // emit input so the pane composer's listener runs (autogrow + onTyping); the controlled/native path
    // (nativeInsert) already dispatches its own InputEvent, so this fires only for our own drawer textareas.
    try { ta.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}
  }
  function paneOfTa(ta) { return ta ? (chat.panes.find((p) => p.inputEl === ta) || null) : null; }
  function setComposerUploading(on, ta) {
    const owner = paneOfTa(ta);
    const targets = owner ? [owner] : chat.panes;
    for (const p of targets) {
      if (p._imgBtn) { p._imgBtn.disabled = on; p._imgBtn.classList.toggle('busy', on); }
      if (p._upStatus) { p._upStatus.style.display = on ? 'flex' : 'none'; p._upStatus.replaceChildren(); if (on) { const sp = document.createElement('span'); sp.className = 'spin'; p._upStatus.append(sp, document.createTextNode('Envoi de l’image…')); } }
    }
  }
  async function handleImageFile(file, ta) {
    if (!file) return;
    if (!/^image\//.test(file.type || '')) { toast('Seules les images (et GIF) peuvent être envoyées ici.'); return; }
    if (file.size > 10 * 1024 * 1024) { toast('Image trop lourde (max 10 Mo).'); return; }
    if (uploading) { toast('Un envoi est déjà en cours…'); return; }
    uploading = true; setComposerUploading(true, ta);
    try {
      const url = await uploadImage(await readAsDataURL(file));
      const pane = paneOfTa(ta);
      insertAtCursor(ta || chat.inputEl, (pane && isExt(pane.conv)) ? url + ' ' : '[img]' + url + '[/img] ');   // t4health embeds BARE urls, no BBCode
    } catch (e) { toast('Envoi de l’image : ' + (e.message || 'échec')); }
    uploading = false; setComposerUploading(false, ta);
    try { (ta || chat.inputEl) && (ta || chat.inputEl).focus(); } catch (_) {}
  }

  // ==========================================================================
  // GIF PICKER — first-party implementation (TR4KER+)
  // A tiny provider registry feeds one reusable panel. Each candidate is normalised to a common
  // shape {id, preview, full, title, ratio, provider}. The inserted URL is the full GIF on an
  // allowlisted CDN (static.klipy.com / giphy.com), so it renders natively for every viewer.
  // Requests clear the page CSP via GM_xmlhttpRequest; the grid is built with DOM APIs only (no
  // innerHTML on network data). Sources: Klipy (default, shared community key, no signup) and Giphy
  // (opt-in, needs the member's own key — the old public beta key is banned).
  // ==========================================================================
  const GIF_PER_PAGE = 16;
  function gifFetchJson(url) {
    return new Promise((resolve, reject) => {
      try {
        GM_xmlhttpRequest({
          method: 'GET', url, headers: { Accept: 'application/json' }, timeout: 15000,
          onload: (r) => { let body = null; try { body = JSON.parse(r.responseText); } catch (_) {} resolve({ ok: r.status >= 200 && r.status < 300, status: r.status, body }); },
          onerror: () => reject(new Error('réseau indisponible')), ontimeout: () => reject(new Error('délai dépassé')),
        });
      } catch (e) { reject(new Error('requête impossible')); }
    });
  }
  function gifLocale() {
    const [lang, region] = String(navigator.language || 'fr-FR').replace('_', '-').split('-');
    const l = (lang || 'fr').toLowerCase().replace(/[^a-z]/g, '').slice(0, 2) || 'fr';
    const r = (region || 'FR').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2) || 'FR';
    return l + '_' + r;
  }
  // provider registry — fetch(query, cursor) → { items:[{id,preview,full,title,ratio,provider}], cursor }
  const GIF_SOURCES = {
    klipy: {
      id: 'klipy',
      key: () => (pref0('klipyKey', '') || '').trim() || 'ez8tkmCLz0aHIu4BUc2x7S32Hnx45BJnUBdNEyG9eRSr4PhQFRH1PsmbPOySt3bT',
      enabled: () => true,
      fetch(query, cursor) {
        const u = new URL('https://api.klipy.com/v2/' + (query ? 'search' : 'featured'));
        u.searchParams.set('key', this.key()); u.searchParams.set('client_key', 'tr4ker-plus');
        if (query) u.searchParams.set('q', query);
        if (cursor) u.searchParams.set('pos', cursor);
        u.searchParams.set('limit', String(GIF_PER_PAGE)); u.searchParams.set('media_filter', 'gif,tinygif'); u.searchParams.set('locale', gifLocale());
        return gifFetchJson(u.toString()).then((r) => {
          if (!r.ok) throw new Error((r.body && ((r.body.error && r.body.error.message) || r.body.message)) || ('Klipy ' + r.status));
          const items = (Array.isArray(r.body && r.body.results) ? r.body.results : []).map((x) => {
            const mf = (x && x.media_formats) || {}; const full = (mf.gif && mf.gif.url) || (x && x.url);
            if (!full) return null; const dims = (mf.tinygif && mf.tinygif.dims) || (mf.gif && mf.gif.dims) || null;
            return { id: String((x && x.id) || full), preview: (mf.tinygif && mf.tinygif.url) || full, full, title: String((x && (x.title || x.content_description)) || ''), ratio: (dims && dims[0] && dims[1]) ? dims[1] / dims[0] : null, provider: 'klipy' };
          }).filter(Boolean);
          return { items, cursor: (typeof (r.body && r.body.next) === 'string' && r.body.next) ? r.body.next : null };
        });
      },
    },
    giphy: {
      id: 'giphy',
      key: () => (pref0('giphyKey', '') || '').trim(),
      enabled() { return !!this.key(); },
      fetch(query, cursor) {
        const off = parseInt(cursor, 10) || 0;
        const u = new URL('https://api.giphy.com/v1/gifs/' + (query ? 'search' : 'trending'));
        u.searchParams.set('api_key', this.key()); if (query) u.searchParams.set('q', query);
        u.searchParams.set('limit', String(GIF_PER_PAGE)); u.searchParams.set('offset', String(off)); u.searchParams.set('rating', 'pg-13');
        return gifFetchJson(u.toString()).then((r) => {
          if (!r.ok) throw new Error((r.body && r.body.meta && r.body.meta.msg) || ('Giphy ' + r.status));
          const items = (Array.isArray(r.body && r.body.data) ? r.body.data : []).map((d) => {
            const im = (d && d.images) || {}; let full = (im.original && im.original.url) || (im.fixed_height && im.fixed_height.url);
            if (!full) return null; full = String(full).split('?')[0];
            const h = im.original && +im.original.height, w = im.original && +im.original.width;
            return { id: String((d && d.id) || full), preview: (im.fixed_height_small && im.fixed_height_small.url) || (im.preview_gif && im.preview_gif.url) || full, full, title: String((d && d.title) || ''), ratio: (h && w) ? h / w : null, provider: 'giphy' };
          }).filter(Boolean);
          const pg = (r.body && r.body.pagination) || {}; const count = typeof pg.count === 'number' ? pg.count : items.length; const total = typeof pg.total_count === 'number' ? pg.total_count : 0;
          return { items, cursor: (count > 0 && (!total || off + count < total)) ? String(off + count) : null };
        });
      },
    },
  };
  // one page across the enabled providers; carries an opaque per-provider cursor map
  function gifSearch(query, cursors) {
    const first = !cursors; const state = cursors || {};
    const calls = Object.values(GIF_SOURCES).filter((s) => s.enabled()).map((s) => {
      if (!first && !state[s.id]) return Promise.resolve({ items: [], cursor: null, id: s.id });
      return s.fetch(query, first ? null : state[s.id]).then((r) => ({ items: r.items, cursor: r.cursor, id: s.id })).catch((e) => ({ items: [], cursor: null, id: s.id, error: (e && e.message) || String(e) }));
    });
    return Promise.all(calls).then((rs) => {
      const items = []; const next = {}; let err = null; const seen = new Set();
      rs.forEach((r) => { r.items.forEach((it) => { if (!seen.has(it.id)) { seen.add(it.id); items.push(it); } }); if (r.cursor) next[r.id] = r.cursor; if (r.error && !err) err = r.error; });
      return { items, cursors: next, done: !Object.keys(next).length, error: items.length ? null : err };
    });
  }
  const GIF_QUICK = ['tendance', 'lol', 'oui', 'non', 'merci', 'wow', 'ok', 'gg'];
  // GIF favorites (épinglés), persisted with the full object so the "Favoris" row can rebuild offline.
  function loadGifPins() { const r = store.get('t4x.gifpins', null); return (r && Array.isArray(r.items)) ? r.items : []; }
  function saveGifPins(items) { store.set('t4x.gifpins', { items: items.slice(0, 24) }); }
  function gifIsPinned(id) { return loadGifPins().some((x) => x.id === id); }
  function gifTogglePin(it) { let items = loadGifPins(); if (items.some((x) => x.id === it.id)) items = items.filter((x) => x.id !== it.id); else items.unshift({ id: it.id, preview: it.preview, full: it.full, title: it.title || '', ratio: it.ratio || null, provider: it.provider }); saveGifPins(items); }
  let gifPanel = null;
  function closeGifPanel() { if (gifPanel) gifPanel.close(); }
  // One-gesture send in the SITE composer: prefer its send button (stable attrs only), else synthesize
  // Enter for the React keydown handler. Fail-soft: if neither works, the GIF stays inserted + focused.
  function gifNativeSend(ta) {
    try {
      const scope = ta.closest('form') || (ta.parentElement && ta.parentElement.parentElement) || document;
      const btn = scope.querySelector('button[type="submit"], button[aria-label*="nvoyer"], button[title*="nvoyer"]');
      if (btn && !btn.disabled && !composerForeign(btn)) { btn.click(); return; }
      ta.focus();
      ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }));
    } catch (_) {}
  }
  // Reusable, host-agnostic: mounts above `anchor` and inserts into `target` (works in our drawer AND
  // the site's native composer). Design: masonry grid (natural GIF aspect), skeleton loaders, quick
  // chips, debounced search + infinite scroll, clear empty/error states, provider attribution.
  function openGifPanel(target, anchor) {
    if (gifPanel) { gifPanel.close(); return; }
    const host = (anchor && anchor.parentNode) || chat.composerEl; if (!host) return;
    if (host.style) host.style.position = 'relative';
    const panel = document.createElement('div'); panel.className = 't4x-gifpanel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Choisir un GIF');
    const head = document.createElement('div'); head.className = 't4x-gifhead';
    const search = document.createElement('input'); search.type = 'text'; search.className = 't4x-gifq'; search.placeholder = 'Rechercher un GIF…'; search.setAttribute('aria-label', 'Rechercher un GIF');
    const x = document.createElement('button'); x.type = 'button'; x.className = 't4x-gifx'; x.title = 'Fermer'; x.setAttribute('aria-label', 'Fermer'); x.innerHTML = '<span class="ic">close</span>';
    head.append(search, x);
    const chips = document.createElement('div'); chips.className = 't4x-gifchips';
    const favRow = document.createElement('div'); favRow.className = 't4x-giffav'; favRow.style.display = 'none';
    const grid = document.createElement('div'); grid.className = 't4x-gifmasonry';
    const scroller = document.createElement('div'); scroller.className = 't4x-gifscroll'; scroller.appendChild(grid);
    const status = document.createElement('div'); status.className = 't4x-gifstatus';
    const foot = document.createElement('div'); foot.className = 't4x-giffoot'; foot.textContent = 'GIF via Klipy' + (GIF_SOURCES.giphy.enabled() ? ' + Giphy' : '');
    panel.append(head, chips, favRow, scroller, status, foot);
    host.appendChild(panel);

    let query = '', cursors = null, loading = false, done = false, token = 0;
    const setStatus = (t) => { status.textContent = t || ''; status.style.display = t ? '' : 'none'; };
    const addSkeletons = (n) => { for (let i = 0; i < n; i++) { const s = document.createElement('div'); s.className = 't4x-gifskel'; s.style.height = (78 + (i * 41) % 96) + 'px'; grid.appendChild(s); } };
    const clearSkeletons = () => grid.querySelectorAll('.t4x-gifskel').forEach((s) => s.remove());
    const pick = (it) => {
      const ta = target || chat.inputEl; const pane = paneOfTa(ta);
      insertAtCursor(ta, (pane && isExt(pane.conv)) ? it.full + ' ' : '[img]' + it.full + '[/img] ');
      closeGifPanel();
      if (pane) { const v = (ta.value || '').trim(); if (v) sendMessage(v, pane); }   // drawer pane: one gesture = send (draft + gif together)
      else if (ta) setTimeout(() => gifNativeSend(ta), 0);                            // site composer: let React flush the input, then trigger its send
      try { (pane ? (pane.inputEl || ta) : ta).focus(); } catch (_) {}
    };
    const makeTile = (it) => {
      const tile = document.createElement('button'); tile.type = 'button'; tile.className = 't4x-giftile' + (gifIsPinned(it.id) ? ' pin' : ''); tile.title = (it.title || 'GIF') + ' — clic droit : épingler'; tile.setAttribute('aria-label', it.title || 'GIF');
      const im = document.createElement('img'); im.src = it.preview; im.loading = 'lazy'; im.referrerPolicy = 'no-referrer'; im.alt = it.title || 'GIF';
      if (it.ratio) im.style.aspectRatio = '1 / ' + Math.max(0.4, Math.min(2.4, it.ratio));   // reserve space → no reflow jank
      tile.appendChild(im);
      tile.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); pick(it); });
      tile.addEventListener('contextmenu', (e) => { e.preventDefault(); gifTogglePin(it); tile.classList.toggle('pin', gifIsPinned(it.id)); renderFav(); });
      im.addEventListener('error', () => tile.remove());
      return tile;
    };
    const renderFav = () => {
      favRow.replaceChildren(); const pins = loadGifPins();
      if (query || !pins.length) { favRow.style.display = 'none'; return; }
      favRow.style.display = '';
      const lbl = document.createElement('div'); lbl.className = 't4x-gifstatus'; lbl.style.cssText = 'padding:1px 2px 4px'; lbl.textContent = 'Favoris · clic droit : épingler / retirer';
      const rr = document.createElement('div'); rr.className = 't4x-gifmasonry t4x-giffavg';
      pins.forEach((it) => rr.appendChild(makeTile(it)));
      favRow.append(lbl, rr);
    };
    const addItems = (items) => { items.forEach((it) => grid.appendChild(makeTile(it))); };
    const run = (q, more) => {
      if (loading) return; if (more && done) return;
      loading = true; const my = ++token;
      if (!more) { query = q || ''; cursors = null; done = false; grid.replaceChildren(); setStatus(''); renderFav(); addSkeletons(6); }
      gifSearch(query, more ? cursors : null).then((r) => {
        if (my !== token) return; loading = false; clearSkeletons(); cursors = r.cursors; done = r.done; addItems(r.items);
        chips.querySelectorAll('.t4x-gifchip').forEach((c) => c.classList.toggle('on', (c.dataset.q || '') === query));
        if (!grid.querySelector('.t4x-giftile')) setStatus(r.error ? ('Indisponible : ' + r.error) : ('Aucun GIF' + (query ? ' pour « ' + query + ' »' : '') + '.'));
        else setStatus('');
        if (!done && r.items.length && scroller.scrollHeight <= scroller.clientHeight + 40) run(query, true);   // first page didn't fill the viewport → keep loading so there's something to scroll
      }).catch(() => { if (my !== token) return; loading = false; clearSkeletons(); if (!grid.querySelector('.t4x-giftile')) setStatus('Recherche indisponible.'); });
    };
    GIF_QUICK.forEach((q) => { const c = document.createElement('button'); c.type = 'button'; c.className = 't4x-gifchip'; c.textContent = q; c.dataset.q = q === 'tendance' ? '' : q; c.addEventListener('click', () => { search.value = c.dataset.q; run(c.dataset.q); }); chips.appendChild(c); });
    let deb = null;
    search.addEventListener('input', () => { clearTimeout(deb); deb = setTimeout(() => run(search.value.trim()), 300); });
    search.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); clearTimeout(deb); run(search.value.trim()); } });
    scroller.addEventListener('scroll', () => { if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 80) run(query, true); });
    x.addEventListener('click', closeGifPanel);
    const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeGifPanel(); } };
    const onOutside = (e) => { const p = (e.composedPath && e.composedPath()) || []; if (p.indexOf(panel) !== -1) return; if (anchor && p.indexOf(anchor) !== -1) return; closeGifPanel(); };
    document.addEventListener('keydown', onKey, true);
    setTimeout(() => document.addEventListener('pointerdown', onOutside, true), 0);
    gifPanel = { el: panel, close() { document.removeEventListener('keydown', onKey, true); document.removeEventListener('pointerdown', onOutside, true); if (panel.parentNode) panel.remove(); gifPanel = null; } };
    run('');
    setTimeout(() => { try { search.focus(); } catch (_) {} }, 30);
  }

  // ==========================================================================
  // QUICK PHRASES — saved replies with a filterable menu + a manager, on both composers.
  // Deterministic recency ordering + an explicit filter field (no draft-as-query footgun, no scoring).
  // Text stored raw, rendered as textContent only; inserted via the (controlled-aware) insertAtCursor.
  // ==========================================================================
  function loadPhrases() {
    const raw = store.get('t4x.phrases', null);
    const items = (raw && Array.isArray(raw.items)) ? raw.items : [];
    const seen = new Set(); const out = [];
    for (const it of items) {
      if (!it || typeof it.text !== 'string') continue;
      const text = it.text.trim().slice(0, 4000); if (!text) continue;
      const kw = Array.isArray(it.keywords) ? it.keywords.map((k) => String(k).trim().slice(0, 24)).filter(Boolean).slice(0, 10) : [];
      const key = text.toLowerCase(); if (seen.has(key)) continue; seen.add(key);
      out.push({ id: it.id || ('p' + Math.random().toString(36).slice(2)), text, keywords: kw, pinned: +it.pinned || 0, uses: +it.uses || 0, lastUsedAt: +it.lastUsedAt || 0, createdAt: +it.createdAt || Date.now(), _norm: (text + ' ' + kw.join(' ')).toLowerCase() });
      if (out.length >= 100) break;
    }
    return out;
  }
  function savePhrases(list) { store.set('t4x.phrases', { v: 1, items: list.map((p) => ({ id: p.id, text: p.text, keywords: p.keywords, pinned: p.pinned, uses: p.uses, lastUsedAt: p.lastUsedAt, createdAt: p.createdAt })) }); }
  function phraseOrder(list) { return list.slice().sort((a, b) => (b.pinned - a.pinned) || (b.lastUsedAt - a.lastUsedAt) || (b.createdAt - a.createdAt)); }
  const noAccent = (s) => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
  let phrasePanel = null, phraseMgr = null;
  function closePhrasePanel() { if (phrasePanel) phrasePanel.close(); }
  function closeComposerPopups() { closeGifPanel(); if (emojiPop) { try { emojiPop.remove(); } catch (e) {} emojiPop = null; } closePhrasePanel(); }
  function openPhrasePanel(target, anchor) {
    if (phrasePanel) { phrasePanel.close(); return; }
    closeComposerPopups();
    const host = (anchor && anchor.parentNode) || chat.composerEl; if (!host) return;
    if (host.style) host.style.position = 'relative';
    ensureSiteCSS();
    const panel = document.createElement('div'); panel.className = 't4x-gifpanel t4x-phrpanel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Réponses rapides');
    const head = document.createElement('div'); head.className = 't4x-gifhead';
    const filter = document.createElement('input'); filter.type = 'text'; filter.className = 't4x-gifq'; filter.placeholder = 'Filtrer…'; filter.setAttribute('aria-label', 'Filtrer les réponses');
    const gear = document.createElement('button'); gear.type = 'button'; gear.className = 't4x-gifx'; gear.title = 'Gérer'; gear.innerHTML = '<span class="ic">settings</span>';
    const x = document.createElement('button'); x.type = 'button'; x.className = 't4x-gifx'; x.title = 'Fermer'; x.innerHTML = '<span class="ic">close</span>';
    head.append(filter, gear, x);
    const list = document.createElement('div'); list.className = 't4x-phrlist';
    const foot = document.createElement('div'); foot.className = 't4x-phrfoot';
    const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 't4x-gifchip';
    foot.appendChild(addBtn);
    panel.append(head, list, foot); host.appendChild(panel);
    let active = -1, rows = [];
    const refreshFoot = () => { const draft = (target && target.value || '').trim(); addBtn.textContent = draft ? '+ Enregistrer le brouillon' : '+ Nouvelle phrase'; };
    const render = () => {
      const q = noAccent(filter.value.trim());
      const items = phraseOrder(loadPhrases()).filter((p) => !q || noAccent(p._norm).includes(q));
      list.replaceChildren(); rows = []; active = -1;
      if (!items.length) { const e = document.createElement('div'); e.className = 't4x-gifstatus'; e.textContent = loadPhrases().length ? 'Aucune correspondance.' : 'Aucune phrase. Écris un message puis « Enregistrer le brouillon », ou ajoute-en via la roue crantée.'; list.appendChild(e); return; }
      items.forEach((p) => {
        const r = document.createElement('button'); r.type = 'button'; r.className = 't4x-phrrow'; r.setAttribute('role', 'option');
        if (p.pinned) { const d = document.createElement('span'); d.className = 't4x-phrpin'; d.textContent = '★'; r.appendChild(d); }
        const t = document.createElement('span'); t.className = 't4x-phrtext'; t.textContent = p.text; t.title = p.text; r.appendChild(t);
        r.addEventListener('click', () => insertPhrase(p, target));
        list.appendChild(r); rows.push(r);
      });
    };
    const insertPhrase = (p, ta) => {
      const t = (ta && ta.isConnected) ? ta : (findComposerTA() || (focusedPane() && focusedPane().inputEl));
      if (!t) { toast('Composeur introuvable.'); return; }
      const before = (t.value || '').slice(0, t.selectionStart != null ? t.selectionStart : t.value.length);
      const pad = before && !/\s$/.test(before) ? ' ' : '';
      insertAtCursor(t, pad + p.text);
      const all = loadPhrases(); const it = all.find((x) => x.id === p.id); if (it) { it.uses++; it.lastUsedAt = Date.now(); savePhrases(all); }
      const pane = paneOfTa(t); if (pane && isExt(pane.conv) && (t.value || '').length > 400) toast('Chat externe : 400 caractères max — raccourcis avant d’envoyer.');
      closePhrasePanel(); try { t.focus(); } catch (e) {}
    };
    addBtn.addEventListener('click', () => {
      const draft = (target && target.value || '').trim();
      if (!draft) { closePhrasePanel(); openPhraseManager(true); return; }
      const all = loadPhrases(); const key = draft.toLowerCase(); const dup = all.find((x) => x.text.toLowerCase() === key);
      if (dup) { dup.lastUsedAt = Date.now(); savePhrases(all); toast('Déjà enregistrée.'); render(); return; }
      if (all.length >= 100) { toast('Limite de 100 phrases atteinte.'); return; }
      all.push({ id: 'p' + Math.random().toString(36).slice(2), text: draft.slice(0, 4000), keywords: [], pinned: 0, uses: 0, lastUsedAt: 0, createdAt: Date.now() });
      savePhrases(all); toast('Phrase enregistrée.'); render();
    });
    gear.addEventListener('click', () => { closePhrasePanel(); openPhraseManager(false); });
    x.addEventListener('click', closePhrasePanel);
    let deb = null; filter.addEventListener('input', () => { clearTimeout(deb); deb = setTimeout(render, 90); });
    filter.addEventListener('keydown', (e) => {
      if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(rows.length - 1, active + 1); rows.forEach((r, i) => r.setAttribute('aria-selected', i === active)); if (rows[active]) rows[active].scrollIntoView({ block: 'nearest' }); }
      else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(0, active - 1); rows.forEach((r, i) => r.setAttribute('aria-selected', i === active)); if (rows[active]) rows[active].scrollIntoView({ block: 'nearest' }); }
      else if (e.key === 'Enter' && active >= 0 && rows[active]) { e.preventDefault(); rows[active].click(); }
    });
    const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); closePhrasePanel(); } };
    const onOutside = (e) => { const p = (e.composedPath && e.composedPath()) || []; if (p.indexOf(panel) !== -1 || (anchor && p.indexOf(anchor) !== -1)) return; closePhrasePanel(); };
    document.addEventListener('keydown', onKey, true);
    setTimeout(() => document.addEventListener('pointerdown', onOutside, true), 0);
    phrasePanel = { el: panel, close() { document.removeEventListener('keydown', onKey, true); document.removeEventListener('pointerdown', onOutside, true); if (panel.parentNode) panel.remove(); phrasePanel = null; } };
    refreshFoot(); render(); setTimeout(() => { try { filter.focus(); } catch (e) {} }, 30);
  }
  function openPhraseManager(addMode) {
    ensureHost(); ensureSettingsCSS();
    if (phraseMgr) { phraseMgr(); return; }
    const veil = document.createElement('div'); veil.className = 'veil on';
    const panel = document.createElement('div'); panel.className = 'pal on st-panel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Gérer les réponses rapides');
    const head = document.createElement('div'); head.className = 'st-head';
    const gl = document.createElement('div'); gl.className = 'st-glyph'; gl.innerHTML = '<span class="ic">quickreply</span>';
    const tt = document.createElement('div'); tt.className = 'st-title'; const bb = document.createElement('b'); bb.textContent = 'Réponses rapides'; tt.appendChild(bb);
    const xk = document.createElement('span'); xk.className = 'hintk'; xk.textContent = 'esc'; xk.style.cursor = 'pointer';
    head.append(gl, tt, xk);
    const body = document.createElement('div'); body.className = 'st-body';
    const addWrap = document.createElement('div'); addWrap.className = 'st-sub'; addWrap.style.flexDirection = 'column';
    const ta = document.createElement('textarea'); ta.className = 'st-in'; ta.style.cssText = 'width:100%;min-height:60px;resize:vertical'; ta.maxLength = 4000; ta.placeholder = 'Texte de la réponse…';
    const cnt = document.createElement('div'); cnt.className = 'note'; cnt.style.textAlign = 'right';
    const kw = document.createElement('input'); kw.className = 'st-in'; kw.style.width = '100%'; kw.placeholder = 'mots-clés (virgules) — optionnel';
    const save = document.createElement('button'); save.className = 'st-btn pri'; save.type = 'button'; save.textContent = 'Ajouter'; save.style.alignSelf = 'flex-start';
    const upd = () => { cnt.textContent = ta.value.length + '/4000'; };
    ta.addEventListener('input', upd); upd();
    addWrap.append(ta, cnt, kw, save);
    const listWrap = document.createElement('div'); listWrap.style.marginTop = '8px';
    const renderList = () => {
      listWrap.replaceChildren();
      const all = phraseOrder(loadPhrases());
      if (!all.length) { listWrap.appendChild((() => { const d = document.createElement('div'); d.className = 'st-note'; d.textContent = 'Aucune phrase enregistrée.'; return d; })()); return; }
      all.forEach((p) => {
        const row = document.createElement('div'); row.className = 'st-sub'; row.style.alignItems = 'flex-start';
        const txt = document.createElement('div'); txt.style.cssText = 'flex:1 1 100%;font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere'; txt.textContent = p.text;
        const meta = document.createElement('div'); meta.style.cssText = 'display:flex;gap:8px;flex:1 1 100%;align-items:center';
        if (p.keywords.length) { const k = document.createElement('span'); k.className = 'note'; k.style.flex = '1'; k.textContent = p.keywords.join(', '); meta.appendChild(k); } else { meta.appendChild((() => { const s = document.createElement('span'); s.style.flex = '1'; return s; })()); }
        const pin = document.createElement('button'); pin.className = 'st-btn'; pin.type = 'button'; pin.textContent = p.pinned ? '★ épinglée' : '☆ épingler';
        pin.addEventListener('click', () => { const all2 = loadPhrases(); const it = all2.find((x) => x.id === p.id); if (it) { it.pinned = it.pinned ? 0 : Date.now(); savePhrases(all2); renderList(); } });
        const del = document.createElement('button'); del.className = 'st-btn'; del.type = 'button'; del.textContent = 'Supprimer';
        del.addEventListener('click', () => { const removed = loadPhrases().find((x) => x.id === p.id); savePhrases(loadPhrases().filter((x) => x.id !== p.id)); renderList(); toast('Phrase supprimée.', 5000, { action: { label: 'Annuler', fn: () => { if (removed) { const cur = loadPhrases(); cur.push(removed); savePhrases(cur); renderList(); } } } }); });
        meta.append(pin, del); row.append(txt, meta); listWrap.appendChild(row);
      });
    };
    save.addEventListener('click', () => {
      const text = ta.value.trim(); if (!text) { ta.focus(); return; }
      const all = loadPhrases(); if (all.some((x) => x.text.toLowerCase() === text.toLowerCase())) { toast('Déjà enregistrée.'); return; }
      if (all.length >= 100) { toast('Limite de 100 phrases atteinte.'); return; }
      const keywords = kw.value.split(/[,;\n]/).map((s) => s.trim().slice(0, 24)).filter(Boolean).slice(0, 10);
      all.push({ id: 'p' + Math.random().toString(36).slice(2), text: text.slice(0, 4000), keywords, pinned: 0, uses: 0, lastUsedAt: 0, createdAt: Date.now() });
      savePhrases(all); ta.value = ''; kw.value = ''; upd(); renderList();
    });
    body.append(addWrap, (() => { const n = document.createElement('div'); n.className = 'st-note'; n.textContent = 'Inclus dans l’export des réglages TR4KER+.'; return n; })(), listWrap);
    panel.append(head, body); root.append(veil, panel);
    const onEsc = (e) => { if (e.key === 'Escape') close(); };
    const close = () => { veil.remove(); panel.remove(); document.removeEventListener('keydown', onEsc, true); phraseMgr = null; };
    phraseMgr = close; veil.addEventListener('click', close); xk.addEventListener('click', close); document.addEventListener('keydown', onEsc, true);
    renderList(); if (addMode) setTimeout(() => ta.focus(), 40);
  }
  try { if (window.__T4X_EXPOSE__) { window.__t4xPhrases = { open: openPhrasePanel, manager: openPhraseManager, load: loadPhrases, save: savePhrases };
    window.__t4xEmoji = { toggle: toggleEmoji, favs: emoFavorites, bump: emoBump, fetchEmotes: fetchEmotes, isEmote: isEmoteUrl, valid: validEmote };
    window.__t4xUsers = { muteToggle: muteToggle, userMuted: userMuted, loadUsers: loadUsers, hlFor: hlFor, showCard: showUcard }; } } catch (e) {}

  // one shared builder for the hover-action glyph buttons (real <button> → keyboard-reachable via
  // .m:focus-within; the .ic span carries the Material Symbols font so emoji buttons added later
  // (reaction favourites) can sit in the same row without inheriting the icon font).
  // position a fixed popover above an anchor, clamped to the viewport, flipping below when there's no room.
  function placeFixedAbove(pop, anchor) {
    const r = (anchor && anchor.getBoundingClientRect && anchor.getBoundingClientRect()) || { left: 20, top: 60, bottom: 80 };
    pop.style.visibility = 'hidden';
    requestAnimationFrame(() => {
      const pw = pop.offsetWidth || 260, ph = pop.offsetHeight || 160;
      let left = Math.min(Math.max(8, r.left), innerWidth - pw - 8);
      let top = r.top - ph - 6; if (top < 8) top = Math.min(r.bottom + 6, innerHeight - ph - 8);
      pop.style.left = left + 'px'; pop.style.top = Math.max(8, top) + 'px'; pop.style.visibility = '';
    });
  }
  function actIconBtn(icon, title, fn) {
    const b = document.createElement('button'); b.type = 'button'; b.title = title; b.setAttribute('aria-label', title);
    const s = document.createElement('span'); s.className = 'ic'; s.textContent = icon; b.appendChild(s);
    b.addEventListener('click', (e) => { e.stopPropagation(); fn(e); });
    return b;
  }
  function mkMsg(m, pane, force) {
    pane = pane || focusedPane();
    // muted user: 'hide' → nothing; 'stub' → a discreet reveal line (per-message)
    if (!force && userMuted(m)) {
      if (pref0('muteMode', 'stub') === 'hide') return null;
      const stub = document.createElement('div'); stub.className = 'm mrun'; stub.dataset.id = m.id;
      const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-mrunhead'; b.textContent = 'Message masqué (' + (m.sender || '?') + ') — afficher';
      b.addEventListener('click', () => { const real = mkMsg(m, pane, true); if (real) stub.replaceWith(real); });
      stub.appendChild(b); return stub;
    }
    const el = document.createElement('div'); el.className = 'm'; el.dataset.id = m.id;
    el.dataset.uid = hlKey(m) || '';
    const hl = hlFor(m); if (hl) { el.classList.add('u-hl'); el.style.setProperty('--t4x-uhl-bg', rgba(hl.color, (hl.alpha || 12) / 100)); el.style.setProperty('--t4x-uhl-edge', rgba(hl.color, 0.55)); }
    const av = document.createElement('div'); av.className = 'av';
    if (m.avatar_url && /^https?:\/\//i.test(m.avatar_url)) { const img = document.createElement('img'); img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; img.src = m.avatar_url; img.onerror = () => { av.textContent = initials(m.sender); img.remove(); }; av.appendChild(img); }
    else av.textContent = initials(m.sender);
    const mc = document.createElement('div'); mc.className = 'mc';
    const mh = document.createElement('div'); mh.className = 'mh';
    const who = document.createElement('span'); who.className = 'who'; who.textContent = m.sender || '?'; who.style.color = roleColor(m.sender_role);
    const muteFromWho = () => { const now = muteToggle(m); toast(now ? ('« ' + (m.sender || '?') + ' » masqué') : (m.sender || '?') + ' réaffiché', 8000, { action: { label: 'Annuler', fn: () => muteToggle(m) } }); };
    who.addEventListener('click', (e) => { if (pref0('mutes', true) && (IS_MAC ? e.metaKey : e.ctrlKey)) { e.preventDefault(); e.stopPropagation(); muteFromWho(); } });
    const tm = document.createElement('span'); tm.className = 'tm'; tm.textContent = fmtTime(m.created_at || m.at);
    mh.append(who, tm);
    const acts = document.createElement('div'); acts.className = 'acts';
    const rep = actIconBtn('reply', 'Répondre', () => { if (!pane) return; pane.replyTo = { id: m.id, sender: m.sender, body: m.body }; renderPaneComposer(pane); pane.inputEl && pane.inputEl.focus(); });
    acts.appendChild(rep);
    buildReactActions(acts, m, pane);   // favourite quick-reactions + "+" picker (Réactions rapides)
    if (pref0('chatHl', true)) acts.appendChild(actIconBtn('palette', 'Surligner ce membre', (e) => openHlPopover(m, (e && e.target && e.target.closest('button')) || acts)));
    if (pref0('mutes', true) && m.sender_id !== chat.myId) acts.appendChild(actIconBtn('volume_off', 'Masquer cet utilisateur (Ctrl/⌘-clic sur le pseudo)', muteFromWho));
    mh.appendChild(acts);
    mc.appendChild(mh);
    if (m.parent && (m.parent.body || m.parent.sender)) { const pq = document.createElement('div'); pq.className = 'pq'; const pMuted = userMuted({ sender_id: m.parent.sender_id, sender: m.parent.sender }); pq.textContent = '↳ ' + (pMuted ? '(utilisateur masqué)' : (m.parent.sender ? m.parent.sender + ' : ' : '') + String(m.parent.body || '')); mc.appendChild(pq); }
    const bd = document.createElement('div'); bd.className = 'bd'; if (pane && e2eDmApplies(m, pane.conv)) e2eRenderBody(bd, m, pane.conv); else if (pane && e2eUnauthApplies(m, pane.conv)) e2eRenderUnauth(bd, m); else renderBody(bd, m.body, chat.myName); mc.appendChild(bd);
    if (m.sender_id !== chat.myId && mentionHit(m.body)) { el.classList.add('ment-hit'); if (m._live && pref0('mentionPulse', true) && !MQ_REDUCE.matches) { el.classList.add('ment-new'); setTimeout(() => el.classList.remove('ment-new'), 3000); } }
    // reactions: the server sends an emoji→count MAP ({"👋":1}), not an array — the old array check never
    // matched, so chips never rendered. Handle both shapes; render as text nodes only (emoji are user data).
    renderReactions(mc, m);
    el.__m = m;   // the message object is the single source of truth for reaction state (incoming frames + optimistic)
    el.append(av, mc);
    return el;
  }

  // reactions come as an emoji→count map (live+history) OR, defensively, a legacy array. Rebuild the
  // single `.rxs` row from scratch each call so live reaction frames can re-render in place (#11).
  function reactionEntries(rx) {
    if (!rx) return [];
    if (Array.isArray(rx)) return rx.map((x) => [x.emoji || '👍', x.count || (x.users && x.users.length) || 1]).filter(([, c]) => c > 0);
    if (typeof rx === 'object') return Object.entries(rx).filter(([, c]) => Number(c) > 0).map(([e, c]) => [e, Number(c)]);
    return [];
  }
  function renderReactions(mc, m) {
    const entries = reactionEntries(m.reactions);
    let rr = mc.querySelector(':scope > .rxs');
    if (!entries.length) { if (rr) rr.remove(); return; }
    if (!rr) { rr = document.createElement('div'); rr.className = 'rxs'; mc.appendChild(rr); }
    rr.replaceChildren();
    const mine = m._myrx || new Set();
    for (const [emo, cnt] of entries) { const s = document.createElement('span'); s.className = 'rx' + (mine.has(emo) ? ' mine' : ''); s.textContent = emo + ' ' + cnt; rr.appendChild(s); }
  }
  // ---- quick reactions on drawer messages (favourites from emofrec `r` + a "+" picker) --------------
  function reactionFavorites() {
    if (_rxFavC) return _rxFavC;
    const items = loadEmofrec();
    const out = Object.keys(items).filter((k) => !k.startsWith('klipy:')).map((k) => ({ k, v: items[k] }))
      .sort((a, b) => (b.v.pin - a.v.pin) || (b.v.r - a.v.r) || (b.v.c - a.v.c) || (b.v.last - a.v.last)).map((x) => x.k);
    for (const d of ['👍', '❤️', '😂']) if (out.length < 4 && out.indexOf(d) === -1) out.push(d);
    _rxFavC = out.slice(0, 4); return _rxFavC;
  }
  function rxLocal(m, emo, delta) {
    // normalise: the ext channel gives an ARRAY of {emoji,count}; convert to the emoji→count map (don't drop it).
    if (Array.isArray(m.reactions)) { const map = {}; m.reactions.forEach((x) => { if (x && x.emoji) map[x.emoji] = Number(x.count) || 0; }); m.reactions = map; }
    else if (typeof m.reactions !== 'object' || m.reactions === null) m.reactions = {};
    const cur = Number(m.reactions[emo] || 0) + delta; if (cur > 0) m.reactions[emo] = cur; else delete m.reactions[emo];
    chat.panes.forEach((p) => { const el = msgEl(p, m.id); if (el) { const mc = el.querySelector('.mc'); if (mc) renderReactions(mc, m); } });
  }
  function sendReaction(m, emoji, pane, add) {
    if (pane && isExt(pane.conv)) extReact(m.id, emoji, !add);
    else { if (sendFrame({ type: add ? 'reaction.add' : 'reaction.remove', message_id: m.id, emoji }) === false) return false; }   // sendFrame toasts on failure
    emoBump(emoji, 'rx'); return true;
  }
  function toggleReaction(m, emoji, pane, btn) {
    m._myrx = m._myrx || new Set(); m._rxecho = m._rxecho || {}; m._rxecho[emoji] = Date.now();
    const had = m._myrx.has(emoji);
    if (had) { m._myrx.delete(emoji); rxLocal(m, emoji, -1); if (sendReaction(m, emoji, pane, false) === false) { m._myrx.add(emoji); rxLocal(m, emoji, +1); return; } if (btn) btn.classList.remove('mine'); }
    else { m._myrx.add(emoji); rxLocal(m, emoji, +1); if (sendReaction(m, emoji, pane, true) === false) { m._myrx.delete(emoji); rxLocal(m, emoji, -1); return; } if (btn) btn.classList.add('mine'); }
  }
  function buildReactActions(acts, m, pane) {
    if (!pref0('rxQuick', true)) { acts.appendChild(actIconBtn('add_reaction', 'Réagir 👍', () => toggleReaction(m, '👍', pane))); return; }
    m._myrx = m._myrx || new Set();
    reactionFavorites().forEach((emo) => {
      const b = document.createElement('button'); b.type = 'button'; b.title = 'Réagir ' + emo; b.setAttribute('aria-label', 'Réagir ' + emo);
      const s = document.createElement('span'); s.className = 'rxq'; s.textContent = emo; b.appendChild(s);
      if (m._myrx.has(emo)) b.classList.add('mine');
      b.addEventListener('click', (e) => { e.stopPropagation(); if (b.dataset.cool) return; b.dataset.cool = '1'; setTimeout(() => { delete b.dataset.cool; }, 600); toggleReaction(m, emo, pane, b); });
      acts.appendChild(b);
    });
    acts.appendChild(actIconBtn('add_reaction', 'Autre réaction', () => openReactionPicker(m, pane, acts)));
  }
  let _rxPick = null;
  function openReactionPicker(m, pane, anchor) {
    if (_rxPick) { _rxPick.remove(); _rxPick = null; return; }
    ensureChatCSS();
    const pop = document.createElement('div'); pop.className = 't4x-rxpick';
    const seen = new Set();
    reactionFavorites().concat(EMOJIS).forEach((emo) => { if (seen.has(emo)) return; seen.add(emo); const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-emob'; b.textContent = emo; b.title = emo; b.addEventListener('click', (e) => { e.stopPropagation(); toggleReaction(m, emo, pane); close(); }); pop.appendChild(b); });
    ensureHost(); root.appendChild(pop); placeFixedAbove(pop, anchor);   // fixed → never clipped by the .msgs overflow (m6)
    _rxPick = pop;
    const close = () => { if (_rxPick) { try { _rxPick.remove(); } catch (e) {} _rxPick = null; } document.removeEventListener('keydown', onk, true); document.removeEventListener('pointerdown', ono, true); };
    const onk = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };
    const ono = (e) => { const p = (e.composedPath && e.composedPath()) || []; if (p.indexOf(pop) !== -1) return; close(); };
    document.addEventListener('keydown', onk, true); setTimeout(() => document.addEventListener('pointerdown', ono, true), 0);
  }
  // ---- per-user: mutes + highlight colours (our chat surfaces; native-site injection deferred) --------
  function unorm(s) { return String(s || '').trim().toLowerCase(); }
  let _usersC = null;   // perf: memoize the parsed users map (hot in mkMsg/userMuted); invalidated by saveUsers + cross-tab listener
  function loadUsers() { if (_usersC) return _usersC; const u = store.get('t4x.users', null); _usersC = (u && typeof u === 'object' && u.byId) ? u : { v: 1, byId: {}, byName: {} }; return _usersC; }
  function saveUsers(u) { _usersC = u; store.set('t4x.users', u); }
  function userMuted(m) {
    if (!pref0('mutes', true) || !m) return false;
    const u = loadUsers();
    if (m.sender_id != null && u.byId[String(m.sender_id)] && u.byId[String(m.sender_id)].hidden) return true;
    if (m.sender && u.byName[unorm(m.sender)] && u.byName[unorm(m.sender)].hidden) return true;
    return false;
  }
  function muteToggle(m) {
    if (m.sender_id != null && m.sender_id === chat.myId) { toast('Tu ne peux pas te masquer toi-même.'); return null; }
    const u = loadUsers(); let now;
    if (m.sender_id != null) { const k = String(m.sender_id); now = !(u.byId[k] && u.byId[k].hidden); if (now) u.byId[k] = { name: m.sender, hidden: true, since: Date.now() }; else delete u.byId[k]; }
    else if (m.sender) { const k = unorm(m.sender); now = !(u.byName[k] && u.byName[k].hidden); if (now) u.byName[k] = { name: m.sender, hidden: true, since: Date.now() }; else delete u.byName[k]; }
    saveUsers(u);
    chat.panes.forEach(loadPane);
    return now;
  }
  let _hlC = null;   // perf: memoize highlights (hot in hlFor per message); writers mutate this object in place
  function loadHl() { if (_hlC) return _hlC; const h = store.get('t4x.chatHighlights', null); _hlC = (h && typeof h === 'object') ? h : {}; return _hlC; }
  function hlKey(m) { return m.sender_id != null ? String(m.sender_id) : (m.sender ? 'ext:' + unorm(m.sender) : null); }
  function hlFor(m) { if (!pref0('chatHl', true)) return null; const h = loadHl(); const k = hlKey(m); const v = k && h[k]; if (!v || !/^#[0-9a-f]{6}$/i.test(v.color || '')) return null; return v; }
  function rgba(hex, a) { const n = parseInt(hex.slice(1), 16); return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')'; }
  const HL_SWATCHES = ['#e0b34c', '#e57f9b', '#5cc8d7', '#a68bfa', '#6fbf82', '#6c9ef8', '#e2934f', '#93a0b8'];
  function openHlPopover(m, anchor) {
    if (_hlPop) { _hlPop.remove(); _hlPop = null; return; }
    ensureChatCSS();
    const pop = document.createElement('div'); pop.className = 't4x-hlpop';
    const cur = hlFor(m) || { color: HL_SWATCHES[0], alpha: 12 };
    const grid = document.createElement('div'); grid.className = 't4x-hlsw';
    const apply = (color, alpha) => { const h = loadHl(); const k = hlKey(m); if (!k) return; h[k] = { name: m.sender, color, alpha: Math.max(6, Math.min(28, alpha || 12)) }; if (Object.keys(h).length > 64) { toast('Limite de 64 membres surlignés.'); return; } store.set('t4x.chatHighlights', h); chat.panes.forEach(loadPane); };
    HL_SWATCHES.forEach((c) => { const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-hlc'; b.style.background = c; b.setAttribute('aria-label', 'Couleur ' + c); b.addEventListener('click', () => { apply(c, cur.alpha || 12); close(); }); grid.appendChild(b); });
    const custom = document.createElement('input'); custom.type = 'color'; custom.value = cur.color; custom.title = 'Couleur personnalisée'; custom.addEventListener('change', () => apply(custom.value, cur.alpha || 12));
    const rm = document.createElement('button'); rm.type = 'button'; rm.className = 't4x-hlrm'; rm.textContent = 'Retirer'; rm.addEventListener('click', () => { const h = loadHl(); const k = hlKey(m); if (k) delete h[k]; store.set('t4x.chatHighlights', h); chat.panes.forEach(loadPane); close(); });
    pop.append(grid, custom, rm);
    ensureHost(); root.appendChild(pop); placeFixedAbove(pop, anchor); _hlPop = pop;
    const close = () => { if (_hlPop) { try { _hlPop.remove(); } catch (e) {} _hlPop = null; } document.removeEventListener('keydown', onk, true); document.removeEventListener('pointerdown', ono, true); };
    const onk = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };
    const ono = (e) => { const p = (e.composedPath && e.composedPath()) || []; if (p.indexOf(pop) !== -1) return; close(); };
    document.addEventListener('keydown', onk, true); setTimeout(() => document.addEventListener('pointerdown', ono, true), 0);
  }
  let _hlPop = null;
  // ---- profile hover card (our chat surfaces) — lazy /api/users/<name>, cached, DOM-built ------------
  const ucardNeg = new Map(); let _ucardEl = null, _ucardTimer = null, _ucardHide = null, _ucardSeq = 0;
  function ensureUcardCSS() { if (ensureUcardCSS._d) return; ensureUcardCSS._d = true; ensureChatCSS(); }
  function ucardHideNow() { if (_ucardHide) { clearTimeout(_ucardHide); _ucardHide = null; } if (_ucardEl) { _ucardEl.style.display = 'none'; _ucardEl.style.pointerEvents = 'none'; } }
  function ensureUcardEl() {
    if (_ucardEl && root.contains(_ucardEl)) return _ucardEl;
    const c = document.createElement('div'); c.className = 't4x-ucard'; c.style.display = 'none';
    c.addEventListener('mouseenter', () => { if (_ucardHide) { clearTimeout(_ucardHide); _ucardHide = null; } });
    c.addEventListener('mouseleave', () => { _ucardHide = setTimeout(ucardHideNow, 150); });
    root.appendChild(c); _ucardEl = c; return c;
  }
  function showUcard(username, anchor) {
    if (!pref0('hoverCard', true) || !username) return;
    const c = ensureUcardEl(); const seq = ++_ucardSeq;
    const r = anchor.getBoundingClientRect();
    const place = () => { c.style.display = 'block'; c.style.pointerEvents = 'auto'; const cw = c.offsetWidth || 300, ch = c.offsetHeight || 120; let left = Math.min(Math.max(8, r.left), innerWidth - cw - 8); let top = r.bottom + 8; if (top + ch > innerHeight - 8) top = Math.max(8, r.top - ch - 8); c.style.left = left + 'px'; c.style.top = top + 'px'; };
    const skel = () => { c.replaceChildren(); const n = document.createElement('div'); n.className = 't4x-ucname'; n.textContent = username; c.appendChild(n); for (let i = 0; i < 3; i++) { const s = document.createElement('div'); s.className = 't4x-gifskel'; s.style.height = '12px'; s.style.margin = '6px 0'; c.appendChild(s); } place(); };
    skel();
    const withLadder = () => { if (seq !== _ucardSeq) return; ladderLookup(username).then((lu) => { injectLadder(c, seq, lu); place(); }); memberStats(username).then((st) => { injectMemberStats(c, seq, st); place(); }); };
    const neg = ucardNeg.get(unorm(username)); if (neg && Date.now() < neg.until) { renderUcard(c, { _err: neg.code }, username); place(); withLadder(); return; }
    apiGet('/api/users/' + encodeURIComponent(username), { ttl: 300000, timeout: 5000 })
      .then((u) => { if (seq !== _ucardSeq) return; renderUcard(c, u, username); place(); withLadder(); })
      .catch((e) => { if (seq !== _ucardSeq) return; const code = /HTTP 403/.test(String(e)) ? 'priv' : (/HTTP 404/.test(String(e)) ? '404' : 'net'); ucardNeg.set(unorm(username), { code, until: Date.now() + (code === 'net' ? 30000 : 300000) }); if (ucardNeg.size > 500) ucardNeg.delete(ucardNeg.keys().next().value); renderUcard(c, { _err: code }, username); place(); withLadder(); });
  }
  function renderUcard(c, u, username) {
    c.replaceChildren();
    const head = document.createElement('div'); head.className = 't4x-uchead';
    const av = document.createElement('div'); av.className = 'av';
    if (u && u.avatar_url && /^https?:\/\//i.test(u.avatar_url)) { const im = document.createElement('img'); im.src = u.avatar_url; im.referrerPolicy = 'no-referrer'; im.loading = 'lazy'; im.onerror = () => { av.textContent = initials(username); try { im.remove(); } catch (e) {} }; av.appendChild(im); } else av.textContent = initials(username);
    const nm = document.createElement('div'); nm.className = 't4x-ucname'; nm.textContent = (u && u.username) || username; if (u && u.role_color) nm.style.color = u.role_color;
    head.append(av, nm); c.appendChild(head);
    if (u && u._err) { const e = document.createElement('div'); e.className = 't4x-ucstat'; e.textContent = u._err === 'priv' ? 'Profil privé.' : u._err === '404' ? 'Profil introuvable.' : 'Profil indisponible pour l’instant.'; c.appendChild(e); return; }
    if (u && u.joined_at) { const j = document.createElement('div'); j.className = 't4x-ucsub'; try { j.textContent = 'Membre depuis ' + new Date(u.joined_at).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long' }); } catch (e) { } c.appendChild(j); }
    const grid = document.createElement('div'); grid.className = 't4x-ucgrid';
    const cell = (k, v, col) => { const d = document.createElement('div'); const kk = document.createElement('div'); kk.className = 't4x-uck'; kk.textContent = k; const vv = document.createElement('div'); vv.className = 't4x-ucv'; vv.textContent = v; if (col) vv.style.color = col; d.append(kk, vv); return d; };
    if (u && u.hide_ratio) grid.appendChild(cell('Ratio', 'Masqué'));
    else if (u) { const up = (Number(u.uploaded) || 0) + (Number(u.bonus_upload) || 0), dn = (Number(u.downloaded) || 0) + (Number(u.bonus_download) || 0); const ra = dn > 0 ? (up / dn) : Infinity; grid.append(cell('Ratio', ra === Infinity ? '∞' : ra.toFixed(2)), cell('Upload', fmtSize(u.uploaded), 'var(--t4x-seed,#4ade80)'), cell('Download', fmtSize(u.downloaded), 'var(--error,#ffb4ab)')); }
    c.appendChild(grid);
  }
  // ---- L4DDER (ladder.kheopsian.com, classement de contribution) dans la carte de profil ----------
  // Données publiques, proxifiées + cachées par t4hub (jamais l'API ladder depuis le navigateur). Le
  // membre absent du ladder n'affiche rien (silencieux). 1 requête GM cachée 10 min par pseudo.
  const _ladderCache = new Map();
  function ladderLookup(name) {
    const k = unorm(name); const hit = _ladderCache.get(k);
    if (hit && Date.now() - hit.at < 600000) return Promise.resolve(hit.v);
    return hubGet('/api/hub/ladder/lookup?u=' + encodeURIComponent(name))
      .then((d) => { const v = (d && d.found) ? d.user : null; _ladderCache.set(k, { v, at: Date.now() }); if (_ladderCache.size > 500) _ladderCache.delete(_ladderCache.keys().next().value); return v; })
      .catch(() => null);
  }
  function fmtTB(x) { const n = Number(x) || 0; return n >= 1000 ? (n / 1000).toFixed(2) + ' Po' : (n >= 10 ? Math.round(n) : n.toFixed(1)) + ' To'; }
  function fmtNum(n) { try { return Number(n).toLocaleString('fr-FR'); } catch (e) { return String(n); } }
  function injectLadder(c, seq, user) {
    if (seq !== _ucardSeq || !c || !root.contains(c) || !user) return;
    if (c.querySelector('.t4x-ucladder')) return;   // idempotent (double resolve)
    const box = document.createElement('div'); box.className = 't4x-ucladder';
    const top = document.createElement('div'); top.className = 't4x-ucltop';
    const lbl = document.createElement('span'); lbl.className = 't4x-ucllbl'; lbl.textContent = 'L4DDER';
    const rank = document.createElement('span'); rank.className = 't4x-uclrank'; rank.textContent = '#' + fmtNum(user.rank);
    const score = document.createElement('span'); score.className = 't4x-uclscore'; score.textContent = (Math.round((user.score || 0) * 10) / 10) + '/100';
    top.append(lbl, rank, score); box.appendChild(top);
    const parts = [];
    if (user.uploaded_TB != null) parts.push(fmtTB(user.uploaded_TB) + ' UL');
    if (user.seeding != null) parts.push(fmtNum(user.seeding) + ' en seed');
    if (parts.length) { const sub = document.createElement('div'); sub.className = 't4x-uclsub'; sub.textContent = parts.join(' · '); box.appendChild(sub); }
    if (user.sub) {
      const bars = document.createElement('div'); bars.className = 't4x-uclbars';
      [['UL', user.sub.up], ['Seed', user.sub.st], ['Upl', user.sub.pub]].forEach(([k, v]) => {
        const b = document.createElement('div'); b.className = 't4x-uclbar';
        const kk = document.createElement('span'); kk.className = 't4x-uclbk'; kk.textContent = k;
        const track = document.createElement('span'); track.className = 't4x-uclbt';
        const fill = document.createElement('span'); fill.className = 't4x-uclbf'; fill.style.width = Math.max(0, Math.min(100, Number(v) || 0)) + '%';
        track.appendChild(fill); b.append(kk, track); bars.appendChild(b);
      });
      box.appendChild(bars);
    }
    c.appendChild(box);
  }
  // ---- Statistiques de messages (agrégat SÛR, non temporel) dans la carte de profil ---------------
  // count + palier + canal principal + actif-depuis, depuis l'archive publique (jamais d'heures/calendrier
  // /graphe ici : ceux-là sont consentis + self-only, plus tard). Silencieux si le membre n'a rien posté.
  const _mstatCache = new Map();
  function memberStats(name) {
    const k = unorm(name); const hit = _mstatCache.get(k);
    if (hit && Date.now() - hit.at < 600000) return Promise.resolve(hit.v);
    return archiveGet('/memberstats?sender=' + encodeURIComponent(name))
      .then((d) => { const v = (d && d.available) ? d : null; _mstatCache.set(k, { v, at: Date.now() }); if (_mstatCache.size > 500) _mstatCache.delete(_mstatCache.keys().next().value); return v; })
      .catch(() => null);
  }
  function fmtMonthYear(ts) { try { return new Date(ts).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); } catch (e) { return ''; } }
  function injectMemberStats(c, seq, st) {
    if (seq !== _ucardSeq || !c || !root.contains(c) || !st || !st.available) return;
    if (c.querySelector('.t4x-ucstats')) return;
    const box = document.createElement('div'); box.className = 't4x-ucstats';
    const top = document.createElement('div'); top.className = 't4x-ucltop';
    const lbl = document.createElement('span'); lbl.className = 't4x-ucllbl'; lbl.textContent = 'MESSAGES';
    const cnt = document.createElement('span'); cnt.className = 't4x-uclrank'; cnt.textContent = fmtNum(st.count);
    const band = document.createElement('span'); band.className = 't4x-uclscore'; band.textContent = st.band;
    top.append(lbl, cnt, band); box.appendChild(top);
    const parts = [];
    if (st.channels && st.channels[0]) parts.push('surtout #' + st.channels[0].ch);
    if (st.first_ts) parts.push('depuis ' + fmtMonthYear(st.first_ts));
    if (parts.length) { const sub = document.createElement('div'); sub.className = 't4x-uclsub'; sub.textContent = parts.join(' · '); box.appendChild(sub); }
    c.appendChild(box);
  }
  function wireHoverCard() {
    if (wireHoverCard._d || !chat.colsEl) return; wireHoverCard._d = true;
    const over = (e) => { if (!pref0('hoverCard', true) || MQ_NOHOVER.matches) return; const a = e.target.closest && e.target.closest('.who, .av'); if (!a) return; const row = a.closest('.m'); if (!row) return; const pane = chat.panes.find((p) => p.el && p.el.contains(row)); if (pane && isExt(pane.conv)) return; if (a.closest('.mrun')) return; const who = row.querySelector('.who'); const name = who && who.textContent; if (!name) return; if (_ucardTimer) clearTimeout(_ucardTimer); _ucardTimer = setTimeout(() => showUcard(name, a), 200); };
    const out = (e) => { if (_ucardTimer) { clearTimeout(_ucardTimer); _ucardTimer = null; } _ucardHide = setTimeout(ucardHideNow, 160); };
    // CLICK a name/avatar -> open the FULL member profile (ladder + rich stats), NOT the avatar picture.
    const click = (e) => {
      if (!pref0('hoverCard', true)) return;
      const a = e.target.closest && e.target.closest('.who, .av'); if (!a) return;
      const row = a.closest('.m'); if (!row) return;
      const pane = chat.panes.find((p) => p.el && p.el.contains(row)); if (pane && isExt(pane.conv)) return;
      if (a.closest('.mrun')) return;
      const who = row.querySelector('.who'); const name = who && who.textContent; if (!name) return;
      e.preventDefault(); e.stopPropagation(); if (_ucardTimer) { clearTimeout(_ucardTimer); _ucardTimer = null; } ucardHideNow();
      openMemberProfile(name);
    };
    chat.colsEl.addEventListener('mouseover', over); chat.colsEl.addEventListener('mouseout', out);
    chat.colsEl.addEventListener('click', click);
  }
  function atBottom(p) { const l = p && p.listEl; return l && (l.scrollHeight - l.scrollTop - l.clientHeight < 80); }
  function scrollBottom(p) { if (p && p.listEl) p.listEl.scrollTop = p.listEl.scrollHeight; }
  function scrollBottomSoon(p) { if (!p || p._sbQ) return; p._sbQ = true; requestAnimationFrame(() => { p._sbQ = false; if (p._stick !== false) scrollBottom(p); }); }   // perf: one layout/frame during floods
  function paneTrim(p) { if (!p || !p.listEl || p._stick === false) return; let n = p.listEl.childElementCount; while (n > 400) { const first = p.listEl.firstElementChild; if (!first) break; const id = first.dataset && first.dataset.id; if (id && p._byId) p._byId.delete(id); first.remove(); n--; } }   // perf: cap DOM growth on long-lived busy channels
  // look up a message row by id in ONE pane — CSS.escape so a crafted remote id can't break the selector
  function msgEl(p, id) { if (!p || !p.listEl || id == null) return null; if (p._byId) { const hit = p._byId.get(String(id)); if (hit && hit.isConnected) return hit; } try { return p.listEl.querySelector('.m[data-id="' + CSS.escape(String(id)) + '"]'); } catch (e) { return null; } }

  // ---- panes ---------------------------------------------------------------
  function paneName(conv) { const c = (chat.channels || []).find(x => x.id === conv); return c ? ('#' + (c.slug || c.name)) : '#…'; }
  function mkPane(conv, meta) {
    const p = { conv, replyTo: null, _selSeq: 0, kind: (meta && meta.kind) || 'channel' };
    if (meta) { p.peerName = meta.name; p.avatar = meta.avatar; p.subject = meta.subject; p.status = meta.status; p.peerId = meta.peerId; }
    const el = document.createElement('div'); el.className = 'pane';
    const ph = document.createElement('div'); ph.className = 'ph';
    const list = document.createElement('div'); list.className = 'msgs'; list.setAttribute('role', 'log'); list.setAttribute('aria-live', 'polite');
    const typing = document.createElement('div'); typing.className = 'typing';
    const comp = document.createElement('div'); comp.className = 'comp';
    el.append(ph, list, typing, comp);
    el.addEventListener('pointerdown', () => focusPane(p), true);
    p.el = el; p.phEl = ph; p.listEl = list; p.typingEl = typing; p.composerEl = comp;
    p._byId = new Map(); p._stick = true;   // perf: O(1) msgEl + stateful stick-to-bottom (no per-message forced reflow)
    list.addEventListener('scroll', () => { p._stick = list.scrollHeight - list.scrollTop - list.clientHeight < 80; }, { passive: true });
    renderPaneHeader(p);
    return p;
  }
  function paneCtlBtns(p) {
    const moveL = chatHBtn('chevron_left', 'Décaler cette colonne à gauche', () => movePane(p, -1)); moveL.classList.add('t4x-pmove');
    const moveR = chatHBtn('chevron_right', 'Décaler cette colonne à droite', () => movePane(p, 1)); moveR.classList.add('t4x-pmove');
    const closeB = chatHBtn('close', 'Fermer cette colonne', () => removePane(p)); closeB.classList.add('t4x-pclose');
    return [moveL, moveR, closeB];
  }
  // The pane header depends on what the column shows: a channel (dropdown picker), a DM (peer + security lock)
  // or a ticket (subject + status). DM/ticket columns are opened from the Messagerie panel, never persisted.
  function renderPaneHeader(p) {
    const ph = p.phEl; if (!ph) return; ph.replaceChildren(); p.selEl = null; p._secBtn = null;
    if (p.kind === 'dm') {
      const lab = document.createElement('div'); lab.className = 't4x-phlabel';
      if (p.avatar) { const av = document.createElement('img'); av.className = 't4x-phav'; av.src = p.avatar; av.alt = ''; av.addEventListener('error', () => av.remove()); lab.appendChild(av); }
      const nm = document.createElement('span'); nm.className = 't4x-phname'; nm.textContent = p.peerName || 'Message privé'; lab.appendChild(nm);
      ph.append(lab, mkDmSecBtn(p), ...paneCtlBtns(p));
    } else if (p.kind === 'ticket') {
      const lab = document.createElement('div'); lab.className = 't4x-phlabel';
      const ic = document.createElement('span'); ic.className = 'ic t4x-phic'; ic.textContent = 'confirmation_number'; lab.appendChild(ic);
      const nm = document.createElement('span'); nm.className = 't4x-phname'; nm.textContent = p.subject || 'Ticket'; lab.appendChild(nm);
      if (p.status) { const b = document.createElement('span'); b.className = 't4x-phstatus s-' + p.status; b.textContent = p.status; lab.appendChild(b); }
      ph.append(lab, ...paneCtlBtns(p));
    } else {
      const sel = document.createElement('select'); sel.className = 't4x-pchan'; sel.title = 'Canal de cette colonne';
      sel.addEventListener('mousedown', () => fillPaneSelect(p));
      sel.addEventListener('focus', () => { focusPane(p); fillPaneSelect(p); });
      sel.addEventListener('change', () => { const c = (chat.channels || []).find((x) => String(x.id) === sel.value); paneSetConv(p, c ? c.id : sel.value); });
      p.selEl = sel; ph.append(sel, ...paneCtlBtns(p)); fillPaneSelect(p);
    }
  }
  // (Re)build a pane's channel dropdown from the live channel list, marking the current channel + unread counts.
  function fillPaneSelect(p) {
    if (!p.selEl) return;
    const chans = chat.channels || [];
    p.selEl.replaceChildren();
    for (const c of chans) {
      const o = document.createElement('option'); o.value = String(c.id);
      const u = chat.unread[c.id];
      let label = '#' + (c.slug || c.name) + (u ? ' (' + (u > 99 ? '99+' : u) + ')' : '');
      if (isExt(c.id) && ext.online > 1) label += ' · ' + ext.online + ' en ligne';
      o.textContent = label;
      if (c.id === p.conv) o.selected = true;
      p.selEl.appendChild(o);
    }
    if (!chans.some((c) => c.id === p.conv)) { const o = document.createElement('option'); o.value = String(p.conv); o.textContent = paneName(p.conv); o.selected = true; p.selEl.appendChild(o); }
  }
  function syncPaneSelect(p) { if (!p.selEl) return; if (document.activeElement === p.selEl) { p.selEl.value = String(p.conv); return; } fillPaneSelect(p); }
  function paneIndex(p) { return chat.panes.indexOf(p); }
  function refreshColsMode() {
    if (chat.colsEl) chat.colsEl.classList.toggle('multi', chat.panes.length > 1);
    chat.panes.forEach((q, j) => { q.el.classList.toggle('foc', j === chat.focus); syncPaneSelect(q); });
    autoChatWidth();
  }
  function focusPane(p) {
    const i = paneIndex(p); if (i < 0) return;
    const changed = chat.focus !== i;
    chat.focus = i;
    chat.panes.forEach((q, j) => q.el.classList.toggle('foc', j === i));
    if (changed) { syncPaneAliases(); renderTabs(); saveLay(); }
  }
  function ensurePanes() {
    if (!chat.el) buildDrawer(); applyChatReading();
    if (chat.panes.length) return;
    const lay = chatLay();
    let convs = (lay.convs && lay.convs.length ? lay.convs : [chat.active || 1]).filter((id, i, a) => a.indexOf(id) === i);
    if (!pref0('extChat', true)) convs = convs.filter((id) => !isExt(id));   // don't restore the external column if it's turned off
    if (!convs.length) convs = [1];
    convs.forEach((id) => { const p = mkPane(id); chat.panes.push(p); chat.colsEl.appendChild(p.el); });
    chat.focus = Math.max(0, Math.min(lay.focus || 0, chat.panes.length - 1));
    syncPaneAliases(); refreshColsMode();
  }
  function addPane(conv, focusIt, meta) {
    ensurePanes();
    const existing = chat.panes.find((x) => x.conv === conv);
    if (existing) { if (focusIt) focusPane(existing); return existing; }
    const p = mkPane(conv, meta);
    chat.panes.push(p); chat.colsEl.appendChild(p.el);
    chat.unread[conv] = 0;
    renderPaneComposer(p); loadPane(p);
    if (focusIt) chat.focus = chat.panes.length - 1;
    syncPaneAliases(); refreshColsMode(); renderTabs(); refreshPillBadge(); saveLay(); applyChatLayout();
    return p;
  }
  // Open a DM or ticket conversation as a drawer column (activates the built-in E2E for DMs). Not persisted.
  function openConvPane(conv, meta) { const p = addPane(conv, true, meta); openDrawer(); return p; }

  // ---- per-conversation security control (DM pane header) — choose Clair / Chiffré / Vérifié per chat ----
  let _secMenu = null;
  function mkDmSecBtn(p) {
    const b = chatHBtn('lock', 'Sécurité de cette conversation', () => openDmSecMenu(p, b));
    b.classList.add('t4x-psec'); p._secBtn = b; updateDmSecBtn(p); return b;
  }
  function updateDmSecBtn(p) {
    const b = p._secBtn; if (!b) return; const span = b.querySelector('.ic'); if (!span) return;
    if (!pref0('e2e', false)) { span.textContent = 'lock_open'; b.title = 'En clair — clique pour activer les MP chiffrés'; b.classList.remove('act'); return; }
    const want = e2eConvMode(p.conv) !== 'clear';
    const peerId = e2ePeer(p.conv); const pin = peerId != null ? e2eId.getPinned(peerId) : null; const verified = !!(pin && pin.verified);
    if (!want) { span.textContent = 'lock_open'; b.title = 'Ce fil est en clair (chiffrement désactivé pour cette conversation)'; b.classList.remove('act'); }
    else { span.textContent = verified ? 'verified_user' : 'lock'; b.title = verified ? 'Chiffré de bout en bout · clé vérifiée' : 'Chiffré de bout en bout (clé épinglée, non vérifiée)'; b.classList.add('act'); }
  }
  function _secAway(e) { if (_secMenu && !e.composedPath().includes(_secMenu)) closeDmSecMenu(); }
  function closeDmSecMenu() { if (_secMenu) { try { _secMenu.remove(); } catch (e) {} _secMenu = null; document.removeEventListener('pointerdown', _secAway, true); } }
  function openDmSecMenu(p, anchor) {
    closeDmSecMenu(); ensureConvUiCSS();
    const m = document.createElement('div'); m.className = 't4x-secmenu';
    const add = (icon, label, sub, fn, on) => {
      const it = document.createElement('button'); it.type = 'button'; it.className = 't4x-secitem' + (on ? ' on' : '');
      const i = document.createElement('span'); i.className = 'ic'; i.textContent = icon;
      const tx = document.createElement('span'); tx.className = 't4x-secl'; const t1 = document.createElement('div'); t1.textContent = label; tx.appendChild(t1);
      if (sub) { const t2 = document.createElement('div'); t2.className = 't4x-secsub'; t2.textContent = sub; tx.appendChild(t2); }
      it.append(i, tx); it.addEventListener('click', (e) => { if (!_e2eTrusted(e)) return; e.stopPropagation(); fn(); }); m.appendChild(it); return it;   // isTrusted-gated like the sibling E2E dialogs: a hostile host cannot synth-click a conversation down to 'clear'
    };
    if (!pref0('e2e', false)) {
      add('lock', 'Activer les MP chiffrés', 'Publie ta clé, puis chiffre les MP entre membres', () => { P = setPref('e2e', true); try { e2eInit(); } catch (e) {} closeDmSecMenu(); updateDmSecBtn(p); renderPaneComposer(p); toast('MP chiffrés activés. Le premier échange épingle la clé du correspondant.', 6000); });
    } else {
      const mode = e2eConvMode(p.conv);
      add('lock_open', 'Clair', 'tr4ker voit les messages', () => { e2eSetConvMode(p.conv, 'clear'); closeDmSecMenu(); updateDmSecBtn(p); renderPaneComposer(p); toast('Ce fil passe en clair.', 3000); }, mode === 'clear');
      add('lock', 'Chiffré', 'Chiffrement de bout en bout', () => { e2eSetConvMode(p.conv, 'auto'); closeDmSecMenu(); updateDmSecBtn(p); renderPaneComposer(p); toast('Ce fil est chiffré de bout en bout.', 3000); }, mode !== 'clear');
      const peerId = e2ePeer(p.conv);
      add('verified_user', 'Vérifier l’empreinte', 'Compare la clé hors-canal (anti-MITM)', () => {
        closeDmSecMenu();
        if (peerId == null) { toast('La clé du correspondant sera épinglée à son premier message.', 5000); return; }
        e2eResolve(peerId).then((r) => { if (r && r.ok && r.peer) { try { e2eVerifyPrompt({ first: true, peerId: peerId, peer: r.peer }, () => updateDmSecBtn(p)); } catch (e) {} } else toast('Clé du correspondant indisponible pour l’instant.', 4000); }).catch(() => toast('Clé indisponible.', 4000));
      });
    }
    root.appendChild(m); _secMenu = m;
    const ar = anchor.getBoundingClientRect();
    m.style.top = Math.min(ar.bottom + 4, window.innerHeight - 180) + 'px';
    m.style.left = Math.max(8, Math.min(ar.left - 40, window.innerWidth - 268)) + 'px';
    setTimeout(() => document.addEventListener('pointerdown', _secAway, true), 0);
  }

  // ---- Messagerie panel: DMs + tickets, reachable from any page; each row opens as a drawer column ----
  let _convUiCssAdded = false, _convPanel = null;
  function ensureConvUiCSS() { if (_convUiCssAdded) return; ensureHost(); const s = document.createElement('style'); s.textContent = CONVUI_CSS; root.appendChild(s); _convUiCssAdded = true; }
  function closeConvList() { if (_convPanel) { try { _convPanel.__veil.remove(); _convPanel.remove(); } catch (e) {} _convPanel = null; } }
  async function openConvList() {
    ensureConvUiCSS(); closeConvList();
    const veil = document.createElement('div'); veil.className = 'veil on'; veil.addEventListener('click', closeConvList);
    const panel = document.createElement('div'); panel.className = 't4x-convpanel'; panel.__veil = veil;
    const head = document.createElement('div'); head.className = 't4x-convhead';
    const h = document.createElement('b'); h.textContent = 'Messagerie';
    const nd = chatHBtn('add_comment', 'Nouveau message privé', () => newDmFlow());
    const nt = chatHBtn('confirmation_number', 'Nouveau ticket', () => newTicketFlow());
    const x = document.createElement('span'); x.className = 'x'; x.textContent = 'close'; x.title = 'Fermer'; x.addEventListener('click', closeConvList);
    head.append(h, nd, nt, x);
    const body = document.createElement('div'); body.className = 't4x-convbody';
    body.textContent = 'Chargement…';
    panel.append(head, body); root.append(veil, panel); _convPanel = panel;
    requestAnimationFrame(() => panel.classList.add('on'));
    try {
      const [dmj, tkj] = await Promise.all([
        fetch('/api/conversations/dms?limit=40', { credentials: 'include' }).then((r) => r.json()).catch(() => ({})),
        fetch('/api/tickets', { credentials: 'include' }).then((r) => r.json()).catch(() => ({})),
      ]);
      if (_convPanel !== panel) return;
      body.replaceChildren();
      const dms = (dmj && dmj.dms) || [];
      const tks = (tkj && tkj.tickets) || [];
      const sec = (label) => { const s = document.createElement('div'); s.className = 't4x-convsec'; s.textContent = label; body.appendChild(s); };
      const rowEl = (opts) => {
        const r = document.createElement('button'); r.type = 'button'; r.className = 't4x-convrow';
        if (opts.avatar) { const av = document.createElement('img'); av.className = 't4x-convav'; av.src = opts.avatar; av.alt = ''; av.addEventListener('error', () => { av.replaceWith(iconAvatar(opts.icon || 'mail')); }); r.appendChild(av); }
        else r.appendChild(iconAvatar(opts.icon || 'mail'));
        const col = document.createElement('div'); col.className = 't4x-convcol';
        const top = document.createElement('div'); top.className = 't4x-convname'; top.textContent = opts.name;
        if (opts.badge) { const b = document.createElement('span'); b.className = 't4x-convbadge'; b.textContent = opts.badge; top.appendChild(b); }
        const sub = document.createElement('div'); sub.className = 't4x-convsub'; sub.textContent = opts.sub || '';
        col.append(top, sub); r.appendChild(col);
        r.addEventListener('click', () => { closeConvList(); opts.open(); });
        return r;
      };
      sec('Messages privés');
      if (!dms.length) { const e = document.createElement('div'); e.className = 't4x-convempty'; e.textContent = 'Aucune conversation privée.'; body.appendChild(e); }
      for (const d of dms) {
        body.appendChild(rowEl({
          name: d.name || ('MP #' + d.id), avatar: d.avatar_url, icon: 'person',
          sub: dmExcerpt(d.last_message || ''), badge: d.unread_count ? (d.unread_count > 99 ? '99+' : String(d.unread_count)) : '',
          open: () => openConvPane(d.id, { kind: 'dm', name: d.name, avatar: d.avatar_url }),
        }));
      }
      sec('Tickets');
      if (!tks.length) { const e = document.createElement('div'); e.className = 't4x-convempty'; e.textContent = 'Aucun ticket.'; body.appendChild(e); }
      for (const t of tks) {
        const st = (t.metadata && t.metadata.status) || '';
        body.appendChild(rowEl({
          name: t.subject || ('Ticket #' + t.conv_id), icon: 'confirmation_number',
          sub: (st ? st + ' · ' : '') + (t.awaiting_staff ? 'en attente du staff' : 'à jour'),
          badge: t.unread_count ? String(t.unread_count) : '',
          open: () => openConvPane(t.conv_id, { kind: 'ticket', subject: t.subject, status: st }),
        }));
      }
    } catch (e) { if (_convPanel === panel) body.textContent = 'Impossible de charger la messagerie (' + e.message + ').'; }
  }
  function iconAvatar(icon) { const d = document.createElement('div'); d.className = 't4x-convav ph'; const s = document.createElement('span'); s.className = 'ic'; s.textContent = icon; d.appendChild(s); return d; }
  async function newDmFlow() {
    const name = prompt('Pseudo du membre à qui écrire :'); if (!name) return;
    try {
      const r = await fetch('/api/dm/' + encodeURIComponent(name.trim()), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' });
      const j = await r.json().catch(() => ({}));
      const id = j.id || j.conv_id || (j.conversation && j.conversation.id);
      if (!r.ok || !id) { toast('Impossible d’ouvrir ce MP (' + (j.error || r.status) + ').', 5000); return; }
      closeConvList(); openConvPane(id, { kind: 'dm', name: name.trim(), avatar: j.avatar_url });
    } catch (e) { toast('Erreur : ' + e.message, 5000); }
  }
  async function newTicketFlow() {
    const subject = prompt('Sujet du ticket :'); if (!subject) return;
    const first = prompt('Ton message :'); if (!first) return;
    try {
      const r = await fetch('/api/tickets', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subject: subject.trim(), message: first.trim() }) });
      const j = await r.json().catch(() => ({}));
      const id = j.conv_id || j.id || (j.ticket && j.ticket.conv_id);
      if (!r.ok || !id) { toast('Impossible de créer le ticket (' + (j.error || r.status) + ').', 5000); return; }
      closeConvList(); openConvPane(id, { kind: 'ticket', subject: subject.trim(), status: 'open' });
      toast('Ticket créé.', 4000);
    } catch (e) { toast('Erreur : ' + e.message, 5000); }
  }
  const CONVUI_CSS = `
    .t4x-secmenu{ position:fixed; z-index:2147483310; width:258px; background:var(--surface-container-high,#1a1d26); color:var(--on-surface,#e6e8f2);
      border:1px solid var(--t4x-hair,rgba(147,165,255,.18)); border-radius:13px; box-shadow:0 20px 56px rgba(0,0,0,.55); padding:6px; }
    .t4x-secitem{ all:unset; cursor:pointer; display:flex; gap:10px; align-items:flex-start; padding:9px 10px; border-radius:9px; width:100%; box-sizing:border-box; }
    .t4x-secitem:hover{ background:color-mix(in srgb,var(--primary,#93a5ff) 14%,transparent); }
    .t4x-secitem .ic{ font-family:'Material Symbols Outlined'; font-size:19px; color:var(--on-surface-variant,#aab2c5); margin-top:1px; }
    .t4x-secitem.on .ic{ color:var(--primary,#93a5ff); }
    .t4x-secl{ font-size:13px; } .t4x-secsub{ font-size:11px; color:var(--on-surface-variant,#8b93a7); margin-top:1px; }
    .t4x-secitem.on .t4x-secl>div:first-child{ color:var(--primary,#93a5ff); font-weight:600; }
    .t4x-convpanel{ position:fixed; z-index:2147483206; top:8vh; left:50%; transform:translateX(-50%) translateY(8px) scale(.98); width:min(460px,94vw); max-height:80vh;
      display:flex; flex-direction:column; background:var(--surface-container-high,#1a1d26); color:var(--on-surface,#e6e8f2);
      border:1px solid var(--t4x-hair,rgba(147,165,255,.16)); border-radius:18px; box-shadow:0 28px 80px rgba(0,0,0,.55); overflow:hidden; opacity:0; transition:opacity .16s,transform .16s; }
    .t4x-convpanel.on{ opacity:1; transform:translateX(-50%) translateY(0) scale(1); }
    .t4x-convhead{ display:flex; align-items:center; gap:4px; padding:11px 14px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .t4x-convhead b{ flex:1; font-size:15px; }
    .t4x-convhead .x{ cursor:pointer; font-family:'Material Symbols Outlined'; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-convbody{ overflow-y:auto; padding:6px; }
    .t4x-convsec{ font-size:11px; letter-spacing:.07em; text-transform:uppercase; color:var(--on-surface-variant,#8b93a7); padding:10px 10px 4px; }
    .t4x-convempty{ font-size:12px; color:var(--on-surface-variant,#8b93a7); padding:4px 12px 8px; }
    .t4x-convrow{ all:unset; cursor:pointer; display:flex; align-items:center; gap:11px; padding:9px 11px; border-radius:11px; width:100%; box-sizing:border-box; }
    .t4x-convrow:hover{ background:var(--surface-container,#141722); }
    .t4x-convav{ width:36px; height:36px; border-radius:50%; flex:0 0 auto; object-fit:cover; background:var(--surface-container,#141722); display:flex; align-items:center; justify-content:center; }
    .t4x-convav.ph .ic{ font-family:'Material Symbols Outlined'; font-size:20px; color:var(--on-surface-variant,#8b93a7); }
    .t4x-convcol{ min-width:0; flex:1; }
    .t4x-convname{ font-size:13.5px; font-weight:600; display:flex; align-items:center; gap:7px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    .t4x-convbadge{ font:600 10px/16px 'JetBrains Mono',monospace; min-width:16px; height:16px; padding:0 4px; border-radius:999px; background:var(--primary,#93a5ff); color:var(--on-primary,#101638); text-align:center; }
    .t4x-convsub{ font-size:12px; color:var(--on-surface-variant,#8b93a7); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    .t4x-phlabel{ flex:1; min-width:0; display:flex; align-items:center; gap:7px; overflow:hidden; }
    .t4x-phav{ width:20px; height:20px; border-radius:50%; object-fit:cover; flex:0 0 auto; }
    .t4x-phic{ font-family:'Material Symbols Outlined'; font-size:16px; color:var(--ann-color,#ffd28a); flex:0 0 auto; }
    .t4x-phname{ font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--on-surface-variant,#b8bdd1); }
    .pane.foc .t4x-phname{ color:var(--primary,#93a5ff); }
    .t4x-phstatus{ font-size:10px; text-transform:uppercase; letter-spacing:.04em; padding:2px 6px; border-radius:999px; background:var(--surface-container,#141722); color:var(--on-surface-variant,#aab2c5); flex:0 0 auto; }
    .t4x-phstatus.s-open{ color:var(--t4x-seed,#4ade80); } .t4x-phstatus.s-closed,.t4x-phstatus.s-resolved{ color:var(--on-surface-variant,#8b93a7); }
    .hb.t4x-psec.act{ color:var(--primary,#93a5ff); }`;
  function splitPane() {
    const openConvs = new Set(chat.panes.map((p) => p.conv));
    const next = (chat.channels || []).find((c) => !openConvs.has(c.id));
    const p = addPane(next ? next.id : (focusedPane() ? focusedPane().conv : 1), true);
    try { if (p && p.selEl) { p.selEl.focus(); } } catch (e) {}   // land in the new column's channel picker so it's obvious how to choose
  }
  // Panorama: one big grid with every channel visible at once (mods/admins). Opt-in, off by default; persisted.
  function toggleGrid() {
    const on = !chatLay().grid; saveLay({ grid: on });
    if (on) openAllChannels();
    refreshColsMode(); renderTabs(); applyChatLayout();
    if (chat._btnGrid) chat._btnGrid.classList.toggle('act', on);
    toast(on ? 'Vue panoramique activée' : 'Vue panoramique désactivée', 2500);
  }
  function openAllChannels() {
    ensurePanes();
    const chans = (chat.channels || []).filter((c) => pref0('extChat', true) || !isExt(c.id));
    const open = new Set(chat.panes.map((x) => x.conv));
    for (const c of chans) { if (chat.panes.length >= 8) break; if (!open.has(c.id)) addPane(c.id, false); }
  }
  function removePane(p) {
    const i = paneIndex(p); if (i < 0) return;
    if (chat.panes.length <= 1) { closeDrawer(); return; }
    chat.panes.splice(i, 1); try { p.el.remove(); } catch (e) {}
    if (i < chat.focus) chat.focus--;                                   // closing a pane LEFT of focus keeps the same one focused
    if (chat.focus >= chat.panes.length) chat.focus = chat.panes.length - 1;
    if (isExt(p.conv) && !extPanes().length && ext.timer) { clearInterval(ext.timer); ext.timer = null; }   // no more external columns → stop the poll
    syncPaneAliases(); refreshColsMode(); renderTabs(); saveLay(); applyChatLayout();
  }
  function movePane(p, dir) {
    const i = paneIndex(p), j = i + dir;
    if (i < 0 || j < 0 || j >= chat.panes.length) return;
    const foc = focusedPane();
    const scrolls = new Map(chat.panes.map((q) => [q, q.listEl.scrollTop]));
    chat.panes.splice(i, 1); chat.panes.splice(j, 0, p);
    chat.panes.forEach((q) => chat.colsEl.appendChild(q.el));      // appendChild MOVES nodes in order
    chat.panes.forEach((q) => { q.listEl.scrollTop = scrolls.get(q) || 0; });
    chat.focus = Math.max(0, chat.panes.indexOf(foc));
    syncPaneAliases(); refreshColsMode(); saveLay();
  }

  async function loadPane(p) {
    if (!p || !p.listEl) return;
    if (isExt(p.conv)) return extLoad(p);          // external channel: t4health transport, not tr4ker
    p.listEl.replaceChildren(); if (p._byId) p._byId.clear();
    const load = document.createElement('div'); load.className = 'ro'; load.textContent = 'Chargement…'; p.listEl.appendChild(load);
    const seq = p._selSeq = (p._selSeq || 0) + 1;          // guard: fast channel switches can resolve out of order
    try {
      const j = await apiGet('/api/conversations/' + p.conv + '/messages?limit=50', { ttl: 8000 });
      if (seq !== p._selSeq) return;                       // a newer selection won — don't render stale history
      const msgs = pickArray(j, ['messages']).slice().reverse(); // API is newest-first → chrono
      p.listEl.replaceChildren(); if (p._byId) p._byId.clear();
      const frag = document.createDocumentFragment();
      for (const m of msgs) { const el = mkMsg(m, p); if (el) { frag.appendChild(el); if (p._byId) p._byId.set(String(m.id), el); } }
      p.listEl.appendChild(frag);
      if (msgs.length) chat.lastId[p.conv] = msgs[msgs.length - 1].id;
      if (p.kind === 'dm' && p._secBtn) updateDmSecBtn(p);   // the peer key is learned while rendering history → refresh the lock state
      p._stick = true; scrollBottom(p);
    } catch (e) { if (seq !== p._selSeq) return; p.listEl.replaceChildren(); const er = document.createElement('div'); er.className = 'ro'; er.textContent = 'Impossible de charger (' + e.message + ').'; p.listEl.appendChild(er); }
  }
  async function paneSetConv(p, id) {
    if (!p) return;
    p.conv = id; p.replyTo = null; chat.unread[id] = 0;
    syncPaneAliases(); refreshColsMode(); renderTabs(); renderPaneComposer(p); refreshPillBadge(); saveLay();
    await loadPane(p);
  }
  async function selectChannel(id) { await paneSetConv(focusedPane(), id); }

  function onChatFrame(f) {
    if (f.type === 'msg.received') {
      f._live = true;
      const muted = userMuted(f);   // muted users: no alert, no unread, rendered as stub/hidden
      // watchlist alert (self-mention or saved keyword) — fires even for background channels
      if (!muted && f.sender_id !== chat.myId && pref0('chatAlerts', true) && mentionHit(f.body)) {
        const ch = (chat.channels || []).find(x => x.id === f.conv_id);
        const who = f.sender || 'Quelqu’un', where = ch ? ' dans #' + (ch.slug || ch.name) : '';
        toast(who + ' t’a mentionné' + where, 6000);
        try { notifyMention('TR4KER — ' + who + where, String(f.body || '').slice(0, 120), () => { openDrawer(); if (f.conv_id) selectChannel(f.conv_id); }, 'msg:' + f.id); } catch (e) {}
      }
      if (!muted) { try { captureSignal(f, f.conv_id, 'live'); } catch (e) {} }   // Signaux inbox (additive; runs during quiet hours too)
      const targets = (chat.open || chat.inline) ? chat.panes.filter((p) => p.conv === f.conv_id) : [];
      if (targets.length) {
        for (const p of targets) {
          if (msgEl(p, f.id)) continue;   // dedupe: the same id can arrive via history backfill AND a live frame
          const el = mkMsg(f, p); if (!el) continue;   // 'hide' mode returns null for muted users
          p.listEl.appendChild(el); if (p._byId) p._byId.set(String(f.id), el); paneTrim(p); scrollBottomSoon(p);
        }
        chat.lastId[f.conv_id] = f.id;
      } else if (!muted && f.sender_id !== chat.myId && (chat.channels || []).some((c) => c.id === f.conv_id)) {
        // only count KNOWN public channels — DM convs (absent from the list) are signalled by onDmFrame,
        // and a DM conv can never become a pane, so counting it here would stick the badge forever (M7).
        chat.unread[f.conv_id] = (chat.unread[f.conv_id] || 0) + 1; if (chat.open || chat.inline) renderTabsSoon(); refreshPillBadge();
      }
    } else if (f.type === 'msg.deleted') {
      chat.panes.forEach((p) => { const el = msgEl(p, f.message_id); if (!el) return; if (el.__m) el.__m.body = '(supprimé)'; const bd = el.querySelector('.bd'); if (bd) { bd.textContent = '(supprimé)'; el.style.opacity = '.5'; } });
    } else if (f.type === 'msg.edited') {
      chat.panes.forEach((p) => { const el = msgEl(p, f.message_id); if (!el) return; if (el.__m) el.__m.body = f.body; const bd = el.querySelector('.bd'); if (bd) renderBody(bd, f.body, chat.myName); });
    } else if (f.type && f.type.indexOf('reaction') === 0) {
      // incoming reaction broadcast: the message object (el.__m) is the source of truth. A full map on the
      // frame replaces it; a delta-only frame adjusts the emoji by ±1. Echo-guarded against our own optimistic
      // toggle within ~2 s (a live reaction.* frame was never observed in probing — P-5).
      const id = f.message_id != null ? f.message_id : f.id;
      chat.panes.forEach((p) => {
        const el = msgEl(p, id); if (!el) return; const mc = el.querySelector('.mc'); if (!mc) return;
        const mm = el.__m || { id, reactions: {} };
        if (f.reactions && typeof f.reactions === 'object') { mm.reactions = f.reactions; renderReactions(mc, mm); }
        else if (f.emoji) { if (mm._rxecho && mm._rxecho[f.emoji] && Date.now() - mm._rxecho[f.emoji] < 2000) return; rxLocal(mm, f.emoji, /remove/.test(f.type) ? -1 : 1); }
      });
    } else if (f.type === 'typing') {
      chat.panes.forEach((p) => {
        if (p.conv === f.conv_id && f.user_id !== chat.myId && p.typingEl) {
          p.typingEl.textContent = (f.user || 'Quelqu’un') + ' écrit…';
          clearTimeout(p._typClr); p._typClr = setTimeout(() => { p.typingEl.textContent = ''; }, 3000);
        }
      });
    } else if (f.type === '_ws_open') {
      if (chat.open || chat.inline) chat.panes.forEach((p) => loadPane(p));   // backfill every pane after a (re)connect
    }
  }

  let typingSent = 0;
  function onTyping(p) { const conv = (p || focusedPane() || { conv: chat.active }).conv; if (isExt(conv)) return; const now = Date.now(); if (now - typingSent > 3500) { typingSent = now; sendFrame({ type: 'typing', conv_id: conv }); } }
  function sendMessage(body, p) {
    p = p || focusedPane(); if (!p) return false;
    if (isExt(p.conv)) {
      // external send is async + can be refused server-side; keep the text and clear only on real success
      extSend(body, p, (ok) => { if (ok) { p.replyTo = null; renderPaneComposer(p); } });   // clear only on real success; on refusal the text stays
      return true;
    }
    // R3-fix (boot race): if E2E is enabled for a DM conv but curve-support is not yet DETERMINED (_e2eSup===null),
    // do NOT fall through to the plaintext path — hold the send until e2eInit resolves. Only a resolved false may send clear.
    if (pref0('e2e', false) && e2eIsDmConv(p.conv) && e2eConvMode(p.conv) !== 'clear' && _e2eSup === null) { toast('E2E : initialisation en cours, réessaie dans un instant.', 4000); try { e2eInit(); } catch (e) {} return true; }
    if (e2eWantEncrypt(p.conv)) {   // E2E DM (per-conv level != clear): seal before send; FAIL CLOSED — never a silent plaintext downgrade
      const replyId = p.replyTo ? p.replyTo.id : null;
      const sendWire = (wire) => { const f = { type: 'msg.send', conv_id: p.conv, body: wire }; if (replyId) f.parent_id = replyId; if (sendFrame(f)) { p.replyTo = null; renderPaneComposer(p); } };
      const sendClear = () => { const f = { type: 'msg.send', conv_id: p.conv, body: body }; if (replyId) f.parent_id = replyId; if (sendFrame(f)) { e2eCacheMineClear(p.conv, body); p.replyTo = null; renderPaneComposer(p); } };
      e2eSeal(p.conv, body).then((r) => {
        if (r && r.wire) { sendWire(r.wire); return; }
        if (r && r.changed) {   // key rotation: never send; OOB re-verify (with backoff against habituation), retry on accept
          toast('E2E : la clé de ce correspondant a changé — message NON envoyé. Vérifie la nouvelle empreinte.', 8000);
          const now = Date.now(); if (!_e2ePromptCool[r.peerId] || now - _e2ePromptCool[r.peerId] > 30000) { _e2ePromptCool[r.peerId] = now; try { e2eVerifyPrompt(r, (ok) => { if (ok) sendMessage(body, p); }); } catch (e) {} }
          return;
        }
        if (r && r.first) { try { e2eVerifyPrompt(r, (ok) => { if (ok) sendMessage(body, p); }); } catch (e) {} return; }   // first contact: confirm fingerprint, then send
        if (r && r.error) { toast('E2E : chiffrement impossible — message NON envoyé.', 7000); return; }   // fail closed; composer text kept
        // r.nokey: no resolvable key -> explicit per-conversation confirm; backward-compat via image (recommended) or plaintext, never silent
        const sendImage = () => e2eSendAsImage(p.conv, body, replyId, (ok) => { if (ok) { p.replyTo = null; renderPaneComposer(p); } });
        const doFallback = (mode) => {
          e2eSeal(p.conv, body).then((r2) => {   // TOCTOU: a key may have been learned while the dialog was open — re-seal before any non-E2E send
            if (r2 && r2.wire) { sendWire(r2.wire); return; }
            if (r2 && r2.first) { try { e2eVerifyPrompt(r2, (ok) => { if (ok) sendMessage(body, p); }); } catch (e) {} return; }
            if (r2 && r2.changed) { toast('E2E : la clé du correspondant a changé — message NON envoyé.', 7000); return; }
            if (r2 && r2.error) { toast('E2E : chiffrement impossible — message NON envoyé.', 7000); return; }
            if (mode === 'image') { toast('E2E : envoyé en image — non chiffré (hébergeur tiers public).', 6000); sendImage(); }
            else { toast('E2E : envoyé EN CLAIR (correspondant sans clé).', 6000); sendClear(); }
          }).catch(() => { toast('E2E : chiffrement impossible — message NON envoyé.', 7000); });
        };
        const remembered = e2eClearOk(p.conv);   // 'image' | 'clear' | null
        if (remembered) { doFallback(remembered); return; }
        e2eNoKeyConfirm(p.conv, () => doFallback('image'), () => doFallback('clear'));
      }).catch(() => { toast('E2E : chiffrement impossible — message NON envoyé.', 7000); });   // fail closed; composer text kept
      return true;
    }
    const frame = { type: 'msg.send', conv_id: p.conv, body };
    if (p.replyTo) frame.parent_id = p.replyTo.id;
    const ok = sendFrame(frame);
    if (ok) { p.replyTo = null; renderPaneComposer(p); } // success rebuilds an empty composer; on failure the text is kept
    return ok;
  }

  // ---- workspace layout: dock left/right (page pushed aside, T4CO-published) or floating window ----
  function setChatMode(mode) { saveLay({ mode }); applyChatLayout(); }
  function autoChatWidth() {
    if (chat.inline || !chat.open || !chat.panes.length) return;
    const lay = chatLay();
    if (lay.mode === 'float') return;
    const cols = lay.grid ? Math.max(1, Math.ceil(chat.panes.length / 2)) : chat.panes.length;   // panorama = 2 rows
    const per = lay.grid ? 300 : 340;
    const need = Math.min(cols * per, Math.round(window.innerWidth * 0.94));
    if ((lay.w || 420) < need) saveLay({ w: need });
  }
  function clearChatPush() {
    try { document.documentElement.style.removeProperty('margin-right'); document.documentElement.style.removeProperty('margin-left'); } catch (e) {}
  }
  function applyChatLayout() {
    if (!chat.el || chat.inline) { clearChatPush(); publishT4co(); return; }
    const lay = chatLay();
    const mode = lay.mode || 'right';
    chat.el.classList.toggle('dockL', mode === 'left');
    chat.el.classList.toggle('float', mode === 'float');
    const grid = !!lay.grid;
    if (chat.colsEl) { chat.colsEl.classList.toggle('grid', grid); if (grid) chat.colsEl.style.setProperty('--t4x-gridcols', Math.max(1, Math.ceil((chat.panes.length || 1) / 2))); }
    if (chat._btnGrid) chat._btnGrid.classList.toggle('act', grid);
    chat.el.style.setProperty('--t4x-chatw', (lay.w || 420) + 'px');
    chat.el.style.setProperty('--t4x-chath', (lay.h || 560) + 'px');
    chat.el.style.setProperty('--t4x-chatx', (lay.x == null ? 80 : lay.x) + 'px');
    chat.el.style.setProperty('--t4x-chaty', (lay.y == null ? 70 : lay.y) + 'px');
    if (chat._btnL) { chat._btnL.classList.toggle('act', mode === 'left'); chat._btnF.classList.toggle('act', mode === 'float'); chat._btnR.classList.toggle('act', mode === 'right'); }
    clearChatPush();
    if (mode === 'float') {
      chat.el.style.right = ''; chat.el.style.left = '';
    } else {
      chat.el.style.width = ''; chat.el.style.height = '';   // drop any inline size a float resize left behind
      // T4CO coexistence: sit flush against another app's dock on the same side (e.g. OmniChat right sidebar)
      const off = t4coForeignGutter(mode);
      chat.el.style.right = mode === 'right' ? off + 'px' : 'auto';
      chat.el.style.left = mode === 'left' ? off + 'px' : '';
      if (chat.open && pref0('chatPush', true)) {
        // push the page (html margin, NOT body — body belongs to other scripts; margins compose additively)
        const w = Math.min(lay.w || 420, Math.round(window.innerWidth * 0.94));
        const bm = parseInt(getComputedStyle(document.body)[mode === 'right' ? 'marginRight' : 'marginLeft'], 10) || 0;
        const push = Math.max(0, Math.min(off + w - bm, Math.round(window.innerWidth * 0.65)));
        try { document.documentElement.style.setProperty('margin-' + mode, push + 'px', 'important'); } catch (e) {}
      }
    }
    publishT4co();
    try { applyPillPos(); } catch (e) {}   // reseat the pill immediately (its own T4CO listener ignores our events)
  }

  async function openDrawer() {
    if (!pref0('chat', true)) return;
    if (chat.inline) return;                 // the chat currently lives inline in the home dashboard
    if (chat.open && chat.el) return;        // already open — don't reload every pane
    ensureOwnSocket();
    if (!chat.el) buildDrawer(); applyChatReading();
    if (!chat.channels) { await loadChannels(); try { const m = await me(); chat.myRole = m.role || 'user'; chat.myId = m.id; chat.myName = m.username; if (m.username) store.set('t4x.myname', m.username); } catch (e) {} }
    ensurePanes();
    chat.open = true; chat.el.classList.add('on'); markOpen(chat.el, true);
    if (!chat._sub) chat._sub = onFrame((f) => onChatFrame(f));
    chat.panes.forEach((p) => { chat.unread[p.conv] = 0; renderPaneComposer(p); loadPane(p); });   // reset unread BEFORE painting the tabs
    refreshColsMode(); renderTabs();
    applyChatLayout();
    refreshPillBadge(); saveLay();
  }
  function closeDrawer() {
    const was = chat.open;
    chat.open = false;
    if (chat.el) { chat.el.classList.remove('on'); markOpen(chat.el, false); }
    clearChatPush(); publishT4co();
    if (!extVisible() && ext.timer) { clearInterval(ext.timer); ext.timer = null; }   // drawer closed → stop the external poll
    if (was && !chat.inline) saveLay();
  }
  function toggleDrawer() { chat.open ? closeDrawer() : openDrawer(); }

  // ==========================================================================
  // EXTERNAL COMMUNITY CHAT — the t4health shoutbox as an extra channel. It lives on OUR infra, so
  // it keeps working when tr4ker itself is down (the whole point). Transport = polled HTTP via
  // GM_xmlhttpRequest (t4health computes presence from pollers, so we ONLY poll while a pane is
  // visible — background polling would fake everyone as online). t4health renders BARE image URLs
  // (no BBCode) and caps messages at 400 chars; sends carry a honeypot field `website: ''`.
  // ==========================================================================
  const EXT_CONV = 'ext';
  const ext = { msgs: [], maxId: 0, online: 0, timer: null, loaded: false, cooldown: 0 };
  function isExt(conv) { return conv === EXT_CONV; }
  function extName() {
    const n = chat.myName || store.get('t4x.myname', null) || pref0('extPseudo', null);
    if (n) return n;
    const v = prompt('Ton pseudo pour le chat communautaire externe :', '');
    if (v && v.trim()) { P = setPref('extPseudo', v.trim().slice(0, 30)); return P.extPseudo; }
    return null;
  }
  function extMap(m) {
    return { id: m.id, sender: m.name || '?', sender_role: m.admin ? 'admin' : undefined,
             body: String(m.body || ''), created_at: m.t, reactions: Array.isArray(m.reacts) ? m.reacts : undefined };
  }
  function extPanes() { return chat.panes.filter((p) => isExt(p.conv)); }
  function extVisible() { return (chat.open || chat.inline) && extPanes().length > 0; }
  function extFetch() {
    return new Promise((res, rej) => {
      GM_xmlhttpRequest({
        method: 'GET', url: HEALTH_URL + '/api/messages', timeout: 8000,
        onload: (r) => { try { res(JSON.parse(r.responseText)); } catch (e) { rej(new Error('réponse invalide')); } },
        onerror: () => rej(new Error('t4health injoignable')), ontimeout: () => rej(new Error('délai dépassé')),
      });
    });
  }
  async function extSync() {                       // refresh ext.* ; returns the NEW messages (or null on failure)
    let d; try { d = await extFetch(); } catch (e) { return null; }
    const msgs = Array.isArray(d.messages) ? d.messages : [];
    ext.online = d.online || 0;
    const fresh = ext.loaded ? msgs.filter((m) => (+m.id || 0) > ext.maxId) : msgs;
    ext.msgs = msgs; ext.loaded = true;
    for (const m of msgs) { const n = +m.id || 0; if (n > ext.maxId) ext.maxId = n; }
    return fresh;
  }
  function extPaneHeader(p) { syncPaneSelect(p); }   // ext online count is rendered into the dropdown option by fillPaneSelect
  function extRenderAll(p) {
    p.listEl.replaceChildren(); if (p._byId) p._byId.clear();
    if (!ext.loaded) { const l = document.createElement('div'); l.className = 'ro'; l.textContent = 'Chargement…'; p.listEl.appendChild(l); return; }
    if (!ext.msgs.length) { const e0 = document.createElement('div'); e0.className = 'ro'; e0.textContent = 'Encore personne ici — écris le premier message.'; p.listEl.appendChild(e0); }
    const frag = document.createDocumentFragment();
    for (const m of ext.msgs) { const el = mkMsg(extMap(m), p); if (el) { frag.appendChild(el); if (p._byId) p._byId.set(String(m.id), el); } }
    p.listEl.appendChild(frag);
    extPaneHeader(p);
    p._stick = true; scrollBottom(p);
  }
  async function extLoad(p) {
    extEnsureTimer(); p._extInit = true;
    extRenderAll(p);                               // instant (cached or "Chargement…")
    if (!ext.loaded) {
      const r = await extSync();
      if (!ext.loaded && r === null) {             // t4health unreachable → say so instead of a frozen "Chargement…"
        p.listEl.replaceChildren(); if (p._byId) p._byId.clear();
        const er = document.createElement('div'); er.className = 'ro';
        er.textContent = 'Chat externe injoignable — nouvelle tentative automatique…'; p.listEl.appendChild(er);
      } else extRenderAll(p);
    }
  }
  function extTick() {
    if (!extVisible() || !pref0('extChat', true)) return;
    extSync().then((fresh) => {
      if (!fresh) return;
      for (const p of extPanes()) {
        if (!p._extInit) { extLoad(p); continue; }
        for (const m of fresh) {
          if (msgEl(p, m.id)) continue;
          const el = mkMsg(extMap(m), p); if (!el) continue; p.listEl.appendChild(el); if (p._byId) p._byId.set(String(m.id), el); paneTrim(p); scrollBottomSoon(p);
        }
        extPaneHeader(p);
      }
    });
  }
  function extEnsureTimer() { if (!ext.timer) ext.timer = setInterval(() => { if (document.hidden) return; try { extTick(); } catch (e) {} }, 5000); }
  function extSend(body, p, done) {
    const cb = (ok) => { try { done && done(ok); } catch (e) {} };
    body = String(body || '').trim(); if (!body) { cb(false); return false; }
    if (Date.now() < ext.cooldown) { toast('Chat externe : patiente quelques secondes entre deux messages.'); cb(false); return false; }
    if (body.length > 400) { toast('Chat externe : 400 caractères max.'); cb(false); return false; }
    const name = extName(); if (!name) { cb(false); return false; }
    GM_xmlhttpRequest({
      method: 'POST', url: HEALTH_URL + '/api/message', headers: { 'Content-Type': 'application/json' },
      data: JSON.stringify({ name, body, website: '', reply_to: p && p.replyTo ? p.replyTo.id : null }), timeout: 10000,
      onload: (r) => {
        let d = null; try { d = JSON.parse(r.responseText); } catch (e) {}
        if (d && d.ok) { ext.cooldown = Date.now() + 5000; setTimeout(extTick, 300); cb(true); }
        else { toast('Chat externe : ' + ((d && d.msg) || 'refusé')); cb(false); }
      },
      onerror: () => { toast('Chat externe : réseau indisponible.'); cb(false); },
      ontimeout: () => { toast('Chat externe : délai dépassé.'); cb(false); },
    });
    ext.cooldown = Date.now() + 2000;
    return true;
  }
  function extReact(id, emoji, remove) {
    GM_xmlhttpRequest({ method: 'POST', url: HEALTH_URL + '/api/react', headers: { 'Content-Type': 'application/json' },
      data: JSON.stringify({ id: +id, emoji: emoji || '👍', remove: !!remove }), timeout: 8000, onload: () => {}, onerror: () => {} });
  }

  // ==========================================================================
  // STATUS / HUD PILL
  // ==========================================================================
  let pillEl = null, pillUnreadEl = null, pillUnreadMiniEl = null, pillRatioEl = null, pillCreditEl = null, pillFlEl = null, pillVoiceBtn = null, pillAiBtn = null, pillUpBtn = null;
  function buildPill() {
    ensureChatCSS(); ensureHost();
    if (pillEl) return;
    const p = document.createElement('div'); p.className = 'pill';
    // drag handle: the pill is user-movable, position persisted. Advertised via data-t4x-movable so a
    // companion script (e.g. Omnichat) can detect we manage our own placement and skip patching us.
    const grip = document.createElement('span'); grip.className = 't4x-grip'; grip.title = 'Glisser pour déplacer la pastille'; grip.innerHTML = '<span class="ic">drag_indicator</span>';
    p.appendChild(grip);
    const rs = document.createElement('div'); rs.className = 'stat'; rs.style.cursor = 'pointer'; rs.title = 'Évolution depuis ta dernière visite'; rs.addEventListener('click', showRatioDelta); const rk = document.createElement('span'); rk.className = 'k'; rk.textContent = 'Ratio'; pillRatioEl = document.createElement('span'); pillRatioEl.className = 'v'; pillRatioEl.textContent = '—'; rs.append(rk, pillRatioEl);
    const cs = document.createElement('div'); cs.className = 'stat'; const ck = document.createElement('span'); ck.className = 'k'; ck.textContent = 'Crédits'; pillCreditEl = document.createElement('span'); pillCreditEl.className = 'v'; pillCreditEl.textContent = '—'; cs.append(ck, pillCreditEl);
    pillFlEl = document.createElement('span'); pillFlEl.className = 'fl'; pillFlEl.textContent = 'FL'; pillFlEl.style.display = 'none';
    const sep = document.createElement('div'); sep.className = 'sep';
    const bk = document.createElement('button'); bk.title = 'Palette (Ctrl/⌘+K)'; bk.innerHTML = '<span class="ic">search</span>'; bk.addEventListener('click', openPalette);
    const bc = document.createElement('button'); bc.title = 'Chat (Ctrl/⌘+J)'; bc.innerHTML = '<span class="ic">forum</span>'; bc.addEventListener('click', toggleDrawer);
    pillUnreadEl = document.createElement('span'); pillUnreadEl.className = 'badge'; pillUnreadEl.style.display = 'none'; bc.appendChild(pillUnreadEl);
    const bsig = document.createElement('button'); bsig.title = 'Signaux — mentions à relire'; bsig.innerHTML = '<span class="ic">notifications</span>'; bsig.addEventListener('click', openSignals); pillSigBtn = bsig;
    sigPillBadge = document.createElement('span'); sigPillBadge.className = 'badge'; sigPillBadge.style.display = 'none'; bsig.appendChild(sigPillBadge);
    bsig.style.display = 'none';   // shown only when there are unread signals (refreshSigBadge)
    const ba = document.createElement('button'); ba.title = 'Assistant IA'; ba.innerHTML = '<span class="ic">auto_awesome</span>'; ba.addEventListener('click', toggleAi); pillAiBtn = ba;
    ba.style.display = pref0('ai', false) ? '' : 'none';   // opt-in (v0.18)
    const bv = document.createElement('button'); bv.title = 'Salon vocal'; bv.innerHTML = '<span class="ic">mic</span>'; bv.addEventListener('click', toggleVoice); pillVoiceBtn = bv;
    const bu = document.createElement('button'); bu.title = 'Mise à jour disponible'; bu.innerHTML = '<span class="ic">system_update_alt</span>'; bu.addEventListener('click', openUpdate); pillUpBtn = bu;
    bu.style.display = updateState.available ? '' : 'none';
    bu.style.boxShadow = '0 0 0 2px var(--t4x-seed,#4ade80)';
    const bm = document.createElement('button'); bm.title = 'Toutes les fonctions TR4KER+'; bm.innerHTML = '<span class="ic">apps</span>'; bm.addEventListener('click', (e) => { e.stopPropagation(); toggleLauncher(bm); });
    // mini mode: the whole pill collapses to this one button; click unfolds it temporarily
    const bmini = document.createElement('button'); bmini.className = 't4x-minibtn'; bmini.title = 'TR4KER+ (clic : déplier)';
    bmini.innerHTML = '<span class="ic">apps</span>';
    pillUnreadMiniEl = document.createElement('span'); pillUnreadMiniEl.className = 'badge'; pillUnreadMiniEl.style.display = 'none'; bmini.appendChild(pillUnreadMiniEl);
    bmini.addEventListener('click', (e) => { e.stopPropagation(); p.classList.toggle('open'); });
    // outside-click folds the mini pill — bound ONCE globally (not per buildPill), reads the LIVE pillEl
    if (!_pillMiniOutside) {
      _pillMiniOutside = true;
      document.addEventListener('pointerdown', (e) => {
        if (!pillEl || !pillEl.classList.contains('mini') || !pillEl.classList.contains('open')) return;
        const path = (e.composedPath && e.composedPath()) || [];
        if (path.indexOf(pillEl) === -1) pillEl.classList.remove('open');
      }, true);
    }
    p.append(bmini, rs, cs, pillFlEl, sep, bk, bc, bsig, ba, bu, bv, bm);
    root.appendChild(p); pillEl = p;
    refreshSigBadge();   // reveal the bell if there are already unread signals
    // legacy signal (data-t4x-movable) + T4CO own-node markers: we manage our own placement, do not move us
    try {
      if (host) { host.setAttribute('data-t4x-movable', '1'); host.setAttribute('data-t4co-app', 'tr4kerplus'); host.setAttribute('data-t4co-selfmanaged', ''); }
      p.setAttribute('data-t4x-movable', '1'); p.setAttribute('data-t4co-app', 'tr4kerplus'); p.setAttribute('data-t4co-selfmanaged', '');
    } catch (e) {}
    wirePillDrag(p, grip);
    applyPillPos();
    if (!pillCoexistTimer) pillCoexistTimer = setInterval(() => { if (!document.hidden) applyPillPos(); }, 10000);   // perf: drift-correction only (t4co:layout gives the instant path)
    _lastBadge = -1;   // v0.53.1: fresh badge spans -> force the next refreshPillBadge to repaint (change-guard sentinel)
    refreshPill();
    // guard the recurring timers + resize hook so a hud off/on cycle doesn't stack duplicate pollers
    if (!_pillTimers) { _pillTimers = true; try { window.addEventListener('resize', t4xDebounce(applyPillPos, 120)); } catch (e) {} try { document.addEventListener('t4co:layout', function (e) { try { if (!e || !e.detail || e.detail.app !== 'tr4kerplus') applyPillPos(); } catch (_) {} }); } catch (e) {} setInterval(() => { if (!document.hidden) refreshPill(); }, 90000); setInterval(() => { if (!document.hidden) refreshPillBadge(); }, 60000);
      try { document.addEventListener('visibilitychange', () => { if (!document.hidden) { try { refreshPill(); } catch (e) {} try { extTick(); } catch (e) {} try { offlineCheck(); } catch (e) {} } }); } catch (e) {} }
  }
  let _pillTimers = false, _pillMiniOutside = false;
  // --- movable pill + coexistence with a right-docked companion sidebar (e.g. Omnichat) -----------
  let pillCoexistTimer = null;
  // --- CONTRAT INTER-SCRIPTS T4CO (spec: forgejo.ekaii.fr/tr4ker/t4co) ------------------------------
  // Coexistence declarative avec les autres userscripts tr4ker (ex. OmniChat). On PUBLIE notre etat de
  // disposition sur <html> (namespace data-t4co-tr4kerplus-*), on marque nos noeuds data-t4co-selfmanaged,
  // et on CONSOMME la gouttiere --t4co-gutter-right d'un app ancre a droite pour placer la pastille a sa
  // gauche. On se declare edge=float / gutter=0 (purement consommateur) donc on n'ecrit JAMAIS la variable
  // partagee = aucun clobber, la regle du max est respectee trivialement. On ne patche le DOM d'aucun autre
  // script : tout passe par attributs + variable CSS + evenement t4co:layout. Ne pas re-debattre : voir la spec.
  // Max reserved gutter published by OTHER T4CO apps on one side (used to seat our own dock flush
  // against e.g. the OmniChat sidebar instead of colliding with it). Legacy fallback: body margin.
  function t4coForeignGutter(side) {
    let m = 0;
    try {
      const de = document.documentElement;
      for (const a of Array.from(de.attributes)) {
        const mm = /^data-t4co-(.+)-edge$/.exec(a.name);
        if (!mm || mm[1] === 'tr4kerplus' || a.value !== side) continue;
        const g = parseInt(de.getAttribute('data-t4co-' + mm[1] + '-gutter'), 10) || 0;
        if (g > m) m = g;
      }
    } catch (_) {}
    if (side === 'right' && m <= 4) { try { m = parseInt(getComputedStyle(document.body).marginRight, 10) || 0; } catch (_) {} }
    return m;
  }
  // Shared-variable max rule from SPEC-T4CO v1.1: --t4co-gutter-<side> = max over every app's
  // data-t4co-<app>-gutter for that side. OmniChat computes the same thing — idempotent, no clobber.
  function t4coRecomputeVars() {
    try {
      const de = document.documentElement;
      const mx = (side) => {
        let m = 0;
        for (const a of Array.from(de.attributes)) {
          const mm = /^data-t4co-(.+)-edge$/.exec(a.name);
          if (!mm || a.value !== side) continue;
          const g = parseInt(de.getAttribute('data-t4co-' + mm[1] + '-gutter'), 10) || 0;
          if (g > m) m = g;
        }
        return m;
      };
      const vr = mx('right') + 'px', vl = mx('left') + 'px';
      if (de.style.getPropertyValue('--t4co-gutter-right') !== vr) de.style.setProperty('--t4co-gutter-right', vr);
      if (de.style.getPropertyValue('--t4co-gutter-left') !== vl) de.style.setProperty('--t4co-gutter-left', vl);
    } catch (_) {}
  }
  let _t4coLast = '';
  function publishT4co() {
    try {
      if (window.self !== window.top) return;
      const de = document.documentElement;
      // v0.31: when the chat workspace is DOCKED we reserve a real gutter (foreign gutter on the same
      // side + our width, i.e. the distance from the screen edge to our far boundary — the same measure
      // OmniChat publishes). Floating/closed → pure consumer again (edge=float, gutter=0).
      const lay = chatLay();
      const docked = !!(chat.open && !chat.inline && chat.el && lay.mode !== 'float');
      const side = docked ? lay.mode : null;
      let gutter = 0;
      if (docked) {
        const w = (lay.w || 420);   // perf: lay.w is what applyChatLayout writes + the ResizeObserver keeps fresh (no forced layout)
        gutter = t4coForeignGutter(side) + Math.round(w);
      }
      const setA = (k, v) => { if (de.getAttribute(k) !== v) de.setAttribute(k, v); };   // idle ticks read-only → no mutation records / root-style churn (perf)
      setA('data-t4co-tr4kerplus', VERSION);
      setA('data-t4co-tr4kerplus-edge', docked ? side : 'float');
      setA('data-t4co-tr4kerplus-gutter', String(gutter));
      setA('data-t4co-tr4kerplus-modal-z', '2147483051');
      t4coRecomputeVars();
      const sig = VERSION + ':' + (docked ? side : 'float') + ':' + gutter;
      if (sig !== _t4coLast) {
        _t4coLast = sig;
        try { document.dispatchEvent(new CustomEvent('t4co:layout', { detail: { app: 'tr4kerplus', edge: docked ? side : 'float', gutter: gutter, version: VERSION } })); } catch (_) {}
      }
    } catch (_) {}
  }
  // Reserved right-edge width: prefer the shared T4CO variable, fall back to the legacy body margin.
  function t4coGutterRight() {
    let g = 0;
    try { g = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--t4co-gutter-right'), 10) || 0; } catch (_) {}
    if (g <= 4) { try { g = parseInt(getComputedStyle(document.body).marginRight, 10) || 0; } catch (_) {} }
    return g;
  }
  // ==========================================================================
  // T4CO BUS (phase 2) — inter-script services over pure CustomEvents on document.
  // Spec: forgejo.ekaii.fr/tr4ker/t4co SPEC-BUS.md. No shared globals, no direct references:
  // hello/who for discovery, req/res correlated by id (first response wins). We PROVIDE the two
  // pilot capabilities: search.torrents (our public Meilisearch) and status.tracker (t4health,
  // cached, so companion scripts stop re-probing a site that has known DDoS). JSON-only payloads,
  // per-app-per-capability rate limit, kill-switch pref 'bus'. Never a secret on the bus.
  // ==========================================================================
  const BUS_APP = 'tr4kerplus';
  const bus = { caps: {}, peers: {}, pending: new Map(), seq: 0, rate: {}, wired: false };
  function busErr(code, message) { return Object.assign(new Error(message), { busCode: code }); }
  function busCapsPublic() { const o = {}; for (const k in bus.caps) o[k] = { v: bus.caps[k].v }; return o; }
  function busEmit(type, detail) { try { document.dispatchEvent(new CustomEvent('t4co:bus:' + type, { detail })); } catch (e) {} }
  function busHello() { if (!pref0('bus', true)) return; busEmit('hello', { app: BUS_APP, version: VERSION, bus: '1', caps: busCapsPublic() }); }
  function busProvide(cap, v, fn) { bus.caps[cap] = { v, fn }; }
  function busRateOk(from, cap) {
    const k = from + '|' + cap, now = Date.now();
    const r = (bus.rate[k] || []).filter((t) => now - t < 60000);
    // `from` is caller-controlled: purge empty buckets and cap distinct keys so a hostile same-origin
    // script spraying random `from` values can't grow bus.rate without bound.
    for (const key in bus.rate) { if (key !== k && !bus.rate[key].some((t) => now - t < 60000)) delete bus.rate[key]; }
    if (!r.length && Object.keys(bus.rate).length > 200) return false;
    bus.rate[k] = r;
    if (r.length >= 30) return false;
    r.push(now); return true;
  }
  function busFail(id, code, message) { busEmit('res', { id, from: BUS_APP, ok: false, error: { code, message } }); }
  function wireBus() {
    if (bus.wired) return; bus.wired = true;
    if (window.self !== window.top) return;
    document.addEventListener('t4co:bus:hello', (e) => {
      const d = e && e.detail; if (!d || !d.app || d.app === BUS_APP) return;
      bus.peers[d.app] = { version: d.version, caps: d.caps || {}, at: Date.now() };
    });
    document.addEventListener('t4co:bus:who', (e) => {
      const d = e && e.detail; if (d && d.app === BUS_APP) return;
      busHello();
    });
    document.addEventListener('t4co:bus:req', async (e) => {
      const d = e && e.detail;
      if (!d || d.from === BUS_APP || !d.cap || d.id == null) return;
      const c = bus.caps[d.cap]; if (!c) return;                        // not ours to answer — stay silent
      if (!pref0('bus', true)) { busFail(d.id, 'disabled', 'bus désactivé côté ' + BUS_APP); return; }
      if (!busRateOk(String(d.from || '?'), d.cap)) { busFail(d.id, 'rate-limited', '30 requêtes/min max par app et par capacité'); return; }
      const args = d.args;
      if (args != null && (typeof args !== 'object' || Array.isArray(args))) { busFail(d.id, 'bad-args', 'args doit être un objet'); return; }
      try {
        const data = await c.fn(args || {});
        busEmit('res', { id: d.id, from: BUS_APP, ok: true, data });
      } catch (err) {
        busFail(d.id, (err && err.busCode) || 'upstream', String((err && err.message) || err).slice(0, 300));
      }
    });
    document.addEventListener('t4co:bus:res', (e) => {
      const d = e && e.detail; if (!d || d.id == null || d.from === BUS_APP) return;
      const p = bus.pending.get(d.id); if (!p) return;                  // not our request, or already settled (first response wins)
      bus.pending.delete(d.id); clearTimeout(p.timer);
      if (d.ok) p.res(d.data);
      else p.rej(busErr((d.error && d.error.code) || 'upstream', (d.error && d.error.message) || 'échec'));
    });
  }
  // Consumer side (for future capabilities OmniChat may provide). Short-circuits our own capabilities.
  function busRequest(cap, args, opts) {
    opts = opts || {};
    if (!pref0('bus', true)) return Promise.reject(busErr('disabled', 'bus désactivé'));
    const own = bus.caps[cap];
    if (own) return Promise.resolve().then(() => own.fn(args || {}));
    const provider = Object.keys(bus.peers).find((a) => bus.peers[a].caps && bus.peers[a].caps[cap]);
    if (!provider) return Promise.reject(busErr('unavailable', 'aucune app ne fournit ' + cap));
    const id = BUS_APP + '-' + (++bus.seq) + '-' + Math.random().toString(36).slice(2, 8);
    return new Promise((res, rej) => {
      const timer = setTimeout(() => { bus.pending.delete(id); rej(busErr('timeout', cap + ' sans réponse')); }, opts.timeout || 3000);
      bus.pending.set(id, { res, rej, timer });
      busEmit('req', { id, from: BUS_APP, cap, args: args || {} });
    });
  }
  // ---- pilot capability: search.torrents v1 (our public Meilisearch, ~345k docs) ---------------
  async function busCapSearch(args) {
    const q = String((args && args.q) || '').trim();
    if (!q || q.length > 200) throw busErr('bad-args', 'q requis (1 à 200 caractères)');
    const limit = Math.max(1, Math.min(Number(args.limit) || 10, 50));
    const opts = { limit };
    if (args.year != null && /^\d{4}$/.test(String(args.year))) opts.filter = 'year = ' + String(args.year);
    if (args.sort === 'recent') opts.sort = ['pub_date_ts:desc'];
    else if (args.sort === 'seeders') opts.sort = ['seeders:desc'];
    const hits = await meiliSearch(q, opts);
    if (!Array.isArray(hits)) throw busErr('upstream', 'Meilisearch indisponible');
    return {
      v: 1, source: 'meilisearch:t4search.ekaii.fr', total: hits.length,
      hits: hits.map((h) => ({
        title: h.title, slug: h.slug, url: h.slug ? '/torrent/' + h.slug : null,
        year: h.year != null ? h.year : null, size_bytes: h.size_bytes != null ? h.size_bytes : null,
        seeders: h.seeders != null ? h.seeders : null,
        // additive fields requested by OmniChat (2026-08-16) — still v1, pure additions
        leechers: h.leechers != null ? h.leechers : null,
        completed: h.grabs != null ? h.grabs : null,
        category: h.category_label || null,
        category_id: h.category_code != null ? String(h.category_code) : null,
      })),
    };
  }
  // ---- pilot capability: status.tracker v1 (t4health, cached ≤60 s — one probe serves everyone) --
  async function busCapStatus(args) {
    const maxAge = (args && args.fresh) ? 0 : 60000;
    if (!health.data || (Date.now() - health.at) > maxAge) { try { await refreshHealth(false); } catch (e) {} }
    const d = health.data;
    if (!d) throw busErr('upstream', 't4health indisponible');
    const services = [];
    try {
      for (const k in (d.services || {})) {
        const s = d.services[k] || {};
        services.push({ id: k, label: s.label || k, state: s.state || 'unknown', latency_ms: s.latency_ms != null ? s.latency_ms : null });
      }
    } catch (e) {}
    return { v: 1, source: 't4health.ekaii.fr', overall: d.overall || 'unknown', at: health.at, services };
  }
  function initBus() {
    wireBus();
    busProvide('search.torrents', 1, busCapSearch);
    busProvide('status.tracker', 1, busCapStatus);
    busHello();
    busEmit('who', { app: BUS_APP });
    if (window.__T4X_EXPOSE__) { try { window.__t4xBus = { request: busRequest, bus }; } catch (e) {} }
  }

  function wirePillDrag(p, grip) {
    let sx = 0, sy = 0, sr = 0, sb = 0, st = 0, sw = 0, sh = 0, moved = false, railDrag = false;
    const onMove = (e) => {
      if (railDrag) {                                          // rail mode: vertical only, glued to the edge
        let t = st + (e.clientY - sy);
        t = Math.max(4, Math.min(t, window.innerHeight - sh - 4));
        p.style.top = t + 'px'; p.style.bottom = 'auto';
        p.__t = t; moved = true; return;
      }
      let r = sr - (e.clientX - sx), b = sb - (e.clientY - sy);
      r = Math.max(4, Math.min(r, window.innerWidth - sw - 4));
      b = Math.max(4, Math.min(b, window.innerHeight - sh - 4));
      p.style.right = r + 'px'; p.style.bottom = b + 'px'; p.style.left = 'auto'; p.style.top = 'auto';
      p.__r = r; p.__b = b; moved = true;
    };
    const onUp = () => {
      document.removeEventListener('pointermove', onMove, true);
      document.removeEventListener('pointerup', onUp, true);
      p.classList.remove('t4x-dragging');
      if (!moved) return;
      if (railDrag && typeof p.__t === 'number') { P = setPref('railTop', Math.round(p.__t)); applyPillPos(); }
      else if (typeof p.__r === 'number') { P = setPref('pillPos', { r: p.__r, b: p.__b }); }
    };
    grip.addEventListener('pointerdown', (e) => {
      try { e.preventDefault(); e.stopPropagation(); } catch (_) {}
      const rect = p.getBoundingClientRect();
      railDrag = p.classList.contains('rail');
      sx = e.clientX; sy = e.clientY; st = rect.top; sw = rect.width; sh = rect.height;
      sr = window.innerWidth - rect.right; sb = window.innerHeight - rect.bottom; moved = false;
      p.classList.add('t4x-dragging');
      document.addEventListener('pointermove', onMove, true);
      document.addEventListener('pointerup', onUp, true);
    });
  }
  // Stand down while a companion script is actively positioning our pill (it injects into our shadow
  // root), to avoid two scripts writing conflicting positions. Otherwise honor the saved position, or
  // auto-offset left of a reserved right margin (a right-docked sidebar), or fall back to the corner.
  // Other scripts' edge tabs (PimpMyShoutbox bubbles, OmniChat's reopen tab) — light-DOM, measurable.
  // In rail mode we actively AVOID them (shift below/above), and we PUBLISH our own rail geometry on
  // <html> (data-t4co-tr4kerplus-rail="right:<top>:<height>") so they can avoid us in return — our
  // shadow root makes us invisible to their elementFromPoint-style detection otherwise.
  function railRivals() {
    const out = [];
    const seen = new Set();
    // known ids first, then a generic fallback so a rename by PMS/OmniChat can't silently re-enable overlap
    const els = new Set();
    ['#tm-t4-settings-bubble', '#tm-t4-mention-inbox-bubble', '#t9-omni-reopen'].forEach((sel) => { const e = document.querySelector(sel); if (e) els.add(e); });
    document.querySelectorAll('[id^="tm-t4-"],[id^="t9-"]').forEach((e) => { const s = e.getAttribute('style') || ''; if (/fixed/.test(s) || getComputedStyle(e).position === 'fixed') els.add(e); });
    const myRect = pillEl.getBoundingClientRect();
    for (const el of els) {
      if (el === pillEl || (host && host.contains(el))) continue;
      const r = el.getBoundingClientRect();
      if (r.width <= 2 || r.height <= 2 || r.right < window.innerWidth - 90) continue;
      // only a real horizontal overlap with OUR rect counts (we may be offset by a gutter)
      if (r.right < myRect.left - 4 || r.left > myRect.right + 4) continue;
      const key = Math.round(r.top) + ':' + Math.round(r.bottom); if (seen.has(key)) continue; seen.add(key);
      out.push(r);
    }
    return out;
  }
  function railPlace() {
    const rect = pillEl.getBoundingClientRect();
    const h = Math.max(60, rect.height || 200);
    let top = pref0('railTop', null);
    if (top == null) top = Math.round(window.innerHeight * 0.4);
    top = Math.max(8, Math.min(top, window.innerHeight - h - 8));
    // collision avoidance (recomputed every pass — if a rival tab disappears we drift back)
    const rivals = railRivals();
    for (let i = 0; i < 4; i++) {
      const hit = rivals.find((r) => top < r.bottom + 8 && top + h > r.top - 8);
      if (!hit) break;
      const below = hit.bottom + 10;
      if (below + h < window.innerHeight - 8) top = below;
      else top = Math.max(8, hit.top - h - 10);
    }
    const g = t4coGutterRight();
    pillEl.style.top = Math.round(top) + 'px';
    pillEl.style.bottom = 'auto'; pillEl.style.left = 'auto';
    pillEl.style.right = (g > 4 ? g : 0) + 'px';
    const railVal = 'right:' + Math.round(top) + ':' + Math.round(h);
    // v0.53.1: guard against the LIVE DOM attribute, not a shadow variable — so any external removal (e.g. the
    // hud-off toggle removing the attr) is always re-published on the next pass, safe by construction.
    try { if (railVal !== document.documentElement.getAttribute('data-t4co-tr4kerplus-rail')) document.documentElement.setAttribute('data-t4co-tr4kerplus-rail', railVal); } catch (e) {}
  }
  function applyPillMode() {
    if (!pillEl) return;
    const mode = pref0('pillMode', 'float');
    pillEl.classList.toggle('rail', mode === 'rail');
    pillEl.classList.toggle('mini', mode === 'mini');
    if (mode !== 'mini') pillEl.classList.remove('open');
    if (mode !== 'rail') { try { document.documentElement.removeAttribute('data-t4co-tr4kerplus-rail'); } catch (e) {} }
  }
  function applyPillPos() {
    publishT4co();
    if (!pillEl) return;
    applyPillMode();
    const managed = !!(root && root.querySelector('#t9-t4x-style')) || !!pillEl.querySelector('.t9x-grip');
    const g = pillEl.querySelector('.t4x-grip'); if (g) g.style.display = managed ? 'none' : '';
    if (managed) return;
    if (pref0('pillMode', 'float') === 'rail') { railPlace(); return; }
    if (window.innerWidth <= 560) { pillEl.style.right = ''; pillEl.style.bottom = ''; pillEl.style.left = ''; pillEl.style.top = ''; return; }
    const pos = pref0('pillPos', null);
    pillEl.style.left = 'auto'; pillEl.style.top = 'auto';
    if (pos && typeof pos.r === 'number') {
      const rect = pillEl.getBoundingClientRect();   // re-clamp to the viewport so a resize can't strand the pill off-screen
      pillEl.style.right = Math.min(Math.max(4, pos.r), Math.max(4, window.innerWidth - rect.width - 4)) + 'px';
      pillEl.style.bottom = Math.min(Math.max(4, pos.b), Math.max(4, window.innerHeight - rect.height - 4)) + 'px'; return;
    }
    const mr = t4coGutterRight();                              // T4CO gutter (or legacy body margin fallback)
    pillEl.style.bottom = '';                                   // CSS default (80px)
    pillEl.style.right = (mr > 4 ? mr + 18 : 18) + 'px';        // sit left of a docked sidebar if any
  }
  function resetPillPos() { P = setPref('pillPos', null); applyPillPos(); toast('Position de la pastille réinitialisée.'); }
  // Feature launcher — a visible 1-click menu of everything TR4KER+ offers (discoverability).
  let launcherEl = null;
  function toggleLauncher() {
    if (launcherEl) { launcherEl.remove(); launcherEl = null; return; }
    const items = [
      ['search', 'Recherche universelle', 'Ctrl/⌘+K', openPalette],
      ['forum', 'Chat communautaire', 'Ctrl/⌘+J', openDrawer],
      // the AI dock is opt-in (v0.18): only listed once the user has switched it on
      ...(pref0('ai', false) ? [['auto_awesome', 'Assistant IA', '', openAi]] : []),
      ...(updateState.available ? [['system_update_alt', 'Mettre à jour TR4KER+', 'v' + updateState.latest, openUpdate]] : []),
      ['mic', 'Salon vocal', 'bêta', openVoice],
      ['monitor_heart', 'État des services', '', openHealth],
      ...(pref0('statsPanel', true) ? [['insights', 'Mes statistiques', '', openStatsPanel]] : []),
      ['leaderboard', 'Classement L4DDER', '', openLadderBoard],
      ...(pref0('inbox', true) ? [['notifications', 'Signaux (mentions à relire)', inboxUnread() ? String(inboxUnread()) : '', openSignals]] : []),
      ...(pref0('ideas', true) ? [['lightbulb', 'Suggestions TR4KER+', 'vote', openIdeas]] : []),
      ['cloud_off', 'Mode hors-ligne (statut, chat de secours, recherche)', '', () => openOffline(false)],
      ['schedule', 'Demandes récentes', '', () => openRequests('recent')],
      ['person', 'Mes demandes', '', () => openRequests('mine')],
      ['notifications', 'Mots-clés chat à surveiller', '', () => { const cur = pref0('chatKeywords', ''); const v = prompt('Mots-clés séparés par des virgules :', cur); if (v !== null) { setPref('chatKeywords', v.trim()); toast('Surveillance mise à jour'); } }],
      ['contrast', 'Thème clair / sombre', '', toggleSiteTheme],
      ['palette', 'Changer d’accent', '', cycleAccent],
      ['open_with', 'Réinitialiser la position de la pastille', '', resetPillPos],
      ['view_week', 'Personnaliser la barre de navigation', '', openNavEditor],
      ['tune', 'Réglages', '', openSettings],
      ['help', 'Aide & tutoriel', '', () => window.open('https://forgejo.ekaii.fr/tr4ker/tr4ker-plus', '_blank', 'noreferrer')],
    ];
    const veil = document.createElement('div'); veil.style.cssText = 'position:fixed;inset:0;z-index:2147483001';
    const m = document.createElement('div'); m.className = 't4x-launcher';
    m.style.cssText = 'position:fixed;right:18px;bottom:124px;z-index:2147483002;width:272px;box-sizing:border-box;background:var(--t4x-glass,rgba(15,17,23,.92));-webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%);color:var(--on-surface,#e6e9f2);border:1px solid var(--t4x-hair,rgba(147,165,255,.14));border-radius:18px;box-shadow:0 20px 60px rgba(0,0,0,.5);overflow:hidden;padding:6px';
    const title = document.createElement('div'); title.textContent = 'TR4KER+ — toutes les fonctions'; title.style.cssText = 'font-weight:700;font-size:11px;letter-spacing:.03em;opacity:.65;padding:8px 10px 6px'; m.appendChild(title);
    items.forEach(([ic, label, hint, fn]) => {
      const row = document.createElement('button'); row.type = 'button';
      row.style.cssText = 'all:unset;display:flex;align-items:center;gap:11px;width:100%;box-sizing:border-box;padding:9px 10px;border-radius:9px;cursor:pointer;font-size:13px';
      row.addEventListener('mouseenter', () => row.style.background = 'var(--secondary-container,#2b3050)');
      row.addEventListener('mouseleave', () => row.style.background = 'transparent');
      const i = document.createElement('span'); i.className = 'ic'; i.textContent = ic; i.style.cssText = 'font-family:"Material Symbols Outlined";font-size:19px;opacity:.85;flex:0 0 auto';
      const l = document.createElement('span'); l.textContent = label; l.style.flex = '1';
      row.append(i, l);
      if (hint) { const h = document.createElement('span'); h.textContent = hint; h.style.cssText = 'font-size:10px;opacity:.5;flex:0 0 auto'; row.appendChild(h); }
      row.addEventListener('click', () => { if (launcherEl) { launcherEl.remove(); launcherEl = null; } try { fn(); } catch (e) { toast('Erreur : ' + e.message); } });
      m.appendChild(row);
    });
    veil.addEventListener('click', () => { if (launcherEl) { launcherEl.remove(); launcherEl = null; } });
    root.append(veil, m);
    launcherEl = { remove() { m.remove(); veil.remove(); } };
  }
  // ==========================================================================
  // SUGGESTION BOARD — "Suggestions TR4KER+" (community ideas, polls, voting, pinning)
  // Self-hosted on the t4x BFF (t4x.ekaii.fr/ideas/api). Identity is the tr4ker username we already
  // hold (declarative, like the external shoutbox); one vote per username, server-side rate limits +
  // honeypot. All idea/comment/poll text is network data → rendered with textContent only (XSS-safe).
  // Maintainer actions (pin / status / delete-any) send the X-Ideas-Admin token from the (secret) pref.
  // ==========================================================================
  const IDEAS_URL = 'https://t4x.ekaii.fr/ideas/api';
  const IDEA_STATUS = { open: { label: '', cls: '' }, planned: { label: 'Prévu', cls: 'plan' }, in_progress: { label: 'En cours', cls: 'prog' }, done: { label: 'Fait', cls: 'done' }, declined: { label: 'Écarté', cls: 'decl' } };
  function ideasName() { return String(chat.myName || store.get('t4x.myname', null) || pref0('extPseudo', null) || '').trim(); }
  function ideasEnsureName() {
    let n = ideasName();
    if (n) return n;
    n = (prompt('Ton pseudo pour les suggestions (idéalement ton pseudo TR4KER) :', '') || '').trim();
    if (n) { store.set('t4x.myname', n); }
    return n;
  }
  function ideasIsAdmin() { return !!(pref0('ideasAdmin', '') || '').trim(); }
  function ideasReq(path, method, body) {
    return new Promise((resolve, reject) => {
      const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
      const tok = (pref0('ideasAdmin', '') || '').trim(); if (tok) headers['X-Ideas-Admin'] = tok;
      try {
        GM_xmlhttpRequest({
          method: method || 'GET', url: IDEAS_URL + path, headers, data: body ? JSON.stringify(body) : undefined, timeout: 15000,
          onload: (r) => { let j = null; try { j = JSON.parse(r.responseText); } catch (e) {} if (r.status >= 200 && r.status < 300) resolve(j || {}); else reject(new Error((j && j.error) || ('HTTP ' + r.status))); },
          onerror: () => reject(new Error('service des suggestions injoignable')), ontimeout: () => reject(new Error('délai dépassé')),
        });
      } catch (e) { reject(e); }
    });
  }
  function agoText(iso) {
    const t = new Date(iso).getTime(); if (!t) return '';
    const s = Math.max(0, (Date.now() - t) / 1000);
    if (s < 60) return 'à l’instant'; if (s < 3600) return 'il y a ' + Math.floor(s / 60) + " min";
    if (s < 86400) return 'il y a ' + Math.floor(s / 3600) + ' h'; if (s < 2592000) return 'il y a ' + Math.floor(s / 86400) + ' j';
    return new Date(iso).toLocaleDateString('fr-FR');
  }
  let _ideasCss = false;
  function ensureIdeasCSS() {
    if (_ideasCss) return; _ideasCss = true; ensureHost();
    const s = document.createElement('style'); s.textContent = `
    .t4x-ideas{ position:fixed; left:50%; top:7vh; transform:translateX(-50%) translateY(8px) scale(.99); z-index:2147483008;
      width:min(720px,96vw); max-height:86vh; display:flex; flex-direction:column; opacity:0; transition:opacity .18s ease, transform .18s cubic-bezier(.3,0,0,1); pointer-events:none;
      background:var(--t4x-glass,rgba(15,17,23,.9)); border:1px solid var(--t4x-hair,rgba(147,165,255,.16)); border-radius:20px;
      box-shadow:0 28px 80px rgba(0,0,0,.55); -webkit-backdrop-filter:blur(20px) saturate(160%); backdrop-filter:blur(20px) saturate(160%); color:var(--on-surface,#e6e8f2); overflow:hidden; }
    .t4x-ideas.on{ opacity:1; transform:translateX(-50%) translateY(0) scale(1); pointer-events:auto; }
    .t4x-ideas .ih{ display:flex; align-items:center; gap:10px; padding:15px 18px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .t4x-ideas .ih .glyph{ width:24px; height:24px; border-radius:7px; background:var(--t4x-grad,linear-gradient(135deg,#93a5ff,#67e8f9)); flex:0 0 auto; }
    .t4x-ideas .ih b{ font-size:15px; flex:1; }
    .t4x-ideas .ih .sub{ font-size:11px; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-ibtn{ all:unset; cursor:pointer; width:30px; height:30px; border-radius:8px; display:flex; align-items:center; justify-content:center; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-ibtn:hover{ background:var(--surface-container-high,#232838); color:var(--on-surface,#e6e8f2); }
    .t4x-ibtn .ic,.t4x-ideas .ic{ font-family:'Material Symbols Outlined'; font-size:19px; }
    .t4x-itools{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 16px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .t4x-ichips{ display:flex; gap:5px; flex-wrap:wrap; }
    .t4x-ichip{ all:unset; cursor:pointer; font-size:12px; padding:5px 11px; border-radius:999px; border:1px solid var(--outline-variant,#3a4056); color:var(--on-surface,#e6e8f2); }
    .t4x-ichip:hover{ background:var(--surface-container,#1a1e2b); }
    .t4x-ichip.on{ background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff); border-color:transparent; }
    .t4x-iq{ flex:1 1 150px; min-width:0; font:inherit; font-size:13px; padding:8px 12px; border-radius:10px; background:var(--surface-container,#141722); color:var(--on-surface,#e6e9f2); border:1px solid var(--outline-variant,rgba(255,255,255,.14)); }
    .t4x-iq:focus{ outline:2px solid var(--primary,#93a5ff); outline-offset:-1px; }
    .t4x-inew{ all:unset; cursor:pointer; display:inline-flex; align-items:center; gap:6px; font-size:13px; font-weight:600; padding:7px 14px; border-radius:999px; background:var(--primary,#93a5ff); color:var(--on-primary,#101638); }
    .t4x-inew:hover{ filter:brightness(1.06); }
    .t4x-ilist{ flex:1; overflow-y:auto; padding:10px 14px 16px; display:flex; flex-direction:column; gap:10px; overscroll-behavior:contain; }
    .t4x-card{ display:flex; gap:12px; padding:13px 14px; border-radius:14px; background:var(--surface-container,#151823); border:1px solid var(--t4x-hair,rgba(147,165,255,.12)); }
    .t4x-card.pin{ border-color:color-mix(in srgb,var(--primary,#93a5ff) 45%,transparent); box-shadow:inset 3px 0 0 var(--primary,#93a5ff); }
    .t4x-votecol{ display:flex; flex-direction:column; align-items:center; gap:1px; flex:0 0 auto; width:44px; }
    .t4x-vbtn{ all:unset; cursor:pointer; width:32px; height:26px; border-radius:8px; display:flex; align-items:center; justify-content:center; color:var(--on-surface-variant,#b8bdd1); background:var(--surface-container-high,#232838); }
    .t4x-vbtn:hover{ color:var(--on-surface,#e6e8f2); }
    .t4x-vbtn.on{ background:var(--primary,#93a5ff); color:var(--on-primary,#101638); }
    .t4x-vbtn .ic{ font-size:18px; }
    .t4x-score{ font:700 14px/1 'JetBrains Mono',monospace; padding:2px 0; }
    .t4x-cmain{ flex:1; min-width:0; }
    .t4x-ctop{ display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
    .t4x-ctitle{ font-size:14.5px; font-weight:600; }
    .t4x-badge{ font-size:10px; font-weight:700; letter-spacing:.03em; text-transform:uppercase; padding:2px 7px; border-radius:999px; }
    .t4x-badge.plan{ background:color-mix(in srgb,var(--ann-color,#ffd28a) 26%,transparent); color:var(--ann-color,#ffd28a); }
    .t4x-badge.prog{ background:color-mix(in srgb,var(--t4x-seed,#4ade80) 20%,transparent); color:var(--t4x-seed,#4ade80); }
    .t4x-badge.done{ background:var(--t4x-seed,#4ade80); color:#0f1117; }
    .t4x-badge.decl{ background:var(--surface-container-high,#232838); color:var(--on-surface-variant,#b8bdd1); }
    .t4x-pin-ic{ color:var(--primary,#93a5ff); font-size:15px; }
    .t4x-cbody{ font-size:13px; line-height:1.45; color:var(--on-surface,#e6e8f2); margin:4px 0 0; white-space:pre-wrap; overflow-wrap:anywhere; }
    .t4x-cmeta{ display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-top:8px; font-size:11.5px; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-cmeta button{ all:unset; cursor:pointer; display:inline-flex; align-items:center; gap:4px; color:inherit; }
    .t4x-cmeta button:hover{ color:var(--on-surface,#e6e8f2); }
    .t4x-cmeta .ic{ font-size:15px; }
    .t4x-poll{ margin-top:10px; display:flex; flex-direction:column; gap:6px; }
    .t4x-poll .pq{ font-size:12.5px; font-weight:600; }
    .t4x-popt{ all:unset; cursor:pointer; position:relative; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:7px 10px; border-radius:9px;
      border:1px solid var(--outline-variant,#3a4056); font-size:12.5px; overflow:hidden; }
    .t4x-popt:hover{ border-color:color-mix(in srgb,var(--primary,#93a5ff) 45%,transparent); }
    .t4x-popt .bar{ position:absolute; inset:0 auto 0 0; background:color-mix(in srgb,var(--primary,#93a5ff) 16%,transparent); z-index:0; transition:width .25s ease; }
    .t4x-popt.mine{ border-color:var(--primary,#93a5ff); }
    .t4x-popt.mine .bar{ background:color-mix(in srgb,var(--primary,#93a5ff) 28%,transparent); }
    .t4x-popt span{ position:relative; z-index:1; }
    .t4x-popt .pct{ font:600 11px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-pollmeta{ font-size:11px; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-comments{ margin-top:10px; border-top:1px solid var(--outline-variant,#3a4056); padding-top:9px; display:flex; flex-direction:column; gap:8px; }
    .t4x-cmt{ display:flex; gap:8px; font-size:12.5px; }
    .t4x-cmt .who{ font-weight:600; color:var(--on-surface,#e6e8f2); }
    .t4x-cmt .cwrap{ flex:1; min-width:0; }
    .t4x-cmt .ct{ color:var(--on-surface,#e6e8f2); white-space:pre-wrap; overflow-wrap:anywhere; }
    .t4x-cmt .cm-meta{ font-size:10.5px; color:var(--on-surface-variant,#b8bdd1); margin-left:6px; }
    .t4x-cmt .cdel{ all:unset; cursor:pointer; color:var(--on-surface-variant,#b8bdd1); font-size:14px; }
    .t4x-cmtbar{ display:flex; gap:7px; }
    .t4x-cmtin{ flex:1; font:inherit; font-size:12.5px; padding:7px 10px; border-radius:9px; background:var(--surface-container,#141722); color:var(--on-surface,#e6e9f2); border:1px solid var(--outline-variant,rgba(255,255,255,.14)); }
    .t4x-mini{ all:unset; cursor:pointer; font-size:12px; font-weight:600; color:var(--primary,#93a5ff); padding:7px 12px; border-radius:9px; border:1px solid color-mix(in srgb,var(--primary,#93a5ff) 40%,transparent); }
    .t4x-mini:hover{ background:color-mix(in srgb,var(--primary,#93a5ff) 14%,transparent); }
    .t4x-iempty{ text-align:center; color:var(--on-surface-variant,#b8bdd1); font-size:13px; padding:40px 16px; }
    /* composer (proposer) */
    .t4x-icomp{ padding:12px 16px; border-bottom:1px solid var(--outline-variant,#3a4056); display:flex; flex-direction:column; gap:8px; background:var(--surface-container-low,#12151f); }
    .t4x-icomp input.t4x-ititle, .t4x-icomp textarea, .t4x-icomp .t4x-polline{ font:inherit; font-size:13px; padding:9px 12px; border-radius:10px; background:var(--surface-container,#141722); color:var(--on-surface,#e6e9f2); border:1px solid var(--outline-variant,rgba(255,255,255,.14)); width:100%; box-sizing:border-box; }
    .t4x-icomp textarea{ resize:vertical; min-height:64px; }
    .t4x-icomp .row2{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
    .t4x-icomp .muted{ font-size:11px; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-pollbox{ display:flex; flex-direction:column; gap:6px; border:1px dashed var(--outline-variant,#3a4056); border-radius:10px; padding:9px; }
    .t4x-selrow{ display:flex; gap:6px; align-items:center; }
    .t4x-selrow select{ font:inherit; font-size:12px; padding:5px 8px; border-radius:8px; background:var(--surface-container,#141722); color:var(--on-surface,#e6e9f2); border:1px solid var(--outline-variant,#3a4056); }
    @media (max-width:560px){ .t4x-ideas{ top:0; left:0; right:0; transform:none; width:100vw; max-height:100vh; border-radius:0; } .t4x-ideas.on{ transform:none; } }
    @media (prefers-reduced-motion: reduce){ .t4x-ideas{ transition:opacity .12s linear !important; } }
    `;
    root.appendChild(s);
  }
  const ideasState = { sort: 'top', status: '', q: '', el: null, listEl: null, unesc: null };
  function closeIdeas() {
    if (!ideasState.el) return;
    if (ideasState.unesc) { ideasState.unesc(); ideasState.unesc = null; }
    const el = ideasState.el; ideasState.el = null; ideasState.listEl = null;
    el.classList.remove('on'); markOpen(el, false); setTimeout(() => { try { el.remove(); } catch (e) {} }, 220);
  }
  function openIdeas() {
    if (!pref0('ideas', true)) { toast('Les suggestions sont désactivées dans les réglages.'); return; }
    ensureHost(); ensureIdeasCSS();
    if (ideasState.el) { closeIdeas(); return; }
    const el = document.createElement('div'); el.className = 't4x-ideas'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Suggestions TR4KER+');
    const head = document.createElement('div'); head.className = 'ih';
    const gl = document.createElement('div'); gl.className = 'glyph';
    const tt = document.createElement('b'); tt.textContent = 'Suggestions TR4KER+';
    const sub = document.createElement('span'); sub.className = 'sub'; sub.textContent = 'proposez et votez';
    const xb = document.createElement('button'); xb.className = 't4x-ibtn'; xb.title = 'Fermer (Échap)'; xb.innerHTML = '<span class="ic">close</span>'; xb.addEventListener('click', closeIdeas);
    head.append(gl, tt, sub, xb);
    // toolbar
    const tools = document.createElement('div'); tools.className = 't4x-itools';
    const sortChips = document.createElement('div'); sortChips.className = 't4x-ichips';
    [['top', 'Populaires'], ['new', 'Récentes'], ['trending', 'Tendance']].forEach(([v, lbl]) => {
      const c = document.createElement('button'); c.className = 't4x-ichip' + (ideasState.sort === v ? ' on' : ''); c.textContent = lbl; c.dataset.v = v;
      c.addEventListener('click', () => { ideasState.sort = v; sortChips.querySelectorAll('.t4x-ichip').forEach((x) => x.classList.toggle('on', x.dataset.v === v)); loadIdeas(); });
      sortChips.appendChild(c);
    });
    const statSel = document.createElement('select'); statSel.className = 't4x-ichip'; statSel.style.cursor = 'pointer';
    [['', 'Tous statuts'], ['open', 'Ouvertes'], ['planned', 'Prévues'], ['in_progress', 'En cours'], ['done', 'Faites']].forEach(([v, lbl]) => { const o = document.createElement('option'); o.value = v; o.textContent = lbl; statSel.appendChild(o); });
    statSel.value = ideasState.status; statSel.addEventListener('change', () => { ideasState.status = statSel.value; loadIdeas(); });
    const q = document.createElement('input'); q.className = 't4x-iq'; q.type = 'search'; q.placeholder = 'Filtrer les suggestions…'; q.value = ideasState.q;
    let deb = null; q.addEventListener('input', () => { clearTimeout(deb); deb = setTimeout(() => { ideasState.q = q.value.trim(); loadIdeas(); }, 250); });
    const newBtn = document.createElement('button'); newBtn.className = 't4x-inew'; newBtn.innerHTML = '<span class="ic">add</span>Proposer';
    newBtn.addEventListener('click', () => toggleIdeaComposer(el, head));
    tools.append(sortChips, statSel, q, newBtn);
    const list = document.createElement('div'); list.className = 't4x-ilist';
    el.append(head, tools, list);
    root.appendChild(el);
    ideasState.el = el; ideasState.listEl = list;
    requestAnimationFrame(() => { el.classList.add('on'); markOpen(el, true); });
    ideasState.unesc = escPush(closeIdeas);
    loadIdeas();
    setTimeout(() => { try { q.focus(); } catch (e) {} }, 60);
  }
  function toggleIdeaComposer(el, head) {
    let comp = el.querySelector('.t4x-icomp');
    if (comp) { comp.remove(); return; }
    comp = document.createElement('div'); comp.className = 't4x-icomp';
    const title = document.createElement('input'); title.className = 't4x-ititle'; title.type = 'text'; title.maxLength = 120; title.placeholder = 'Ton idée en une phrase…';
    const body = document.createElement('textarea'); body.maxLength = 4000; body.placeholder = 'Détaille si besoin (optionnel).';
    const website = document.createElement('input'); website.style.display = 'none'; website.tabIndex = -1; website.autocomplete = 'off'; website.name = 'website';   // honeypot
    // optional poll
    const pollBox = document.createElement('div'); pollBox.className = 't4x-pollbox'; pollBox.style.display = 'none';
    const pQ = document.createElement('input'); pQ.className = 't4x-polline'; pQ.maxLength = 140; pQ.placeholder = 'Question du sondage';
    const optWrap = document.createElement('div'); optWrap.style.display = 'flex'; optWrap.style.flexDirection = 'column'; optWrap.style.gap = '5px';
    const mkOpt = (ph) => { const i = document.createElement('input'); i.className = 't4x-polline'; i.maxLength = 60; i.placeholder = ph; return i; };
    optWrap.append(mkOpt('Option 1'), mkOpt('Option 2'));
    const addOpt = document.createElement('button'); addOpt.className = 't4x-mini'; addOpt.type = 'button'; addOpt.textContent = '+ option';
    addOpt.addEventListener('click', () => { if (optWrap.children.length < 6) optWrap.appendChild(mkOpt('Option ' + (optWrap.children.length + 1))); });
    const multi = document.createElement('label'); multi.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:12px'; const mc = document.createElement('input'); mc.type = 'checkbox'; multi.append(mc, document.createTextNode('choix multiples'));
    pollBox.append(pQ, optWrap, (() => { const r = document.createElement('div'); r.className = 'row2'; r.append(addOpt, multi); return r; })());
    const row2 = document.createElement('div'); row2.className = 'row2';
    const pollToggle = document.createElement('button'); pollToggle.className = 't4x-mini'; pollToggle.type = 'button'; pollToggle.textContent = '+ Ajouter un sondage';
    pollToggle.addEventListener('click', () => { const on = pollBox.style.display === 'none'; pollBox.style.display = on ? 'flex' : 'none'; pollToggle.textContent = on ? '− Retirer le sondage' : '+ Ajouter un sondage'; });
    const submit = document.createElement('button'); submit.className = 't4x-inew'; submit.innerHTML = '<span class="ic">send</span>Publier';
    const cancel = document.createElement('button'); cancel.className = 't4x-ibtn'; cancel.style.width = 'auto'; cancel.style.padding = '0 10px'; cancel.textContent = 'Annuler'; cancel.addEventListener('click', () => comp.remove());
    row2.append(pollToggle, submit, cancel);
    const muted = document.createElement('div'); muted.className = 'muted'; muted.textContent = 'Publié sous ton pseudo TR4KER. Sois constructif — les doublons seront regroupés.';
    comp.append(title, body, pollBox, row2, muted, website);
    head.after(comp);
    setTimeout(() => title.focus(), 30);
    submit.addEventListener('click', async () => {
      if (website.value) { comp.remove(); return; }   // bot filled the honeypot
      const t = title.value.trim(); if (t.length < 3) { toast('Donne un titre (3 caractères min).'); title.focus(); return; }
      const name = ideasEnsureName(); if (!name) { toast('Il faut un pseudo pour publier.'); return; }
      const payload = { title: t, body: body.value.trim(), username: name, install: installId() };
      if (pollBox.style.display !== 'none') {
        const opts = [...optWrap.querySelectorAll('input')].map((i) => i.value.trim()).filter(Boolean);
        if (pQ.value.trim() && opts.length >= 2) payload.poll = { question: pQ.value.trim(), options: opts, multi: mc.checked };
        else if (pQ.value.trim() || opts.length) { toast('Un sondage a besoin d’une question et d’au moins 2 options.'); return; }
      }
      submit.disabled = true;
      try { await ideasReq('/create', 'POST', payload); toast('Suggestion publiée — merci !'); comp.remove(); ideasState.sort = 'new'; loadIdeas(); }
      catch (e) { toast('Publication impossible : ' + e.message); submit.disabled = false; }
    });
  }
  async function loadIdeas() {
    const list = ideasState.listEl; if (!list) return;
    list.replaceChildren(); const ld = document.createElement('div'); ld.className = 't4x-iempty'; ld.textContent = 'Chargement…'; list.appendChild(ld);
    const params = new URLSearchParams({ sort: ideasState.sort, status: ideasState.status || 'all', me: ideasName() || '' });
    if (ideasState.q) params.set('q', ideasState.q);
    try {
      const r = await ideasReq('/list?' + params.toString(), 'GET');
      if (list !== ideasState.listEl) return;   // panel closed/reopened
      list.replaceChildren();
      const arr = (r && r.ideas) || [];
      if (!arr.length) { const e = document.createElement('div'); e.className = 't4x-iempty'; e.textContent = ideasState.q ? 'Aucune suggestion ne correspond.' : 'Aucune suggestion pour l’instant — sois le premier à en proposer une.'; list.appendChild(e); return; }
      for (const idea of arr) list.appendChild(ideaCard(idea));
    } catch (e) {
      if (list !== ideasState.listEl) return;
      list.replaceChildren(); const er = dashError ? null : null;
      const w = document.createElement('div'); w.className = 't4x-iempty'; w.textContent = 'Suggestions indisponibles : ' + e.message; list.appendChild(w);
    }
  }
  function ideaVoteCol(idea, card) {
    const col = document.createElement('div'); col.className = 't4x-votecol';
    const up = document.createElement('button'); up.className = 't4x-vbtn' + (idea.my_vote === 1 ? ' on' : ''); up.title = 'Soutenir'; up.setAttribute('aria-label', 'Soutenir'); up.innerHTML = '<span class="ic">expand_less</span>';
    const sc = document.createElement('div'); sc.className = 't4x-score'; sc.textContent = idea.score;
    const dn = document.createElement('button'); dn.className = 't4x-vbtn' + (idea.my_vote === -1 ? ' on' : ''); dn.title = 'Pas convaincu'; dn.setAttribute('aria-label', 'Pas convaincu'); dn.innerHTML = '<span class="ic">expand_more</span>';
    const doVote = async (dir) => {
      const name = ideasEnsureName(); if (!name) { toast('Un pseudo est requis pour voter.'); return; }
      const nd = idea.my_vote === dir ? 0 : dir;
      try { const r = await ideasReq('/vote', 'POST', { id: idea.id, dir: nd, username: name }); if (r && r.summary) { Object.assign(idea, r.summary); const nc = ideaCard(idea); card.replaceWith(nc); } }
      catch (e) { toast('Vote impossible : ' + e.message); }
    };
    up.addEventListener('click', () => doVote(1)); dn.addEventListener('click', () => doVote(-1));
    col.append(up, sc, dn);
    return col;
  }
  function ideaPoll(idea, card) {
    const poll = idea.poll; const box = document.createElement('div'); box.className = 't4x-poll';
    const q = document.createElement('div'); q.className = 'pq'; q.textContent = '📊 ' + poll.question; box.appendChild(q);
    const total = poll.total_voters || 0;
    poll.options.forEach((o) => {
      const b = document.createElement('button'); b.className = 't4x-popt' + (o.mine ? ' mine' : ''); b.type = 'button';
      const bar = document.createElement('div'); bar.className = 'bar'; bar.style.width = (total ? Math.round(o.votes / total * 100) : 0) + '%';
      const lab = document.createElement('span'); lab.textContent = o.label;
      const pct = document.createElement('span'); pct.className = 'pct'; pct.textContent = o.votes + (total ? ' · ' + Math.round(o.votes / total * 100) + '%' : '');
      b.append(bar, lab, pct);
      if (poll.closed) { b.disabled = true; b.style.cursor = 'default'; }
      else b.addEventListener('click', async () => {
        const name = ideasEnsureName(); if (!name) { toast('Un pseudo est requis pour voter.'); return; }
        try { const r = await ideasReq('/poll_vote', 'POST', { id: idea.id, option_id: o.id, username: name }); if (r && r.summary) { Object.assign(idea, r.summary); const nc = ideaCard(idea); card.replaceWith(nc); } }
        catch (e) { toast('Vote impossible : ' + e.message); }
      });
      box.appendChild(b);
    });
    const meta = document.createElement('div'); meta.className = 't4x-pollmeta'; meta.textContent = (poll.multi ? 'Choix multiples · ' : '') + total + ' votant' + (total > 1 ? 's' : '') + (poll.closed ? ' · clos' : '');
    box.appendChild(meta);
    return box;
  }
  function ideaCard(idea) {
    const card = document.createElement('div'); card.className = 't4x-card' + (idea.pinned ? ' pin' : '');
    card.append(ideaVoteCol(idea, card));
    const main = document.createElement('div'); main.className = 't4x-cmain';
    const top = document.createElement('div'); top.className = 't4x-ctop';
    if (idea.pinned) { const p = document.createElement('span'); p.className = 'ic t4x-pin-ic'; p.textContent = 'push_pin'; p.title = 'Épinglé'; top.appendChild(p); }
    const ti = document.createElement('span'); ti.className = 't4x-ctitle'; ti.textContent = idea.title; top.appendChild(ti);
    const st = IDEA_STATUS[idea.status]; if (st && st.label) { const bd = document.createElement('span'); bd.className = 't4x-badge ' + st.cls; bd.textContent = st.label; top.appendChild(bd); }
    main.appendChild(top);
    if (idea.body) { const bd = document.createElement('div'); bd.className = 't4x-cbody'; bd.textContent = idea.body; main.appendChild(bd); }
    if (idea.poll) main.appendChild(ideaPoll(idea, card));
    const meta = document.createElement('div'); meta.className = 't4x-cmeta';
    const auth = document.createElement('span'); auth.textContent = 'par ' + idea.author + ' · ' + agoText(idea.created_at); meta.appendChild(auth);
    const cmtBtn = document.createElement('button'); { const _ci = document.createElement('span'); _ci.className = 'ic'; _ci.textContent = 'chat_bubble'; cmtBtn.append(_ci, document.createTextNode(String(Number(idea.comment_count) || 0))); } cmtBtn.title = 'Commentaires';
    let cbox = null;
    cmtBtn.addEventListener('click', () => { if (cbox) { cbox.remove(); cbox = null; return; } cbox = document.createElement('div'); cbox.className = 't4x-comments'; main.appendChild(cbox); loadComments(idea, cbox); });
    meta.appendChild(cmtBtn);
    const flagBtn = document.createElement('button'); flagBtn.innerHTML = '<span class="ic">flag</span>'; flagBtn.title = 'Signaler';
    flagBtn.addEventListener('click', async () => { try { await ideasReq('/flag', 'POST', { id: idea.id, username: ideasName() }); toast('Signalé — merci.'); } catch (e) { toast(e.message); } });
    meta.appendChild(flagBtn);
    if (idea.mine || ideasIsAdmin()) {
      const del = document.createElement('button'); del.innerHTML = '<span class="ic">delete</span>'; del.title = 'Supprimer';
      del.addEventListener('click', () => { toast('Supprimer cette suggestion ?', 6000, { action: { label: 'Supprimer', fn: async () => { try { await ideasReq('/delete', 'POST', { id: idea.id, username: ideasName(), install: installId() }); card.remove(); } catch (e) { toast(e.message); } } } }); });
      meta.appendChild(del);
    }
    if (ideasIsAdmin()) meta.appendChild(ideaAdminCtl(idea, card));
    main.appendChild(meta);
    card.appendChild(main);
    return card;
  }
  function ideaAdminCtl(idea, card) {
    const wrap = document.createElement('span'); wrap.style.display = 'inline-flex'; wrap.style.gap = '8px';
    const pin = document.createElement('button'); pin.innerHTML = '<span class="ic">push_pin</span>'; pin.title = idea.pinned ? 'Désépingler' : 'Épingler';
    pin.addEventListener('click', async () => { try { const r = await ideasReq('/admin', 'POST', { id: idea.id, action: idea.pinned ? 'unpin' : 'pin' }); if (r.summary) { Object.assign(idea, r.summary); card.replaceWith(ideaCard(idea)); } } catch (e) { toast(e.message); } });
    const sel = document.createElement('select'); sel.className = 't4x-ichip'; sel.title = 'Statut (mainteneur)';
    [['open', 'Ouverte'], ['planned', 'Prévue'], ['in_progress', 'En cours'], ['done', 'Faite'], ['declined', 'Écartée']].forEach(([v, lbl]) => { const o = document.createElement('option'); o.value = v; o.textContent = lbl; sel.appendChild(o); });
    sel.value = idea.status; sel.addEventListener('change', async () => { try { const r = await ideasReq('/admin', 'POST', { id: idea.id, action: 'status', value: sel.value }); if (r.summary) { Object.assign(idea, r.summary); card.replaceWith(ideaCard(idea)); } } catch (e) { toast(e.message); } });
    wrap.append(pin, sel);
    return wrap;
  }
  async function loadComments(idea, cbox) {
    cbox.replaceChildren(); const ld = document.createElement('div'); ld.className = 'muted'; ld.style.fontSize = '11px'; ld.textContent = 'Chargement…'; cbox.appendChild(ld);
    let detail = null;
    try { detail = await ideasReq('/get?id=' + idea.id + '&me=' + encodeURIComponent(ideasName() || ''), 'GET'); } catch (e) { cbox.replaceChildren(); const er = document.createElement('div'); er.className = 'muted'; er.textContent = 'Commentaires indisponibles.'; cbox.appendChild(er); return; }
    cbox.replaceChildren();
    const comments = (detail && detail.comments) || [];
    for (const c of comments) {
      const row = document.createElement('div'); row.className = 't4x-cmt';
      const w = document.createElement('div'); w.className = 'cwrap';
      const head = document.createElement('div'); const who = document.createElement('span'); who.className = 'who'; who.textContent = c.author; const mt = document.createElement('span'); mt.className = 'cm-meta'; mt.textContent = agoText(c.created_at); head.append(who, mt);
      if (c.mine || ideasIsAdmin()) { const d = document.createElement('button'); d.className = 'cdel'; d.innerHTML = '<span class="ic" style="font-family:\'Material Symbols Outlined\';font-size:14px">close</span>'; d.title = 'Supprimer'; d.addEventListener('click', async () => { try { await ideasReq('/delete', 'POST', { id: idea.id, comment_id: c.id, username: ideasName(), install: installId() }); row.remove(); } catch (e) { toast(e.message); } }); head.appendChild(d); }
      const ct = document.createElement('div'); ct.className = 'ct'; ct.textContent = c.body;
      w.append(head, ct); row.appendChild(w); cbox.appendChild(row);
    }
    const bar = document.createElement('div'); bar.className = 't4x-cmtbar';
    const inp = document.createElement('input'); inp.className = 't4x-cmtin'; inp.maxLength = 1000; inp.placeholder = 'Ajouter un commentaire…';
    const send = document.createElement('button'); send.className = 't4x-mini'; send.textContent = 'Envoyer';
    const doSend = async () => { const v = inp.value.trim(); if (!v) return; const name = ideasEnsureName(); if (!name) { toast('Un pseudo est requis.'); return; } send.disabled = true; try { await ideasReq('/comment', 'POST', { id: idea.id, body: v, username: name, install: installId() }); inp.value = ''; send.disabled = false; loadComments(idea, cbox); } catch (e) { toast(e.message); send.disabled = false; } };
    inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doSend(); } });
    send.addEventListener('click', doSend);
    bar.append(inp, send); cbox.appendChild(bar);
  }
  try { if (window.__T4X_EXPOSE__) { window.__t4xIdeas = { open: openIdeas, card: ideaCard }; } } catch (e) {}

  // ==========================================================================
  // SIGNAUX — persistent inbox of @mentions, watched keywords and replies-to-me.
  // Captured live from the WS tee + backfilled once at boot via the shared channel-history sweep, so
  // "what did I miss overnight" has an answer. Records are DOM-built (renderBody → spoilers stay masked).
  // ==========================================================================
  const sweepConsumers = []; let _sweepStamp = 0;
  function onSweep(fn) { sweepConsumers.push(fn); }
  function sweepChannelHistory(opts) {
    opts = opts || {};
    if (!chat.channels) return;
    if (!opts.force && Date.now() - _sweepStamp < 600000) return; _sweepStamp = Date.now();
    const d0 = inboxData(); const floor = Math.max(d0.sweepAt || 0, d0.clearedAt || 0); const runAt = Date.now();   // ONE floor for the whole run (was reset per-channel → only channel 0 captured)
    const chans = (chat.channels || []).filter((c) => !c.ext);
    let i = 0;
    const step = () => {
      if (i >= chans.length) { const d = inboxData(); d.sweepAt = runAt; inboxDirty(); refreshSigBadge(); return; }
      const c = chans[i++];
      apiGet('/api/conversations/' + c.id + '/messages?limit=50', { ttl: 8000 })
        .then((j) => { const msgs = pickArray(j, ['messages']); sweepConsumers.forEach((fn) => { try { fn(c.id, msgs, floor); } catch (e) {} }); })
        .catch(() => {}).finally(() => setTimeout(step, 400));
    };
    step();
  }
  let _inbox = null, _inboxT = null;
  function inboxData() { if (_inbox) return _inbox; const raw = store.get('t4x.inbox', null); _inbox = (raw && Array.isArray(raw.items)) ? raw : { v: 1, clearedAt: 0, sweepAt: 0, items: [] }; return _inbox; }
  function inboxFlush() { if (!_inbox) return; try { store.set('t4x.inbox', _inbox); } catch (e) { try { _inbox.items = _inbox.items.filter((x) => !x.read).slice(-150); store.set('t4x.inbox', _inbox); } catch (e2) {} } }
  function inboxDirty() { if (_inboxT) return; _inboxT = setTimeout(() => { _inboxT = null; inboxFlush(); }, 2000); }
  function inboxPrune(d) {
    let u = d.items.filter((x) => !x.read); let r = d.items.filter((x) => x.read);
    const cutoff = Date.now() - 14 * 864e5; r = r.filter((x) => (x.seenAt || 0) > cutoff).slice(-30);
    u = u.slice(-120);   // even unread is bounded — a loose keyword must not grow storage without limit
    const keep = Math.max(0, 150 - u.length); r = keep ? r.slice(-keep) : [];   // slice(-0) returned the WHOLE array before
    d.items = u.concat(r);
  }
  function inboxUpsert(rec) {
    const d = inboxData();
    if (new Date(rec.created_at).getTime() <= (d.clearedAt || 0)) return false;
    const ex = d.items.find((x) => x.id === rec.id);
    if (ex) { ex.body = rec.body; ex.parent = rec.parent; } else { rec.seenAt = Date.now(); d.items.push(rec); }
    inboxPrune(d); inboxDirty(); return !ex;
  }
  function inboxUnread() { return inboxData().items.filter((x) => !x.read).length; }
  function captureSignal(m, conv, src) {
    if (!pref0('inbox', true) || !m) return;
    if (m.sender_id != null && m.sender_id === chat.myId) return;
    if (isExt(conv)) return;
    if (userMuted(m)) return;   // a muted member never enters the inbox (live OR backfill) — M9
    const term = mentionHit(m.body);
    let reason = null, which = '';
    if (term) { reason = (chat.myName && term.toLowerCase() === String(chat.myName).toLowerCase()) ? 'mention' : 'keyword'; which = term; }
    else if (pref0('inboxReplies', true) && m.parent && ((m.parent.sender_id != null && String(m.parent.sender_id) === String(chat.myId)) || (m.parent.sender_id == null && chat.myName && m.parent.sender === chat.myName))) reason = 'reply';
    if (!reason) return;
    const fresh = inboxUpsert({ id: String(m.id), conv: conv, sender: m.sender, sender_role: m.sender_role, avatar_url: m.avatar_url,
      body: String(m.body || '').slice(0, 240), parent: m.parent ? { sender: m.parent.sender, body: String(m.parent.body || '').slice(0, 200) } : null,
      reason: reason, term: which, created_at: m.created_at || m.at || new Date().toISOString(), src: src || 'live', read: false });
    if (fresh) refreshSigBadge();
  }
  onSweep((conv, msgs, floor) => {
    (msgs || []).forEach((m) => { if (new Date(m.created_at || m.at || 0).getTime() > (floor || 0)) captureSignal(m, conv, 'backfill'); });
  });
  const sigState = { el: null, listEl: null, filter: 'unread', q: '', unesc: null };
  function refreshSigBadge() {
    const n = inboxUnread();
    if (pillSigBtn) pillSigBtn.style.display = (n > 0 && pref0('inbox', true) && pref0('hud', true)) ? '' : 'none';
    if (sigPillBadge) { sigPillBadge.textContent = n > 99 ? '99+' : n; sigPillBadge.style.display = n > 0 ? '' : 'none'; }
    if (sigDrawerDot) sigDrawerDot.style.display = n > 0 ? '' : 'none';
  }
  let sigPillBadge = null, sigDrawerDot = null, pillSigBtn = null;
  let _sigCss = false;
  function ensureSigCSS() {
    if (_sigCss) return; _sigCss = true; ensureHost();
    const s = document.createElement('style'); s.textContent = `
    .t4x-sig{ position:fixed; right:18px; bottom:136px; z-index:2147483003; width:min(440px,94vw); max-height:min(72vh,640px);
      display:flex; flex-direction:column; opacity:0; transform:translateY(8px) scale(.99); transition:opacity .18s ease, transform .18s cubic-bezier(.3,0,0,1); pointer-events:none;
      background:var(--t4x-glass,rgba(15,17,23,.92)); border:1px solid var(--t4x-hair,rgba(147,165,255,.16)); border-radius:18px; box-shadow:0 24px 64px rgba(0,0,0,.5);
      -webkit-backdrop-filter:blur(20px) saturate(160%); backdrop-filter:blur(20px) saturate(160%); color:var(--on-surface,#e6e8f2); overflow:hidden; }
    .t4x-sig.on{ opacity:1; transform:translateY(0) scale(1); pointer-events:auto; }
    .t4x-sig .sh{ display:flex; align-items:center; gap:8px; padding:12px 14px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .t4x-sig .sh b{ flex:1; font-size:14px; }
    .t4x-sig .sh .cnt{ font:600 11px/1 'JetBrains Mono',monospace; color:var(--primary,#93a5ff); }
    .t4x-sig .sh button{ all:unset; cursor:pointer; width:28px; height:28px; border-radius:7px; display:flex; align-items:center; justify-content:center; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-sig .sh button:hover{ background:var(--surface-container-high,#232838); color:var(--on-surface,#e6e8f2); }
    .t4x-sig .sh .ic{ font-family:'Material Symbols Outlined'; font-size:18px; }
    .t4x-sigtools{ display:flex; gap:6px; align-items:center; padding:8px 12px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .t4x-sigchip{ all:unset; cursor:pointer; font-size:12px; padding:4px 11px; border-radius:999px; border:1px solid var(--outline-variant,#3a4056); color:var(--on-surface,#e6e8f2); }
    .t4x-sigchip.on{ background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff); border-color:transparent; }
    .t4x-sigq{ flex:1; min-width:0; font:inherit; font-size:12.5px; padding:6px 10px; border-radius:9px; background:var(--surface-container,#141722); color:var(--on-surface,#e6e9f2); border:1px solid var(--outline-variant,rgba(255,255,255,.14)); }
    .t4x-siglist{ flex:1; overflow-y:auto; padding:8px 10px 12px; display:flex; flex-direction:column; gap:6px; }
    .t4x-sigrow{ display:flex; gap:9px; padding:9px 10px; border-radius:11px; background:var(--surface-container,#151823); cursor:pointer; }
    .t4x-sigrow.unread{ box-shadow:inset 3px 0 0 var(--primary,#93a5ff); background:color-mix(in srgb,var(--primary,#93a5ff) 8%,var(--surface-container,#151823)); }
    .t4x-sigrow.read{ opacity:.62; }
    .t4x-sigrow .av{ width:28px; height:28px; border-radius:8px; flex:0 0 auto; overflow:hidden; background:var(--surface-container-high,#232838); display:flex; align-items:center; justify-content:center; font:600 12px/1 sans-serif; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-sigrow .av img{ width:100%; height:100%; object-fit:cover; }
    .t4x-sigrow .sc{ flex:1; min-width:0; }
    .t4x-sigtop{ display:flex; align-items:baseline; gap:6px; }
    .t4x-sigtop .who{ font-size:13px; font-weight:600; }
    .t4x-sigtop .rea{ font-family:'Material Symbols Outlined'; font-size:14px; color:var(--primary,#93a5ff); }
    .t4x-sigtop .kw{ font-size:10px; padding:1px 6px; border-radius:999px; background:var(--surface-container-high,#232838); color:var(--on-surface-variant,#b8bdd1); }
    .t4x-sigtop .tm{ margin-left:auto; font:10px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-sigrow .bd{ font-size:12.5px; line-height:1.4; margin-top:2px; overflow-wrap:anywhere; }
    .t4x-sigrow .ch{ font-size:10.5px; color:var(--on-surface-variant,#b8bdd1); margin-top:3px; }
    .t4x-sigempty{ text-align:center; color:var(--on-surface-variant,#b8bdd1); font-size:13px; padding:40px 16px; }
    @media (max-width:560px){ .t4x-sig{ left:12px; right:12px; bottom:76px; width:auto; max-height:78vh; } }
    @media (prefers-reduced-motion: reduce){ .t4x-sig{ transition:opacity .12s linear !important; transform:none !important; } }`;
    root.appendChild(s);
  }
  function closeSignals() { if (!sigState.el) return; if (sigState.unesc) { sigState.unesc(); sigState.unesc = null; } const el = sigState.el; sigState.el = null; sigState.listEl = null; el.classList.remove('on'); markOpen(el, false); setTimeout(() => { try { el.remove(); } catch (e) {} }, 200); }

  // ---- SHOUTBOX INFINIE : recherche dans l'archive durable (canaux publics) ------------------------
  // Recherche plein texte gardée par un jeton communautaire public (limité en débit ; contenu de canaux
  // publics). La recherche par membre (reconstruction rétroactive) exige une connexion et arrivera plus
  // recherche par membre plus tard. Rendu 100% DOM ; marqueurs de snippet \u0002/\u0003 -> <mark>
  // l'ingest, donc jamais dans le corps).
  const ARCHIVE_URL = 'https://t4x.ekaii.fr/bff/archive';
  const ARCHIVE_PUB_TOKEN = 't4xpub_a8b85185ec698154f6907b7acfcc7fa0';   // public : ne garde que du canal public
  const searchState = { el: null, listEl: null, q: '', ch: '', sender: '', sort: 'recent', page: 0, seq: 0, more: false, rows: [], unesc: null, loading: false };
  let _searchCss = false;
  const _SNX = '\u0002', _SEX = '\u0003';
  function archiveGet(path) {
    return new Promise((res, rej) => {
      GM_xmlhttpRequest({ method: 'GET', url: ARCHIVE_URL + path, timeout: 12000, headers: { 'X-T4x-Archive': ARCHIVE_PUB_TOKEN },
        onload: (r) => { let d = null; try { d = JSON.parse(r.responseText); } catch (e) {} if (r.status >= 400) return rej(new Error((d && d.error) || ('HTTP ' + r.status))); res(d || {}); },
        onerror: () => rej(new Error('injoignable')), ontimeout: () => rej(new Error('délai')) });
    });
  }
  function renderSnippet(text) {
    const frag = document.createDocumentFragment();
    String(text == null ? '' : text).split(_SNX).forEach((chunk, i) => {
      if (i === 0) { frag.appendChild(document.createTextNode(chunk)); return; }
      const j = chunk.indexOf(_SEX);
      if (j < 0) { frag.appendChild(document.createTextNode(chunk)); return; }
      const mk = document.createElement('mark'); mk.textContent = chunk.slice(0, j); frag.appendChild(mk);
      frag.appendChild(document.createTextNode(chunk.slice(j + 1)));
    });
    return frag;
  }
  function chLabel(ch) { const c = (chat.channels || []).find((x) => (x.slug || String(x.id)) === ch); return c ? (c.name || c.slug) : ch; }
  function ensureSearchCSS() {
    if (_searchCss) return; _searchCss = true; ensureHost();
    const s = document.createElement('style'); s.textContent = `
    .t4x-search{ position:fixed; right:18px; bottom:136px; z-index:2147483003; width:min(520px,95vw); max-height:min(76vh,680px);
      display:flex; flex-direction:column; border-radius:16px; color:var(--on-surface,#e6e8f2);
      background:var(--t4x-glass,rgba(15,17,23,.94)); border:1px solid var(--t4x-hair,rgba(147,165,255,.16)); box-shadow:0 24px 70px rgba(0,0,0,.55);
      -webkit-backdrop-filter:blur(20px) saturate(160%); backdrop-filter:blur(20px) saturate(160%);
      opacity:0; transform:translateY(8px) scale(.98); transition:opacity .16s, transform .16s; pointer-events:none; }
    .t4x-search.on{ opacity:1; transform:translateY(0) scale(1); pointer-events:auto; }
    .t4x-search .sh{ display:flex; align-items:center; gap:8px; padding:12px 14px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .t4x-search .sh b{ flex:1; font-size:14px; } .t4x-search .sh .cnt{ font:600 11px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-search .sh button{ all:unset; cursor:pointer; width:28px; height:28px; border-radius:7px; display:flex; align-items:center; justify-content:center; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-search .sh button:hover{ background:var(--surface-container-high,#232838); color:var(--on-surface,#e6e8f2); }
    .t4x-search .sh .ic{ font-family:'Material Symbols Outlined'; font-size:18px; }
    .t4x-schtools{ display:flex; flex-wrap:wrap; gap:8px; padding:10px 14px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .t4x-schq{ min-width:150px; }
    .t4x-schq{ flex:1; all:unset; box-sizing:border-box; padding:8px 11px; border-radius:9px; background:var(--surface-container-high,rgba(147,165,255,.08)); border:1px solid var(--t4x-hair,rgba(147,165,255,.16)); font-size:13px; color:var(--on-surface,#e6e8f2); }
    .t4x-schch{ all:unset; box-sizing:border-box; padding:8px 10px; border-radius:9px; background:var(--surface-container-high,rgba(147,165,255,.08)); border:1px solid var(--t4x-hair,rgba(147,165,255,.16)); font-size:12px; color:var(--on-surface,#e6e8f2); cursor:pointer; max-width:150px; }
    .t4x-schlist{ overflow-y:auto; padding:6px 6px 10px; }
    .t4x-schrow{ padding:8px 10px; border-radius:10px; cursor:pointer; }
    .t4x-schrow:hover{ background:var(--surface-container-high,rgba(147,165,255,.09)); }
    .t4x-schtop{ display:flex; align-items:baseline; gap:7px; font-size:11px; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-schtop .who{ font-weight:600; color:var(--on-surface,#e6e8f2); } .t4x-schtop .ch{ font:600 10px/1 'JetBrains Mono',monospace; color:var(--primary,#93a5ff); }
    .t4x-schtop .tm{ margin-left:auto; font:500 10px/1 'JetBrains Mono',monospace; }
    .t4x-schbody{ font-size:13px; line-height:1.4; margin-top:3px; word-break:break-word; }
    .t4x-schbody mark{ background:color-mix(in srgb, var(--primary,#93a5ff) 34%, transparent); color:inherit; border-radius:3px; padding:0 1px; }
    .t4x-schctx{ margin-top:7px; padding:7px 9px; border-left:2px solid var(--t4x-hair,rgba(147,165,255,.28)); background:var(--surface-container,rgba(147,165,255,.05)); border-radius:0 8px 8px 0; }
    .t4x-schcm{ font-size:12px; line-height:1.45; padding:2px 0; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-schcm.tgt{ color:var(--on-surface,#e6e8f2); } .t4x-schcm .cw{ font-weight:600; color:var(--on-surface,#e6e8f2); }
    .t4x-schcm .del{ font-style:italic; opacity:.7; }
    .t4x-schmore, .t4x-schempty, .t4x-scherr{ text-align:center; font-size:12px; color:var(--on-surface-variant,#b8bdd1); padding:14px; }
    .t4x-schmore{ cursor:pointer; } .t4x-schmore:hover{ color:var(--on-surface,#e6e8f2); }
    .t4x-loginback{ position:fixed; inset:0; z-index:2147483040; display:flex; align-items:center; justify-content:center; background:rgba(6,8,12,.55); -webkit-backdrop-filter:blur(4px); backdrop-filter:blur(4px); opacity:0; transition:opacity .16s; pointer-events:none; }
    .t4x-loginback.on{ opacity:1; pointer-events:auto; }
    .t4x-login{ width:min(360px,94vw); border-radius:16px; color:var(--on-surface,#e6e8f2); background:var(--t4x-glass,rgba(15,17,23,.96)); border:1px solid var(--t4x-hair,rgba(147,165,255,.18)); box-shadow:0 30px 80px rgba(0,0,0,.6); transform:translateY(8px) scale(.98); transition:transform .16s; }
    .t4x-loginback.on .t4x-login{ transform:translateY(0) scale(1); }
    .t4x-logininfo{ font-size:11.5px; line-height:1.45; color:var(--on-surface-variant,#b8bdd1); margin-bottom:11px; }
    .t4x-loginbtn{ all:unset; box-sizing:border-box; cursor:pointer; display:block; width:100%; text-align:center; margin-top:11px; padding:10px; border-radius:10px; background:var(--primary,#93a5ff); color:var(--on-primary,#0b1020); font-weight:600; font-size:13px; }
    .t4x-loginbtn:hover{ filter:brightness(1.06); } .t4x-loginbtn[disabled]{ opacity:.5; cursor:default; }
    `; (root || document.head).appendChild(s);
  }
  function schDoSearch(reset) {
    const st = searchState; if (!st.listEl) return;
    if (reset) { st.page = 0; st.rows = []; }
    const seq = ++st.seq; const q = st.q.trim();
    if (!q) { st.listEl.replaceChildren(mkEl('div', 't4x-schempty', 'Tape un mot pour fouiller tout l’historique public.')); return; }
    if (reset) st.listEl.replaceChildren(mkEl('div', 't4x-schempty', 'Recherche…'));
    st.loading = true;
    const qs = '?q=' + encodeURIComponent(q) + (st.ch ? '&ch=' + encodeURIComponent(st.ch) : '') + (st.sender ? '&sender=' + encodeURIComponent(st.sender) : '') + '&page=' + st.page + '&sort=' + encodeURIComponent(st.sort || 'recent');
    archiveGet('/search' + qs).then((d) => {
      if (seq !== st.seq) return;
      st.loading = false;
      if (typeof d.page === 'number' && d.page < st.page) { st.more = false; schRender(st.rows.length); return; }   // server-clamped pagination → stop (no dup rows)
      const from = st.rows.length;
      st.more = !!d.more; st.rows = st.rows.concat(d.rows || []); schRender(from);
    }).catch((e) => {
      if (seq !== st.seq) return;
      st.loading = false;
      if (st.sender && /connexion|non autorisé|401/i.test(e.message)) {
        st.listEl.replaceChildren(mkEl('div', 't4x-schempty', 'Recherche par membre : connexion requise.'));
        const b = mkEl('button', 't4x-loginbtn', 'Se connecter'); b.style.cssText = 'max-width:200px;margin:10px auto 0';
        b.addEventListener('click', () => openBffLogin(() => schDoSearch(true))); st.listEl.appendChild(b); return;
      }
      if (st.rows.length) { const er = st.listEl.querySelector('.t4x-schmore'); if (er) er.textContent = 'Erreur — cliquer pour réessayer'; return; }   // mid-scroll failure: keep existing results
      st.listEl.replaceChildren(mkEl('div', 't4x-scherr', 'Recherche indisponible (' + e.message + ').'));
    });
  }
  function mkEl(tag, cls, txt) { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
  function schLoadNext() { const st = searchState; if (!st.more || st.loading || !st.listEl) return; st.page += 1; schDoSearch(false); }
  function schRender(from) {
    const st = searchState; if (!st.listEl) return;
    const start = from || 0;
    if (start) { const old = st.listEl.querySelector('.t4x-schmore'); if (old) old.remove(); }
    else st.listEl.replaceChildren();
    if (!st.rows.length) { st.listEl.replaceChildren(mkEl('div', 't4x-schempty', 'Aucun message trouvé.')); return; }
    st.rows.slice(start).forEach((r) => {
      const row = mkEl('div', 't4x-schrow'); row.setAttribute('role', 'button');
      const top = mkEl('div', 't4x-schtop');
      top.append(mkEl('span', 'ch', '#' + chLabel(r.ch)), mkEl('span', 'who', r.sender || '?'), mkEl('span', 'tm', schDate(r.ts)));
      const body = mkEl('div', 't4x-schbody'); body.appendChild(renderSnippet(r.snippet));
      row.append(top, body);
      row.addEventListener('click', () => schToggleCtx(row, r));
      st.listEl.appendChild(row);
    });
    if (st.more) { const m = mkEl('div', 't4x-schmore', 'Charger plus'); m.addEventListener('click', schLoadNext); st.listEl.appendChild(m); }
  }
  function schDate(ts) { try { const d = new Date(ts); return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' }) + ' ' + fmtTime(ts); } catch (e) { return ''; } }
  function schToggleCtx(row, r) {
    const ex = row.querySelector('.t4x-schctx'); if (ex) { ex.remove(); return; }
    const box = mkEl('div', 't4x-schctx'); box.appendChild(mkEl('div', 't4x-schcm', 'Contexte…')); row.appendChild(box);
    archiveGet('/context?id=' + encodeURIComponent(r.id) + '&radius=6').then((d) => {
      box.replaceChildren();
      (d.rows || []).forEach((m) => {
        const line = mkEl('div', 't4x-schcm' + (String(m.id) === String(r.id) ? ' tgt' : ''));
        line.append(mkEl('span', 'cw', (m.sender || '?') + ' '));
        if (m.body == null) line.appendChild(mkEl('span', 'del', '(message supprimé)'));
        else line.appendChild(document.createTextNode(String(m.body)));
        box.appendChild(line);
      });
      if (!(d.rows || []).length) box.appendChild(mkEl('div', 't4x-schcm', 'Contexte introuvable.'));
    }).catch((e) => { box.replaceChildren(mkEl('div', 't4x-schcm', 'Contexte indisponible.')); });
  }
  function openArchiveSearch() {
    if (!pref0('archiveSearch', true)) { toast('La recherche d’archive est désactivée dans les réglages.'); return; }
    ensureHost(); ensureSearchCSS();
    if (searchState.el) { closeArchiveSearch(); return; }
    const el = mkEl('div', 't4x-search'); el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Recherche dans l’historique');
    if (innerWidth > 620) el.style.right = panelRight() + 'px';
    const head = mkEl('div', 'sh'); const b = mkEl('b', null, 'Recherche'); const cnt = mkEl('span', 'cnt', '');
    const xb = mkEl('button', null); xb.title = 'Fermer (Échap)'; xb.appendChild(mkEl('span', 'ic', 'close')); xb.addEventListener('click', closeArchiveSearch);
    head.append(b, cnt, xb);
    const tools = mkEl('div', 't4x-schtools');
    const q = mkEl('input', 't4x-schq'); q.type = 'search'; q.placeholder = 'Fouiller tout l’historique — un mot, une phrase…'; q.value = searchState.q;
    const sel = mkEl('select', 't4x-schch'); sel.appendChild(new Option('Tous les canaux', ''));
    (chat.channels || []).filter((c) => !isExt(c.id)).forEach((c) => sel.appendChild(new Option('#' + (c.name || c.slug), c.slug || String(c.id))));
    sel.value = searchState.ch;
    let deb = null; q.addEventListener('input', () => { clearTimeout(deb); searchState.q = q.value; deb = setTimeout(() => schDoSearch(true), 260); });
    q.addEventListener('keydown', (e) => { if (e.key === 'Enter') { clearTimeout(deb); schDoSearch(true); } });
    sel.addEventListener('change', () => { searchState.ch = sel.value; schDoSearch(true); });
    const senderIn = mkEl('input', 't4x-schch'); senderIn.type = 'search'; senderIn.placeholder = '@membre'; senderIn.value = searchState.sender; senderIn.title = 'Filtrer par membre (connexion requise)'; senderIn.style.maxWidth = '116px';
    senderIn.addEventListener('input', () => { searchState.sender = senderIn.value.trim(); });
    senderIn.addEventListener('keydown', (e) => { if (e.key === 'Enter') { clearTimeout(deb); schDoSearch(true); } });
    const sortSel = mkEl('select', 't4x-schch'); sortSel.title = 'Ordre des résultats'; sortSel.style.maxWidth = '104px';
    sortSel.appendChild(new Option('Récents', 'recent')); sortSel.appendChild(new Option('Pertinence', 'rel'));
    sortSel.value = searchState.sort || 'recent';
    sortSel.addEventListener('change', () => { searchState.sort = sortSel.value; schDoSearch(true); });
    tools.append(q, sel, sortSel, senderIn);
    const list = mkEl('div', 't4x-schlist'); list.setAttribute('role', 'listbox');
    list.addEventListener('scroll', () => { if (searchState.more && !searchState.loading && list.scrollTop + list.clientHeight >= list.scrollHeight - 180) schLoadNext(); }, { passive: true });
    el.append(head, tools, list); root.appendChild(el);
    searchState.el = el; searchState.listEl = list;
    requestAnimationFrame(() => { el.classList.add('on'); markOpen(el, true); q.focus(); });
    searchState.unesc = escPush(closeArchiveSearch);
    if (searchState.q.trim()) schDoSearch(true); else list.appendChild(mkEl('div', 't4x-schempty', 'Tape un mot pour fouiller tout l’historique public.'));
  }
  function closeArchiveSearch() { const st = searchState; if (!st.el) return; if (st.unesc) { st.unesc(); st.unesc = null; } const el = st.el; st.el = null; st.listEl = null; el.classList.remove('on'); markOpen(el, false); setTimeout(() => { try { el.remove(); } catch (e) {} }, 200); }
  // Notification/mention trop ancienne pour le chat live : on la retrouve dans l'archive et on affiche
  // le message + son contexte (c'est le « cliquer sur une vieille notif y ramène quand même »).
  function showArchivedContext(id) {
    if (!pref0('archiveSearch', true)) return;
    ensureHost(); ensureSearchCSS();
    const el = mkEl('div', 't4x-search'); el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Message archivé');
    if (innerWidth > 620) el.style.right = panelRight() + 'px';
    const head = mkEl('div', 'sh'); const xb = mkEl('button'); xb.title = 'Fermer (Échap)'; xb.appendChild(mkEl('span', 'ic', 'close'));
    head.append(mkEl('b', null, 'Message archivé'), xb);
    const list = mkEl('div', 't4x-schlist'); const box = mkEl('div', 't4x-schctx'); box.style.margin = '10px'; box.appendChild(mkEl('div', 't4x-schcm', 'Contexte…'));
    list.appendChild(box); el.append(head, list); root.appendChild(el);
    requestAnimationFrame(() => { el.classList.add('on'); markOpen(el, true); });
    const unesc = escPush(close);
    function close() { try { unesc(); } catch (e) {} el.classList.remove('on'); markOpen(el, false); setTimeout(() => { try { el.remove(); } catch (e) {} }, 200); }
    xb.addEventListener('click', close);
    archiveGet('/context?id=' + encodeURIComponent(id) + '&radius=8').then((d) => {
      box.replaceChildren();
      (d.rows || []).forEach((m) => {
        const line = mkEl('div', 't4x-schcm' + (String(m.id) === String(id) ? ' tgt' : ''));
        line.append(mkEl('span', 'cw', (m.sender || '?') + ' '));
        if (m.body == null) line.appendChild(mkEl('span', 'del', '(message supprimé)'));
        else line.appendChild(document.createTextNode(String(m.body)));
        box.appendChild(line);
      });
      if (!(d.rows || []).length) box.appendChild(mkEl('div', 't4x-schcm', 'Message introuvable dans l’archive.'));
      const tgt = box.querySelector('.tgt'); if (tgt) tgt.scrollIntoView({ block: 'center' });
    }).catch(() => { box.replaceChildren(mkEl('div', 't4x-schcm', 'Archive indisponible.')); });
  }
  // ---- BFF login (proxy de session tr4ker : mot de passe jamais stocké) ----------------------------
  // Débloque la recherche par membre + les stats détaillées self-only. La session __Host-t4x_sess (12 h)
  // voyage automatiquement avec nos requêtes GM vers t4x. Le mot de passe est relayé une fois vers tr4ker
  // par notre proxy, jamais gardé (voir services/bff/server.js).
  let _bffAuth = null;
  function bffMe() { return new Promise((res) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://t4x.ekaii.fr/bff/me', timeout: 8000, onload: (r) => { try { res(JSON.parse(r.responseText)); } catch (e) { res({ authenticated: false }); } }, onerror: () => res({ authenticated: false }), ontimeout: () => res({ authenticated: false }) }); }); }
  function bffLoginReq(identifier, password, code) { return new Promise((res, rej) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://t4x.ekaii.fr/bff/login', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ identifier, password, code }), timeout: 20000, onload: (r) => { let d = null; try { d = JSON.parse(r.responseText); } catch (e) {} d ? res(d) : rej(new Error('réponse invalide')); }, onerror: () => rej(new Error('injoignable')), ontimeout: () => rej(new Error('délai')) }); }); }
  function bffEnsureAuth(onOk) { bffMe().then((m) => { if (m && m.authenticated) { _bffAuth = m; onOk(m.user); } else openBffLogin(onOk); }); }
  function openBffLogin(onOk) {
    ensureHost(); ensureSearchCSS();
    if (root.querySelector('.t4x-loginback')) return;
    const back = mkEl('div', 't4x-loginback');
    const el = mkEl('div', 't4x-login'); el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Connexion TR4KER+');
    const h = mkEl('div', 'sh'); const xb = mkEl('button'); xb.title = 'Fermer'; xb.appendChild(mkEl('span', 'ic', 'close')); h.append(mkEl('b', null, 'Connexion'), xb);
    const info = mkEl('div', 't4x-logininfo', 'Ta session tr4ker est relayée par notre proxy — le mot de passe n’est jamais stocké. Nécessaire pour la recherche par membre et tes statistiques détaillées.');
    const id = mkEl('input', 't4x-schq'); id.placeholder = 'identifiant ou email'; id.autocomplete = 'username';
    const pw = mkEl('input', 't4x-schq'); pw.type = 'password'; pw.placeholder = 'mot de passe'; pw.autocomplete = 'current-password'; pw.style.marginTop = '8px';
    const code = mkEl('input', 't4x-schq'); code.placeholder = 'code 2FA'; code.style.marginTop = '8px'; code.style.display = 'none'; code.inputMode = 'numeric';
    const go = mkEl('button', 't4x-loginbtn', 'Se connecter');
    const err = mkEl('div', 't4x-gifstatus');
    const bd = mkEl('div'); bd.style.cssText = 'padding:13px 15px'; bd.append(info, id, pw, code, go, err);
    el.append(h, bd); back.appendChild(el); root.appendChild(back);
    requestAnimationFrame(() => { back.classList.add('on'); markOpen(back, true); try { id.focus(); } catch (e) {} });
    const unesc = escPush(close);
    function close() { try { unesc(); } catch (e) {} back.classList.remove('on'); markOpen(back, false); setTimeout(() => { try { back.remove(); } catch (e) {} }, 200); }
    xb.addEventListener('click', close); back.addEventListener('click', (e) => { if (e.target === back) close(); });
    let need2fa = false;
    const submit = async () => {
      err.textContent = ''; if (!id.value.trim() || !pw.value) { err.textContent = 'Identifiant et mot de passe requis.'; return; }
      go.disabled = true; err.textContent = 'Connexion…';
      try {
        const d = await bffLoginReq(id.value.trim(), pw.value, need2fa ? code.value.trim() : undefined);
        if (d.totp_required) { need2fa = true; code.style.display = 'block'; try { code.focus(); } catch (e) {} err.textContent = 'Entre ton code 2FA.'; go.disabled = false; return; }
        if (d.error) { err.textContent = d.error; go.disabled = false; return; }
        if (d.ok) { _bffAuth = { authenticated: true, user: d.user }; toast('Connecté' + (d.user && d.user.username ? ' — ' + d.user.username : '') + '.'); close(); if (onOk) onOk(d.user); return; }
        err.textContent = 'Réponse inattendue.'; go.disabled = false;
      } catch (e) { err.textContent = e.message; go.disabled = false; }
    };
    go.addEventListener('click', submit);
    [id, pw, code].forEach((inp) => inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }));
  }
  try { if (window.__T4X_EXPOSE__) { window.__t4xSearch = { open: openArchiveSearch, snippet: renderSnippet, doSearch: schDoSearch, state: searchState, archived: showArchivedContext }; window.__t4xAuth = { login: openBffLogin, me: bffMe, ensure: bffEnsureAuth }; } } catch (e) {}

  // ---- MON ACTIVITÉ : stats détaillées SELF-ONLY (heatmap horaire + jours + calendrier + graphe) -----
  // Le BFF calcule sur TON propre nom (jamais un nom fourni par le client). Heures affichées en local.
  let _myStatsCss = false, myStatsEl = null;
  function richStats() {
    return new Promise((res, rej) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://t4x.ekaii.fr/bff/me/richstats', timeout: 12000, onload: (r) => { let d = null; try { d = JSON.parse(r.responseText); } catch (e) {} if (r.status === 401) return rej(new Error('401')); if (r.status >= 400) return rej(new Error((d && d.error) || ('HTTP ' + r.status))); res(d || {}); }, onerror: () => rej(new Error('injoignable')), ontimeout: () => rej(new Error('délai')) }); });
  }
  function ensureMyStatsCSS() {
    if (_myStatsCss) return; _myStatsCss = true; ensureHost();
    const s = document.createElement('style'); s.textContent = `
    .t4x-mystats .t4x-schlist{ padding:12px 14px; }
    .t4x-msec{ margin-bottom:16px; }
    .t4x-msh{ font:700 9.5px/1 'JetBrains Mono',monospace; letter-spacing:.14em; text-transform:uppercase; color:var(--primary,#93a5ff); margin-bottom:9px; }
    .t4x-msbig{ font:700 26px/1 'JetBrains Mono',monospace; } .t4x-msbig small{ font:500 12px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); margin-left:7px; }
    .t4x-heat{ display:grid; grid-template-columns:repeat(24,1fr); gap:2px; }
    .t4x-heatc{ aspect-ratio:1; border-radius:2px; background:var(--surface-container-high,rgba(147,165,255,.10)); }
    .t4x-heatx{ display:grid; grid-template-columns:repeat(24,1fr); gap:2px; margin-top:4px; font:500 8px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#8b91a6); }
    .t4x-wk{ display:flex; flex-direction:column; gap:5px; }
    .t4x-wkr{ display:flex; align-items:center; gap:8px; } .t4x-wkl{ width:30px; font:600 10px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); flex:0 0 auto; }
    .t4x-wkt{ flex:1; height:9px; border-radius:3px; background:var(--surface-container-high,rgba(147,165,255,.10)); overflow:hidden; }
    .t4x-wkf{ display:block; height:100%; border-radius:3px; background:linear-gradient(90deg,var(--primary,#93a5ff),var(--t4x-seed,#4ade80)); }
    .t4x-wkn{ width:38px; text-align:right; font:600 10px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); flex:0 0 auto; }
    .t4x-cal{ display:flex; gap:2px; overflow-x:auto; padding-bottom:3px; } .t4x-calcol{ display:flex; flex-direction:column; gap:2px; }
    .t4x-calcell{ width:11px; height:11px; border-radius:2px; background:var(--surface-container-high,rgba(147,165,255,.10)); } .t4x-calcell.empty{ background:transparent; }
    .t4x-gr{ display:flex; flex-direction:column; gap:6px; } .t4x-grr{ display:flex; align-items:center; gap:8px; }
    .t4x-grn{ width:90px; font-size:11.5px; color:var(--on-surface,#e6e8f2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:0 0 auto; }
    .t4x-grt{ flex:1; height:8px; border-radius:3px; background:var(--surface-container-high,rgba(147,165,255,.10)); overflow:hidden; } .t4x-grf{ display:block; height:100%; background:var(--primary,#93a5ff); }
    .t4x-grc{ width:30px; text-align:right; font:600 10px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); flex:0 0 auto; }
    .t4x-msnote{ font-size:10.5px; color:var(--on-surface-variant,#8b91a6); margin-top:5px; }
    .t4x-mprofacct{ margin-left:auto; font-size:11.5px; font-weight:700; color:var(--on-primary,#0f1117); background:var(--primary,#93a5ff); padding:5px 11px; border-radius:999px; text-decoration:none; white-space:nowrap; }
    .t4x-mprofacct:hover{ filter:brightness(1.08); }
    .t4x-mproflink{ margin:0 10px; font-size:11px; font-weight:600; color:var(--primary,#93a5ff); text-decoration:none; white-space:nowrap; }
    .t4x-mproflink:hover{ text-decoration:underline; }
    .t4x-mprofhead{ margin-bottom:14px; padding-bottom:13px; border-bottom:1px solid var(--outline-variant,rgba(147,165,255,.14)); }
    .t4x-mprofhead .t4x-ucladder{ margin-top:11px; }
    .m .who,.m .av{ cursor:pointer; }
    .t4x-ladrow{ display:flex; align-items:center; gap:10px; padding:8px 10px; border-radius:9px; cursor:pointer; }
    .t4x-ladrow:hover{ background:var(--surface-container-high,rgba(147,165,255,.08)); }
    .t4x-ladrow.me{ background:color-mix(in srgb,var(--primary,#93a5ff) 12%,transparent); outline:1px solid color-mix(in srgb,var(--primary,#93a5ff) 40%,transparent); }
    .t4x-ladrk{ width:52px; flex:0 0 auto; font:700 12px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#8b91a6); }
    .t4x-ladnm{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:600; color:var(--on-surface,#e6e9f2); }
    .t4x-ladsc{ flex:0 0 auto; font:700 13px/1 'JetBrains Mono',monospace; color:var(--primary,#93a5ff); width:42px; text-align:right; }
    .t4x-ladsub{ flex:0 0 auto; font-size:10.5px; color:var(--on-surface-variant,#8b91a6); width:120px; text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
    .t4x-ladmore{ padding:10px; text-align:center; font-size:11px; color:var(--on-surface-variant,#8b91a6); }
    @media(max-width:520px){ .t4x-ladsub{ display:none; } }
    @media (prefers-reduced-motion: reduce){ .t4x-wkf,.t4x-grf{ transition:none; } }
    `; (root || document.head).appendChild(s);
  }
  function mystatSection(title) { const w = document.createElement('div'); w.className = 't4x-msec'; const h = document.createElement('div'); h.className = 't4x-msh'; h.textContent = title; w.appendChild(h); return w; }
  function renderMyStats(list, d) {
    list.replaceChildren();
    const tot = mystatSection('Messages'); const big = document.createElement('div'); big.className = 't4x-msbig'; big.appendChild(document.createTextNode(fmtNum(d.total))); const sm = document.createElement('small'); sm.textContent = 'sur les canaux publics archivés'; big.appendChild(sm); tot.appendChild(big); list.appendChild(tot);
    // hourly heatmap, rotated to viewer-local
    const off = -Math.round(new Date().getTimezoneOffset() / 60);
    const local = Array.from({ length: 24 }, (_, L) => d.hourly[((L - off) % 24 + 24) % 24] || 0);
    const hmax = Math.max(1, ...local);
    const hs = mystatSection('Heures d’activité'); const heat = document.createElement('div'); heat.className = 't4x-heat';
    local.forEach((n, h) => { const c = document.createElement('div'); c.className = 't4x-heatc'; if (n) c.style.background = 'color-mix(in srgb, var(--primary,#93a5ff) ' + Math.round(14 + 78 * n / hmax) + '%, transparent)'; c.title = String(h).padStart(2, '0') + 'h : ' + n; heat.appendChild(c); });
    const hx = document.createElement('div'); hx.className = 't4x-heatx'; [0, 6, 12, 18].forEach((h) => { const s2 = document.createElement('div'); s2.style.gridColumn = (h + 1) + ' / span 6'; s2.textContent = String(h).padStart(2, '0') + 'h'; hx.appendChild(s2); });
    hs.append(heat, hx); const note = document.createElement('div'); note.className = 't4x-msnote'; note.textContent = 'heure locale'; hs.appendChild(note); list.appendChild(hs);
    // weekday, Monday-first (data is 0=dim..6=sam)
    const order = [1, 2, 3, 4, 5, 6, 0], labels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
    const wmax = Math.max(1, ...d.weekday);
    const ws = mystatSection('Jours'); const wk = document.createElement('div'); wk.className = 't4x-wk';
    order.forEach((idx, i) => { const n = d.weekday[idx] || 0; const r = document.createElement('div'); r.className = 't4x-wkr'; const l = document.createElement('span'); l.className = 't4x-wkl'; l.textContent = labels[i]; const t = document.createElement('span'); t.className = 't4x-wkt'; const f = document.createElement('span'); f.className = 't4x-wkf'; f.style.width = Math.round(100 * n / wmax) + '%'; t.appendChild(f); const nn = document.createElement('span'); nn.className = 't4x-wkn'; nn.textContent = fmtNum(n); r.append(l, t, nn); wk.appendChild(r); });
    ws.appendChild(wk); list.appendChild(ws);
    // calendar
    const cal = buildCalendar(d.calendar || []); if (cal) { const cs = mystatSection('Calendrier'); cs.appendChild(cal); list.appendChild(cs); }
    // reply graph
    if (d.graph && d.graph.length) {
      const gmax = Math.max(1, ...d.graph.map((g) => g.n)); const gs = mystatSection('Répond surtout à');
      const gr = document.createElement('div'); gr.className = 't4x-gr';
      d.graph.forEach((g) => { const r = document.createElement('div'); r.className = 't4x-grr'; const nm = document.createElement('span'); nm.className = 't4x-grn'; nm.textContent = g.name; const t = document.createElement('span'); t.className = 't4x-grt'; const f = document.createElement('span'); f.className = 't4x-grf'; f.style.width = Math.round(100 * g.n / gmax) + '%'; t.appendChild(f); const c = document.createElement('span'); c.className = 't4x-grc'; c.textContent = fmtNum(g.n); r.append(nm, t, c); gr.appendChild(r); });
      gs.appendChild(gr); list.appendChild(gs);
      const note2 = document.createElement('div'); note2.className = 't4x-msnote'; note2.textContent = 'liens de réponse depuis août 2026'; gs.appendChild(note2);
    }
  }
  function buildCalendar(cal) {
    const map = {}; let min = null, max = null;
    cal.forEach((x) => { map[x.d] = x.n; if (!min || x.d < min) min = x.d; if (!max || x.d > max) max = x.d; });
    if (!min) return null;
    const start = new Date(min + 'T00:00:00Z'), end = new Date(max + 'T00:00:00Z');
    const cur = new Date(start); const dow = (cur.getUTCDay() + 6) % 7; cur.setUTCDate(cur.getUTCDate() - dow);   // back to Monday
    const mx = Math.max(1, ...cal.map((x) => x.n));
    const grid = document.createElement('div'); grid.className = 't4x-cal'; let guard = 0;
    while (cur <= end && guard++ < 400) {
      const col = document.createElement('div'); col.className = 't4x-calcol';
      for (let i = 0; i < 7; i++) {
        const ds = cur.toISOString().slice(0, 10); const cell = document.createElement('div'); cell.className = 't4x-calcell';
        if (cur >= start && cur <= end) { const n = map[ds] || 0; if (n) cell.style.background = 'color-mix(in srgb, var(--primary,#93a5ff) ' + Math.round(18 + 72 * n / mx) + '%, transparent)'; cell.title = ds + ' : ' + n + ' msg'; }
        else cell.classList.add('empty');
        col.appendChild(cell); cur.setUTCDate(cur.getUTCDate() + 1);
      }
      grid.appendChild(col);
    }
    return grid;
  }
  function closeMyStats() { if (!myStatsEl) return; const el = myStatsEl; myStatsEl = null; if (el._unesc) { try { el._unesc(); } catch (e) {} } el.classList.remove('on'); markOpen(el, false); setTimeout(() => { try { el.remove(); } catch (e) {} }, 200); }
  function openMyStats() {
    ensureHost(); ensureSearchCSS(); ensureMyStatsCSS();
    if (myStatsEl) { closeMyStats(); return; }
    const el = mkEl('div', 't4x-search t4x-mystats'); el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Mon activité');
    if (innerWidth > 620) el.style.right = panelRight() + 'px';
    const head = mkEl('div', 'sh'); const xb = mkEl('button'); xb.title = 'Fermer (Échap)'; xb.appendChild(mkEl('span', 'ic', 'close')); xb.addEventListener('click', closeMyStats);
    head.append(mkEl('b', null, 'Mon activité'), xb);
    const list = mkEl('div', 't4x-schlist'); list.appendChild(mkEl('div', 't4x-schempty', 'Chargement…'));
    el.append(head, list); root.appendChild(el); myStatsEl = el;
    requestAnimationFrame(() => { el.classList.add('on'); markOpen(el, true); });
    el._unesc = escPush(closeMyStats);
    const load = () => { list.replaceChildren(mkEl('div', 't4x-schempty', 'Chargement…')); richStats().then((d) => { if (!myStatsEl) return; if (!d || !d.available) { list.replaceChildren(mkEl('div', 't4x-schempty', 'Aucune activité archivée pour toi (ou profil sans messages sur les canaux publics).')); return; } renderMyStats(list, d); }).catch((e) => { if (!myStatsEl) return; if (/401/.test(e.message)) { list.replaceChildren(mkEl('div', 't4x-schempty', 'Connexion requise pour voir tes statistiques détaillées.')); const b = mkEl('button', 't4x-loginbtn', 'Se connecter'); b.style.cssText = 'max-width:200px;margin:10px auto 0'; b.addEventListener('click', () => openBffLogin(load)); list.appendChild(b); } else list.replaceChildren(mkEl('div', 't4x-scherr', 'Stats indisponibles (' + e.message + ').')); }); };
    load();
  }
  try { if (window.__T4X_EXPOSE__) { window.__t4xMyStats = { open: openMyStats, render: renderMyStats, cal: buildCalendar }; } } catch (e) {}
  // ---- FULL MEMBER PROFILE (click a name/avatar) — identity + L4DDER + rich archive stats for ANY member ----
  // All time-resolved stats come from the PUBLIC channel archive (/bff/archive/richstats), same source + gate
  // as the hover card; forgotten_senders opt-out is honored server-side. Ratio/upload from /api/users/<name>.
  let _mprofEl = null, _mprofName = null;
  function closeMemberProfile() { if (!_mprofEl) return; const el = _mprofEl; _mprofEl = null; _mprofName = null; if (el._unesc) { try { el._unesc(); } catch (e) {} } el.classList.remove('on'); markOpen(el, false); setTimeout(() => { try { el.remove(); } catch (e) {} }, 200); }
  function mprofLadderBlock(user) {
    const box = mkEl('div', 't4x-ucladder'); const top = mkEl('div', 't4x-ucltop');
    top.append(mkEl('span', 't4x-ucllbl', 'L4DDER'), mkEl('span', 't4x-uclrank', '#' + fmtNum(user.rank)), mkEl('span', 't4x-uclscore', (Math.round((user.score || 0) * 10) / 10) + '/100'));
    box.appendChild(top);
    const parts = []; if (user.uploaded_TB != null) parts.push(fmtTB(user.uploaded_TB) + ' UL'); if (user.seeding != null) parts.push(fmtNum(user.seeding) + ' en seed');
    if (parts.length) box.appendChild(mkEl('div', 't4x-uclsub', parts.join(' · ')));
    if (user.sub) { const bars = mkEl('div', 't4x-uclbars'); [['UL', user.sub.up], ['Seed', user.sub.st], ['Upl', user.sub.pub]].forEach(([k, v]) => { const b = mkEl('div', 't4x-uclbar'); const track = mkEl('span', 't4x-uclbt'); const fill = mkEl('span', 't4x-uclbf'); fill.style.width = Math.max(0, Math.min(100, Number(v) || 0)) + '%'; track.appendChild(fill); b.append(mkEl('span', 't4x-uclbk', k), track); bars.appendChild(b); }); box.appendChild(bars); }
    return box;
  }
  function renderMemberHeader(box, name, u, ladder) {
    box.replaceChildren();
    const head = mkEl('div', 't4x-uchead'); const av = mkEl('div', 'av');
    if (u && u.avatar_url && /^https?:\/\//i.test(u.avatar_url)) { const im = document.createElement('img'); im.src = u.avatar_url; im.referrerPolicy = 'no-referrer'; im.loading = 'lazy'; im.onerror = () => { av.textContent = initials(name); try { im.remove(); } catch (e) {} }; av.appendChild(im); } else av.textContent = initials(name);
    const nm = mkEl('div', 't4x-ucname', (u && u.username) || name); if (u && u.role_color) nm.style.color = u.role_color;
    head.append(av, nm); box.appendChild(head);
    if (u && u.joined_at) { try { box.appendChild(mkEl('div', 't4x-ucsub', 'Membre depuis ' + new Date(u.joined_at).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long' }))); } catch (e) {} }
    if (u && !u._err) { const grid = mkEl('div', 't4x-ucgrid'); const cell = (k, v, col) => { const d = mkEl('div'); d.append(mkEl('div', 't4x-uck', k), (() => { const vv = mkEl('div', 't4x-ucv', v); if (col) vv.style.color = col; return vv; })()); return d; };
      if (u.hide_ratio) grid.appendChild(cell('Ratio', 'Masqué'));
      else { const up = (Number(u.uploaded) || 0) + (Number(u.bonus_upload) || 0), dn = (Number(u.downloaded) || 0) + (Number(u.bonus_download) || 0); const ra = dn > 0 ? (up / dn) : Infinity; grid.append(cell('Ratio', ra === Infinity ? '∞' : ra.toFixed(2)), cell('Upload', fmtSize(u.uploaded), 'var(--t4x-seed,#4ade80)'), cell('Download', fmtSize(u.downloaded), 'var(--error,#ffb4ab)')); }
      box.appendChild(grid);
    } else if (u && u._err) box.appendChild(mkEl('div', 't4x-ucstat', u._err === 'priv' ? 'Profil privé (ratio masqué).' : u._err === '404' ? 'Profil site introuvable.' : 'Profil site indisponible.'));
    if (ladder) box.appendChild(mprofLadderBlock(ladder));
  }
  function openMemberProfile(name) {
    name = (name || '').trim(); if (!name) return;
    ensureHost(); ensureSearchCSS(); ensureMyStatsCSS(); ensureChatCSS();
    if (_mprofEl && _mprofName === unorm(name)) { closeMemberProfile(); return; }   // toggle same member
    if (_mprofEl) closeMemberProfile();
    const el = mkEl('div', 't4x-search t4x-mystats t4x-mprof'); el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Profil de ' + name);
    if (innerWidth > 620) el.style.right = panelRight() + 'px';
    const head = mkEl('div', 'sh'); const xb = mkEl('button'); xb.title = 'Fermer (Échap)'; xb.appendChild(mkEl('span', 'ic', 'close')); xb.addEventListener('click', closeMemberProfile);
    // -> the member's REAL site account page (/user/<name>); clicking a head opens THIS panel, so give a way there.
    const acct = document.createElement('a'); acct.className = 't4x-mprofacct'; acct.textContent = '→ Compte'; acct.href = '/user/' + encodeURIComponent(name); acct.title = 'Voir le compte de ' + name + ' sur TR4KER';
    acct.addEventListener('click', (ev) => { ev.preventDefault(); closeMemberProfile(); try { navigate('/user/' + encodeURIComponent(name)); } catch (e) { location.href = '/user/' + encodeURIComponent(name); } });
    const share = document.createElement('a'); share.className = 't4x-mproflink'; share.textContent = 'public ↗'; share.href = 'https://profile.ekaii.fr/u/' + encodeURIComponent(name); share.target = '_blank'; share.rel = 'noreferrer noopener'; share.title = 'Ouvrir le profil public partageable (profile.ekaii.fr)';
    head.append(mkEl('b', null, 'Profil — ' + name), acct, share, xb);
    const list = mkEl('div', 't4x-schlist');
    const cardBox = mkEl('div'); cardBox.appendChild(mkEl('div', 't4x-schempty', 'Chargement du profil…'));
    list.append(cardBox); el.append(head, list); root.appendChild(el); _mprofEl = el; _mprofName = unorm(name);
    requestAnimationFrame(() => { el.classList.add('on'); markOpen(el, true); });
    el._unesc = escPush(closeMemberProfile);
    const alive = () => _mprofEl === el;
    // one combined fetch (ladder + rich archive stats + trend), rendered by the unified card
    memberData(name).then((d) => {
      if (!alive()) return;
      if (!d || (d.error && !d.ladder && !(d.rich && d.rich.available))) { cardBox.replaceChildren(mkEl('div', 't4x-schempty', 'Profil indisponible (' + ((d && d.error) || '?') + ').')); return; }
      buildStatsCard(cardBox, name, d, (n) => openMemberProfile(n));
    }).catch((e) => { if (alive()) cardBox.replaceChildren(mkEl('div', 't4x-scherr', 'Profil indisponible (' + (e.message || '?') + ').')); });
  }
  try { if (window.__T4X_EXPOSE__) { window.__t4xMemberProfile = { open: openMemberProfile, close: closeMemberProfile }; } } catch (e) {}
  // ===== Unified member stats card (redesign) — self-contained (scoped .t4xmc- CSS injected once), works in
  // the extension shadow panel AND injected into the native /user/<name> page. Renders: identity + L4DDER,
  // score/upload/seed TRENDS (sparklines from ladder history), messages, hours heatmap, weekday, calendar,
  // reply graph. All network data via textContent / validated attrs (host-safe). =========================
  // ============================================================================================
  // TR4KER+ — unified member stats card v2 (drop-in replacement)
  // Replaces the same-named symbols: MSTATS_CSS, ensureMstatsCSS, mcEl, mcCalendar, buildStatsCard.
  // mcSpark / mcTrendCard are SUPERSEDED — delete them (nothing else references them).
  // New clearly-named helpers: mcNS, mcIcon, mcTip, mcSection, mcDial, mcTile, mcPercentiles,
  // mcLineChart, mcObserve, mcHeat, mcHeatLegend, mcWeekdays, mcReplyGraph + mcFmt* formatters.
  // In-scope deps (already in the IIFE): fmtNum, fmtTB, unorm.
  // Hard rules kept: DOM-only construction (mcEl / createElementNS), textContent for EVERY
  // network-derived string (usernames, tiers, bands) — the only markup built from literals is
  // static SVG geometry fully under our control. Scoped .t4xmc- CSS injected once per root
  // (document OR shadow root). Tooltip is position:absolute INSIDE the card (an ancestor of the
  // drawer carries a transform, which re-roots position:fixed — so fixed is never used here).
  // ============================================================================================
  const MSTATS_CSS = `
.t4xmc{
  --mc-ink:var(--on-surface,#e6e9f2); --mc-mut:var(--on-surface-variant,#9aa1b4);
  --mc-sig:var(--primary,#7c9cff); --mc-up:var(--t4x-seed,#4ade80); --mc-dn:var(--error,#ff8a80);
  --mc-srf:var(--surface,#0f1117);
  --mc-edge:color-mix(in srgb,var(--mc-sig) 16%,transparent);
  --mc-fill:color-mix(in srgb,var(--mc-sig) 9%,transparent);
  --mc-card:color-mix(in srgb,var(--mc-sig) 6%,transparent);
  --mc-hair:color-mix(in srgb,var(--mc-ink) 10%,transparent);
  --mc-mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
  position:relative; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
  color:var(--mc-ink); font-size:13px; line-height:1.4;
}
.t4xmc *,.t4xmc *::before,.t4xmc *::after{box-sizing:border-box}
/* ---- entrance rhythm ---- */
@keyframes t4xmc-in{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}
@keyframes t4xmc-grow{from{transform:scaleX(0)}}
.t4xmc-in{animation:t4xmc-in .5s cubic-bezier(.22,.68,.32,1) both}
/* ---- header ---- */
.t4xmc-hd{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.t4xmc-av{width:54px;height:54px;border-radius:16px;flex:0 0 auto;display:flex;align-items:center;justify-content:center;
  background:linear-gradient(135deg,color-mix(in srgb,var(--mc-sig) 28%,transparent),color-mix(in srgb,var(--mc-up) 16%,transparent));
  border:1px solid var(--mc-edge);font:700 17px/1 var(--mc-mono);color:var(--mc-ink);overflow:hidden}
.t4xmc-idb{min-width:0;flex:1 1 120px}
.t4xmc-nm{font-size:22px;font-weight:800;line-height:1.12;letter-spacing:-.01em;overflow-wrap:anywhere}
.t4xmc-rk{font:700 11.5px/1 var(--mc-mono);color:var(--mc-sig);margin-top:6px}
.t4xmc-rk .sc{color:var(--mc-mut);margin-left:8px;font-weight:600}
.t4xmc-dials{display:flex;gap:16px;margin-left:auto}
.t4xmc-dial{width:54px;text-align:center;cursor:default;border-radius:10px}
.t4xmc-dial svg{display:block;margin:0 auto}
.t4xmc-dial .tr{fill:none;stroke:color-mix(in srgb,var(--mc-ink) 12%,transparent);stroke-width:4}
.t4xmc-dial .pg{fill:none;stroke:var(--mc-sig);stroke-width:4;stroke-linecap:round;
  transition:stroke-dashoffset .9s cubic-bezier(.22,.68,.32,1)}
.t4xmc-dial.alt .pg{stroke:var(--mc-up)}
.t4xmc-dial .tx{font:700 13px var(--mc-mono);fill:var(--mc-ink)}
.t4xmc-dial .lb{font:700 8.5px/1 var(--mc-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--mc-mut);margin-top:4px}
/* ---- stat tiles ---- */
.t4xmc-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(128px,1fr));gap:10px;margin-top:18px}
.t4xmc-tile{background:var(--mc-card);border:1px solid var(--mc-edge);border-radius:14px;padding:11px 13px 12px;
  transition:border-color .18s,transform .18s,box-shadow .18s}
.t4xmc-tile:hover{border-color:color-mix(in srgb,var(--mc-sig) 42%,transparent);transform:translateY(-1px);
  box-shadow:0 6px 18px rgba(0,0,0,.16)}
.t4xmc-tile .th{display:flex;align-items:center;gap:7px;color:var(--mc-mut)}
.t4xmc-ic{width:15px;height:15px;color:var(--mc-sig);flex:0 0 auto}
.t4xmc-tile .tl{font:700 9.5px/1 var(--mc-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--mc-mut)}
.t4xmc-tile .tv{font:800 19px/1.15 var(--mc-mono);margin-top:8px;overflow-wrap:anywhere}
.t4xmc-tile .ts{font:600 10.5px/1.3 var(--mc-mono);color:var(--mc-mut);margin-top:5px}
.t4xmc-tile .ts.up{color:var(--mc-up)}.t4xmc-tile .ts.dn{color:var(--mc-dn)}
/* ---- sections ---- */
.t4xmc-sec{margin-top:22px}
.t4xmc-sh{display:flex;align-items:baseline;gap:10px;margin-bottom:10px}
.t4xmc-h{margin:0;font:800 10.5px/1 var(--mc-mono);letter-spacing:.15em;text-transform:uppercase;color:var(--mc-mut)}
.t4xmc-hm{margin-left:auto;font:600 9.5px/1 var(--mc-mono);color:var(--mc-mut)}
.t4xmc-note{color:var(--mc-mut);font:500 10.5px/1.5 var(--mc-mono);margin-top:7px}
.t4xmc-muted{color:var(--mc-mut);font-size:13px;padding:6px 0}
/* ---- percentile strip (secondary) ---- */
.t4xmc-bars{display:flex;flex-direction:column;gap:4px;max-width:460px}
.t4xmc-bar{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:9px;transition:background .15s}
.t4xmc-bar:hover{background:var(--mc-fill)}
.t4xmc-bar .k{width:64px;flex:0 0 auto;font:600 10.5px/1 var(--mc-mono);color:var(--mc-mut)}
.t4xmc-bar .t{flex:1;height:8px;border-radius:4px;background:var(--mc-fill);overflow:hidden}
.t4xmc-bar .f{display:block;height:100%;border-radius:0 4px 4px 0;transform-origin:left;
  background:linear-gradient(90deg,var(--mc-sig),var(--mc-up));animation:t4xmc-grow .7s cubic-bezier(.22,.68,.32,1) both}
.t4xmc-bar .n{width:34px;flex:0 0 auto;text-align:right;font:700 10.5px/1 var(--mc-mono);color:var(--mc-mut)}
.t4xmc-bar:hover .n{color:var(--mc-ink)}
/* ---- progression charts ---- */
.t4xmc-trend{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px}
.t4xmc-cc2{background:var(--mc-card);border:1px solid var(--mc-edge);border-radius:14px;padding:11px 13px 10px;min-width:0;
  transition:border-color .18s}
.t4xmc-cc2:hover{border-color:color-mix(in srgb,var(--mc-sig) 42%,transparent)}
.t4xmc-ct{display:flex;align-items:center;gap:8px}
.t4xmc-ct .l{font:700 9.5px/1 var(--mc-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--mc-mut)}
.t4xmc-chip{margin-left:auto;font:700 9.5px/1 var(--mc-mono);padding:3px 7px;border-radius:999px;
  color:var(--mc-up);background:color-mix(in srgb,var(--mc-up) 14%,transparent)}
.t4xmc-chip.dn{color:var(--mc-dn);background:color-mix(in srgb,var(--mc-dn) 14%,transparent)}
.t4xmc-cv{font:800 17px/1.15 var(--mc-mono);margin:7px 0 4px}
.t4xmc-fig{position:relative;outline:none;cursor:crosshair;border-radius:8px}
.t4xmc-csvg{display:block;width:100%}
.t4xmc-csvg .g{stroke:var(--mc-hair);stroke-width:1}
.t4xmc-csvg .gv{font:600 8.5px var(--mc-mono);fill:var(--mc-mut);opacity:.85}
.t4xmc-csvg .a{fill:var(--mc-sig);opacity:.1}
.t4xmc-csvg .p{fill:none;stroke:var(--mc-sig);stroke-width:2;stroke-linejoin:round;stroke-linecap:round}
.t4xmc-csvg .e{fill:var(--mc-sig);stroke:var(--mc-srf);stroke-width:2}
.t4xmc-csvg .x{stroke:color-mix(in srgb,var(--mc-ink) 32%,transparent);stroke-width:1;opacity:0;transition:opacity .12s}
.t4xmc-csvg .d{fill:var(--mc-sig);stroke:var(--mc-srf);stroke-width:2;opacity:0;transition:opacity .12s}
.t4xmc-csvg.hov .x,.t4xmc-csvg.hov .d{opacity:1}
.t4xmc-crng{display:flex;justify-content:space-between;font:600 9px/1 var(--mc-mono);color:var(--mc-mut);margin-top:6px}
/* ---- sequential levels (shared: heatmap cells, calendar cells, legend swatches) ---- */
.t4xmc .l0{background:var(--mc-fill)}
.t4xmc .l1{background:color-mix(in srgb,var(--mc-sig) 26%,transparent)}
.t4xmc .l2{background:color-mix(in srgb,var(--mc-sig) 46%,transparent)}
.t4xmc .l3{background:color-mix(in srgb,var(--mc-sig) 68%,transparent)}
.t4xmc .l4{background:color-mix(in srgb,var(--mc-sig) 92%,transparent)}
.t4xmc-lg{display:flex;align-items:center;gap:4px;margin-left:auto;font:600 9px/1 var(--mc-mono);color:var(--mc-mut)}
.t4xmc-lg i{width:9px;height:9px;border-radius:2.5px;display:block}
/* ---- hours heatmap ---- */
.t4xmc-heat{display:grid;grid-template-columns:repeat(24,1fr);gap:3px}
.t4xmc-hc{aspect-ratio:1;border-radius:3.5px;position:relative;transition:transform .12s,box-shadow .12s}
.t4xmc-hc:hover{transform:scale(1.22);box-shadow:0 0 0 1.5px var(--mc-sig);z-index:1}
.t4xmc-hx{display:grid;grid-template-columns:repeat(24,1fr);gap:3px;margin-top:5px;font:500 9px/1 var(--mc-mono);color:var(--mc-mut)}
/* ---- weekday bars ---- */
.t4xmc-wk{display:flex;flex-direction:column;gap:3px}
.t4xmc-wr{display:flex;align-items:center;gap:9px;padding:3px 6px;border-radius:8px;transition:background .15s}
.t4xmc-wr:hover{background:var(--mc-fill)}
.t4xmc-wl{width:32px;flex:0 0 auto;font:600 10.5px/1 var(--mc-mono);color:var(--mc-mut)}
.t4xmc-wt{flex:1;height:9px;border-radius:4px;background:var(--mc-fill);overflow:hidden}
.t4xmc-wf{display:block;height:100%;border-radius:0 4px 4px 0;transform-origin:left;
  background:linear-gradient(90deg,var(--mc-sig),var(--mc-up));animation:t4xmc-grow .7s cubic-bezier(.22,.68,.32,1) both}
.t4xmc-wn{width:46px;flex:0 0 auto;text-align:right;font:600 10.5px/1 var(--mc-mono);color:var(--mc-mut)}
.t4xmc-wr.max .t4xmc-wn{color:var(--mc-ink);font-weight:700}
/* ---- contribution calendar ---- */
.t4xmc-calwrap{display:flex;gap:6px;align-items:flex-start}
.t4xmc-calwd{display:grid;grid-template-rows:13px repeat(7,11px);gap:3px;flex:0 0 auto;
  font:600 8.5px/11px var(--mc-mono);color:var(--mc-mut)}
.t4xmc-calscroll{overflow-x:auto;flex:1;min-width:0;padding-bottom:3px;scrollbar-width:thin}
.t4xmc-calgrid{display:grid;grid-auto-flow:column;grid-template-rows:13px repeat(7,11px);grid-auto-columns:11px;gap:3px;width:max-content}
.t4xmc-ce{width:11px;height:11px;border-radius:3px;position:relative;transition:transform .12s,box-shadow .12s}
.t4xmc-ce:hover{transform:scale(1.3);box-shadow:0 0 0 1.5px var(--mc-sig);z-index:1}
.t4xmc-ce.e{visibility:hidden}
.t4xmc-ce.now{box-shadow:inset 0 0 0 1.5px var(--mc-sig)}
.t4xmc-ml{font:600 8.5px/13px var(--mc-mono);color:var(--mc-mut);white-space:nowrap;overflow:visible;width:11px}
.t4xmc-calfoot{display:flex;align-items:center;gap:10px;margin-top:8px}
.t4xmc-calsum{font:600 9.5px/1 var(--mc-mono);color:var(--mc-mut)}
/* ---- reply graph ---- */
.t4xmc-gr{display:flex;flex-direction:column;gap:3px}
.t4xmc-gr .r{display:flex;align-items:center;gap:9px;padding:4px 6px;border-radius:9px;transition:background .15s}
.t4xmc-gr .r.act{cursor:pointer}
.t4xmc-gr .r:hover{background:var(--mc-fill)}
.t4xmc-gr .nm{flex:0 0 auto;width:118px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;font-weight:600;transition:color .15s}
.t4xmc-gr .r:hover .nm{color:var(--mc-sig)}
.t4xmc-gr .go{flex:0 0 auto;width:12px;font:700 11px/1 var(--mc-mono);color:var(--mc-sig);opacity:0;transition:opacity .15s}
.t4xmc-gr .r:hover .go,.t4xmc-gr .r:focus-visible .go{opacity:1}
.t4xmc-gr .t{flex:1;height:8px;border-radius:4px;background:var(--mc-fill);overflow:hidden}
.t4xmc-gr .f{display:block;height:100%;border-radius:0 4px 4px 0;background:var(--mc-sig);transform-origin:left;
  animation:t4xmc-grow .7s cubic-bezier(.22,.68,.32,1) both}
.t4xmc-gr .c{flex:0 0 auto;width:36px;text-align:right;font:600 10.5px/1 var(--mc-mono);color:var(--mc-mut)}
/* ---- shared tooltip (absolute in-card: fixed would break under the drawer's transform) ---- */
.t4xmc-tip{position:absolute;left:0;top:0;z-index:9;pointer-events:none;opacity:0;white-space:nowrap;
  background:color-mix(in srgb,var(--mc-srf) 94%,var(--mc-ink));border:1px solid color-mix(in srgb,var(--mc-ink) 18%,transparent);
  border-radius:10px;padding:7px 10px;box-shadow:0 10px 30px rgba(0,0,0,.3);
  transition:opacity .12s,transform .09s ease-out;will-change:transform}
.t4xmc-tip.on{opacity:1}
.t4xmc-tip .v{font:700 12.5px/1.2 var(--mc-mono);color:var(--mc-ink)}
.t4xmc-tip .s{font:600 10px/1.2 var(--mc-mono);color:var(--mc-mut);margin-top:3px}
/* ---- a11y ---- */
.t4xmc :focus-visible{outline:2px solid var(--mc-sig);outline-offset:2px;border-radius:8px}
@media (prefers-reduced-motion:reduce){
  .t4xmc *,.t4xmc *::before,.t4xmc *::after{animation:none!important;transition:none!important}
}`;
  function ensureMstatsCSS(rootNode) {
    const doc = rootNode || document;
    if (doc.getElementById && doc.getElementById('t4xmc-css')) return;
    const st = document.createElement('style'); st.id = 't4xmc-css'; st.textContent = MSTATS_CSS;
    (doc.head || doc.body || doc).appendChild(st);
  }
  function mcEl(t, c, tx) { const e = document.createElement(t); if (c) e.className = c; if (tx != null) e.textContent = tx; return e; }
  function mcNS(tag, attrs) { const e = document.createElementNS('http://www.w3.org/2000/svg', tag); if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]); return e; }
  // ---- formatters (fr) --------------------------------------------------------------------
  function mcFmtScore(x) { return String(Math.round((Number(x) || 0) * 10) / 10).replace('.', ','); }
  function mcFmtRatio(r) { const n = Number(r); if (!isFinite(n) || n < 0) return '∞'; if (n >= 100) return fmtNum(Math.round(n)); return (n >= 10 ? n.toFixed(1) : n.toFixed(2)).replace('.', ','); }
  // seedtime is CUMULATIVE torrent-seed-days (summed across all seeded torrents), NOT wall-clock —
  // so never render it as calendar years. Compact days with k/M suffix keeps it honest + legible.
  function mcFmtDays(d) { const n = Math.max(0, Math.round(Number(d) || 0)); if (n < 1000) return fmtNum(n) + ' j'; if (n < 1e6) return (n / 1000).toFixed(n < 1e4 ? 1 : 0).replace('.', ',') + ' k j'; return (n / 1e6).toFixed(1).replace('.', ',') + ' M j'; }
  function mcFmtTBFine(x) { const n = Number(x) || 0; if (n >= 1000) return (n / 1000).toFixed(2).replace('.', ',') + ' Po'; if (n >= 100) return Math.round(n) + ' To'; return (n >= 10 ? n.toFixed(1) : n.toFixed(2)).replace('.', ',') + ' To'; }
  function mcFmtDeltaTB(d) { const n = Math.abs(Number(d) || 0); if (n >= 1000) return (n / 1000).toFixed(2).replace('.', ',') + ' Po'; if (n >= 1) return (n >= 10 ? n.toFixed(1) : n.toFixed(2)).replace('.', ',') + ' To'; return Math.max(1, Math.round(n * 1000)) + ' Go'; }
  function mcFmtDate(ts, withYear) { try { return new Date(ts * 1000).toLocaleDateString('fr-FR', withYear ? { day: 'numeric', month: 'short', year: 'numeric' } : { day: 'numeric', month: 'short' }); } catch (e) { return ''; } }
  function mcFmtDayFull(ds) { try { return new Date(ds + 'T00:00:00Z').toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }); } catch (e) { return ds; } }
  function mcSpanLabel(trend) { if (!trend || trend.length < 2) return ''; const d = Math.round((trend[trend.length - 1].ts - trend[0].ts) / 86400); return d < 2 ? '24 h' : d + ' j'; }
  function mcLvl(n, max) { if (!n || n <= 0) return 0; const q = n / (max || 1); return q <= 0.25 ? 1 : q <= 0.5 ? 2 : q <= 0.75 ? 3 : 4; }
  // ---- static icon paths (hard-coded geometry — never network data) -------------------------
  const MC_ICONS = {
    up: ['M12 17V5', 'M6.5 10 12 4.5 17.5 10', 'M5 19.5h14'],
    ratio: ['M8 4v15', 'M4.5 7.5 8 4l3.5 3.5', 'M16 20V5', 'M12.5 16.5 16 20l3.5-3.5'],
    seed: ['M12 21v-8', 'M12 13C12 8.6 8.9 6 4.8 6c0 4.4 3.1 7 7.2 7Z', 'M12 13c0-4.4 3.1-7 7.2-7 0 4.4-3.1 7-7.2 7Z'],
    time: ['M12 4a8 8 0 1 1 0 16 8 8 0 0 1 0-16Z', 'M12 8v4.2l2.8 1.9'],
    tier: ['M12 10.5a4.75 4.75 0 1 1 0 9.5 4.75 4.75 0 0 1 0-9.5Z', 'M8.5 3.5 10.6 9', 'M15.5 3.5 13.4 9'],
    msg: ['M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H10l-6 5V6Z'],
  };
  function mcIcon(name) {
    const svg = mcNS('svg', { viewBox: '0 0 24 24', class: 't4xmc-ic', 'aria-hidden': 'true', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' });
    (MC_ICONS[name] || []).forEach((d) => svg.appendChild(mcNS('path', { d })));
    return svg;
  }
  // ---- shared tooltip: ONE positioned div per card, reused by charts/heatmap/calendar/dials.
  // Rect reads are hoisted: the container rect refreshes on zone pointerenter, tip size is only
  // re-measured when its content changes — pointermove does transforms only (no layout thrash).
  function mcTip(container) {
    let el = null, lastKey = '', tw = 0, th = 0, rect = null;
    const ensure = () => { if (!el || !el.isConnected) { el = mcEl('div', 't4xmc-tip'); el.setAttribute('aria-hidden', 'true'); container.appendChild(el); lastKey = ''; } return el; };
    return {
      refreshRect() { rect = container.getBoundingClientRect(); },
      show(cx, cy, main, sub) {                       // cx/cy: viewport coords of the anchor point
        const t = ensure(); if (!rect) this.refreshRect();
        const key = main + '\x00' + (sub || '');
        if (key !== lastKey) {
          t.replaceChildren(mcEl('div', 'v', main));
          if (sub) t.appendChild(mcEl('div', 's', sub));
          lastKey = key; t.classList.add('on'); tw = t.offsetWidth; th = t.offsetHeight;
        } else t.classList.add('on');
        let x = cx - rect.left - tw / 2, y = cy - rect.top - th - 10;
        x = Math.max(4, Math.min(x, rect.width - tw - 4));
        if (y < 2) y = cy - rect.top + 14;
        t.style.transform = 'translate(' + Math.round(x) + 'px,' + Math.round(y) + 'px)';
      },
      hide() { if (el) el.classList.remove('on'); },
    };
  }
  // hover+focus tooltip anchored to an element's top-center (dials, percentile rows)
  function mcHoverTip(el, tip, main, sub) {
    const show = () => { tip.refreshRect(); const r = el.getBoundingClientRect(); tip.show(r.left + r.width / 2, r.top, typeof main === 'function' ? main() : main, typeof sub === 'function' ? sub() : sub); };
    el.addEventListener('pointerenter', show); el.addEventListener('focus', show);
    el.addEventListener('pointerleave', () => tip.hide()); el.addEventListener('blur', () => tip.hide());
  }
  // ---- section scaffold ---------------------------------------------------------------------
  function mcSection(title, meta) {
    const s = mcEl('section', 't4xmc-sec');
    const h = mcEl('div', 't4xmc-sh'); h.appendChild(mcEl('h4', 't4xmc-h', title));
    if (meta != null) h.appendChild(typeof meta === 'string' ? mcEl('span', 't4xmc-hm', meta) : meta);
    s.appendChild(h); return s;
  }
  // ---- score dial (header) — animated ring, tooltip with the exact value --------------------
  function mcDial(value, label, alt, tip, tipMain, tipSub) {
    const v = Math.max(0, Math.min(100, Number(value) || 0));
    const S = 48, R = 20, C = 2 * Math.PI * R;
    const box = mcEl('div', 't4xmc-dial' + (alt ? ' alt' : '')); box.setAttribute('tabindex', '0');
    box.setAttribute('role', 'img'); box.setAttribute('aria-label', (tipMain || '') + (tipSub ? ' — ' + tipSub : ''));
    const svg = mcNS('svg', { width: S, height: S, viewBox: '0 0 48 48', 'aria-hidden': 'true' });
    svg.appendChild(mcNS('circle', { class: 'tr', cx: 24, cy: 24, r: R }));
    const pg = mcNS('circle', { class: 'pg', cx: 24, cy: 24, r: R, 'stroke-dasharray': C.toFixed(1), 'stroke-dashoffset': C.toFixed(1), transform: 'rotate(-90 24 24)' });
    svg.appendChild(pg);
    const tx = mcNS('text', { class: 'tx', x: 24, y: 28.5, 'text-anchor': 'middle' }); tx.textContent = String(Math.round(v)); svg.appendChild(tx);
    box.appendChild(svg); box.appendChild(mcEl('div', 'lb', label));
    requestAnimationFrame(() => requestAnimationFrame(() => { pg.style.strokeDashoffset = (C * (1 - v / 100)).toFixed(1); }));
    if (tip && tipMain) mcHoverTip(box, tip, tipMain, tipSub);
    return box;
  }
  // ---- stat tile: icon + label + REAL number + optional delta/context line ------------------
  function mcTile(icon, label, value, sub, kind) {
    const t = mcEl('div', 't4xmc-tile');
    const hd = mcEl('div', 'th'); hd.appendChild(mcIcon(icon)); hd.appendChild(mcEl('span', 'tl', label)); t.appendChild(hd);
    t.appendChild(mcEl('div', 'tv', value));
    if (sub) t.appendChild(mcEl('div', 'ts' + (kind ? ' ' + kind : ''), sub));
    return t;
  }
  // ---- percentile strip (secondary "how they rank" — NOT the headline) ----------------------
  function mcPercentiles(sub, tip) {
    const box = mcEl('div', 't4xmc-bars');
    [['Envoyé', sub.up], ['Seedtime', sub.st], ['Uploads', sub.pub]].forEach(([k, v]) => {
      const p = Math.max(0, Math.min(100, Number(v) || 0));
      const row = mcEl('div', 't4xmc-bar'); row.setAttribute('tabindex', '0');
      row.appendChild(mcEl('span', 'k', k));
      const tr = mcEl('span', 't'); const f = mcEl('span', 'f'); f.style.width = p + '%'; tr.appendChild(f); row.appendChild(tr);
      row.appendChild(mcEl('span', 'n', String(Math.round(p))));
      mcHoverTip(row, tip, 'percentile ' + Math.round(p), 'mieux que ' + Math.round(p) + ' % des membres classés');
      box.appendChild(row);
    });
    return box;
  }
  // ---- one shared ResizeObserver for all charts (redraw on real width changes only) ---------
  let _mcRO = null;
  function mcObserve(el, fn) {
    if (typeof ResizeObserver === 'undefined') return;
    if (!_mcRO) _mcRO = new ResizeObserver((list) => { for (const en of list) { const t = en.target; if (!t.isConnected) { _mcRO.unobserve(t); continue; } if (t.__mcDraw) t.__mcDraw(); } });
    el.__mcDraw = fn; _mcRO.observe(el);
  }
  // ---- interactive line chart: real axes hints, crosshair + tooltip (value AND date),
  //      keyboard ←/→ navigation, redraw on resize. cfg: {label, pts:[{t,v,sub?}], fmt, deltaFmt?, tip}
  function mcLineChart(cfg) {
    const pts = cfg.pts, n = pts.length, fmt = cfg.fmt, tip = cfg.tip;
    const card = mcEl('div', 't4xmc-cc2');
    const top = mcEl('div', 't4xmc-ct'); top.appendChild(mcEl('span', 'l', cfg.label));
    const delta = pts[n - 1].v - pts[0].v;
    if (Math.abs(delta) > 1e-9) top.appendChild(mcEl('span', 't4xmc-chip' + (delta >= 0 ? '' : ' dn'), (delta >= 0 ? '▲ ' : '▼ ') + (cfg.deltaFmt || fmt)(Math.abs(delta))));
    card.appendChild(top);
    card.appendChild(mcEl('div', 't4xmc-cv', fmt(pts[n - 1].v)));
    const fig = mcEl('div', 't4xmc-fig'); fig.setAttribute('tabindex', '0'); fig.setAttribute('role', 'img');
    fig.setAttribute('aria-label', cfg.label + ' — ' + n + ' points, de ' + fmt(pts[0].v) + ' à ' + fmt(pts[n - 1].v) + ' (flèches ← → pour parcourir)');
    card.appendChild(fig);
    const multiYear = new Date(pts[0].t * 1000).getFullYear() !== new Date(pts[n - 1].t * 1000).getFullYear();
    const rng = mcEl('div', 't4xmc-crng');
    rng.append(mcEl('span', null, mcFmtDate(pts[0].t, multiYear)), mcEl('span', null, mcFmtDate(pts[n - 1].t, multiYear)));
    card.appendChild(rng);
    const H = 76, PT = 8, PB = 8;
    let xs = null, ys = null, W = 0, svg = null, cross = null, dot = null, figRect = null, idx = -1;
    function draw() {
      const w = fig.clientWidth; if (w < 40) return false;
      if (xs && Math.abs(w - W) < 9) return true;          // ignore sub-pixel/noise resizes
      W = w; idx = -1; tip.hide();
      const t0 = pts[0].t, tspan = (pts[n - 1].t - t0) || 1;
      let vmin = Infinity, vmax = -Infinity;
      for (let i = 0; i < n; i++) { const v = pts[i].v; if (v < vmin) vmin = v; if (v > vmax) vmax = v; }
      const rawMin = vmin, rawMax = vmax, flat = rawMin === rawMax;
      if (flat) { vmin -= 1; vmax += 1; } else { const pad = (vmax - vmin) * 0.08; vmin -= pad; vmax += pad; }
      const IL = 2, IR = 2, plotW = w - IL - IR, plotH = H - PT - PB;
      xs = new Array(n); ys = new Array(n);
      for (let i = 0; i < n; i++) { xs[i] = IL + ((pts[i].t - t0) / tspan) * plotW; ys[i] = PT + (1 - (pts[i].v - vmin) / (vmax - vmin)) * plotH; }
      svg = mcNS('svg', { width: w, height: H, class: 't4xmc-csvg', 'aria-hidden': 'true' });
      const yA = PT + (1 - (rawMax - vmin) / (vmax - vmin)) * plotH, yB = PT + (1 - (rawMin - vmin) / (vmax - vmin)) * plotH;
      if (!flat) {
        svg.appendChild(mcNS('line', { class: 'g', x1: 0, y1: yA.toFixed(1), x2: w, y2: yA.toFixed(1) }));
        svg.appendChild(mcNS('line', { class: 'g', x1: 0, y1: yB.toFixed(1), x2: w, y2: yB.toFixed(1) }));
        // min/max hints — skipped when they'd just repeat the big current value shown above
        const curS = fmt(pts[n - 1].v), maxS = fmt(rawMax), minS = fmt(rawMin);
        if (maxS !== curS) { const lmax = mcNS('text', { class: 'gv', x: 3, y: Math.max(8, yA - 3).toFixed(1) }); lmax.textContent = maxS; svg.appendChild(lmax); }
        if (minS !== curS && minS !== maxS) { const lmin = mcNS('text', { class: 'gv', x: 3, y: Math.min(H - 1, yB + 9).toFixed(1) }); lmin.textContent = minS; svg.appendChild(lmin); }
      } else svg.appendChild(mcNS('line', { class: 'g', x1: 0, y1: H / 2, x2: w, y2: H / 2 }));
      let d = 'M' + xs[0].toFixed(1) + ' ' + ys[0].toFixed(1);
      for (let i = 1; i < n; i++) d += 'L' + xs[i].toFixed(1) + ' ' + ys[i].toFixed(1);
      svg.appendChild(mcNS('path', { class: 'a', d: d + 'L' + xs[n - 1].toFixed(1) + ' ' + (H - PB) + 'L' + xs[0].toFixed(1) + ' ' + (H - PB) + 'Z' }));
      svg.appendChild(mcNS('path', { class: 'p', d }));
      svg.appendChild(mcNS('circle', { class: 'e', cx: xs[n - 1].toFixed(1), cy: ys[n - 1].toFixed(1), r: 3.5 }));
      cross = mcNS('line', { class: 'x', x1: 0, y1: PT, x2: 0, y2: H - PB });
      dot = mcNS('circle', { class: 'd', cx: 0, cy: 0, r: 4.5 });
      svg.appendChild(cross); svg.appendChild(dot);
      fig.replaceChildren(svg);
      return true;
    }
    function showIdx(i) {
      if (!xs || !figRect) return;
      idx = Math.max(0, Math.min(n - 1, i));
      const x = xs[idx], y = ys[idx], p = pts[idx];
      cross.setAttribute('x1', x.toFixed(1)); cross.setAttribute('x2', x.toFixed(1));
      dot.setAttribute('cx', x.toFixed(1)); dot.setAttribute('cy', y.toFixed(1));
      svg.classList.add('hov');
      tip.show(figRect.left + x, figRect.top + y, fmt(p.v), mcFmtDate(p.t, true) + (p.sub ? ' · ' + p.sub : ''));
    }
    const arm = () => { tip.refreshRect(); figRect = fig.getBoundingClientRect(); };
    const clear = () => { if (svg) svg.classList.remove('hov'); tip.hide(); idx = -1; };
    fig.addEventListener('pointerenter', arm);
    fig.addEventListener('pointermove', (e) => {
      if (!xs) return; if (!figRect) arm();
      const px = e.clientX - figRect.left;
      let best = 0, bd = Infinity;
      for (let i = 0; i < n; i++) { const dd = Math.abs(xs[i] - px); if (dd < bd) { bd = dd; best = i; } }
      showIdx(best);
    });
    fig.addEventListener('pointerleave', clear);
    fig.addEventListener('focus', () => { arm(); showIdx(idx < 0 ? n - 1 : idx); });
    fig.addEventListener('blur', clear);
    fig.addEventListener('keydown', (e) => {
      if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
      e.preventDefault(); if (!xs) return; arm();
      showIdx((idx < 0 ? n - 1 : idx) + (e.key === 'ArrowRight' ? 1 : -1));
    });
    let tries = 0;
    const kick = () => { if (draw()) { mcObserve(fig, draw); return; } if (++tries < 30) requestAnimationFrame(kick); else mcObserve(fig, draw); };
    requestAnimationFrame(kick);
    return card;
  }
  // ---- legend for the sequential ramp (shared by heatmap + calendar) ------------------------
  function mcHeatLegend() {
    const lg = mcEl('span', 't4xmc-lg');
    lg.appendChild(mcEl('span', null, 'moins'));
    for (let i = 0; i <= 4; i++) lg.appendChild(mcEl('i', 'l' + i));
    lg.appendChild(mcEl('span', null, 'plus'));
    return lg;
  }
  // ---- 24h heatmap (local time) with delegated hover tooltip --------------------------------
  function mcHeat(local, tip) {
    const wrap = mcEl('div');
    const max = Math.max.apply(null, [1].concat(local));
    const grid = mcEl('div', 't4xmc-heat');
    grid.setAttribute('role', 'img'); grid.setAttribute('aria-label', 'Répartition des messages par heure locale');
    local.forEach((nn, h) => { const c = mcEl('div', 't4xmc-hc l' + mcLvl(nn, max)); c.dataset.h = h; c.dataset.n = nn; grid.appendChild(c); });
    grid.addEventListener('pointerover', (e) => {
      const c = e.target.closest && e.target.closest('.t4xmc-hc'); if (!c) return;
      tip.refreshRect(); const r = c.getBoundingClientRect(); const nn = Number(c.dataset.n) || 0;
      tip.show(r.left + r.width / 2, r.top, fmtNum(nn) + (nn > 1 ? ' messages' : ' message'), c.dataset.h + ' h — ' + ((Number(c.dataset.h) + 1) % 24) + ' h · heure locale');
    });
    grid.addEventListener('pointerleave', () => tip.hide());
    wrap.appendChild(grid);
    const hx = mcEl('div', 't4xmc-hx');
    [0, 6, 12, 18].forEach((h) => { const x = mcEl('div', null, String(h).padStart(2, '0') + 'h'); x.style.gridColumn = (h + 1) + ' / span 6'; hx.appendChild(x); });
    wrap.appendChild(hx);
    return wrap;
  }
  // ---- weekday bars -------------------------------------------------------------------------
  function mcWeekdays(weekday) {
    const order = [1, 2, 3, 4, 5, 6, 0], labels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
    const wmax = Math.max.apply(null, [1].concat(weekday));
    const wk = mcEl('div', 't4xmc-wk');
    order.forEach((di, i) => {
      const nn = weekday[di] || 0;
      const r = mcEl('div', 't4xmc-wr' + (nn === wmax && nn > 0 ? ' max' : ''));
      r.appendChild(mcEl('span', 't4xmc-wl', labels[i]));
      const t = mcEl('span', 't4xmc-wt'); const f = mcEl('span', 't4xmc-wf'); f.style.width = Math.round(100 * nn / wmax) + '%'; t.appendChild(f); r.appendChild(t);
      r.appendChild(mcEl('span', 't4xmc-wn', fmtNum(nn)));
      wk.appendChild(r);
    });
    return wk;
  }
  // ---- GitHub-style contribution calendar: month labels, weekday guides, legend,
  //      per-day tooltip, auto-scrolled to the most recent week ------------------------------
  function mcCalendar(cal, tip) {
    const map = {}; let min = null, max = null, total = 0, active = 0;
    (cal || []).forEach((x) => {
      if (!x || !x.d) return; map[x.d] = x.n; total += (x.n || 0); if (x.n > 0) active++;
      if (!min || x.d < min) min = x.d; if (!max || x.d > max) max = x.d;
    });
    if (!min) return null;
    const start = new Date(min + 'T00:00:00Z'), end = new Date(max + 'T00:00:00Z');
    const cur = new Date(start); cur.setUTCDate(cur.getUTCDate() - ((cur.getUTCDay() + 6) % 7));  // back to Monday
    const mx = Math.max.apply(null, [1].concat((cal || []).map((x) => x.n || 0)));
    const todayISO = new Date().toISOString().slice(0, 10);
    const wrap = mcEl('div');
    const flexw = mcEl('div', 't4xmc-calwrap');
    // weekday guide column (rows: months header, Lun..Dim)
    const wd = mcEl('div', 't4xmc-calwd');
    for (let r = 0; r < 8; r++) wd.appendChild(mcEl('span', null, r === 2 ? 'Lun' : r === 4 ? 'Mer' : r === 6 ? 'Ven' : ''));
    flexw.appendChild(wd);
    const sc = mcEl('div', 't4xmc-calscroll');
    const grid = mcEl('div', 't4xmc-calgrid');
    grid.setAttribute('role', 'img'); grid.setAttribute('aria-label', 'Calendrier des messages, ' + fmtNum(total) + ' messages sur ' + active + ' jours actifs');
    let guard = 0, lastMonth = -1, lastLabelCol = -10, col = 0;
    while (cur <= end && guard++ < 420) {
      const m = cur.getUTCMonth();
      let ml = '';
      if (m !== lastMonth && col - lastLabelCol >= 3) {
        try { ml = new Date(cur).toLocaleDateString('fr-FR', { month: 'short', timeZone: 'UTC' }); } catch (e) {}
        lastLabelCol = col;
      }
      lastMonth = m;
      grid.appendChild(mcEl('div', 't4xmc-ml', ml));
      for (let i = 0; i < 7; i++) {
        const ds = cur.toISOString().slice(0, 10);
        if (cur >= start && cur <= end) {
          const nn = map[ds] || 0;
          const cell = mcEl('div', 't4xmc-ce l' + mcLvl(nn, mx) + (ds === todayISO ? ' now' : ''));
          cell.dataset.d = ds; cell.dataset.n = nn;
          grid.appendChild(cell);
        } else grid.appendChild(mcEl('div', 't4xmc-ce e'));
        cur.setUTCDate(cur.getUTCDate() + 1);
      }
      col++;
    }
    grid.addEventListener('pointerover', (e) => {
      const c = e.target.closest && e.target.closest('.t4xmc-ce'); if (!c || c.classList.contains('e')) return;
      tip.refreshRect(); const r = c.getBoundingClientRect(); const nn = Number(c.dataset.n) || 0;
      tip.show(r.left + r.width / 2, r.top, fmtNum(nn) + (nn > 1 ? ' messages' : ' message'), mcFmtDayFull(c.dataset.d));
    });
    grid.addEventListener('pointerleave', () => tip.hide());
    sc.appendChild(grid); flexw.appendChild(sc); wrap.appendChild(flexw);
    const foot = mcEl('div', 't4xmc-calfoot');
    foot.appendChild(mcEl('span', 't4xmc-calsum', fmtNum(total) + ' messages · ' + fmtNum(active) + ' j actifs'));
    foot.appendChild(mcHeatLegend());
    wrap.appendChild(foot);
    // land on (and stay pinned to) the most recent weeks until the user scrolls away themselves
    let userScrolled = false;
    sc.addEventListener('scroll', () => {
      if (Math.abs(sc.scrollLeft - (sc.scrollWidth - sc.clientWidth)) > 2) userScrolled = true;
    }, { passive: true });
    const pin = () => { try { sc.scrollLeft = sc.scrollWidth; } catch (e) {} };
    requestAnimationFrame(pin);
    mcObserve(sc, () => { if (!userScrolled) pin(); });
    return wrap;
  }
  // ---- reply graph ("répond surtout à") — clickable + keyboard-activable rows ---------------
  function mcReplyGraph(graph, onName) {
    const gmax = Math.max.apply(null, [1].concat(graph.map((g) => g.n || 0)));
    const gr = mcEl('div', 't4xmc-gr');
    graph.forEach((g) => {
      const r = mcEl('div', 'r' + (onName ? ' act' : ''));
      r.appendChild(mcEl('span', 'nm', g.name));
      r.appendChild(mcEl('span', 'go', '→'));
      const t = mcEl('span', 't'); const f = mcEl('span', 'f'); f.style.width = Math.round(100 * (g.n || 0) / gmax) + '%'; t.appendChild(f); r.appendChild(t);
      r.appendChild(mcEl('span', 'c', fmtNum(g.n || 0)));
      if (onName) {
        r.setAttribute('role', 'button'); r.setAttribute('tabindex', '0');
        r.title = 'Ouvrir le profil de ' + g.name;
        const go = () => onName(g.name);
        r.addEventListener('click', go);
        r.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
      }
      gr.appendChild(r);
    });
    return gr;
  }
  // ============================================================================================
  // buildStatsCard(container, name, data, onName) — same signature/contract as v1.
  // Layout: header (identity + score dials) → REAL-NUMBER stat tiles → percentile strip
  // (secondary) → interactive progression charts → hours heatmap → weekday bars → contribution
  // calendar → reply graph. Sections stagger in; everything degrades gracefully on partial data.
  // ============================================================================================
  function buildStatsCard(container, name, data, onName) {
    ensureMstatsCSS(container.getRootNode ? container.getRootNode() : document);
    container.classList.add('t4xmc'); container.replaceChildren();
    const u = data.ladder || null;
    const rich = (data.rich && data.rich.available) ? data.rich : null;
    const agg = data.agg || null;
    const trend = (Array.isArray(data.trend) ? data.trend : []).filter((p) => p && Number(p.ts) > 0).slice().sort((a, b) => a.ts - b.ts);
    const tip = mcTip(container);
    let seq = 0;
    const reveal = (el) => { el.classList.add('t4xmc-in'); el.style.animationDelay = Math.min(seq++ * 45, 360) + 'ms'; container.appendChild(el); return el; };
    // ---- header: identity + rank + score dials (L4DDER, and community when present) ----
    const hd = mcEl('div', 't4xmc-hd');
    hd.appendChild(mcEl('div', 't4xmc-av', (name || '?').slice(0, 2).toUpperCase()));
    const idb = mcEl('div', 't4xmc-idb');
    idb.appendChild(mcEl('div', 't4xmc-nm', name));
    const rk = mcEl('div', 't4xmc-rk');
    if (u) {
      rk.appendChild(document.createTextNode('L4DDER #' + fmtNum(u.rank)));
      if (rich) rk.appendChild(mcEl('span', 'sc', fmtNum(rich.total) + ' messages'));
    } else rk.textContent = 'Non classé au L4DDER';
    idb.appendChild(rk); hd.appendChild(idb);
    if (u) {
      const dials = mcEl('div', 't4xmc-dials');
      dials.appendChild(mcDial(u.score, 'L4DDER', false, tip, mcFmtScore(u.score) + '/100', 'score L4DDER · rang #' + fmtNum(u.rank)));
      if (u.community && u.community.score != null) {
        const c = u.community;
        dials.appendChild(mcDial(c.score, 'Commu', true, tip, mcFmtScore(c.score) + '/100', 'score communautaire' + (c.chat_score != null ? ' · chat ' + mcFmtScore(c.chat_score) + '/100' : '')));
      }
      hd.appendChild(dials);
    }
    reveal(hd);
    // ---- headline: the REAL numbers as tiles (with recent Δ from the trend window) ----
    const tiles = [];
    if (u) {
      const span = mcSpanLabel(trend);
      if (u.uploaded_TB != null) {
        let sub = null, kind = null;
        const ups = trend.map((p) => p.uploaded_TB).filter((v) => v != null);
        if (ups.length >= 2 && (ups[ups.length - 1] - ups[0]) > 0.0005) { sub = '+' + mcFmtDeltaTB(ups[ups.length - 1] - ups[0]) + ' · ' + span; kind = 'up'; }
        tiles.push(mcTile('up', 'Envoyé', fmtTB(u.uploaded_TB), sub, kind));
      }
      if (u.ratio != null) tiles.push(mcTile('ratio', 'Ratio', mcFmtRatio(u.ratio), null, null));
      if (u.seeding != null) {
        let sub = 'torrents', kind = null;
        const ss = trend.map((p) => p.seeding).filter((v) => v != null);
        if (ss.length >= 2 && ss[ss.length - 1] !== ss[0]) { const d = ss[ss.length - 1] - ss[0]; sub = (d > 0 ? '+' : '−') + fmtNum(Math.abs(d)) + ' · ' + span; kind = d > 0 ? 'up' : 'dn'; }
        tiles.push(mcTile('seed', 'En seed', fmtNum(u.seeding), sub, kind));
      }
      if (u.seedtime_days != null) tiles.push(mcTile('time', 'Seedtime', mcFmtDays(u.seedtime_days), 'cumul. seed', null));
      if (u.upload_tier != null && u.upload_tier !== '') tiles.push(mcTile('tier', 'Palier', String(u.upload_tier), 'upload', null));
    }
    if (rich) tiles.push(mcTile('msg', 'Messages', fmtNum(rich.total), 'canaux publics archivés', null));
    else if (agg && agg.available && agg.band) tiles.push(mcTile('msg', 'Messages', String(agg.band), 'estimation archive', null));
    if (tiles.length) { const g = mcEl('div', 't4xmc-tiles'); tiles.forEach((t) => g.appendChild(t)); reveal(g); }
    // ---- secondary: where those numbers place them (percentiles) ----
    if (u && u.sub) {
      const s = mcSection('Contribution L4DDER', 'percentiles');
      s.appendChild(mcPercentiles(u.sub, tip));
      s.appendChild(mcEl('div', 't4xmc-note', 'position relative aux membres classés · 100 = meilleur'));
      reveal(s);
    }
    // ---- progression: interactive charts from the ladder trend ----
    const sSeed = trend.filter((p) => p.seeding != null).map((p) => ({ t: p.ts, v: Number(p.seeding) }));
    const sUL = trend.filter((p) => p.uploaded_TB != null).map((p) => ({ t: p.ts, v: Number(p.uploaded_TB) }));
    const sSc = trend.filter((p) => p.score != null).map((p) => ({ t: p.ts, v: Number(p.score), sub: p.rank != null ? 'rang #' + fmtNum(p.rank) : null }));
    const charts = [];
    if (sSeed.length >= 2) charts.push(mcLineChart({ label: 'En seed', pts: sSeed, fmt: (v) => fmtNum(Math.round(v)), tip }));
    if (sUL.length >= 2) charts.push(mcLineChart({ label: 'Envoyé', pts: sUL, fmt: mcFmtTBFine, deltaFmt: mcFmtDeltaTB, tip }));
    if (sSc.length >= 2) charts.push(mcLineChart({ label: 'Score', pts: sSc, fmt: (v) => mcFmtScore(v) + '/100', deltaFmt: (v) => mcFmtScore(v) + ' pt', tip }));
    if (charts.length) {
      const s = mcSection('Évolution', 'sur ' + mcSpanLabel(trend));
      const grid = mcEl('div', 't4xmc-trend'); charts.forEach((c) => grid.appendChild(c)); s.appendChild(grid);
      reveal(s);
    } else if (u && trend.length === 1) {
      const s = mcSection('Évolution');
      s.appendChild(mcEl('div', 't4xmc-note', 'suivi démarré — l’historique se construit au fil des relevés'));
      reveal(s);
    }
    // ---- archive activity (public channels) ----
    if (rich) {
      if (Array.isArray(rich.hourly) && rich.hourly.length === 24) {
        const off = -Math.round(new Date().getTimezoneOffset() / 60);
        const local = []; for (let L = 0; L < 24; L++) local[L] = rich.hourly[((L - off) % 24 + 24) % 24] || 0;
        const peakN = Math.max.apply(null, local), peakH = local.indexOf(peakN);
        const s = mcSection('Heures d’activité', mcHeatLegend());
        s.appendChild(mcHeat(local, tip));
        s.appendChild(mcEl('div', 't4xmc-note', 'heure locale' + (peakN > 0 ? ' · pic à ' + peakH + ' h' : '')));
        reveal(s);
      }
      if (Array.isArray(rich.weekday) && rich.weekday.length === 7) {
        const s = mcSection('Jours');
        s.appendChild(mcWeekdays(rich.weekday));
        reveal(s);
      }
      const cal = mcCalendar(rich.calendar || [], tip);
      if (cal) { const s = mcSection('Calendrier'); s.appendChild(cal); reveal(s); }
      if (rich.graph && rich.graph.length) {
        const s = mcSection('Répond surtout à');
        s.appendChild(mcReplyGraph(rich.graph, onName));
        reveal(s);
      }
    } else if (!u) {
      container.appendChild(mcEl('div', 't4xmc-muted', 'Aucune donnée publique pour ce membre (ou membre retiré de l’archive).'));
    } else {
      const s = mcEl('div', 't4xmc-sec'); s.appendChild(mcEl('div', 't4xmc-muted', 'Aucun message archivé sur les canaux publics.'));
      reveal(s);
    }
  }
  // fetch combined member data (ladder + rich + agg + trend) from t4hub, cached briefly
  const _memberCache = new Map();
  function memberData(name) {
    const k = unorm(name); const hit = _memberCache.get(k); if (hit && Date.now() - hit.at < 120000) return Promise.resolve(hit.v);
    return hubGet('/api/hub/member?u=' + encodeURIComponent(name)).then((d) => { _memberCache.set(k, { v: d, at: Date.now() }); if (_memberCache.size > 500) _memberCache.delete(_memberCache.keys().next().value); return d; });
  }
  // NATIVE /user/<name> page: inject a TR4KER+ card (L4DDER + activity + trends) so the stats appear ON the
  // real profile page. Anchored structurally near the site's "Statistiques" card (no hashed-class dependency);
  // idempotent per user (survives React re-renders via scheduleAugment).
  function augmentUserPage() {
    if (!pref0('hoverCard', true)) return;
    const m = location.pathname.match(/^\/user\/([^\/?#]+)/); if (!m) return;
    const name = decodeURIComponent(m[1]).trim(); if (!name) return;
    const existing = document.getElementById('t4x-userprofile');
    if (existing) { if (existing.dataset.u === unorm(name) && existing.isConnected) return; try { existing.remove(); } catch (e) {} }
    // anchor: the site's "Statistiques" card. Find the label, walk up to the card that is one of several siblings.
    const label = [...document.querySelectorAll('h2, h3, [class*="cardLabel" i], [class*="label" i]')]
      .find((x) => !x.querySelector('*') && /^statistiques$/i.test((x.textContent || '').trim()));
    if (!label) return;
    let card = label; for (let i = 0; i < 6 && card.parentElement; i++) { if (card.parentElement.children.length >= 2 && card.getBoundingClientRect().height > 60) break; card = card.parentElement; }
    if (!card || !card.parentElement) return;
    const wrap = document.createElement('div'); wrap.id = 't4x-userprofile'; wrap.dataset.u = unorm(name);
    wrap.style.cssText = 'margin:16px 0;padding:18px 20px;border-radius:16px;background:color-mix(in srgb,var(--surface,#14161e) 80%,transparent);border:1px solid rgba(140,160,255,.16)';
    const h = document.createElement('div'); h.textContent = 'TR4KER+ · L4DDER & activité'; h.style.cssText = "font:800 11px/1 ui-monospace,monospace;letter-spacing:.14em;text-transform:uppercase;color:#8ea0d8;margin-bottom:14px";
    const box = document.createElement('div'); box.appendChild(mcEl('div', 't4xmc-muted', 'Chargement…'));
    wrap.append(h, box); card.parentElement.insertBefore(wrap, card.nextSibling);
    memberData(name).then((d) => {
      if (!wrap.isConnected) return;
      if (!d || (d.error && !d.ladder && !(d.rich && d.rich.available))) { box.replaceChildren(mcEl('div', 't4xmc-muted', 'Profil communautaire indisponible pour le moment.')); return; }
      buildStatsCard(box, name, d, (n) => { try { navigate('/user/' + encodeURIComponent(n)); } catch (e) { location.href = '/user/' + encodeURIComponent(n); } });
    }).catch(() => { if (wrap.isConnected) box.replaceChildren(mcEl('div', 't4xmc-muted', 'Profil communautaire indisponible.')); });
  }
  // ---- L4DDER leaderboard (browsable top-N, search, click a row -> member profile, your rank highlighted) ----
  let _ladbEl = null, _ladbRows = null;
  function closeLadderBoard() { if (!_ladbEl) return; const el = _ladbEl; _ladbEl = null; if (el._unesc) { try { el._unesc(); } catch (e) {} } el.classList.remove('on'); markOpen(el, false); setTimeout(() => { try { el.remove(); } catch (e) {} }, 200); }
  function ladbRow(u, mine) {
    const row = mkEl('div', 't4x-ladrow' + (mine ? ' me' : '')); row.setAttribute('role', 'button');
    const rk = mkEl('span', 't4x-ladrk', '#' + fmtNum(u.rank));
    const nm = mkEl('span', 't4x-ladnm', u.username || '?');
    const sc = mkEl('span', 't4x-ladsc', (Math.round((u.score || 0) * 10) / 10) + '');
    const sub = mkEl('span', 't4x-ladsub'); const parts = []; if (u.uploaded_TB != null) parts.push(fmtTB(u.uploaded_TB)); if (u.seeding != null) parts.push(fmtNum(u.seeding) + ' seed'); sub.textContent = parts.join(' · ');
    row.append(rk, nm, sc, sub);
    row.addEventListener('click', () => { if (u.username) openMemberProfile(u.username); });
    return row;
  }
  function renderLadderBoard(list, rows, q) {
    const mine = unorm(chat.myName || store.get('t4x.myname', '') || '');
    const ql = (q || '').trim().toLowerCase();
    const filtered = ql ? rows.filter((u) => (u.username || '').toLowerCase().includes(ql)) : rows;
    list.replaceChildren();
    if (!filtered.length) { list.appendChild(mkEl('div', 't4x-schempty', ql ? 'Aucun membre pour « ' + q +' ».' : 'Classement indisponible.')); return; }
    const cap = ql ? filtered.length : Math.min(filtered.length, 200);
    for (let i = 0; i < cap; i++) list.appendChild(ladbRow(filtered[i], unorm(filtered[i].username || '') === mine));
    if (!ql && filtered.length > cap) list.appendChild(mkEl('div', 't4x-ladmore', filtered.length + ' membres classés · affine avec la recherche'));
  }
  function openLadderBoard() {
    ensureHost(); ensureSearchCSS(); ensureMyStatsCSS();
    if (_ladbEl) { closeLadderBoard(); return; }
    const el = mkEl('div', 't4x-search t4x-ladboard'); el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Classement L4DDER');
    if (innerWidth > 620) el.style.right = panelRight() + 'px';
    const head = mkEl('div', 'sh'); const xb = mkEl('button'); xb.title = 'Fermer (Échap)'; xb.appendChild(mkEl('span', 'ic', 'close')); xb.addEventListener('click', closeLadderBoard);
    head.append(mkEl('b', null, 'L4DDER — classement'), xb);
    const tools = mkEl('div', 't4x-schtools'); const q = mkEl('input', 't4x-schq'); q.type = 'search'; q.placeholder = 'Chercher un membre…';
    tools.appendChild(q);
    const list = mkEl('div', 't4x-schlist'); list.appendChild(mkEl('div', 't4x-schempty', 'Chargement du classement…'));
    el.append(head, tools, list); root.appendChild(el); _ladbEl = el;
    requestAnimationFrame(() => { el.classList.add('on'); markOpen(el, true); });
    el._unesc = escPush(closeLadderBoard);
    q.addEventListener('input', () => { if (_ladbRows) renderLadderBoard(list, _ladbRows, q.value); });
    const load = () => { hubGet('/api/hub/ladder?limit=500').then((d) => {
      if (!_ladbEl) return; const rows = (d && Array.isArray(d.rows)) ? d.rows : [];
      _ladbRows = rows; renderLadderBoard(list, rows, q.value);
      if (d && d.n) head.querySelector('b').textContent = 'L4DDER — top ' + Math.min(rows.length, 500) + ' / ' + fmtNum(d.n);
    }).catch((e) => { if (_ladbEl) list.replaceChildren(mkEl('div', 't4x-scherr', 'Classement indisponible (' + (e.message || '?') + ').')); }); };
    load();
  }
  try { if (window.__T4X_EXPOSE__) { window.__t4xLadder = { open: openLadderBoard, close: closeLadderBoard }; } } catch (e) {}

  function openSignals() {
    if (!pref0('inbox', true)) { toast('Les signaux sont désactivés dans les réglages.'); return; }
    ensureHost(); ensureSigCSS(); ensureChatCSS();
    if (sigState.el) { closeSignals(); return; }
    const el = document.createElement('div'); el.className = 't4x-sig'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Signaux');
    if (innerWidth > 560) el.style.right = panelRight() + 'px';   // clear the AI dock and a right-docked drawer
    const head = document.createElement('div'); head.className = 'sh';
    const b = document.createElement('b'); b.textContent = 'Signaux'; const cnt = document.createElement('span'); cnt.className = 'cnt';
    const mkb = (icon, title, fn) => { const x = document.createElement('button'); x.type = 'button'; x.title = title; x.innerHTML = '<span class="ic">' + icon + '</span>'; x.addEventListener('click', fn); return x; };
    const allRead = mkb('done_all', 'Tout marquer lu', () => { const d = inboxData(); d.items.forEach((r) => r.read = true); inboxDirty(); refreshSigBadge(); render(); });
    const clear = mkb('delete_sweep', 'Effacer l’historique', () => { toast('Effacer tout l’historique des signaux ?', 6000, { action: { label: 'Effacer', fn: () => { const d = inboxData(); d.clearedAt = Date.now(); d.items = []; inboxDirty(); refreshSigBadge(); render(); } } }); });
    head.append(b, cnt, allRead, clear, mkb('close', 'Fermer (Échap)', closeSignals));
    const tools = document.createElement('div'); tools.className = 't4x-sigtools';
    const mkChip = (v, lbl) => { const c = document.createElement('button'); c.className = 't4x-sigchip' + (sigState.filter === v ? ' on' : ''); c.textContent = lbl; c.dataset.v = v; c.addEventListener('click', () => { sigState.filter = v; tools.querySelectorAll('.t4x-sigchip').forEach((x) => x.classList.toggle('on', x.dataset.v === v)); render(); }); return c; };
    const q = document.createElement('input'); q.className = 't4x-sigq'; q.type = 'search'; q.placeholder = 'Filtrer — pseudo, canal, texte…'; q.value = sigState.q;
    let deb = null; q.addEventListener('input', () => { clearTimeout(deb); deb = setTimeout(() => { sigState.q = q.value.trim().toLowerCase(); render(); }, 120); });
    tools.append(mkChip('unread', 'Non lus'), mkChip('all', 'Tous'), q);
    const list = document.createElement('div'); list.className = 't4x-siglist';
    el.append(head, tools, list); root.appendChild(el);
    sigState.el = el; sigState.listEl = list;
    requestAnimationFrame(() => { el.classList.add('on'); markOpen(el, true); });
    sigState.unesc = escPush(closeSignals);
    const render = () => {
      const d = inboxData(); const un = inboxUnread(); cnt.textContent = un ? un + ' non lus' : '';
      let items = d.items.slice().sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
      if (sigState.filter === 'unread') items = items.filter((r) => !r.read);
      if (sigState.q) items = items.filter((r) => (r.sender + ' ' + r.body + ' ' + (r.term || '')).toLowerCase().includes(sigState.q));
      list.replaceChildren();
      if (!items.length) { const e = document.createElement('div'); e.className = 't4x-sigempty'; e.textContent = sigState.q ? 'Aucun signal ne correspond.' : (d.items.length ? 'Tout est lu.' : 'Aucune mention manquée. Les mentions, mots-clés et réponses sont capturés en direct et relus au démarrage.'); list.appendChild(e); return; }
      const chans = chat.channels || [];
      items.forEach((r) => {
        const row = document.createElement('div'); row.className = 't4x-sigrow ' + (r.read ? 'read' : 'unread');
        const av = document.createElement('div'); av.className = 'av'; if (r.avatar_url && /^https?:\/\//i.test(r.avatar_url)) { const im = document.createElement('img'); im.src = r.avatar_url; im.referrerPolicy = 'no-referrer'; im.onerror = () => { av.textContent = initials(r.sender); try { im.remove(); } catch (e) {} }; av.appendChild(im); } else av.textContent = initials(r.sender);
        const sc = document.createElement('div'); sc.className = 'sc';
        const top = document.createElement('div'); top.className = 't4x-sigtop';
        const who = document.createElement('span'); who.className = 'who'; who.textContent = r.sender || '?'; who.style.color = roleColor(r.sender_role);
        const rea = document.createElement('span'); rea.className = 'rea'; rea.textContent = r.reason === 'reply' ? 'reply' : r.reason === 'keyword' ? 'tag' : 'alternate_email';
        top.append(rea, who);
        if (r.reason === 'keyword' && r.term) { const kw = document.createElement('span'); kw.className = 'kw'; kw.textContent = r.term; top.appendChild(kw); }
        const tm = document.createElement('span'); tm.className = 'tm'; tm.textContent = agoText(r.created_at); tm.title = new Date(r.created_at).toLocaleString('fr-FR'); top.appendChild(tm);
        const bd = document.createElement('div'); bd.className = 'bd'; renderBody(bd, r.body, chat.myName);
        sc.append(top, bd);
        if (r.parent && (r.parent.body || r.parent.sender)) { const pq = document.createElement('div'); pq.className = 'ch'; pq.textContent = '↳ ' + (r.parent.sender ? r.parent.sender + ' : ' : '') + String(r.parent.body || ''); sc.appendChild(pq); }
        const ch = chans.find((c) => c.id === r.conv); const cl = document.createElement('div'); cl.className = 'ch'; cl.textContent = '#' + (ch ? (ch.slug || ch.name) : r.conv); sc.appendChild(cl);
        row.append(av, sc);
        row.addEventListener('click', () => { r.read = true; inboxDirty(); refreshSigBadge(); openDrawer(); if (r.conv) selectChannel(r.conv); setTimeout(() => { let found = false; chat.panes.forEach((p) => { const mel = msgEl(p, r.id); if (mel) { found = true; mel.scrollIntoView({ block: 'center' }); mel.classList.add('ment-new'); setTimeout(() => mel.classList.remove('ment-new'), 2400); } }); if (!found && r.id != null) showArchivedContext(r.id); }, 800); closeSignals(); });
        list.appendChild(row);
      });
    };
    render();
  }
  function aiOpen() { try { return !!(ai && ai.open); } catch (e) { return false; } }
  // right offset for the corner panels (Signaux / Mes stats) so they clear the AI dock AND a right-docked
  // chat drawer instead of painting over them (M18).
  function panelRight() {
    let r = 18;
    try { if (chat.open && !chat.inline && chat.el && !chat.el.classList.contains('float') && !chat.el.classList.contains('dockL')) r += (chat.el.offsetWidth || 0) + 12; } catch (e) {}
    try { if (aiOpen()) r += Math.min(420, innerWidth * 0.94) + 12; } catch (e) {}
    return r;
  }
  // ---- DM card: a new private message → masked-excerpt card + one-click open (P-3/P-4) --------------
  let _dmCard = null, _dmCool = {};
  function dmExcerpt(body) {
    return String(body || '').replace(/\[spoiler\][\s\S]*?\[\/spoiler\]/gi, '▓ spoiler').replace(/\[img\]\s*[^\[\]\s]+\s*\[\/img\]/gi, '[image]')
      .replace(/https?:\/\/\S+\.(?:png|jpe?g|gif|webp)\S*/gi, '[image]').replace(/\s+/g, ' ').trim().slice(0, 140);
  }
  function showDmCard(conv, sender, excerpt, avatar) {
    ensureHost(); if (!toastWrap) { toastWrap = document.createElement('div'); toastWrap.className = 'toasts'; root.appendChild(toastWrap); }
    if (_dmCard) { try { _dmCard.remove(); } catch (e) {} _dmCard = null; }
    const t = document.createElement('div'); t.className = 'toast act dmcard'; t.setAttribute('role', 'status');
    const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:10px;align-items:flex-start';
    const av = document.createElement('div'); av.className = 't4x-dmav'; if (avatar && /^https?:\/\//i.test(avatar)) { const im = document.createElement('img'); im.src = avatar; im.referrerPolicy = 'no-referrer'; im.onerror = () => { av.textContent = initials(sender); try { im.remove(); } catch (e) {} }; av.appendChild(im); } else av.textContent = initials(sender);
    const col = document.createElement('div'); col.style.cssText = 'flex:1;min-width:0';
    const kick = document.createElement('div'); kick.className = 't4x-dmkick'; kick.textContent = 'MESSAGE PRIVÉ';
    const nm = document.createElement('div'); nm.style.cssText = 'font-weight:600;font-size:13px'; nm.textContent = sender || '?';
    const ex = document.createElement('div'); ex.style.cssText = 'font-size:12px;color:var(--on-surface-variant,#b8bdd1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; ex.textContent = excerpt;
    col.append(kick, nm, ex); row.append(av, col);
    const acts = document.createElement('div'); acts.style.cssText = 'display:flex;gap:8px;margin-top:8px;justify-content:flex-end';
    const mk = (label, fn, pri) => { const bb = document.createElement('button'); bb.type = 'button'; bb.className = 't4x-toast-act'; if (!pri) bb.style.color = 'var(--on-surface-variant,#b8bdd1)'; bb.textContent = label; bb.addEventListener('click', fn); return bb; };
    let timer = setTimeout(() => close(), 10000);
    const close = () => { clearTimeout(timer); if (_dmCard === t) _dmCard = null; t.classList.remove('on'); setTimeout(() => { try { t.remove(); } catch (e) {} }, 200); };
    acts.append(mk('Plus tard', close), mk('Ouvrir', () => { navigate('/communication?conv=' + conv); close(); }, true));
    t.append(row, acts);
    t.addEventListener('mouseenter', () => clearTimeout(timer)); t.addEventListener('mouseleave', () => { timer = setTimeout(close, 3000); });
    toastWrap.appendChild(t); _dmCard = t; requestAnimationFrame(() => t.classList.add('on'));
    scheduleSweep();
  }
  function onDmFrame(f) {
    if (!pref0('dmCard', true) || !f || f.type !== 'msg.received') return;
    if (f.sender_id != null && f.sender_id === chat.myId) return;
    if (f.conv_id == null || isExt(f.conv_id)) return;
    if (userMuted(f)) return;   // a muted member's DM stays silent (m4)
    // FAIL CLOSED: until the REAL channel list is loaded, we cannot tell a DM from a public message, so a
    // public shoutbox line would be mislabelled "MESSAGE PRIVÉ". A fallback list is treated as unknown (B3).
    if (!chat.channels || !chat._channelsReal) { try { loadChannels(); } catch (e) {} return; }
    if (chat.channels.some((c) => c.id === f.conv_id)) return;   // public channel, not a DM
    try { e2eLearnPeer(f.conv_id, f.sender_id); } catch (e) {}   // E2E: TOFU-pin this DM's peer (first sighting only) so outgoing can be sealed
    try { if (location.pathname === '/communication' && new URLSearchParams(location.search).get('conv') === String(f.conv_id)) return; } catch (e) {}
    const now = Date.now(); const recent = _dmCool[f.conv_id] && now - _dmCool[f.conv_id] < 30000; _dmCool[f.conv_id] = now;
    const ex = dmExcerpt(f.body);
    showDmCard(f.conv_id, f.sender, ex, f.avatar_url);
    if (!recent) { try { notifyMention('MP de ' + (f.sender || '?'), ex, () => navigate('/communication?conv=' + f.conv_id), 'dm:' + f.id); } catch (e) {} }
  }
  try { if (window.__T4X_EXPOSE__) { window.__t4xSig = { open: openSignals, capture: captureSignal, data: inboxData, unread: inboxUnread, sweep: sweepChannelHistory }; window.__t4xDm = { frame: onDmFrame, excerpt: dmExcerpt }; } } catch (e) {}

  // ---- vigilance: quiet hours, system notification + beep, ratio floor + freeleech transitions ----
  function inQuietHours() {
    const a = pref0('quietStart', ''), b = pref0('quietEnd', '');
    if (!/^\d{1,2}:\d{2}$/.test(a) || !/^\d{1,2}:\d{2}$/.test(b)) return false;
    const mins = (x) => { const [h, m] = x.split(':').map(Number); return h * 60 + m; };
    const d = new Date(), n = d.getHours() * 60 + d.getMinutes(), s = mins(a), e = mins(b);
    return s <= e ? (n >= s && n < e) : (n >= s || n < e);   // handles overnight ranges
  }
  // ---- mention sound: 3 synth voices, loudness-matched, with the AudioContext autoplay-resume fix ----
  // Chrome suspends a context created before a user gesture; the old beep() never resumed it, so the
  // first mention was silent until some click happened. playBeep awaits resume() (the "Tester" button
  // in settings is that gesture). Voices are clean-room, no files, no network.
  let _audioCtx = null;
  function _tone(ctx, o) {
    const osc = ctx.createOscillator(), g = ctx.createGain();
    osc.type = o.type || 'sine';
    const t0 = ctx.currentTime + (o.delay || 0);
    osc.frequency.setValueAtTime(o.f0, t0);
    if (o.f1) osc.frequency.exponentialRampToValueAtTime(o.f1, t0 + o.dur);
    g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(o.gain, t0 + (o.attack || 0.01));
    g.gain.exponentialRampToValueAtTime(0.0001, t0 + o.dur);
    osc.connect(g); g.connect(ctx.destination); osc.start(t0); osc.stop(t0 + o.dur + 0.02);
  }
  async function playBeep(style) {
    try {
      style = style || pref0('soundStyle', 'ping');
      _audioCtx = _audioCtx || new (window.AudioContext || window.webkitAudioContext)();
      if (_audioCtx.state === 'suspended') { try { await _audioCtx.resume(); } catch (e) { return; } }
      if (style === 'soft') _tone(_audioCtx, { type: 'triangle', f0: 520, f1: 660, dur: 0.32, gain: 0.05, attack: 0.02 });
      else if (style === 'double') { _tone(_audioCtx, { f0: 660, dur: 0.12, gain: 0.055 }); _tone(_audioCtx, { f0: 880, dur: 0.12, gain: 0.055, delay: 0.09 }); }
      else _tone(_audioCtx, { f0: 880, dur: 0.18, gain: 0.06 });
    } catch (e) {}
  }
  function beep() { playBeep(); }

  // ---- single-voice multi-tab alert arbitration -------------------------------------------------
  // Two independent claims per event: the SOUND (focused tab wins, hidden tabs jitter) claimed before
  // beep(), and the OS NOTIFICATION claimed AFTER the hidden-tab gate so exactly one hidden tab notifies.
  // A "seen" ring survives reloads so a reconnect replay / boot backfill never re-alerts an old event.
  const _tabId = (() => { try { return crypto.randomUUID(); } catch (e) { return 't' + Math.random().toString(36).slice(2); } })();
  function _alertSeen(key) { try { const a = JSON.parse(localStorage.getItem('t4x:alert.seen') || '[]'); return a.indexOf(key) !== -1; } catch (e) { return false; } }
  function _alertRemember(key) { try { let a = JSON.parse(localStorage.getItem('t4x:alert.seen') || '[]'); if (a.indexOf(key) === -1) { a.push(key); if (a.length > 200) a = a.slice(-200); localStorage.setItem('t4x:alert.seen', JSON.stringify(a)); } } catch (e) {} }
  // claim: read → if already this key, lose; else write ours, re-read, win iff it's still ours. Throw → win (fail-open).
  function _claim(slot, key) {
    if (!pref0('alertSolo', true)) return true;
    try {
      const cur = JSON.parse(localStorage.getItem(slot) || 'null');
      if (cur && cur.eventKey === key) return false;
      localStorage.setItem(slot, JSON.stringify({ eventKey: key, tabId: _tabId, at: Date.now() }));
      const back = JSON.parse(localStorage.getItem(slot) || 'null');
      return !!(back && back.eventKey === key && back.tabId === _tabId);
    } catch (e) { return true; }
  }
  function notifyMention(title, body, onClick, eventKey) {
    // seen-memory is written ABOVE quiet hours so a quiet-hours event can never re-alert later on replay
    if (eventKey) { if (_alertSeen(eventKey)) return; _alertRemember(eventKey); }
    if (inQuietHours()) return;
    const doSound = () => { if (pref0('mentionSound', false)) { if (!eventKey || _claim('t4x:alert.snd', 'snd:' + eventKey)) beep(); } };
    // focused/visible tab plays immediately; hidden tabs jitter so the sound comes from where you look
    if (document.visibilityState === 'visible') doSound();
    else setTimeout(doSound, 40 + Math.random() * 80);
    if (!pref0('mentionNotify', true)) return;
    if (document.visibilityState === 'visible') return;   // in-view toast already covers the foreground
    if (eventKey && !_claim('t4x:alert.ntf', 'ntf:' + eventKey)) return;   // another hidden tab already notified
    try { GM_notification({ title, text: body, timeout: 8000, onclick: () => { try { window.focus(); } catch (e) {} if (onClick) onClick(); } }); } catch (e) {}
  }

  // ---- ratio buffer ("Marge") : bytes downloadable before the site floor; missing upload below it ----
  function siteMinRatio() { const v = Number(pref0('siteMinRatio', 0.5)); return v > 0 ? v : 0.5; }
  function t4xBuffer(up, dn, floor) { return up / floor - dn; }                 // >0 = DL headroom (bytes); <0 = deficit (DL-space)
  function t4xFloorDeficit(up, dn, floor) { return Math.max(0, floor * dn - up); } // upload bytes missing to reach `floor`
  function bufferPhrase(up, dn) {
    if (!(up >= 0 && dn >= 0) || (up === 0 && dn === 0)) return '';
    const floor = siteMinRatio(); const buf = t4xBuffer(up, dn, floor);
    if (buf >= 0) return 'Marge avant ' + floor.toString().replace('.', ',') + ' : +' + fmtSize(buf) + ' de download';
    return 'Sous ' + floor.toString().replace('.', ',') + ' — il manque ' + fmtSize(floor * -buf) + ' d’upload';
  }
  let _flLast = null, _ratioLastAlert = 0;
  function checkVigilance(m, ratio, up, dn) {
    // freeleech global ON/OFF transition — act fast on a free window
    if (pref0('flAlert', true)) {
      const fl = !!m.freeleech_global;
      if (_flLast !== null && fl !== _flLast) {
        const msg = fl ? 'Freeleech global ACTIVÉ — le download ne compte plus dans le ratio.' : 'Freeleech global terminé.';
        toast('⚡ ' + msg, 7000);
        if (fl && !inQuietHours()) { try { GM_notification({ title: 'TR4KER — Freeleech', text: msg, timeout: 9000 }); } catch (e) {} }
      }
      _flLast = fl;
    }
    // ratio floor — warn once per crossing, cooldown 30 min so it doesn't nag
    if (pref0('ratioAlert', true) && ratio !== Infinity && ratio != null) {
      const floor = Number(pref0('ratioFloor', 1.0)) || 1.0;
      if (ratio < floor && Date.now() - _ratioLastAlert > 1800000) {
        _ratioLastAlert = Date.now();
        let msg = '⚠️ Ton ratio (' + ratio.toFixed(2).replace('.', ',') + ') est sous ' + String(floor).replace('.', ',');
        if (pref0('statsBuffer', true) && up != null && dn != null) {
          const need = t4xFloorDeficit(up, dn, floor);
          if (need > 0) msg += ' — il manque ' + fmtSize(need) + ' d’upload';
          const site = siteMinRatio(); const b = t4xBuffer(up, dn, site);
          msg += (b >= 0 ? '. Marge avant le plancher du site (' + String(site).replace('.', ',') + ') : +' + fmtSize(b) + ' de download.'
                         : '. Tu es sous le plancher du site (' + String(site).replace('.', ',') + ') : il manque ' + fmtSize(site * -b) + ' d’upload.');
        } else msg += ' — remets-toi en seed.';
        toast(msg, 9000);
      }
      if (ratio >= floor) _ratioLastAlert = 0;   // re-arm once back above the floor
    }
  }
  async function refreshPill() {
    try {
      const m = await me();
      if (m) {
        // the site's ratio is bonus-inclusive: (uploaded + bonus_upload) / (downloaded + bonus_download).
        // Naive uploaded/downloaded drifted from the displayed value after a site update — match the site.
        const up = (Number(m.uploaded) || 0) + (Number(m.bonus_upload) || 0);
        const dn = (Number(m.downloaded) || 0) + (Number(m.bonus_download) || 0);
        const ratio = dn > 0 ? (up / dn) : Infinity;
        pillRatioEl.textContent = ratio === Infinity ? '∞' : ratio.toFixed(2);
        pillRatioEl.style.color = ratio >= 1 ? 'var(--t4x-seed,#4ade80)' : ratio < 0.55 ? 'var(--error,#ffb4ab)' : 'var(--ann-color,#ffd28a)';
        pillCreditEl.textContent = m.money != null ? Intl.NumberFormat('fr-FR', { notation: 'compact' }).format(m.money) : '—';
        pillFlEl.style.display = m.freeleech_global ? '' : 'none';
        // rail/mini modes hide the stat columns — keep the numbers one hover away
        const marge = pref0('statsBuffer', true) ? bufferPhrase(up, dn) : '';
        if (pillEl) pillEl.title = 'Ratio ' + pillRatioEl.textContent + ' · Crédits ' + pillCreditEl.textContent + (marge ? ' · ' + marge : '') + (m.freeleech_global ? ' · FL actif' : '');
        try { checkVigilance(m, ratio, up, dn); } catch (e) {}   // ratio floor + freeleech transition alerts
        chat.myId = m.id; chat.myRole = m.role || 'user';
        // stats-since-last-visit: compute delta vs stored snapshot, refresh snapshot hourly
        const snap = store.get('t4x.visit', null);
        const now = { up: m.uploaded, dn: m.downloaded, money: m.money, ratio: (ratio === Infinity ? null : ratio), t: Date.now() };
        window.__t4x_delta = (snap && typeof snap.up === 'number') ? { dUp: now.up - snap.up, dDn: now.dn - snap.dn, dMoney: (now.money || 0) - (snap.money || 0), dRatio: (now.ratio != null && snap.ratio != null) ? now.ratio - snap.ratio : null, ago: now.t - snap.t } : null;
        if (!snap || now.t - snap.t > 3600000) store.set('t4x.visit', now);
      }
    } catch (e) {}
    refreshPillBadge();
  }
  function showRatioDelta() {
    if (pref0('statsPanel', true)) { openStatsPanel(); return; }
    const d = window.__t4x_delta;
    if (!d) { toast('Référence enregistrée — reviens plus tard pour voir l’évolution de ton ratio.'); return; }
    const hrs = Math.max(1, Math.round(d.ago / 3600000));
    const rd = d.dRatio != null ? (' | ratio ' + (d.dRatio >= 0 ? '+' : '') + d.dRatio.toFixed(2)) : '';
    toast('Depuis ~' + hrs + 'h : upload +' + fmtSize(Math.max(0, d.dUp)) + ' | download +' + fmtSize(Math.max(0, d.dDn)) + ' | crédits ' + (d.dMoney >= 0 ? '+' : '') + Math.round(d.dMoney) + rd, 7000);
  }
  // ---- "Mes stats" panel: buffer + per-period figures + 30-day activity chart (P-1 confirmed shape) ----
  function dailyDeltas(snaps) {
    if (!Array.isArray(snaps)) return [];
    const by = {}; snaps.forEach((s) => { if (s && s.date) by[s.date] = { date: s.date, u: Number(s.uploaded_bytes) || 0, d: Number(s.downloaded_bytes) || 0 }; });
    const arr = Object.values(by).sort((a, b) => (a.date < b.date ? -1 : 1)).slice(-31);
    const out = []; for (let i = 1; i < arr.length; i++) out.push({ date: arr[i].date, up: Math.max(0, arr[i].u - arr[i - 1].u), dn: Math.max(0, arr[i].d - arr[i - 1].d), rawUp: arr[i].u - arr[i - 1].u, rawDn: arr[i].d - arr[i - 1].d });
    return out;
  }
  let _statsEl = null, _statsUnesc = null, _statsCss = false;
  function ensureStatsCSS() {
    if (_statsCss) return; _statsCss = true; ensureHost();
    const s = document.createElement('style'); s.textContent = `
    .t4x-stats{ position:fixed; right:18px; bottom:136px; z-index:2147483003; width:min(380px,calc(100vw - 24px)); max-height:min(70vh,620px);
      display:flex; flex-direction:column; opacity:0; transform:translateY(8px) scale(.99); transition:opacity .18s ease, transform .18s cubic-bezier(.3,0,0,1); pointer-events:none;
      background:var(--t4x-glass,rgba(15,17,23,.92)); border:1px solid var(--t4x-hair,rgba(147,165,255,.16)); border-radius:18px; box-shadow:0 24px 64px rgba(0,0,0,.5);
      -webkit-backdrop-filter:blur(20px) saturate(160%); backdrop-filter:blur(20px) saturate(160%); color:var(--on-surface,#e6e8f2); overflow:hidden; }
    .t4x-stats.on{ opacity:1; transform:translateY(0) scale(1); pointer-events:auto; }
    .t4x-stats .sh{ display:flex; align-items:center; gap:8px; padding:12px 14px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .t4x-stats .sh b{ flex:1; font-size:14px; } .t4x-stats .sh .maj{ font:10px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-stats .sh button{ all:unset; cursor:pointer; width:26px; height:26px; border-radius:7px; display:flex; align-items:center; justify-content:center; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-stats .sh .ic{ font-family:'Material Symbols Outlined'; font-size:18px; }
    .t4x-stbody{ overflow-y:auto; padding:12px 14px; display:flex; flex-direction:column; gap:12px; }
    .t4x-stbuf{ font-size:12.5px; line-height:1.5; }
    .t4x-stbuf .n{ font:600 12.5px/1.5 'JetBrains Mono',monospace; }
    .t4x-stseg{ display:flex; gap:6px; }
    .t4x-stseg button{ all:unset; cursor:pointer; flex:1; text-align:center; font-size:12px; padding:6px 0; border-radius:9px; border:1px solid var(--outline-variant,#3a4056); color:var(--on-surface,#e6e8f2); }
    .t4x-stseg button.on{ background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff); border-color:transparent; }
    .t4x-stfig{ display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
    .t4x-stfig .c{ text-align:center; } .t4x-stfig .k{ font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-stfig .v{ font:600 13px/1.3 'JetBrains Mono',monospace; }
    .t4x-stchart{ width:100%; } .t4x-stlegend{ font-size:10.5px; color:var(--on-surface-variant,#b8bdd1); display:flex; gap:12px; justify-content:center; }
    .t4x-stfoot{ font-size:11.5px; color:var(--on-surface-variant,#b8bdd1); }
    @media (max-width:560px){ .t4x-stats{ left:12px; right:12px; width:auto; bottom:76px; } }
    @media (prefers-reduced-motion: reduce){ .t4x-stats{ transition:opacity .12s linear !important; transform:none !important; } }`;
    root.appendChild(s);
  }
  function closeStatsPanel() { if (!_statsEl) return; if (_statsUnesc) { _statsUnesc(); _statsUnesc = null; } const el = _statsEl; _statsEl = null; el.classList.remove('on'); markOpen(el, false); setTimeout(() => { try { el.remove(); } catch (e) {} }, 200); }
  function statsChart(deltas) {
    const NS = 'http://www.w3.org/2000/svg'; const W = 340, H = 96, mid = H / 2;
    const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H); svg.setAttribute('class', 't4x-stchart'); svg.setAttribute('role', 'img');
    if (deltas.length < 1) { const t = document.createElementNS(NS, 'text'); t.setAttribute('x', W / 2); t.setAttribute('y', mid); t.setAttribute('text-anchor', 'middle'); t.setAttribute('fill', 'var(--on-surface-variant,#b8bdd1)'); t.setAttribute('font-size', '11'); t.textContent = 'Historique journalier indisponible.'; svg.appendChild(t); return svg; }
    const max = Math.max(1, ...deltas.map((d) => Math.max(d.up, d.dn)));
    const bw = W / deltas.length; const seed = 'var(--t4x-seed,#4ade80)', prim = 'var(--primary,#93a5ff)';
    const line = document.createElementNS(NS, 'line'); line.setAttribute('x1', 0); line.setAttribute('x2', W); line.setAttribute('y1', mid); line.setAttribute('y2', mid); line.setAttribute('stroke', 'var(--outline-variant,#3a4056)'); line.setAttribute('stroke-width', '1'); svg.appendChild(line);
    deltas.forEach((d, i) => {
      const x = i * bw + 1, w = Math.max(1, bw - 2);
      const uh = (d.up / max) * (mid - 4), dh = (d.dn / max) * (mid - 4);
      const ru = document.createElementNS(NS, 'rect'); ru.setAttribute('x', x); ru.setAttribute('y', mid - uh); ru.setAttribute('width', w); ru.setAttribute('height', uh); ru.setAttribute('fill', seed);
      const rd = document.createElementNS(NS, 'rect'); rd.setAttribute('x', x); rd.setAttribute('y', mid); rd.setAttribute('width', w); rd.setAttribute('height', dh); rd.setAttribute('fill', prim);
      const ti = document.createElementNS(NS, 'title'); ti.textContent = d.date + ' · ↑ ' + fmtSize(d.up) + ' · ↓ ' + fmtSize(d.dn) + (d.rawUp < 0 || d.rawDn < 0 ? ' (correction)' : ''); ru.appendChild(ti.cloneNode(true)); rd.appendChild(ti);
      svg.append(ru, rd);
    });
    return svg;
  }
  function openStatsPanel() {
    if (!pref0('statsPanel', true)) { showRatioDeltaToast(); return; }
    ensureHost(); ensureStatsCSS(); if (_statsEl) { closeStatsPanel(); return; }
    const el = document.createElement('div'); el.className = 't4x-stats'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Mes stats');
    if (innerWidth > 560) el.style.right = panelRight() + 'px';   // clear the AI dock and a right-docked drawer (M18)
    const head = document.createElement('div'); head.className = 'sh'; const b = document.createElement('b'); b.textContent = 'Mes stats'; const maj = document.createElement('span'); maj.className = 'maj';
    const xb = document.createElement('button'); xb.type = 'button'; xb.title = 'Fermer'; xb.innerHTML = '<span class="ic">close</span>'; xb.addEventListener('click', closeStatsPanel);
    head.append(b, maj, xb);
    const body = document.createElement('div'); body.className = 't4x-stbody'; el.append(head, body); root.appendChild(el);
    _statsEl = el; requestAnimationFrame(() => { el.classList.add('on'); markOpen(el, true); }); _statsUnesc = escPush(closeStatsPanel);
    const cached = dashCacheGet && dashCacheGet('me'); if (cached) renderStats(body, maj, cached);
    else { const sk = document.createElement('div'); sk.className = 't4x-stbuf'; sk.textContent = 'Chargement…'; body.appendChild(sk); }
    Promise.all([me().catch(() => null), apiGet('/api/me/stats', { ttl: 60000 }).catch(() => null)]).then(([m, st]) => {
      if (!_statsEl) return; if (!m && !st) { body.replaceChildren(); const e = document.createElement('div'); e.className = 't4x-stbuf'; e.textContent = 'Stats indisponibles.'; body.appendChild(e); return; }
      const data = { m, st }; try { if (dashCacheSet) dashCacheSet('me', data); } catch (e) {} renderStats(body, maj, data);
    });
  }
  function renderStats(body, maj, data) {
    body.replaceChildren();
    const m = data.m || {}, st = (data.st && data.st.statistics) || {};
    const up = (Number(m.uploaded) || 0) + (Number(m.bonus_upload) || 0), dn = (Number(m.downloaded) || 0) + (Number(m.bonus_download) || 0);
    try { if (data.st && data.st.statistics && data.st.statistics.updated_at) maj.textContent = 'MAJ ' + new Date(data.st.statistics.updated_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); } catch (e) {}
    // buffer block (net-new info; Ratio/Crédits deliberately NOT repeated — they're on the pill)
    if (pref0('statsBuffer', true)) { const buf = document.createElement('div'); buf.className = 't4x-stbuf'; const ph = bufferPhrase(up, dn); if (ph) { const s = document.createElement('span'); s.className = 'n'; s.textContent = ph; buf.appendChild(s); body.appendChild(buf); } }
    if (m.freeleech_global) { const fl = document.createElement('div'); fl.className = 't4x-stbuf'; fl.textContent = '⚡ Freeleech global actif'; fl.style.color = 'var(--t4x-fl,#fbbf24)'; body.appendChild(fl); }
    // period segmented
    const seg = document.createElement('div'); seg.className = 't4x-stseg'; const fig = document.createElement('div'); fig.className = 't4x-stfig';
    const periods = [['24h', '_last_24h'], ['7j', '_last_7d'], ['30j', '_last_30d']];
    const paintFig = (suf) => { fig.replaceChildren(); const cell = (k, v, col) => { const c = document.createElement('div'); c.className = 'c'; const kk = document.createElement('div'); kk.className = 'k'; kk.textContent = k; const vv = document.createElement('div'); vv.className = 'v'; vv.textContent = v; if (col) vv.style.color = col; c.append(kk, vv); return c; };
      const u = Number(st['uploaded' + suf]) || 0, d = Number(st['downloaded' + suf]) || 0; const r = d > 0 ? (u / d).toFixed(2) : '∞';
      fig.append(cell('↑ Upload', fmtSize(u), 'var(--t4x-seed,#4ade80)'), cell('↓ Download', fmtSize(d), 'var(--primary,#93a5ff)'), cell('Ratio', r)); };
    let curP = pref0('statsPeriod', '7d'); const sufOf = { '24h': '_last_24h', '7d': '_last_7d', '30d': '_last_30d' };
    periods.forEach(([lbl, suf]) => { const key = suf === '_last_24h' ? '24h' : suf === '_last_7d' ? '7d' : '30d'; const bt = document.createElement('button'); bt.type = 'button'; bt.textContent = lbl; if (key === curP) bt.classList.add('on'); bt.addEventListener('click', () => { curP = key; P = setPref('statsPeriod', key); seg.querySelectorAll('button').forEach((x) => x.classList.remove('on')); bt.classList.add('on'); paintFig(suf); }); seg.appendChild(bt); });
    body.append(seg, fig); paintFig(sufOf[curP] || '_last_7d');
    // 30-day chart
    const deltas = dailyDeltas(data.st && data.st.snapshots);
    const chartWrap = document.createElement('div'); chartWrap.appendChild(statsChart(deltas));
    const leg = document.createElement('div'); leg.className = 't4x-stlegend'; leg.innerHTML = '<span style="color:var(--t4x-seed,#4ade80)">■ upload</span><span style="color:var(--primary,#93a5ff)">■ download</span>';
    body.append(chartWrap, leg);
    // footer: seed + seedtime
    const seed = Number(st.torrents_seeding) || 0, stime = Number(st.total_seedtime_seconds) || 0;
    const foot = document.createElement('div'); foot.className = 't4x-stfoot'; foot.textContent = 'En seed : ' + seed + ' torrents · seedtime ' + Math.round(stime / 86400) + ' j'; body.appendChild(foot);
  }
  function showRatioDeltaToast() { const d = window.__t4x_delta; if (!d) { toast('Référence enregistrée — reviens plus tard.'); return; } const hrs = Math.max(1, Math.round(d.ago / 3600000)); toast('Depuis ~' + hrs + 'h : upload +' + fmtSize(Math.max(0, d.dUp)) + ' | download +' + fmtSize(Math.max(0, d.dDn)), 7000); }
  try { if (window.__T4X_EXPOSE__) { window.__t4xStats = { open: openStatsPanel, deltas: dailyDeltas }; } } catch (e) {}
  let _lastUnread = 0, _lastBadge = -1, _pillBusy = false, _pillAgain = false;
  function updateTabTitle(n) {
    try { const cur = document.title.replace(/^\(\d+\+?\)\s+/, ''); document.title = n > 0 ? '(' + (n > 99 ? '99+' : n) + ') ' + cur : cur; } catch (e) {}
  }
  async function refreshPillBadge() {
    if (_pillBusy) { _pillAgain = true; return; }   // coalesce overlapping per-message calls
    _pillBusy = true;
    try {
      let n = Object.values(chat.unread).reduce((a, b) => a + (b || 0), 0);
      try { const u = await apiGet('/api/me/notifications/unread', { ttl: 45000 }); if (u && typeof u.chat === 'number' && !chat.open) n = Math.max(n, u.chat); } catch (e) {}
      if (n !== _lastBadge) {   // change-guard: skip DOM/title churn when the count is unchanged
        for (const el of [pillUnreadEl, pillUnreadMiniEl]) {
          if (!el) continue;
          if (n > 0) { el.textContent = n > 99 ? '99+' : n; el.style.display = ''; } else el.style.display = 'none';
        }
        updateTabTitle(n);   // unread count in the browser tab (community request)
        _lastBadge = n;
      }
      _lastUnread = n;
    } finally { _pillBusy = false; }
    if (_pillAgain) { _pillAgain = false; refreshPillBadge(); }
  }
  // test-only (never set in prod): lets the regression suite drive the HUD rebuild + rail republish paths
  try { if (window.__T4X_EXPOSE__) { window.__t4xPill = { build: buildPill, badge: refreshPillBadge, rail: railPlace, el: () => pillEl, unread: () => pillUnreadEl, setUnread: (m) => { chat.unread = m || {}; chat.open = false; }, hudOff: () => { if (pillEl) { pillEl.remove(); pillEl = null; pillUnreadEl = null; pillUnreadMiniEl = null; try { document.documentElement.removeAttribute('data-t4co-tr4kerplus-rail'); } catch (e) {} } } }; } } catch (e) {}

  // pref helper with default
  function pref0(k, d) { const v = P[k]; return v === undefined ? d : v; }

  // ==========================================================================
  // AI ASSISTANT DOCK — streams from the self-hosted t4ai RAG service
  // ==========================================================================
  const T4AI_URL = 'https://t4ai.ekaii.fr';
  function installId() {
    let id = store.get('t4x.install', '');
    if (!id) { id = 't4x_' + Math.random().toString(36).slice(2) + Date.now().toString(36); store.set('t4x.install', id); }
    return id;
  }
  const ai = { el: null, listEl: null, inputEl: null, statEl: null, open: false, busy: false, cur: null };

  function aiCSS() {
    return `
    .ai{ position:fixed; right:18px; bottom:74px; width:min(420px,94vw); height:min(560px,72vh); display:flex; flex-direction:column;
      background:var(--t4x-glass,rgba(15,17,23,.92)); border:1px solid var(--t4x-hair,rgba(147,165,255,.14)); border-radius:18px;
      box-shadow:0 24px 64px rgba(0,0,0,.5); -webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%); color:var(--on-surface,#e6e8f2);
      transform:translateY(10px) scale(.98); opacity:0; transition:opacity .18s, transform .18s cubic-bezier(.3,0,0,1);
      pointer-events:none; z-index:2147483002; overflow:hidden; }
    .ai.on{ opacity:1; transform:none; pointer-events:auto; }
    .ai .ah{ display:flex; align-items:center; gap:8px; padding:12px 14px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .ai .ah .glyph{ width:20px;height:20px;border-radius:6px;background:var(--t4x-grad,linear-gradient(135deg,#93a5ff,#67e8f9)); }
    .ai .ah b{ flex:1; font-size:14px; }
    .ai .ah .astat{ font:11px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); opacity:.7; }
    /* per-answer timing: live counter while streaming, then first-word + total */
    .ai .ameta{ font:11px/1.4 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); opacity:.65; margin-top:5px; }
    .ai .ah .x{ cursor:pointer; font-family:'Material Symbols Outlined'; color:var(--on-surface-variant,#b8bdd1); }
    .ai .amsgs{ flex:1; overflow-y:auto; padding:12px 14px; display:flex; flex-direction:column; gap:12px; }
    .ai .am{ font-size:13px; line-height:1.5; }
    .ai .am.u{ align-self:flex-end; max-width:85%; background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff);
      padding:8px 12px; border-radius:14px 14px 4px 14px; }
    .ai .am.a{ align-self:flex-start; max-width:94%; }
    .ai .am.a .body{ background:var(--surface-container,#1a1e2b); padding:10px 13px; border-radius:14px 14px 14px 4px; }
    .ai .am.a .body p{ margin:0 0 8px; } .ai .am.a .body p:last-child{ margin:0; }
    .ai .am.a .body strong{ color:var(--on-surface,#e6e8f2); } .ai .am.a .body code{ font-family:'JetBrains Mono',monospace; font-size:12px; background:var(--surface-container-high,#232838); padding:1px 5px; border-radius:5px; }
    .ai .am.a .body a{ color:var(--primary,#93a5ff); }
    .ai .am.a .cites{ margin-top:6px; display:flex; gap:6px; flex-wrap:wrap; }
    .ai .am.a .cites a{ font:11px/1 'JetBrains Mono',monospace; color:var(--primary,#93a5ff); border:1px solid var(--outline-variant,#3a4056); border-radius:999px; padding:4px 8px; text-decoration:none; }
    .ai .am.err{ align-self:flex-start; color:var(--error,#ffb4ab); font-size:12px; }
    .ai .cursorblink::after{ content:'▍'; animation:t4blink 1s steps(2) infinite; color:var(--primary,#93a5ff); }
    @keyframes t4blink{ 50%{ opacity:0; } }
    .ai .foot{ border-top:1px solid var(--outline-variant,#3a4056); padding:8px 10px; }
    .ai .foot .note{ font-size:10px; color:var(--on-surface-variant,#b8bdd1); text-align:center; padding-bottom:6px; }
    .ai textarea{ width:100%; resize:none; background:var(--surface-container,#1a1e2b); color:var(--on-surface,#e6e8f2);
      border:1px solid var(--outline-variant,#3a4056); border-radius:12px; padding:9px 12px; font:14px/1.4 'Geist Variable',sans-serif; outline:none; }
    .ai textarea:focus{ border-color:var(--primary,#93a5ff); }
    .ai .starter{ display:flex; flex-wrap:wrap; gap:6px; padding:0 0 8px; }
    .ai .starter button{ all:unset; cursor:pointer; font-size:12px; padding:6px 10px; border-radius:999px; background:var(--surface-container,#1a1e2b);
      border:1px solid var(--outline-variant,#3a4056); color:var(--on-surface-variant,#b8bdd1); }
    .ai .starter button:hover{ background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff); }
    `;
  }
  let aiCssAdded = false;
  function ensureAiCSS() { if (aiCssAdded) return; ensureHost(); const s = document.createElement('style'); s.textContent = aiCSS(); root.appendChild(s); aiCssAdded = true; }

  function routeInfo() {
    const p = location.pathname;
    const m = p.match(/^\/torrents?\/([^/?#]+)/);
    return { route: p, slug: m ? m[1] : '' };
  }

  // safe markdown-lite → DOM (no innerHTML on model output)
  function mdLite(text) {
    const frag = document.createDocumentFragment();
    for (const para of String(text).split(/\n\n+/)) {
      const p = document.createElement('p');
      _inline(para.replace(/\n/g, ' '), p);
      frag.appendChild(p);
    }
    return frag;
  }
  function _inline(s, parent) {
    // tokens: **bold**, `code`, [wiki:slug], bare https url
    const re = /(\*\*[^*]+\*\*|`[^`]+`|\[wiki:[a-z0-9-]+\]|https?:\/\/[^\s)]+)/gi;
    let last = 0, m;
    while ((m = re.exec(s))) {
      if (m.index > last) parent.appendChild(document.createTextNode(s.slice(last, m.index)));
      const t = m[0];
      if (t.startsWith('**')) { const b = document.createElement('strong'); b.textContent = t.slice(2, -2); parent.appendChild(b); }
      else if (t.startsWith('`')) { const c = document.createElement('code'); c.textContent = t.slice(1, -1); parent.appendChild(c); }
      else if (t.startsWith('[wiki:')) { const slug = t.slice(6, -1); const a = document.createElement('a'); a.textContent = slug; a.href = '/wiki/' + slug; a.addEventListener('click', (e) => { e.preventDefault(); closeAi(); navigate('/wiki/' + slug); }); parent.appendChild(a); }
      else { const a = document.createElement('a'); a.textContent = t; a.href = t; a.target = '_blank'; a.rel = 'noreferrer'; parent.appendChild(a); }
      last = re.lastIndex;
    }
    if (last < s.length) parent.appendChild(document.createTextNode(s.slice(last)));
  }

  function buildAiDock() {
    ensureAiCSS();
    const el = document.createElement('div'); el.className = 'ai'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Assistant TR4KER+');
    const h = document.createElement('div'); h.className = 'ah';
    const g = document.createElement('div'); g.className = 'glyph';
    const b = document.createElement('b'); b.textContent = 'Assistant';
    const stat = document.createElement('span'); stat.className = 'astat'; ai.statEl = stat;
    const x = document.createElement('span'); x.className = 'x'; x.textContent = 'close'; x.title = 'Fermer'; x.addEventListener('click', closeAi);
    h.append(g, b, stat, x);
    const list = document.createElement('div'); list.className = 'amsgs'; list.setAttribute('role', 'log'); list.setAttribute('aria-live', 'polite');
    const foot = document.createElement('div'); foot.className = 'foot';
    const note = document.createElement('div'); note.className = 'note'; note.textContent = 'Outil communautaire non officiel — non affilié au staff.';
    const ta = document.createElement('textarea'); ta.rows = 1; ta.placeholder = 'Pose ta question sur TR4KER…'; ta.setAttribute('aria-label', 'Question');
    ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 110) + 'px'; });
    ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); const v = ta.value.trim(); if (v && !ai.busy) { ta.value = ''; ta.style.height = 'auto'; askAi(v); } } });
    foot.append(note, ta);
    el.append(h, list, foot);
    root.appendChild(el);
    ai.el = el; ai.listEl = list; ai.inputEl = ta;
    el.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAi(); }, true);
    aiRefreshStat();
    _aiWelcome();
  }
  function _aiWelcome() {
    ai.listEl.replaceChildren();
    const w = document.createElement('div'); w.className = 'am a';
    const body = document.createElement('div'); body.className = 'body';
    body.appendChild(mdLite("Salut. Je réponds sur le fonctionnement de TR4KER (ratio, crédits, upload, cross-seed, bugs connus…) à partir du wiki et de la communauté. Je ne suis pas le staff : pour un souci de compte, ouvre un ticket."));
    w.appendChild(body);
    const st = document.createElement('div'); st.className = 'starter';
    const starters = [['Expliquer cette page', () => askAi('Explique-moi cette page et ce que je peux y faire.', true)],
      ['Améliorer mon ratio', () => askAi('Comment améliorer mon ratio ?')],
      ['Le freeleech ?', () => askAi("C'est quoi le freeleech et comment en profiter ?")]];
    for (const [label, fn] of starters) { const bt = document.createElement('button'); bt.textContent = label; bt.addEventListener('click', () => { if (!ai.busy) fn(); }); st.appendChild(bt); }
    ai.listEl.append(w, st);
  }

  function openAi() {
    if (!pref0('ai', false)) { toast('Assistant IA désactivé — active-le dans les réglages TR4KER+.'); return; }
    if (!ai.el) buildAiDock();
    ai.open = true; ai.el.classList.add('on'); markOpen(ai.el, true);
    setTimeout(() => ai.inputEl && ai.inputEl.focus(), 20);
  }
  function closeAi() { ai.open = false; if (ai.el) { ai.el.classList.remove('on'); markOpen(ai.el, false); } }
  function toggleAi() { ai.open ? closeAi() : openAi(); }

  function _aiAppendUser(q) { const m = document.createElement('div'); m.className = 'am u'; m.textContent = q; ai.listEl.appendChild(m); ai.listEl.scrollTop = ai.listEl.scrollHeight; }
  function _aiNewAnswer() {
    const m = document.createElement('div'); m.className = 'am a';
    const body = document.createElement('div'); body.className = 'body cursorblink';
    m.appendChild(body); ai.listEl.appendChild(m); ai.listEl.scrollTop = ai.listEl.scrollHeight;
    return body;
  }

  // ---- answer timing -------------------------------------------------------
  // The RAG service shells out to an LLM, so answers take seconds, not milliseconds. Show the wait
  // as it happens (a frozen dock reads as broken) and the real cost afterwards: time to the first
  // word and total time. Keep the last 20 totals so the header can advertise a realistic median.
  const secs = (ms) => (ms / 1000).toFixed(1).replace('.', ',') + ' s';
  function aiRecord(ms) {
    const a = store.get('t4x.aiTimes', []); a.push(Math.round(ms));
    store.set('t4x.aiTimes', a.slice(-20)); aiRefreshStat();
  }
  function aiMedian() {
    const a = (store.get('t4x.aiTimes', []) || []).slice().sort((x, y) => x - y);
    if (!a.length) return null;
    return a.length % 2 ? a[(a.length - 1) / 2] : Math.round((a[a.length / 2 - 1] + a[a.length / 2]) / 2);
  }
  function aiRefreshStat() {
    if (!ai.statEl) return;
    const m = aiMedian();
    ai.statEl.textContent = m ? '~' + secs(m) + ' / question' : '';
    ai.statEl.title = m ? 'Temps de réponse médian sur tes ' + (store.get('t4x.aiTimes', []) || []).length + ' dernières questions' : '';
  }

  function askAi(q, explain) {
    if (ai.busy) return;
    if (!ai.el) buildAiDock();
    if (!ai.open) openAi();
    // clear the welcome/starters on first real turn
    if (ai.listEl.querySelector('.starter')) ai.listEl.replaceChildren();
    _aiAppendUser(q);
    const body = _aiNewAnswer();
    ai.busy = true;
    const info = routeInfo();
    let acc = '';
    const t0 = performance.now();
    let tFirst = null, failed = false, renderQ = false;
    const meta = document.createElement('div'); meta.className = 'ameta'; meta.textContent = '… 0,0 s';
    body.parentNode.appendChild(meta);
    const tick = setInterval(() => { meta.textContent = '… ' + secs(performance.now() - t0); }, 100);
    const render = () => { body.replaceChildren(mdLite(acc)); ai.listEl.scrollTop = ai.listEl.scrollHeight; };
    streamAi({ q, route: info.route, slug: info.slug, context: explain ? 'La personne demande une explication de la page actuelle.' : '' }, {
      onText: (d) => { if (tFirst === null) tFirst = performance.now() - t0; acc += d; if (!renderQ) { renderQ = true; requestAnimationFrame(() => { renderQ = false; render(); }); } },   // perf: coalesce SSE re-renders to one/frame
      onCites: (slugs) => {
        if (!slugs || !slugs.length) return;
        const c = document.createElement('div'); c.className = 'cites';
        for (const s of slugs) { const a = document.createElement('a'); a.textContent = s; a.href = '/wiki/' + s; a.addEventListener('click', (e) => { e.preventDefault(); closeAi(); navigate('/wiki/' + s); }); c.appendChild(a); }
        body.parentNode.appendChild(c);
      },
      onErr: (msg) => { failed = true; body.classList.remove('cursorblink'); const e = document.createElement('div'); e.className = 'am err'; e.textContent = '⚠ ' + (msg || 'erreur'); ai.listEl.appendChild(e); },
      onDone: () => {
        clearInterval(tick); renderQ = false; render();   // ensure the final chunk lands

        body.classList.remove('cursorblink'); ai.busy = false;
        if (!acc.trim() && !body.textContent) body.textContent = '(pas de réponse)';
        const total = performance.now() - t0;
        meta.textContent = 'réponse en ' + secs(total) + (tFirst !== null ? ' · premier mot ' + secs(tFirst) : '');
        body.parentNode.appendChild(meta);   // keep the timing line below the citations
        if (!failed && acc.trim()) aiRecord(total);   // only real answers feed the median
      },
    });
  }

  // ==========================================================================
  // BRING YOUR OWN MODEL
  // --------------------------------------------------------------------------
  // The shared assistant runs on one person's quota, so it is rate-limited and it is a single
  // point of failure. A member who has their own key can use it instead — and still get OUR
  // grounding: t4ai's /v1/context returns the retrieved KB, the live service status, the live
  // catalogue hits and the same guardrail prompt, WITHOUT calling any model. The key is sent
  // straight from the browser to the chosen provider and never touches our servers; it is stored
  // in the userscript's own storage, which is why the settings row says so plainly.
  // ==========================================================================
  const PROVIDERS = {
    t4ai: { label: 'Assistant communautaire (par défaut)', needsKey: false },
    anthropic: {
      label: 'Anthropic (ta clé)', needsKey: true, base: 'https://api.anthropic.com', model: 'claude-sonnet-5',
      build: (base, key, model, sys, user) => ({
        url: base.replace(/\/$/, '') + '/v1/messages',
        headers: { 'Content-Type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' },
        body: { model, max_tokens: 2000, stream: true, system: sys, messages: [{ role: 'user', content: user }] },
      }),
      delta: (ev) => (ev && ev.type === 'content_block_delta' && ev.delta && (ev.delta.text || '')) || '',
    },
    openai: {
      label: 'OpenAI / compatible (ta clé)', needsKey: true, base: 'https://api.openai.com', model: 'gpt-4o-mini',
      build: (base, key, model, sys, user) => ({
        url: base.replace(/\/$/, '') + '/v1/chat/completions',
        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + key },
        body: { model, stream: true, messages: [{ role: 'system', content: sys }, { role: 'user', content: user }] },
      }),
      delta: (ev) => (ev && ev.choices && ev.choices[0] && ev.choices[0].delta && (ev.choices[0].delta.content || '')) || '',
    },
  };
  function aiProvider() {
    const p = pref0('aiProvider', 't4ai');
    return PROVIDERS[p] ? p : 't4ai';
  }
  // fetch the grounded context (no LLM call, no tokens spent on our side)
  function fetchContext(payload) {
    return new Promise((res, rej) => {
      let done = false;
      const to = setTimeout(() => { if (!done) { done = true; rej(new Error('contexte : délai dépassé')); } }, 15000);
      try {
        GM_xmlhttpRequest({
          method: 'POST', url: T4AI_URL + '/v1/context', timeout: 15000,
          headers: { 'Content-Type': 'application/json', 'X-Install-Id': installId() },
          data: JSON.stringify(payload),
          onload: (r) => {
            clearTimeout(to); if (done) return; done = true;
            try {
              const j = JSON.parse(r.responseText);
              if (j.error) rej(new Error(j.error)); else res(j);
            } catch (e) { rej(new Error('contexte illisible')); }
          },
          onerror: () => { clearTimeout(to); if (!done) { done = true; rej(new Error('contexte injoignable')); } },
          ontimeout: () => { clearTimeout(to); if (!done) { done = true; rej(new Error('contexte : délai dépassé')); } },
        });
      } catch (e) { rej(e); }
    });
  }
  // stream from the member's own provider, feeding the SAME callbacks as the shared backend
  async function streamOwn(payload, cb) {
    const pk = aiProvider();
    const P = PROVIDERS[pk];
    const key = pref0('aiKey', '');
    if (!key) { cb.onErr('aucune clé API enregistrée — réglages TR4KER+'); cb.onDone(); return; }
    let ctx;
    try { ctx = await fetchContext(payload); }
    catch (e) { cb.onErr(e.message); cb.onDone(); return; }
    const req = P.build(pref0('aiBase', '') || P.base, key, pref0('aiModel', '') || P.model, ctx.system, ctx.user);
    let cursor = 0, sawText = false;
    const feed = (buf) => {
      let idx;
      while ((idx = buf.indexOf('\n\n', cursor)) !== -1) {
        const frame = buf.slice(cursor, idx); cursor = idx + 2;
        for (const line of frame.split('\n')) {
          if (!line.startsWith('data:')) continue;
          const raw = line.slice(5).trim();
          if (!raw || raw === '[DONE]') continue;
          let ev; try { ev = JSON.parse(raw); } catch (e) { continue; }
          const d = P.delta(ev);
          if (d) { sawText = true; cb.onText(d); }
        }
      }
    };
    try {
      GM_xmlhttpRequest({
        method: 'POST', url: req.url, headers: req.headers, data: JSON.stringify(req.body),
        onprogress: (r) => { try { feed(r.responseText || ''); } catch (e) {} },
        onload: (r) => {
          try { feed(r.responseText || ''); } catch (e) {}
          if (!sawText) {
            // not a stream we understood — surface the provider's own error, it is the useful one
            let msg = 'réponse vide du fournisseur';
            try { const j = JSON.parse(r.responseText); msg = (j.error && (j.error.message || j.error.type)) || msg; } catch (e) {}
            cb.onErr('HTTP ' + r.status + ' — ' + msg);
          } else if (ctx.citations && ctx.citations.length) cb.onCites(ctx.citations);
          cb.onDone();
        },
        onerror: () => { cb.onErr('fournisseur injoignable (clé ou hôte ?)'); cb.onDone(); },
        ontimeout: () => { cb.onErr('délai dépassé'); cb.onDone(); },
        timeout: 120000,
      });
    } catch (e) { cb.onErr('GM_xmlhttpRequest indisponible'); cb.onDone(); }
  }

  function streamAi(payload, cb) {
    if (aiProvider() !== 't4ai') { streamOwn(payload, cb); return; }
    let cursor = 0;
    const feed = (buf) => {
      let idx;
      while ((idx = buf.indexOf('\n\n', cursor)) !== -1) {
        const frame = buf.slice(cursor, idx); cursor = idx + 2;
        for (const line of frame.split('\n')) {
          if (!line.startsWith('data:')) continue;
          let d; try { d = JSON.parse(line.slice(5).trim()); } catch (e) { continue; }
          if (d.type === 'text') cb.onText(d.delta || (d.data || ''));
          else if (d.type === 'citations') cb.onCites(d.slugs || []);
          else if (d.type === 'refusal') cb.onErr(d.message || 'Je ne peux pas répondre à ça.');
          else if (d.type === 'error') cb.onErr(d.message || (d.data || 'erreur'));
        }
      }
    };
    try {
      GM_xmlhttpRequest({
        method: 'POST', url: T4AI_URL + '/v1/chat',
        headers: { 'Content-Type': 'application/json', 'X-Install-Id': installId(), 'Accept': 'text/event-stream' },
        data: JSON.stringify(payload),
        onprogress: (r) => { try { feed(r.responseText || ''); } catch (e) {} },
        onload: (r) => { try { feed(r.responseText || ''); } catch (e) {} cb.onDone(); },
        onerror: () => { cb.onErr('assistant injoignable'); cb.onDone(); },
        ontimeout: () => { cb.onErr('délai dépassé'); cb.onDone(); },
        timeout: 120000,
      });
    } catch (e) { cb.onErr('GM_xmlhttpRequest indisponible'); cb.onDone(); }
  }

  // ==========================================================================
  // SETTINGS BACKUP — move your setup to another machine
  // The API key is deliberately LEFT OUT of the export: this file is meant to be copied around
  // and a secret in a file people pass to each other is a footgun, not a feature. Everything
  // else (theme, accent, feature switches, chat watchlist, provider and model) travels.
  // ==========================================================================
  const SECRET_KEYS = ['aiKey', 'klipyKey', 'giphyKey', 'ideasAdmin'];
  function exportPrefs() {
    const p = Object.assign({}, prefs());
    for (const k of SECRET_KEYS) delete p[k];
    const doc = {
      app: 'TR4KER+', version: VERSION, exported_at: new Date().toISOString(),
      note: 'La clé API n’est volontairement pas exportée — à ressaisir sur l’autre machine.',
      prefs: p,
      // extra state kept in its own store keys (not in prefs) — the README promises a full move.
      // Only user-AUTHORED state travels; regenerable caches (emoji manifest, YT titles, dash) never do.
      extra: {
        chatlay: store.get('t4x.chatlay', null), navx: store.get('t4x.navx', null), myname: store.get('t4x.myname', null),
        phrases: store.get('t4x.phrases', null), emofrec: store.get('t4x.emofrec', null),
        emojiRecent: store.get('t4x.emojiRecent', null), users: store.get('t4x.users', null),
        chatHighlights: store.get('t4x.chatHighlights', null),
      },
    };
    try {
      const blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url; a.download = 'tr4ker-plus-reglages.json';
      document.documentElement.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 4000);
      toast('Réglages exportés (sans la clé API).');
    } catch (e) { toast('Export impossible : ' + e.message); }
  }
  function importPrefs(after) {
    const inp = document.createElement('input');
    inp.type = 'file'; inp.accept = 'application/json,.json';
    inp.style.cssText = 'position:fixed;left:-9999px';
    inp.addEventListener('change', () => {
      const f = inp.files && inp.files[0];
      if (!f) { inp.remove(); return; }
      const rd = new FileReader();
      rd.onload = () => {
        try {
          const doc = JSON.parse(String(rd.result || '{}'));
          const incoming = doc && doc.prefs;
          if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) throw new Error('fichier non reconnu');
          // only accept keys we actually know: an unknown key is either a typo or a newer
          // version's setting, and silently adopting it would be surprising either way.
          const known = Object.keys(DEFAULTS).concat(['chatKeywords', 'siteTheme', 'emojiTab', 'composerTools', 'navConfig']);   // aiProvider/aiModel/aiBase excluded: a shared file must not be able to move your AI key
          const merged = prefs();
          let n = 0;
          for (const k of known) {
            if (Object.prototype.hasOwnProperty.call(incoming, k) && !SECRET_KEYS.includes(k)) { merged[k] = incoming[k]; n++; }
          }
          store.set('t4x.prefs', merged);
          // restore the extra state (chat layout / harvested nav / pseudo) from a full export
          const ex = doc && doc.extra;
          if (ex && typeof ex === 'object') {
            if (ex.chatlay && typeof ex.chatlay === 'object') store.set('t4x.chatlay', ex.chatlay);
            if (ex.navx && typeof ex.navx === 'object') store.set('t4x.navx', ex.navx);
            if (ex.myname && typeof ex.myname === 'string') store.set('t4x.myname', ex.myname);
            for (const k of ['phrases', 'emofrec', 'emojiRecent', 'users', 'chatHighlights']) { if (ex[k] && typeof ex[k] === 'object') store.set('t4x.' + k, ex[k]); }
          }
          P = prefs();
          applyTheme(); applySiteTheme();
          if (pillAiBtn) pillAiBtn.style.display = P.ai ? '' : 'none';
          try { applyPillPos(); } catch (e) {}
          updateBanner();
          toast(n + ' réglage(s) importé(s)' + (P.ai ? ' — pense à ressaisir ta clé API.' : '.') + ' Recharge pour tout appliquer.', 6000);
          if (after) after();
        } catch (e) { toast('Import impossible : ' + e.message); }
        inp.remove();
      };
      rd.onerror = () => { toast('Lecture du fichier impossible'); inp.remove(); };
      rd.readAsText(f);
    });
    document.documentElement.appendChild(inp);
    inp.click();
  }

  // ==========================================================================
  // SETTINGS PANEL (minimal for v0.1)
  // ==========================================================================
  let settingsEl = null;
  let _stCssAdded = false;
  function ensureSettingsCSS() {
    if (_stCssAdded) return; _stCssAdded = true; ensureHost();
    const st = document.createElement('style');
    st.textContent = `
    .st-panel{ width:min(560px,94vw); max-width:none; max-height:min(78vh,720px); top:11vh; border-radius:20px;
      display:flex; flex-direction:column; overflow:hidden; padding:0; }
    .st-head{ flex:0 0 auto; height:56px; padding:0 18px; display:flex; align-items:center; gap:10px;
      border-bottom:1px solid var(--outline-variant,#3a4056); }
    .st-glyph{ width:26px; height:26px; border-radius:8px; display:flex; align-items:center; justify-content:center;
      background:color-mix(in oklab, var(--primary,#93a5ff) 18%, transparent); color:var(--primary,#93a5ff); flex:0 0 auto; }
    .st-glyph .ic{ font-family:'Material Symbols Outlined'; font-size:16px; }
    .st-title{ flex:1; display:flex; align-items:baseline; gap:8px; }
    .st-title b{ font-size:15px; font-weight:600; color:var(--on-surface,#e6e8f2); }
    .st-title span{ font:12px 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); }
    .st-body{ flex:1; min-height:0; overflow-y:auto; padding:6px 10px 14px; overscroll-behavior:contain; }
    .st-sec{ display:flex; align-items:center; gap:8px; padding:18px 8px 6px; }
    .st-sec:first-child{ padding-top:10px; }
    .st-sec .ic{ font-family:'Material Symbols Outlined'; font-size:16px; color:var(--primary,#93a5ff); opacity:.9; }
    .st-sec span{ font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--on-surface-variant,#b8bdd1); }
    .st-row{ display:grid; grid-template-columns:1fr auto; column-gap:14px; align-items:center;
      padding:9px 10px; border-radius:12px; cursor:pointer; transition:background .12s; }
    .st-row:hover{ background:var(--surface-container,#1a1e2b); }
    .st-lb{ font-size:13.5px; font-weight:500; color:var(--on-surface,#e6e8f2); display:block; }
    .st-ds{ font-size:11.5px; color:var(--on-surface-variant,#b8bdd1); margin-top:1px; line-height:1.35; }
    .st-row input{ position:absolute; opacity:0; width:1px; height:1px; }
    .st-sw{ position:relative; width:34px; height:20px; border-radius:999px; flex:0 0 auto;
      background:var(--surface-container-high,#232838); border:1px solid var(--outline-variant,#3a4056);
      transition:background .15s, border-color .15s; }
    .st-sw::after{ content:''; position:absolute; top:2px; left:2px; width:14px; height:14px; border-radius:50%;
      background:var(--on-surface-variant,#b8bdd1); transition:transform .15s cubic-bezier(.3,0,0,1), background .15s; }
    .st-row:hover input:not(:checked)+.st-sw{ border-color:color-mix(in oklab, var(--primary,#93a5ff) 40%, transparent); }
    .st-row input:checked+.st-sw{ background:var(--primary,#93a5ff); border-color:var(--primary,#93a5ff); }
    .st-row input:checked+.st-sw::after{ transform:translateX(14px); background:var(--on-primary,#101638); }
    .st-row input:focus-visible+.st-sw{ outline:2px solid var(--primary,#93a5ff); outline-offset:2px; }
    .st-chips{ display:flex; gap:6px; flex-wrap:wrap; padding:2px 10px 8px 10px; }
    .st-chips[data-off]{ opacity:.4; pointer-events:none; }
    .st-chip{ all:unset; box-sizing:border-box; display:inline-flex; align-items:center; gap:7px; padding:6px 12px;
      border-radius:999px; border:1px solid var(--outline-variant,#3a4056); font-size:12px; cursor:pointer;
      color:var(--on-surface,#e6e8f2); transition:background .12s; }
    .st-chip:hover{ background:var(--surface-container,#1a1e2b); }
    .st-chip.on{ background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff); border-color:transparent; }
    .st-chip .dot{ width:8px; height:8px; border-radius:50%; flex:0 0 auto; }
    .st-sub[hidden]{ display:none !important; }
    .st-sub{ margin:2px 10px 6px; padding:10px 12px; border-radius:12px; background:var(--surface-container,#1a1e2b);
      border:1px solid var(--t4x-hair,rgba(147,165,255,.14)); display:flex; flex-wrap:wrap; gap:8px; }
    .st-sub .note{ font-size:11px; opacity:.6; flex:0 0 100%; line-height:1.45; color:var(--on-surface,#e6e8f2); }
    .st-in{ font:inherit; font-size:12px; padding:7px 9px; border-radius:8px; flex:1 1 160px; min-width:0;
      background:var(--surface-container-high,#232838); color:var(--on-surface,#e6e8f2); border:1px solid var(--outline-variant,#3a4056); }
    .st-foot{ margin-top:18px; padding:12px 10px 2px; border-top:1px solid var(--outline-variant,#3a4056);
      display:flex; flex-direction:column; gap:10px; }
    .st-foot .frow{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
    .st-foot .frow>span:first-child{ font-size:13px; color:var(--on-surface,#e6e8f2); flex:1 0 auto; }
    .st-btn{ all:unset; box-sizing:border-box; padding:7px 14px; border-radius:999px; border:1px solid var(--outline-variant,#3a4056);
      font-size:12px; cursor:pointer; color:var(--on-surface,#e6e8f2); transition:background .12s; }
    .st-btn:hover{ background:var(--surface-container,#1a1e2b); }
    .st-btn.pri{ border-color:var(--primary,#93a5ff); color:var(--primary,#93a5ff); }
    .st-ver{ font:12px 'JetBrains Mono',monospace; color:var(--on-surface,#e6e8f2); flex:1 0 auto; }
    .st-note{ font-size:11px; color:var(--on-surface-variant,#b8bdd1); padding-bottom:8px; }`;
    root.appendChild(st);
  }
  const ACCENT_COLORS = { indigo: '#93a5ff', cyan: '#4dd0e1', amber: '#fbbf24', rose: '#f48fb1' };
  function openSettings() {
    ensureHost(); ensureSettingsCSS();
    try { closeIdeas(); } catch (e) {}   // board panel is above the settings dialog (M19)
    if (settingsEl) { settingsEl(); return; }   // settingsEl is the close() fn; calling it detaches + nulls
    const veil = document.createElement('div'); veil.className = 'veil on';
    const panel = document.createElement('div'); panel.className = 'pal on st-panel';
    panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Réglages TR4KER+');
    const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; };

    // ---- header (sticky by construction: outside the scroll container) ----
    const head = el('div', 'st-head');
    const glyph = el('div', 'st-glyph'); glyph.appendChild(el('span', 'ic', 'tune'));
    const title = el('div', 'st-title'); title.append(el('b', '', 'TR4KER+'), el('span', '', VERSION));
    const x = el('span', 'hintk', 'esc'); x.style.cursor = 'pointer';
    head.append(glyph, title, x);
    const body = el('div', 'st-body');

    // ---- live-apply side effects (unchanged semantics) ----
    const applyToggle = (k, on) => {
      P = setPref(k, on); applyTheme();
      if (k === 'hud') { if (on) { try { buildPill(); } catch (e) {} } else if (pillEl) { pillEl.remove(); pillEl = null; pillAiBtn = null; pillUpBtn = null; pillVoiceBtn = null; pillUnreadMiniEl = null; pillSigBtn = null; sigPillBadge = null; try { document.documentElement.removeAttribute('data-t4co-tr4kerplus-rail'); } catch (e) {} publishT4co(); } }
      else if (k === 'chat' && !on) { closeDrawer(); }
      else if (k === 'gif') { closeGifPanel(); if (chat.composerEl) { try { renderComposer(); } catch (e) {} } }
      else if (k === 'phrases') { try { closePhrasePanel(); renderComposer(); teardownComposerTools(); ensureComposerTools(); } catch (e) {} }
      else if (k === 'siteEmojis') { try { document.documentElement.classList.toggle('t4x-emoj-on', on); if (emojiPop) { emojiPop.remove(); emojiPop = null; } renderComposer(); } catch (e) {} }
      else if (k === 'emojiRecents') { try { if (emojiPop) { emojiPop.remove(); emojiPop = null; } } catch (e) {} }
      else if (k === 'siteNav') { try { on ? ensureSiteNav() : teardownSiteNav(); } catch (e) {} }
      else if (k === 'e2e') { try { if (on) { e2eInit().then(() => { if (_e2eSup !== true) toast('Ton navigateur ne supporte pas encore le chiffrement E2E (mets-le à jour) — les MP restent en clair.', 8000); else toast('MP chiffrés activés. Le premier échange épingle la clé du correspondant.', 6000); }); } } catch (e) {} }
      else if (k === 'siteComposer') { try { on ? ensureComposerTools() : teardownComposerTools(); } catch (e) {} }
      else if (k === 'commSide') { try { on ? ensureCommSidebar() : teardownCommSidebar(); } catch (e) {} }
      else if (k === 'chanSplit') { try { on ? ensureChanSplit() : teardownChanSplit(); } catch (e) {} }
      else if (k === 'chatPush') { try { applyChatLayout(); } catch (e) {} }
      else if (k === 'bus' && on) { try { wireBus(); busHello(); } catch (e) {} }
      else if (k === 'ideas' && !on) { try { closeIdeas(); } catch (e) {} }
      else if (k === 'inbox') { try { if (!on) closeSignals(); refreshSigBadge(); } catch (e) {} }
      else if (k === 'lightbox' && !on) { try { closeLightbox(); } catch (e) {} }
      else if (k === 'rxQuick' || k === 'chatHl' || k === 'mutes') { try { chat.panes.forEach(loadPane); } catch (e) {} }
      else if (k === 'hoverCard' && !on) { try { ucardHideNow(); } catch (e) {} }
      else if (k === 'ytPlayer' && !on) { try { closeYt(); } catch (e) {} }
      else if (k === 'extChat') { try { if (chat.channels) ensureExtChannel(chat.channels); if (!on) { teardownExtChanRow(); extPanes().forEach((q) => removePane(q)); } renderTabs(); } catch (e) {} }
      else if (k === 'offline' && !on) { try { closeOffline(); } catch (e) {} }
      else if (k === 'homeDash') { try { on ? ensureHomeDash() : teardownHomeDash(); } catch (e) {} }
      else if (k === 'hideRedundant') { try { if (on) hideRedundantHome(); else document.querySelectorAll('[data-t4xhid]').forEach((n) => { n.style.removeProperty('display'); n.removeAttribute('data-t4xhid'); }); } catch (e) {} }
      else if (k === 'ai') { if (!on) closeAi(); if (pillAiBtn) pillAiBtn.style.display = on ? '' : 'none'; }
      else if (k === 'updates' && on) { checkUpdate(true); }
      else if (k === 'statusBanner') { updateBanner(); }
    };
    const hooks = {};   // per-key extra UI reactions (chips dependencies, sub-block reveal)
    const row = (k, label, desc) => {
      const r = el('label', 'st-row');
      const tx = el('div');
      tx.appendChild(el('span', 'st-lb', label));
      if (desc) tx.appendChild(el('div', 'st-ds', desc));
      const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!P[k];
      cb.addEventListener('change', () => { applyToggle(k, cb.checked); if (hooks[k]) { try { hooks[k](cb.checked); } catch (e) {} } });
      r.append(tx, cb, el('span', 'st-sw'));
      return r;
    };
    const section = (icon, name) => { const s = el('div', 'st-sec'); s.append(el('span', 'ic', icon), el('span', '', name)); body.appendChild(s); };
    const chipsRow = (items, cur, onPick) => {
      const wrap = el('div', 'st-chips');
      const paint = (sel) => {
        wrap.replaceChildren();
        for (const it of items) {
          const c = el('button', 'st-chip' + (it.v === sel ? ' on' : '')); c.type = 'button';
          if (it.color) { const d = el('span', 'dot'); d.style.background = it.color; c.appendChild(d); }
          c.appendChild(document.createTextNode(it.label));
          c.addEventListener('click', () => { onPick(it.v); paint(it.v); });
          wrap.appendChild(c);
        }
      };
      paint(cur);
      return wrap;
    };

    // ---- 1. Apparence ----
    section('palette', 'Apparence');
    body.appendChild(row('theme', 'Thème « Encre & Signal »', 'Habillage complet du site, sombre et clair'));
    const accChips = chipsRow(
      [['indigo', 'Indigo'], ['cyan', 'Cyan'], ['amber', 'Ambre'], ['rose', 'Rose']].map(([v, label]) => ({ v, label, color: ACCENT_COLORS[v] })),
      pref0('accent', 'indigo'), (v) => { P = setPref('accent', v); applyTheme(); });
    if (!P.theme) accChips.setAttribute('data-off', '');
    hooks.theme = (on) => { if (on) accChips.removeAttribute('data-off'); else accChips.setAttribute('data-off', ''); };
    body.appendChild(accChips);

    // Theme mode: Clair / Sombre / Ardoise. Ardoise = dark base + the softer "slate" surface tone. One control
    // drives the site light/dark flag AND our tone; needs the reskin ON to paint (dimmed with the accent chips).
    const themeModeCap = el('div', 'st-ds', 'Mode');
    const themeCurMode = () => (P.siteTheme === 'light' ? 'light' : (P.tone === 'slate' ? 'slate' : 'dark'));
    const themeApplyMode = (v) => {
      const st = v === 'light' ? 'light' : 'dark';
      const tn = v === 'slate' ? 'slate' : 'default';
      P = setPref('siteTheme', st); P = setPref('tone', tn);
      try { document.documentElement.setAttribute('data-theme', st); } catch (e) {}
      applyTheme();
    };
    const themeModeChips = chipsRow(
      [['light', 'Clair', '#e7eaf4'], ['dark', 'Sombre', '#0f1117'], ['slate', 'Ardoise', '#2a2f3b']].map(([v, label, color]) => ({ v, label, color })),
      themeCurMode(), themeApplyMode);
    if (!P.theme) themeModeChips.setAttribute('data-off', '');
    const _prevThemeHook = hooks.theme;
    hooks.theme = (on) => { if (_prevThemeHook) _prevThemeHook(on); if (on) themeModeChips.removeAttribute('data-off'); else themeModeChips.setAttribute('data-off', ''); };
    body.appendChild(themeModeCap); body.appendChild(themeModeChips);

    // Scrollbar colour: Auto (follows theme) + presets + a custom colour. Applies to the page AND our panels.
    const sbCap = el('div', 'st-ds', 'Couleur des barres de défilement');
    const sbWrap = el('div', 'st-chips');
    const SB_PRESETS = [['auto', 'Auto', null], ['#93a5ff', 'Indigo', '#93a5ff'], ['#5fd0e6', 'Cyan', '#5fd0e6'], ['#4ade80', 'Vert', '#4ade80'], ['#fb7185', 'Rose', '#fb7185'], ['#8a91a6', 'Gris', '#8a91a6']];
    const sbInput = document.createElement('input'); sbInput.type = 'color';
    sbInput.style.cssText = 'position:absolute;width:0;height:0;opacity:0;pointer-events:none';
    const paintSb = () => {
      const cur = P.scrollbarColor || 'auto';
      const isPreset = SB_PRESETS.some(([v]) => v === cur);
      sbWrap.replaceChildren();
      for (const [v, label, color] of SB_PRESETS) {
        const c = el('button', 'st-chip' + (v === cur ? ' on' : '')); c.type = 'button';
        if (color) { const d = el('span', 'dot'); d.style.background = color; c.appendChild(d); }
        c.appendChild(document.createTextNode(label));
        c.addEventListener('click', () => { P = setPref('scrollbarColor', v); applyScrollbar(); paintSb(); });
        sbWrap.appendChild(c);
      }
      const cc = el('button', 'st-chip' + (!isPreset ? ' on' : '')); cc.type = 'button';
      const d = el('span', 'dot'); d.style.background = (/^#[0-9a-fA-F]{6}$/.test(cur) ? cur : '#ffffff'); cc.appendChild(d);
      cc.appendChild(document.createTextNode('Perso'));
      cc.addEventListener('click', () => { sbInput.value = (/^#[0-9a-fA-F]{6}$/.test(P.scrollbarColor || '') ? P.scrollbarColor : '#93a5ff'); sbInput.click(); });
      sbWrap.appendChild(cc);
    };
    sbInput.addEventListener('input', () => { P = setPref('scrollbarColor', sbInput.value); applyScrollbar(); paintSb(); });
    paintSb();
    body.appendChild(sbCap); body.appendChild(sbWrap); body.appendChild(sbInput);

    body.appendChild(row('polish', 'Finitions', 'Barres de la page, focus visibles, chiffres tabulaires (nos panneaux sont toujours thémés)'));
    body.appendChild(row('hideRedundant', 'Alléger l’accueil', 'Masque les blocs du site doublés par le tableau de bord'));

    // ---- 2. Pastille ----
    section('radio_button_checked', 'Pastille');
    body.appendChild(row('hud', 'Afficher la pastille', 'Ratio, crédits et boutons d’accès rapide'));
    const pmChips = chipsRow(
      [{ v: 'float', label: 'Flottante' }, { v: 'rail', label: 'Boutons latéraux' }, { v: 'mini', label: 'Repliée' }],
      pref0('pillMode', 'float'), (v) => { P = setPref('pillMode', v); try { applyPillPos(); } catch (e) {} });
    if (!P.hud) pmChips.setAttribute('data-off', '');
    hooks.hud = (on) => { if (on) pmChips.removeAttribute('data-off'); else pmChips.setAttribute('data-off', ''); };
    body.appendChild(pmChips);
    body.appendChild(row('statsPanel', 'Panneau « Mes stats »', 'Détail marge de ratio, périodes et activité 30 j au clic sur la pastille'));

    // ---- 2b. Vigilance ----
    section('notifications_active', 'Vigilance');
    body.appendChild(row('inbox', 'Signaux (boîte des mentions)', 'Mentions, mots-clés et réponses manqués — relus au démarrage'));
    const inbSub = el('div', 'st-sub'); const inbRep = el('label'); inbRep.style.cssText = 'display:flex;align-items:center;gap:7px;font-size:12px;color:var(--on-surface,#e6e8f2)';
    const inbRepCb = document.createElement('input'); inbRepCb.type = 'checkbox'; inbRepCb.checked = !!pref0('inboxReplies', true); inbRepCb.addEventListener('change', () => { P = setPref('inboxReplies', inbRepCb.checked); });
    inbRep.append(inbRepCb, document.createTextNode('Capter aussi les réponses à mes messages')); inbSub.append(inbRep);
    inbSub.hidden = !P.inbox; hooks.inbox = (on) => { inbSub.hidden = !on; };
    body.appendChild(inbSub);
    body.appendChild(row('flAlert', 'Alerte freeleech', 'Prévient quand le freeleech global s’active ou s’arrête'));
    body.appendChild(row('ratioAlert', 'Alerte ratio bas', 'Avertit quand ton ratio passe sous le plancher'));
    body.appendChild(row('statsBuffer', 'Marge de ratio', 'Affiche combien tu peux encore télécharger avant le plancher du site'));
    const vigSub = el('div', 'st-sub');
    const floorWrap = el('label'); floorWrap.style.cssText = 'display:flex;align-items:center;gap:8px;flex:1 1 100%;font-size:12px;color:var(--on-surface,#e6e8f2)';
    floorWrap.appendChild(el('span', '', 'Plancher de ratio (le tien)'));
    const floorIn = document.createElement('input'); floorIn.type = 'number'; floorIn.step = '0.1'; floorIn.min = '0'; floorIn.className = 'st-in'; floorIn.style.maxWidth = '90px'; floorIn.value = pref0('ratioFloor', 1.0);
    floorIn.addEventListener('change', () => { const v = parseFloat(floorIn.value); if (!isNaN(v) && v >= 0) P = setPref('ratioFloor', v); });
    floorWrap.appendChild(floorIn); vigSub.appendChild(floorWrap);
    const siteWrap = el('label'); siteWrap.style.cssText = 'display:flex;align-items:center;gap:8px;flex:1 1 100%;font-size:12px;color:var(--on-surface,#e6e8f2)';
    siteWrap.appendChild(el('span', '', 'Plancher du site'));
    const siteIn = document.createElement('input'); siteIn.type = 'number'; siteIn.step = '0.05'; siteIn.min = '0.05'; siteIn.className = 'st-in'; siteIn.style.maxWidth = '90px'; siteIn.value = pref0('siteMinRatio', 0.5);
    siteIn.addEventListener('change', () => { const v = parseFloat(siteIn.value); if (!isNaN(v) && v > 0) P = setPref('siteMinRatio', v); });
    siteWrap.appendChild(siteIn); vigSub.appendChild(siteWrap);
    vigSub.appendChild(el('div', 'note', 'Le plancher du site vient des règles (0,5 par défaut) — vérifie-le sur le wiki. Ta marge s’affiche dans l’infobulle de la pastille et dans l’alerte.'));
    body.appendChild(vigSub);
    // mentions: sound → voices → single-alert → system notification → quiet hours (row→sub adjacency)
    body.appendChild(row('mentionSound', 'Son de mention', 'Petit bip quand on te mentionne ou cite un mot-clé'));
    const soundSub = el('div', 'st-sub');
    const voiceChips = chipsRow([{ v: 'ping', label: 'Ping' }, { v: 'soft', label: 'Feutré' }, { v: 'double', label: 'Double' }],
      pref0('soundStyle', 'ping'), (v) => { P = setPref('soundStyle', v); });
    const testBtn = el('button', 'st-btn', 'Tester'); testBtn.type = 'button'; testBtn.addEventListener('click', () => playBeep(pref0('soundStyle', 'ping')));
    soundSub.append(voiceChips, testBtn, el('div', 'note', 'Le test sert aussi à débloquer le son si le navigateur l’avait suspendu.'));
    soundSub.hidden = !P.mentionSound;
    hooks.mentionSound = (on) => { soundSub.hidden = !on; };
    body.appendChild(soundSub);
    body.appendChild(row('alertSolo', 'Une seule alerte multi-onglets', 'Avec plusieurs onglets ouverts, un seul émet le son et la notification'));
    body.appendChild(row('dmCard', 'Carte message privé', 'Qui t’écrit + aperçu masqué + accès direct, sur un nouveau MP'));
    body.appendChild(row('mentionNotify', 'Notification de mention', 'Notification système quand on te mentionne (onglet en fond)'));
    const quietSub = el('div', 'st-sub');
    const mkQ = (key, ph) => { const i = document.createElement('input'); i.type = 'time'; i.className = 'st-in'; i.style.maxWidth = '120px'; i.value = pref0(key, ''); i.addEventListener('change', () => { P = setPref(key, i.value); }); return i; };
    quietSub.append(el('div', 'note', 'Heures calmes — aucune notification ni son sur cette plage (laisse vide pour désactiver).'),
                    (() => { const w = el('label'); w.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:12px;color:var(--on-surface,#e6e8f2)'; w.append(el('span', '', 'De'), mkQ('quietStart'), el('span', '', 'à'), mkQ('quietEnd')); return w; })());
    body.appendChild(quietSub);

    // ---- 3. Recherche & navigation ----
    section('search', 'Recherche & navigation');
    body.appendChild(row('palette', 'Palette de commandes', 'Recherche universelle — Ctrl/⌘ K'));
    body.appendChild(row('resultHover', 'Vignette au survol', 'Survole un résultat : fiche complète (infos, NFO, favori, merci) sans ouvrir la page'));
    body.appendChild(row('grouping', 'Grouper les versions', 'Une ligne par œuvre, les releases derrière « N versions »'));
    body.appendChild(row('siteNav', 'Barre du site enrichie', 'Tous les liens dans l’en-tête, ordre modifiable'));
    body.appendChild(row('hotkeys', 'Raccourcis torrent', 'D télécharge, C copie le lien, M le magnet'));

    // ---- 4. Chat ----
    section('forum', 'Chat');
    body.appendChild(row('chat', 'Chat sur toutes les pages', 'Colonnes, ancrable, flottant — Ctrl/⌘ J'));
    body.appendChild(row('chatPush', 'Pousser la page', 'Le chat ancré décale le site au lieu de le couvrir'));
    body.appendChild(row('extChat', 'Canal TR4KER+ (externe)', 'Chat de secours hébergé chez nous, survit aux pannes'));
    body.appendChild(row('gif', 'GIF animés', 'Recherche de GIF dans les composeurs (Klipy)'));
    const gifSub = el('div', 'st-sub');
    const mkIn = (type, ph, key) => { const i = document.createElement('input'); i.type = type; i.placeholder = ph; i.className = 'st-in'; i.value = pref0(key, ''); i.addEventListener('change', () => { P = setPref(key, i.value.trim()); }); return i; };
    gifSub.append(mkIn('password', 'clé Klipy perso (sinon clé partagée)', 'klipyKey'),
                  mkIn('password', 'clé Giphy (active la source Giphy)', 'giphyKey'),
                  el('div', 'note', 'Klipy fonctionne sans rien saisir (clé communautaire partagée). Mets ta propre clé Klipy pour ton quota, ou une clé Giphy. Ces clés restent dans Tampermonkey, jamais sur nos serveurs.'));
    gifSub.hidden = !P.gif;
    hooks.gif = (on) => { gifSub.hidden = !on; };
    body.appendChild(gifSub);
    body.appendChild(row('emojiRecents', 'Emojis fréquents', 'Favoris et épingles (clic droit) dans le sélecteur d’emoji'));
    body.appendChild(row('siteEmojis', 'Émoticônes du site', 'Onglet « tr4ker » (émotes de la communauté) + affichage inline'));
    body.appendChild(row('phrases', 'Réponses rapides', 'Phrases enregistrées, insérables en un clic dans les composeurs'));
    const phrSub = el('div', 'st-sub');
    const phrMgrBtn = el('button', 'st-btn', 'Gérer les phrases…'); phrMgrBtn.type = 'button'; phrMgrBtn.addEventListener('click', () => openPhraseManager(false));
    phrSub.appendChild(phrMgrBtn); phrSub.hidden = !P.phrases; hooks.phrases = (on) => { phrSub.hidden = !on; };
    body.appendChild(phrSub);
    body.appendChild(row('siteComposer', 'Outils dans le chat du site', 'GIF, upload d’image, gras, spoiler sur le composeur natif'));
    body.appendChild(row('commSide', 'Barre latérale réglable', 'Largeur des canaux et MP ajustable à la souris'));
    body.appendChild(row('chanSplit', 'Bouton « colonne »', 'Ouvre un canal du site en colonne TR4KER+'));
    // reading comfort (text scale + density)
    const readSub = el('div', 'st-sub');
    const rlabel = el('div'); rlabel.style.cssText = 'flex:1 1 100%;font-size:12.5px;font-weight:600;color:var(--on-surface,#e6e8f2)'; rlabel.textContent = 'Lecture du chat';
    const scaleWrap = el('label'); scaleWrap.style.cssText = 'display:flex;align-items:center;gap:8px;flex:1 1 100%;font-size:12px;color:var(--on-surface,#e6e8f2)';
    const sl = document.createElement('input'); sl.type = 'range'; sl.min = '85'; sl.max = '170'; sl.step = '5'; sl.style.flex = '1'; sl.value = String(Math.round(normScale(pref0('chatFontScale', 1)) * 100));
    sl.setAttribute('aria-label', 'Taille du texte du chat');
    const val = document.createElement('input'); val.type = 'text'; val.className = 'st-in'; val.style.maxWidth = '58px'; val.value = sl.value + '%';
    const applyScale = (raw) => { const sc = normScale(raw); P = setPref('chatFontScale', sc); sl.value = String(Math.round(sc * 100)); val.value = Math.round(sc * 100) + '%'; applyChatReading(); };
    sl.addEventListener('input', () => applyScale(sl.value));
    val.addEventListener('change', () => applyScale(val.value));
    const aMinus = el('button', 'st-btn', 'A−'); aMinus.type = 'button'; aMinus.addEventListener('click', () => applyScale(normScale(pref0('chatFontScale', 1)) - 0.05));
    const aPlus = el('button', 'st-btn', 'A+'); aPlus.type = 'button'; aPlus.addEventListener('click', () => applyScale(normScale(pref0('chatFontScale', 1)) + 0.05));
    scaleWrap.append(el('span', '', 'Taille'), sl, val, aMinus, aPlus);
    const densChips = chipsRow([{ v: 'cozy', label: 'Confortable' }, { v: 'compact', label: 'Compacte' }],
      pref0('chatDensity', 'cozy'), (v) => { P = setPref('chatDensity', v); applyChatReading(); });
    const reset = el('button', 'st-btn', 'Réinitialiser'); reset.type = 'button'; reset.addEventListener('click', () => { P = setPref('chatFontScale', 1); P = setPref('chatDensity', 'cozy'); sl.value = '100'; val.value = '100%'; densChips.querySelectorAll('.st-chip').forEach((c) => c.classList.toggle('on', c.textContent === 'Confortable')); applyChatReading(); });
    readSub.append(rlabel, scaleWrap, densChips, reset);
    readSub.hidden = !P.chat;
    hooks.chat = (on) => { readSub.hidden = !on; };
    body.appendChild(readSub);
    body.appendChild(row('ytPlayer', 'Mini-lecteur YouTube', 'Les liens YouTube deviennent un lecteur flottant dans la page'));
    const ytSub = el('div', 'st-sub');
    const ytModeSel = document.createElement('select'); ytModeSel.className = 'st-in'; ytModeSel.style.maxWidth = '160px';
    [['auto', 'Lecteur flottant'], ['tab', 'Nouvel onglet']].forEach(([v, l]) => { const o = document.createElement('option'); o.value = v; o.textContent = l; ytModeSel.appendChild(o); });
    ytModeSel.value = pref0('ytMode', 'auto'); ytModeSel.addEventListener('change', () => { P = setPref('ytMode', ytModeSel.value); });
    const ytNativeLbl = el('label'); ytNativeLbl.style.cssText = 'display:flex;align-items:center;gap:7px;font-size:12px;color:var(--on-surface,#e6e8f2)';
    const ytNativeCb = document.createElement('input'); ytNativeCb.type = 'checkbox'; ytNativeCb.checked = !!pref0('ytNative', true); ytNativeCb.addEventListener('change', () => { P = setPref('ytNative', ytNativeCb.checked); });
    ytNativeLbl.append(ytNativeCb, document.createTextNode('Aussi dans le chat du site'));
    ytSub.append(ytModeSel, ytNativeLbl); ytSub.hidden = !P.ytPlayer; hooks.ytPlayer = (on) => { ytSub.hidden = !on; };
    body.appendChild(ytSub);
    body.appendChild(row('rxQuick', 'Réactions rapides', 'Réactions favorites + « + » au survol des messages du chat'));
    body.appendChild(row('chatHl', 'Surlignage de membres', 'Couleur par membre dans le chat (icône palette au survol)'));
    body.appendChild(row('mentionPulse', 'Pulsation sur mention', 'Un flash discret quand on te mentionne en direct'));
    body.appendChild(row('hoverCard', 'Carte de membre au survol', 'Aperçu du profil (ratio, upload) en survolant un pseudo'));
    body.appendChild(row('mutes', 'Utilisateurs masqués', 'Masquer les messages de certains membres (Ctrl/⌘-clic sur le pseudo)'));
    const muteSub = el('div', 'st-sub'); muteSub.style.flexDirection = 'column';
    const modeChips = chipsRow([{ v: 'stub', label: 'Repliés' }, { v: 'hide', label: 'Invisibles' }], pref0('muteMode', 'stub'), (v) => { P = setPref('muteMode', v); try { chat.panes.forEach(loadPane); } catch (e) {} });
    const muteList = el('div'); muteList.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;width:100%';
    const paintMuteList = () => {
      muteList.replaceChildren(); const u = loadUsers();
      const entries = [].concat(Object.keys(u.byId).map((k) => ({ label: u.byId[k].name || ('#' + k), rm: () => { const uu = loadUsers(); delete uu.byId[k]; saveUsers(uu); } })), Object.keys(u.byName).map((k) => ({ label: u.byName[k].name || k, rm: () => { const uu = loadUsers(); delete uu.byName[k]; saveUsers(uu); } })));
      if (!entries.length) { muteList.appendChild(el('div', 'note', 'Aucun utilisateur masqué.')); return; }
      entries.forEach((e2) => { const c = el('span', 'st-chip'); c.appendChild(document.createTextNode(e2.label + ' ')); const x = document.createElement('button'); x.type = 'button'; x.textContent = '✕'; x.style.cssText = 'all:unset;cursor:pointer;color:var(--error,#ffb4ab)'; x.addEventListener('click', () => { e2.rm(); paintMuteList(); chat.panes.forEach(loadPane); }); c.appendChild(x); muteList.appendChild(c); });
    };
    const addName = document.createElement('input'); addName.className = 'st-in'; addName.placeholder = 'masquer un pseudo…'; addName.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const n = addName.value.trim(); if (n) { const u = loadUsers(); u.byName[unorm(n)] = { name: n, hidden: true, since: Date.now() }; saveUsers(u); addName.value = ''; paintMuteList(); chat.panes.forEach(loadPane); } } });
    muteSub.append(modeChips, muteList, addName); paintMuteList();
    muteSub.hidden = !P.mutes; hooks.mutes = (on) => { muteSub.hidden = !on; };
    body.appendChild(muteSub);
    body.appendChild(row('lightbox', 'Visionneuse d’images', 'Ouvrir les images en grand sans quitter la page'));

    // ---- 5. Accueil & pannes ----
    section('monitor_heart', 'Accueil & pannes');
    body.appendChild(row('homeDash', 'Tableau de bord d’accueil', 'Chat, statut des services et récap sur la home'));
    body.appendChild(row('offline', 'Mode secours automatique', 'Interface complète quand le site tombe'));
    body.appendChild(row('statusBanner', 'Bandeau de panne', 'Alerte discrète en haut quand TR4KER est dégradé'));
    body.appendChild(row('e2e', 'MP chiffrés (E2E, bêta)', 'Chiffrement de bout en bout des messages privés entre membres TR4KER+ (navigateur récent requis ; le serveur ne voit que du chiffré). Sinon, avertissement avant tout envoi en clair.'));

    // ---- 6. Assistant & système ----
    section('settings', 'Assistant & système');
    body.appendChild(row('ai', 'Assistant IA', 'Répond sur le site (wiki, chat, statut) — désactivé par défaut'));
    const aiSub = el('div', 'st-sub');
    const provSel = document.createElement('select'); provSel.className = 'st-in';
    for (const [k, v] of Object.entries(PROVIDERS)) { const o = document.createElement('option'); o.value = k; o.textContent = v.label; provSel.appendChild(o); }
    provSel.value = aiProvider();
    const keyIn = mkIn('password', 'clé API', 'aiKey');
    const modelIn = mkIn('text', 'modèle', 'aiModel');
    const baseIn = mkIn('text', 'URL de base (optionnel)', 'aiBase');
    const aiNote = el('div', 'note', '');
    const syncProv = () => {
      const pv = PROVIDERS[provSel.value] || PROVIDERS.t4ai;
      const own = !!pv.needsKey;
      [keyIn, modelIn, baseIn].forEach((n) => n.style.display = own ? '' : 'none');
      modelIn.placeholder = 'modèle — défaut ' + (pv.model || '');
      baseIn.placeholder = 'URL de base — défaut ' + (pv.base || '');
      aiNote.textContent = own
        ? 'Ta clé part de ton navigateur directement chez le fournisseur ; elle est stockée dans Tampermonkey sur cette machine et ne transite jamais par nos serveurs. On ne fournit que le contexte (wiki, statut en direct, catalogue) et les garde-fous — donc tes tokens, ton modèle, pas de limite de débit de notre côté.'
        : 'Assistant communautaire hébergé : gratuit, mais partagé — limité en nombre de questions par heure.';
    };
    provSel.addEventListener('change', () => { P = setPref('aiProvider', provSel.value); syncProv(); });
    aiSub.append(provSel, keyIn, modelIn, baseIn, aiNote); syncProv();
    aiSub.hidden = !P.ai;
    hooks.ai = (on) => { aiSub.hidden = !on; };
    body.appendChild(aiSub);
    body.appendChild(row('ideas', 'Suggestions TR4KER+', 'Propose et vote les améliorations — sondages, épingles'));
    const ideasSub = el('div', 'st-sub');
    ideasSub.append(mkIn('password', 'jeton mainteneur (épingler / statut) — optionnel', 'ideasAdmin'),
                    el('div', 'note', 'Laisse vide : tu peux quand même proposer, voter et commenter. Le jeton (côté serveur, réservé au mainteneur) débloque l’épinglage et le changement de statut. Il reste dans Tampermonkey, jamais exporté.'));
    ideasSub.hidden = !P.ideas;
    hooks.ideas = (on) => { ideasSub.hidden = !on; };
    body.appendChild(ideasSub);
    body.appendChild(row('bus', 'Bus T4CO', 'Partage recherche et statut avec OmniChat'));
    body.appendChild(row('updates', 'Alertes de mise à jour', 'Vérifie une nouvelle version toutes les 6 h'));

    // ---- footer: backup + version + disclaimer ----
    const foot = el('div', 'st-foot');
    const f1 = el('div', 'frow'); f1.appendChild(el('span', '', 'Tes réglages'));
    const bExp = el('button', 'st-btn', 'Exporter'); bExp.type = 'button'; bExp.addEventListener('click', exportPrefs);
    const bImp = el('button', 'st-btn', 'Importer'); bImp.type = 'button'; bImp.addEventListener('click', () => importPrefs(() => { close(); openSettings(); }));
    f1.append(bExp, bImp);
    const f2 = el('div', 'frow');
    const verTxt = el('span', 'st-ver');
    const paintVer = () => {
      verTxt.textContent = updateState.available
        ? 'v' + VERSION + ' — v' + updateState.latest + ' disponible'
        : 'v' + VERSION + (updateState.latest ? ' — à jour' : '');
      verTxt.style.color = updateState.available ? 'var(--t4x-seed,#4ade80)' : '';
    };
    paintVer();
    const bChk = el('button', 'st-btn', 'Vérifier'); bChk.type = 'button';
    const bUp = el('button', 'st-btn pri', 'Mettre à jour'); bUp.type = 'button';
    bChk.addEventListener('click', () => { bChk.textContent = '…'; checkUpdate(true); setTimeout(() => { bChk.textContent = 'Vérifier'; paintVer(); bUp.style.display = updateState.available ? '' : 'none'; }, 1600); });
    bUp.style.display = updateState.available ? '' : 'none';
    bUp.addEventListener('click', openUpdate);
    f2.append(verTxt, bChk, bUp);
    foot.append(f1, f2, el('div', 'st-note', 'Outil communautaire non officiel, sans lien avec le staff TR4KER.'));
    body.appendChild(foot);

    panel.append(head, body);
    root.append(veil, panel);   // keep them IN the shadow root (previously moved into a detached div → invisible)
    const onEsc = (e) => { if (e.key === 'Escape') close(); };
    const close = () => { veil.remove(); panel.remove(); document.removeEventListener('keydown', onEsc, true); settingsEl = null; };
    settingsEl = close;
    veil.addEventListener('click', close); x.addEventListener('click', close);
    document.addEventListener('keydown', onEsc, true);
  }

  // ==========================================================================
  // UPLOAD ASSISTANT (IA) — the member picks the release; the assistant does the tedious parts: parse the
  // name, check for a DUPLICATE (server-authoritative /api/torrents/preflight), pull TMDB metadata, and draft
  // a normalised title + NFO/description with t4ai (RAG-grounded on the site's own nomenclature wiki). It
  // NEVER submits: it fills/copies for the member to review and click send (upload = ratio/economy/moderation
  // stakes — a human stays in the loop). Optional: push the resulting magnet to a LOCAL qBittorrent WebUI.
  // ==========================================================================
  let _uaCssAdded = false, _uaPanel = null;
  function ensureUaCSS() { if (_uaCssAdded) return; ensureHost(); const s = document.createElement('style'); s.textContent = UA_CSS; root.appendChild(s); _uaCssAdded = true; }
  function closeUploadAssist() { if (_uaPanel) { try { _uaPanel.__veil.remove(); _uaPanel.remove(); } catch (e) {} _uaPanel = null; } }
  // scene/name parser — pulls the structured bits most trackers key on out of a release name
  function parseRelease(name) {
    const s = String(name || '').replace(/[._]+/g, ' ').replace(/\s+/g, ' ').trim();
    const year = (s.match(/\b(19|20)\d{2}\b/) || [])[0] || '';
    const se = s.match(/\bS(\d{1,2})(?:\s*E(\d{1,2}))?\b/i);
    const res = (s.match(/\b(2160p|1080p|720p|480p|4k|uhd)\b/i) || [''])[0];
    const src = (s.match(/\b(blu-?ray|bdrip|web-?dl|webrip|web|hdtv|dvdrip|remux|bdremux)\b/i) || [''])[0];
    const codec = (s.match(/\b(x265|x264|hevc|avc|h\.?264|h\.?265|av1|xvid)\b/i) || [''])[0];
    const audio = (s.match(/\b(dts-?hd|dts|truehd|e-?ac-?3|ac-?3|aac|flac|dd\+?\s?5\.1|atmos|multi|vff|vostfr|vfq|vf)\b/ig) || []).join(' ');
    let title = s;
    const cut = s.match(/\b(19|20)\d{2}\b|\b(2160p|1080p|720p|480p|s\d{1,2}|blu-?ray|web-?dl|webrip|web|hdtv|x26[45]|hevc|multi|vostfr)\b/i);
    if (cut) title = s.slice(0, cut.index).trim();
    title = title.replace(/[\-–(]\s*$/, '').trim();
    const group = ((name || '').match(/-([A-Za-z0-9]+)(?:\.\w{2,4})?$/) || [])[1] || '';
    const kind = se ? 'série' : (year ? 'film' : '');
    return { title, year, season: se ? +se[1] : null, episode: se && se[2] ? +se[2] : null, res, src, codec, audio, group, kind, raw: name };
  }
  async function uaPreflight(title, cat) {
    try { const r = await fetch('/api/torrents/preflight', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title, category_slug: cat || 'films' }) }); return r.ok ? await r.json() : null; } catch (e) { return null; }
  }
  async function uaTmdb(q) { try { const j = await apiGet('/api/tmdb/search?q=' + encodeURIComponent(q), { ttl: 600000, timeout: 9000 }); return (j && (j.results || j.items)) || []; } catch (e) { return []; } }
  function uaField(lbl, val) { const d = document.createElement('div'); d.className = 't4x-uarow'; const k = document.createElement('span'); k.className = 't4x-uak'; k.textContent = lbl; const v = document.createElement('span'); v.className = 't4x-uav'; v.textContent = val || '—'; d.append(k, v); return d; }
  function openUploadAssist() {
    ensureUaCSS(); closeUploadAssist(); try { closePalette(); } catch (e) {}
    const veil = document.createElement('div'); veil.className = 'veil on'; veil.addEventListener('click', closeUploadAssist);
    const panel = document.createElement('div'); panel.className = 't4x-uapanel'; panel.__veil = veil;
    const head = document.createElement('div'); head.className = 't4x-uahead';
    const h = document.createElement('b'); h.textContent = 'Assistant d’upload';
    const badge = document.createElement('span'); badge.className = 't4x-uabadge'; badge.textContent = 'IA · tu valides, tu envoies';
    const x = document.createElement('span'); x.className = 'x'; x.textContent = 'close'; x.title = 'Fermer'; x.addEventListener('click', closeUploadAssist);
    head.append(h, badge, x);
    const body = document.createElement('div'); body.className = 't4x-uabody';
    // input row
    const nameLbl = document.createElement('label'); nameLbl.className = 't4x-ualbl'; nameLbl.textContent = 'Nom de la release (ou du fichier)';
    const nameIn = document.createElement('input'); nameIn.type = 'text'; nameIn.className = 't4x-uain'; nameIn.placeholder = 'Ex. Le.Nom.2024.MULTi.1080p.BluRay.x264-GROUPE';
    // prefill from the site's own /upload title field if present
    try { const site = document.querySelector('input[name*="title" i], input[placeholder*="titre" i], input[placeholder*="nom" i]'); if (site && site.value) nameIn.value = site.value; } catch (e) {}
    const fileIn = document.createElement('input'); fileIn.type = 'file'; fileIn.className = 't4x-uafile';
    fileIn.addEventListener('change', () => { const f = fileIn.files && fileIn.files[0]; if (f && !nameIn.value) nameIn.value = f.name.replace(/\.[^.]+$/, ''); if (f) miIn.placeholder = 'MediaInfo (colle-le ici) — fichier : ' + f.name + ' · ' + fmtSize(f.size); });
    const miWrap = document.createElement('details'); miWrap.className = 't4x-uami';
    const miSum = document.createElement('summary'); miSum.textContent = 'MediaInfo (optionnel — colle le rapport pour un NFO complet)'; miWrap.appendChild(miSum);
    const miIn = document.createElement('textarea'); miIn.className = 't4x-uata'; miIn.rows = 4; miIn.placeholder = 'General / Video / Audio…'; miWrap.appendChild(miIn);
    const go = document.createElement('button'); go.type = 'button'; go.className = 't4x-uago'; go.textContent = 'Analyser';
    const out = document.createElement('div'); out.className = 't4x-uaout';
    go.addEventListener('click', () => runUpload(nameIn.value.trim(), miIn.value.trim(), out, go));
    nameIn.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go.click(); } });
    body.append(nameLbl, nameIn, fileIn, miWrap, go, out);
    panel.append(head, body); root.append(veil, panel); _uaPanel = panel;
    requestAnimationFrame(() => panel.classList.add('on'));
    setTimeout(() => nameIn.focus(), 20);
  }
  async function runUpload(name, mediainfo, out, go) {
    if (!name) { toast('Donne au moins le nom de la release.', 3000); return; }
    go.disabled = true; go.textContent = 'Analyse…'; out.replaceChildren();
    const parsed = parseRelease(name);
    // 1) parsed facts
    const sec1 = document.createElement('div'); sec1.className = 't4x-uasec'; sec1.textContent = 'Ce que je lis dans le nom';
    const facts = document.createElement('div'); facts.className = 't4x-uafacts';
    facts.append(uaField('Titre', parsed.title), uaField('Année', parsed.year), uaField('Type', parsed.kind),
      ...(parsed.season ? [uaField('Saison', 'S' + parsed.season + (parsed.episode ? 'E' + parsed.episode : ''))] : []),
      uaField('Qualité', [parsed.res, parsed.src, parsed.codec].filter(Boolean).join(' ')), uaField('Audio', parsed.audio), uaField('Groupe', parsed.group));
    out.append(sec1, facts);
    // 2) duplicate check (authoritative, non-persisting) + TMDB in parallel
    const dupSec = document.createElement('div'); dupSec.className = 't4x-uasec'; dupSec.textContent = 'Doublon ?'; out.append(dupSec);
    const dupBox = document.createElement('div'); dupBox.className = 't4x-uadup'; dupBox.textContent = 'Vérification…'; out.append(dupBox);
    const metaSec = document.createElement('div'); metaSec.className = 't4x-uasec'; metaSec.textContent = 'Métadonnées'; out.append(metaSec);
    const metaBox = document.createElement('div'); metaBox.className = 't4x-uameta'; metaBox.textContent = 'Recherche…'; out.append(metaBox);
    const [pf, tm] = await Promise.all([uaPreflight(parsed.title), uaTmdb(parsed.title)]);   // TMDB matches on title alone; year is metadata, not part of the query
    // duplicates
    dupBox.replaceChildren();
    const conflict = pf && (pf.hash_conflict || pf.title_conflict || pf.nfo_conflict);
    const similar = (pf && pf.similar) || [];
    if (conflict) { const w = document.createElement('div'); w.className = 't4x-uawarn'; w.textContent = '⚠ Conflit détecté (' + [pf.hash_conflict && 'même hash', pf.title_conflict && 'même titre', pf.nfo_conflict && 'même NFO'].filter(Boolean).join(', ') + ') — c’est probablement déjà en ligne.'; dupBox.appendChild(w); }
    if (similar.length) { const t = document.createElement('div'); t.className = 't4x-uasim'; t.textContent = similar.length + ' torrent(s) similaire(s) :'; dupBox.appendChild(t);
      for (const s of similar.slice(0, 5)) { const a = document.createElement('a'); a.className = 't4x-ualink'; a.href = '/torrent/' + (s.slug || ''); a.textContent = s.name || s.title || s.slug; a.addEventListener('click', (e) => { e.preventDefault(); closeUploadAssist(); navigate('/torrent/' + s.slug); }); dupBox.appendChild(a); } }
    if (!conflict && !similar.length) { const ok = document.createElement('div'); ok.className = 't4x-uaok'; ok.textContent = '✓ Aucun doublon évident.'; dupBox.appendChild(ok); }
    // TMDB
    metaBox.replaceChildren();
    let picked = null;
    if (tm.length) {
      picked = tm[0];
      const grid = document.createElement('div'); grid.className = 't4x-uatmg';
      for (const r of tm.slice(0, 6)) {
        const card = document.createElement('button'); card.type = 'button'; card.className = 't4x-uatm' + (r === picked ? ' on' : '');
        const pp = r.poster_path || r.poster_url; if (pp) { const im = document.createElement('img'); im.src = /^https?:/.test(pp) ? pp : ('https://image.tmdb.org/t/p/w154' + pp); im.alt = ''; im.addEventListener('error', () => im.remove()); card.appendChild(im); }
        const cap = document.createElement('span'); cap.textContent = (r.title || r.name || '') + (r.release_date || r.first_air_date ? ' (' + String(r.release_date || r.first_air_date).slice(0, 4) + ')' : ''); card.appendChild(cap);
        card.addEventListener('click', () => { picked = r; grid.querySelectorAll('.t4x-uatm').forEach((c) => c.classList.remove('on')); card.classList.add('on'); });
        grid.appendChild(card);
      }
      metaBox.appendChild(grid);
    } else { metaBox.textContent = 'Pas de correspondance TMDB (tu peux quand même générer le NFO).'; }
    // 3) draft NFO/description with the community assistant (grounded on the wiki nomenclature)
    const draftSec = document.createElement('div'); draftSec.className = 't4x-uasec'; draftSec.textContent = 'NFO / description (brouillon — édite librement)'; out.append(draftSec);
    const draft = document.createElement('textarea'); draft.className = 't4x-uadraft'; draft.rows = 12; draft.placeholder = 'Le brouillon apparaît ici…'; out.append(draft);
    const acts = document.createElement('div'); acts.className = 't4x-uaacts'; out.append(acts);
    const mkBtn = (icon, label, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-uabtn'; const i = document.createElement('span'); i.className = 'ic'; i.textContent = icon; b.append(i, document.createTextNode(' ' + label)); b.addEventListener('click', () => fn(b)); acts.appendChild(b); return b; };
    const regen = mkBtn('auto_awesome', 'Générer le NFO', () => uaGenerate(parsed, picked, mediainfo, draft, regen));
    mkBtn('content_copy', 'Copier', () => { try { GM_setClipboard(draft.value); toast('Copié.'); } catch (e) {} });
    mkBtn('edit_note', 'Pré-remplir le formulaire', () => uaPrefill(parsed, draft.value));
    mkBtn('open_in_new', 'Ouvrir /upload', () => { closeUploadAssist(); navigate('/upload'); });
    go.disabled = false; go.textContent = 'Analyser';
    uaGenerate(parsed, picked, mediainfo, draft, regen);   // kick off the first draft automatically
  }
  function uaGenerate(parsed, tmdb, mediainfo, draft, btn) {
    if (btn) { btn.disabled = true; btn.classList.add('busy'); }
    const syn = tmdb ? (tmdb.overview || tmdb.synopsis || '') : '';
    const ctx = [
      'Release: ' + parsed.raw,
      'Titre: ' + parsed.title, parsed.year && ('Année: ' + parsed.year), parsed.kind && ('Type: ' + parsed.kind),
      parsed.season && ('Saison/Épisode: S' + parsed.season + (parsed.episode ? 'E' + parsed.episode : '')),
      (parsed.res || parsed.src || parsed.codec) && ('Qualité: ' + [parsed.res, parsed.src, parsed.codec].filter(Boolean).join(' ')),
      parsed.audio && ('Audio: ' + parsed.audio), parsed.group && ('Groupe: ' + parsed.group),
      tmdb && ('TMDB: ' + (tmdb.title || tmdb.name || '') + (tmdb.id ? ' (id ' + tmdb.id + ')' : '')),
      syn && ('Synopsis: ' + syn),
      mediainfo && ('MediaInfo:\n' + mediainfo.slice(0, 4000)),
    ].filter(Boolean).join('\n');
    const q = 'Tu es l’assistant d’upload de tr4ker. À partir des informations ci-dessous, rédige une DESCRIPTION/NFO prête à coller, en FRANÇAIS, en respectant STRICTEMENT la nomenclature et les règles d’upload du wiki tr4ker (titres, sections obligatoires, format). Structure : titre normalisé, synopsis, informations techniques (résolution, codec, audio, langues, taille si connue), et une note. N’invente pas de valeurs techniques absentes ; si le MediaInfo manque, laisse des champs à compléter entre crochets. Ne mets pas de préambule, donne directement le NFO.\n\n' + ctx;
    draft.value = ''; let acc = '';
    streamAi({ q: q, route: '/upload', slug: '', context: 'Assistance à la mise en ligne d’un torrent, conforme aux règles du wiki.' }, {
      onText: (d) => { acc += d; draft.value = acc; draft.scrollTop = draft.scrollHeight; },
      onCites: () => {},
      onErr: (msg) => { if (!acc) draft.value = '(assistant indisponible : ' + (msg || 'erreur') + ') — tu peux rédiger à la main.'; },
      onDone: () => { if (btn) { btn.disabled = false; btn.classList.remove('busy'); } if (!acc.trim() && !draft.value) draft.value = '(pas de réponse)'; },
    });
  }
  // Best-effort: fill the site's own /upload fields we can confidently identify; otherwise copy + tell the user.
  function uaPrefill(parsed, text) {
    let filled = 0;
    try {
      const titleEl = document.querySelector('input[name*="title" i], input[placeholder*="titre" i]');
      if (titleEl) { titleEl.value = parsed.title + (parsed.year ? ' ' + parsed.year : ''); titleEl.dispatchEvent(new Event('input', { bubbles: true })); filled++; }
      const tas = [...document.querySelectorAll('textarea')].filter((t) => !t.closest('#t4x-root'));
      const descEl = tas.find((t) => /desc|nfo|présent|present|contenu/i.test((t.placeholder || '') + (t.name || '') + (t.getAttribute('aria-label') || ''))) || tas[0];
      if (descEl && text) { descEl.value = text; descEl.dispatchEvent(new Event('input', { bubbles: true })); filled++; }
    } catch (e) {}
    if (filled) { closeUploadAssist(); toast('Formulaire pré-rempli (' + filled + ' champ' + (filled > 1 ? 's' : '') + ') — relis et complète avant d’envoyer.', 6000); }
    else { try { GM_setClipboard(text); } catch (e) {} toast('Champs du formulaire non trouvés — description copiée, colle-la sur /upload.', 6000); }
  }
  const UA_CSS = `
    .t4x-uapanel{ position:fixed; z-index:2147483206; top:6vh; left:50%; transform:translateX(-50%) translateY(8px) scale(.98); width:min(560px,95vw); max-height:86vh;
      display:flex; flex-direction:column; background:var(--surface-container-high,#1a1d26); color:var(--on-surface,#e6e8f2);
      border:1px solid var(--t4x-hair,rgba(147,165,255,.16)); border-radius:18px; box-shadow:0 28px 80px rgba(0,0,0,.55); overflow:hidden; opacity:0; transition:opacity .16s,transform .16s; }
    .t4x-uapanel.on{ opacity:1; transform:translateX(-50%) translateY(0) scale(1); }
    .t4x-uahead{ display:flex; align-items:center; gap:8px; padding:12px 15px; border-bottom:1px solid var(--outline-variant,#3a4056); }
    .t4x-uahead b{ font-size:15px; }
    .t4x-uabadge{ flex:1; font-size:11px; color:var(--on-surface-variant,#8b93a7); }
    .t4x-uahead .x{ cursor:pointer; font-family:'Material Symbols Outlined'; color:var(--on-surface-variant,#b8bdd1); }
    .t4x-uabody{ overflow-y:auto; padding:14px 15px; }
    .t4x-ualbl{ display:block; font-size:12px; color:var(--on-surface-variant,#aab2c5); margin:2px 0 6px; }
    .t4x-uain{ width:100%; box-sizing:border-box; font:inherit; font-size:13px; padding:10px 12px; border-radius:10px; background:var(--surface-container,#141722); color:var(--on-surface,#e6e8f2); border:1px solid var(--outline-variant,rgba(255,255,255,.15)); }
    .t4x-uafile{ margin:9px 0; font-size:12px; color:var(--on-surface-variant,#aab2c5); }
    .t4x-uami{ margin:4px 0 10px; } .t4x-uami summary{ cursor:pointer; font-size:12px; color:var(--on-surface-variant,#aab2c5); }
    .t4x-uata,.t4x-uadraft{ width:100%; box-sizing:border-box; font:12px/1.5 ui-monospace,monospace; margin-top:7px; padding:9px 11px; border-radius:10px; background:var(--surface-container,#141722); color:var(--on-surface,#e6e8f2); border:1px solid var(--outline-variant,rgba(255,255,255,.15)); resize:vertical; }
    .t4x-uago{ all:unset; cursor:pointer; display:block; width:100%; box-sizing:border-box; text-align:center; margin:4px 0 6px; padding:10px; border-radius:11px; font-size:13px; font-weight:600; background:var(--primary,#93a5ff); color:var(--on-primary,#0b0d13); }
    .t4x-uago:hover{ filter:brightness(1.06); } .t4x-uago:disabled{ opacity:.55; cursor:default; }
    .t4x-uasec{ font-size:11px; letter-spacing:.06em; text-transform:uppercase; color:var(--on-surface-variant,#8b93a7); margin:14px 0 6px; }
    .t4x-uafacts{ display:grid; grid-template-columns:1fr 1fr; gap:4px 12px; }
    .t4x-uarow{ display:flex; gap:8px; font-size:12px; } .t4x-uak{ color:var(--on-surface-variant,#8b93a7); min-width:64px; } .t4x-uav{ color:var(--on-surface,#e6e8f2); }
    .t4x-uadup, .t4x-uameta{ font-size:12px; color:var(--on-surface-variant,#aab2c5); }
    .t4x-uawarn{ color:var(--t4x-fl,#fbbf24); font-weight:600; margin-bottom:5px; } .t4x-uaok{ color:var(--t4x-seed,#4ade80); }
    .t4x-uasim{ margin-top:4px; } .t4x-ualink{ display:block; color:var(--primary,#93a5ff); font-size:12px; padding:2px 0; text-decoration:none; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    .t4x-uatmg{ display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
    .t4x-uatm{ all:unset; cursor:pointer; display:flex; flex-direction:column; gap:5px; padding:6px; border-radius:10px; border:1px solid transparent; background:var(--surface-container,#141722); }
    .t4x-uatm img{ width:100%; border-radius:7px; display:block; } .t4x-uatm span{ font-size:11px; color:var(--on-surface-variant,#aab2c5); line-height:1.3; }
    .t4x-uatm.on{ border-color:var(--primary,#93a5ff); } .t4x-uatm.on span{ color:var(--primary,#93a5ff); }
    .t4x-uaacts{ display:flex; flex-wrap:wrap; gap:7px; margin-top:10px; }
    .t4x-uabtn{ all:unset; cursor:pointer; display:inline-flex; align-items:center; gap:2px; font-size:12px; padding:8px 11px; border-radius:10px; background:var(--surface-container,#141722); color:var(--on-surface-variant,#c2c8da); }
    .t4x-uabtn .ic{ font-family:'Material Symbols Outlined'; font-size:17px; }
    .t4x-uabtn:hover{ background:color-mix(in srgb,var(--primary,#93a5ff) 16%,transparent); color:var(--primary,#93a5ff); } .t4x-uabtn.busy{ opacity:.5; cursor:default; }`;

  // ==========================================================================
  // HOTKEYS + MENU + BOOT
  // ==========================================================================
  function hotkeys(e) {
    const mod = IS_MAC ? e.metaKey : e.ctrlKey;
    if (mod && !e.shiftKey && !e.altKey && (e.key === 'k' || e.key === 'K')) {
      e.preventDefault(); e.stopPropagation(); togglePalette();
    } else if (mod && !e.shiftKey && !e.altKey && (e.key === 'j' || e.key === 'J')) {
      e.preventDefault(); e.stopPropagation(); toggleDrawer();
    } else if (mod && e.shiftKey && !e.altKey && (e.key === 'x' || e.key === 'X')) {
      e.preventDefault(); e.stopPropagation(); closeAllOverlays();   // panic: unstick the UI
    }
  }

  // ==========================================================================
  // TORRENT PAGE HOTKEYS — keyboard-first actions on /torrent/<slug>
  // Read-only actions only: download the .torrent, copy the link, copy the magnet. Nothing here
  // writes to the site, so a mistyped key can never do something the member has to undo.
  // ==========================================================================
  function editing(e) {
    const a = (e.composedPath && e.composedPath()[0]) || e.target;
    if (!a) return false;
    const tag = (a.tagName || '').toLowerCase();
    return tag === 'input' || tag === 'textarea' || tag === 'select' || a.isContentEditable === true;
  }
  function currentTorrentSlug() {
    const m = location.pathname.match(/^\/torrents?\/([^/?#]+)/);
    return m ? m[1] : '';
  }
  // The rendered page does not print the infohash, so scraping the DOM for a 40-hex string finds
  // nothing. The detail endpoint returns it as `info_hash` — ask the API, keep the DOM as a
  // last resort in case the response shape drifts.
  async function torrentInfohash(slug) {
    try {
      const j = await apiGet('/api/torrents/' + encodeURIComponent(slug), { ttl: 300000, timeout: 6000 });
      const t = (j && (j.torrent || j)) || {};
      const h = t.info_hash || t.infohash || t.hash || '';
      if (/^[0-9a-fA-F]{40}$/.test(h)) return h.toLowerCase();
    } catch (e) {}
    try {
      const m = (document.body ? document.body.textContent : '').match(/\b([0-9a-fA-F]{40})\b/);
      return m ? m[1].toLowerCase() : '';
    } catch (e) { return ''; }
  }
  function torrentTitle(slug) {
    return document.title.replace(/\s*[|·—-]\s*TR4KER.*$/i, '').trim() || slug;
  }
  function torrentHotkeys(e) {
    if (!pref0('hotkeys', true)) return;
    if (e.ctrlKey || e.metaKey || e.altKey || editing(e)) return;
    if (palOpen || chat.open || chat.inline || ai.open || (health.panel && health.panel.open) || launcherEl || settingsEl || navEditorClose || gifPanel || ideasState.el) return;
    const slug = currentTorrentSlug();
    if (!slug) return;
    const k = (e.key || '').toLowerCase();
    if (k === 'd') {
      e.preventDefault();
      downloadTorrent(slug, torrentTitle(slug));
    } else if (k === 'c') {
      e.preventDefault();
      GM_setClipboard(location.origin + '/torrent/' + slug); toast('Lien copié');
    } else if (k === 'm') {
      e.preventDefault();
      torrentInfohash(slug).then((ih) => {
        if (!ih) { toast('Infohash introuvable pour ce torrent'); return; }
        GM_setClipboard('magnet:?xt=urn:btih:' + ih + '&dn=' + encodeURIComponent(torrentTitle(slug)));
        toast('Magnet copié');
      });
    } else if (k === '?') {
      e.preventDefault();
      toast('Raccourcis de cette page : D télécharger le .torrent · C copier le lien · M copier le magnet · ⌘/Ctrl+K recherche', 7000);
    }
  }

  // ==========================================================================
  // SITE NAVBAR + NATIVE COMPOSER AUGMENTATION (light DOM, React-SPA-safe)
  // We inject into the site's OWN header + chat composer. React re-renders and
  // client-side route changes wipe injected light-DOM nodes, so every injector
  // is idempotent and re-run from a debounced observer + our t4x:locationchange
  // event. Selectors anchor only on STABLE attributes (aria-label, href,
  // placeholder) — never on the site's hashed CSS-module classnames, which
  // change on every site build. Verified live 2026-08-10 (recon in t4shot).
  // ==========================================================================
  function t4xDebounce(fn, ms) { let t; return function () { clearTimeout(t); t = setTimeout(fn, ms); }; }

  // ---- controlled-React textarea insertion -------------------------------------------------
  // The site's composer is a CONTROLLED React <textarea>: assigning `.value=` updates React's
  // internal _valueTracker cache, so the dispatched input event looks like "no change" and the
  // sent message stays stale/empty. The prototype value setter bypasses the tracker; then a
  // bubbling InputEvent makes React re-read the real DOM value. (MoE design pass, composer lens.)
  const NATIVE_TA_SET = (() => { try { return Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; } catch (e) { return null; } })();
  function nativeSetValue(ta, v) { if (NATIVE_TA_SET) NATIVE_TA_SET.call(ta, v); else ta.value = v; ta.dispatchEvent(new InputEvent('input', { bubbles: true })); }
  function nativeInsert(ta, text) {
    ta.focus();
    const s = ta.selectionStart || 0, e = ta.selectionEnd || 0, cur = ta.value || '';
    const max = ta.maxLength > 0 ? ta.maxLength : Infinity;
    let ins = String(text); const budget = max - (cur.length - (e - s));
    if (ins.length > budget) ins = ins.slice(0, Math.max(0, budget));
    nativeSetValue(ta, cur.slice(0, s) + ins + cur.slice(e));
    const c = s + ins.length; try { ta.setSelectionRange(c, c); } catch (_) {}
  }
  function nativeWrap(ta, open, close) {
    ta.focus();
    const s = ta.selectionStart || 0, e = ta.selectionEnd || 0, cur = ta.value || '', sel = cur.slice(s, e);
    const max = ta.maxLength > 0 ? ta.maxLength : Infinity;
    if (cur.length - sel.length + open.length + sel.length + close.length > max) { try { toast('Message trop long pour cette mise en forme.'); } catch (_) {} return; }
    nativeSetValue(ta, cur.slice(0, s) + open + sel + close + cur.slice(e));
    const c = sel ? (s + open.length + sel.length + close.length) : (s + open.length);
    try { ta.setSelectionRange(c, c); } catch (_) {}
  }
  // make the shared helper controlled-aware so reused GIF/image code works on the site textarea too
  let _insPatched = false;
  function patchInsertForControlled() {
    if (_insPatched) return; _insPatched = true;
    const orig = insertAtCursor;
    insertAtCursor = function (ta, text) { if (ta && ta._valueTracker) { nativeInsert(ta, text); return; } return orig(ta, text); };
  }

  // ---- light-DOM styles (nav + composer + gif popup reused outside the shadow root) --------
  let _siteCssAdded = false;
  function ensureSiteCSS() {
    if (_siteCssAdded) return; _siteCssAdded = true;
    const css = `
      html.t4x-sitenav-on header nav[aria-label="Navigation principale"]{display:none!important}
      #t4x-sitenav{display:flex;align-items:center;gap:3px;min-width:0;overflow:hidden;margin:0 4px;position:relative}
      .t4x-snav-link{display:inline-flex;align-items:center;gap:6px;padding:7px 10px;border-radius:10px;font:600 12px/1 inherit;letter-spacing:.01em;color:var(--on-surface-variant,#b8bdd1);text-decoration:none;white-space:nowrap;cursor:pointer;transition:background .12s,color .12s;flex:0 0 auto}
      .t4x-snav-link:hover{background:color-mix(in srgb,var(--primary,#93a5ff) 14%,transparent);color:var(--on-surface,#e6e8f2)}
      .t4x-snav-link.t4x-active{color:var(--primary,#93a5ff);background:color-mix(in srgb,var(--primary,#93a5ff) 12%,transparent)}
      .t4x-snav-link .ic{font-family:'Material Symbols Outlined';font-size:18px;line-height:1}
      #t4x-sitenav.t4x-compact .t4x-snav-link .t4x-lbl{display:none}
      .t4x-snav-more{flex:0 0 auto;cursor:pointer;color:var(--on-surface-variant,#b8bdd1)}
      .t4x-snav-more .ic{font-family:'Material Symbols Outlined';font-size:20px}
      .t4x-snav-pop{position:fixed;z-index:2147483040;min-width:210px;max-height:70vh;overflow:auto;padding:6px;border-radius:14px;background:var(--surface-container-high,#1a1d26);border:1px solid var(--outline-variant,rgba(255,255,255,.15));box-shadow:0 20px 60px rgba(0,0,0,.5)}
      .t4x-snav-pop a,.t4x-snav-pop button{all:unset;display:flex;align-items:center;gap:10px;width:100%;box-sizing:border-box;padding:9px 11px;border-radius:9px;cursor:pointer;font:500 13px/1 inherit;color:var(--on-surface,#e6e8f2)}
      .t4x-snav-pop a:hover,.t4x-snav-pop button:hover{background:color-mix(in srgb,var(--primary,#93a5ff) 14%,transparent)}
      .t4x-snav-pop .ic{font-family:'Material Symbols Outlined';font-size:19px;opacity:.85}
      #t4x-composer-tools{display:inline-flex;align-items:center;gap:2px}
      .t4x-gifpanel{position:absolute;left:0;right:0;bottom:100%;margin-bottom:8px;z-index:2147483041;padding:9px;border-radius:16px;background:var(--surface-container-high,rgba(20,23,34,.98));-webkit-backdrop-filter:blur(18px) saturate(160%); backdrop-filter:blur(18px) saturate(160%);border:1px solid var(--outline-variant,rgba(147,165,255,.18));box-shadow:0 -16px 44px rgba(0,0,0,.5)}
      .t4x-gifhead{display:flex;gap:6px;margin-bottom:7px}
      .t4x-gifq{flex:1;min-width:0;font:inherit;font-size:13px;padding:9px 12px;border-radius:11px;background:var(--surface-container,#141722);color:var(--on-surface,#e6e9f2);border:1px solid var(--outline-variant,rgba(255,255,255,.15))}
      .t4x-gifq:focus{outline:2px solid var(--primary,#93a5ff);outline-offset:-1px}
      .t4x-gifx{all:unset;cursor:pointer;width:34px;display:flex;align-items:center;justify-content:center;border-radius:10px;color:var(--on-surface-variant,#aab2c5)}
      .t4x-gifx .ic{font-family:'Material Symbols Outlined';font-size:20px}
      .t4x-gifchips{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px}
      .t4x-gifchip{all:unset;cursor:pointer;font-size:11px;padding:4px 10px;border-radius:999px;background:var(--surface-container,#141722);color:var(--on-surface-variant,#aab2c5);border:1px solid var(--outline-variant,rgba(255,255,255,.1));text-transform:capitalize}
      .t4x-gifchip:hover{color:var(--on-surface,#e6e8f2)}
      .t4x-gifchip.on{background:color-mix(in srgb,var(--primary,#93a5ff) 20%,transparent);color:var(--primary,#93a5ff);border-color:transparent}
      .t4x-gifmasonry{columns:92px 6;column-gap:6px}.t4x-gifscroll{max-height:210px;overflow-y:auto;overscroll-behavior:contain}
      .t4x-giftile{all:unset;cursor:pointer;display:block;width:100%;break-inside:avoid;margin:0 0 6px;border-radius:10px;overflow:hidden;background:var(--surface-container,#141722);border:1px solid transparent;transition:border-color .12s,transform .1s}
      .t4x-giftile:hover{border-color:var(--primary,#93a5ff)}
      .t4x-giftile:active{transform:scale(.98)}
      .t4x-giftile img{width:100%;display:block}.t4x-giftile.pin{position:relative}.t4x-giftile.pin::after{content:"";position:absolute;top:4px;right:4px;width:7px;height:7px;border-radius:50%;background:var(--primary,#93a5ff);box-shadow:0 0 0 2px var(--surface-container,#141722)}.t4x-giffav{margin-bottom:7px}.t4x-giffavg{max-height:none}
      .t4x-gifskel{break-inside:avoid;margin:0 0 6px;border-radius:10px;background:linear-gradient(100deg,var(--surface-container,#141722) 30%,var(--surface-container-high,#232838) 50%,var(--surface-container,#141722) 70%);background-size:200% 100%;animation:t4xgifsh 1.1s linear infinite}
      .t4x-gifstatus{font-size:11px;color:var(--on-surface-variant,#aab2c5);text-align:center;padding:8px 0 2px}
      .t4x-giffoot{font-size:10px;color:var(--on-surface-variant,#aab2c5);opacity:.6;text-align:right;padding-top:6px}
      @keyframes t4xgifsh{to{background-position:-200% 0}}
      .t4x-dropzone{outline:2px dashed var(--primary,#93a5ff)!important;outline-offset:-3px;border-radius:12px}
      aside[data-t4x-commside]{width:var(--t4x-commw,336px)!important;flex:0 0 var(--t4x-commw,336px)!important;position:relative!important}
      .t4x-sideresize{position:absolute;top:0;right:0;width:7px;height:100%;cursor:col-resize;z-index:30;background:transparent;transition:background .12s}
      .t4x-sideresize:hover,.t4x-sideresize.on{background:color-mix(in srgb,var(--primary,#93a5ff) 45%,transparent)}
      .t4x-splitbtn{all:unset;box-sizing:border-box;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;margin-left:auto;opacity:0;flex:0 0 auto;color:inherit;transition:opacity .12s,background .12s}
      [role="button"]:hover>.t4x-splitbtn{opacity:.72}
      .t4x-splitbtn:hover{opacity:1!important;background:color-mix(in srgb,var(--primary,#93a5ff) 22%,transparent)}
      .t4x-splitbtn .material-symbols-outlined{font-size:16px;line-height:1}` + PHRASE_CSS + EMOJI_CSS
      + YT_CHIP_CSS
      + 'html.t4x-emoji-native-off button[aria-label="Choisir un emoji"]{display:none!important}'
      + ('html.t4x-emoj-on main [data-msg-id] img[src^="' + EMOTE_GATEWAY + '/emochat/"]{height:1.5em!important;width:auto!important;vertical-align:-.35em;display:inline-block;border-radius:0!important}');
    try { GM_addStyle(css); } catch (e) { const s = document.createElement('style'); s.textContent = css; (document.head || document.documentElement).appendChild(s); }
  }

  // ---- nav model -----------------------------------------------------------------------------
  const NAV_INVENTORY = {
    '/': { icon: 'home', label: 'Accueil' },
    '/torrents': { icon: 'movie', label: 'Torrents' },
    '/exclusivite': { icon: 'star', label: 'Exclusivités' },
    '/communication': { icon: 'chat', label: 'Chat' },
    '/wiki': { icon: 'menu_book', label: 'Wiki' },
    '/communaute/forum': { icon: 'forum', label: 'Forum' },
    '/communaute/teams': { icon: 'groups', label: 'Teams' },
    '/communaute/demandes': { icon: 'redeem', label: 'Demandes' },
    '/communaute/boutique': { icon: 'storefront', label: 'Boutique' },
    '/donation': { icon: 'volunteer_activism', label: 'Soutenir' },
    '/mon-compte': { icon: 'person', label: 'Mon compte' },
    '/mon-compte/statistiques': { icon: 'bar_chart', label: 'Stats' },
    '/my-uploads': { icon: 'upload', label: 'Mes uploads' },
    '/achievements': { icon: 'emoji_events', label: 'Succès' },
    '/settings': { icon: 'settings', label: 'Paramètres' },
    '/migrations': { icon: 'swap_horiz', label: 'Migrations' },
  };
  const NAV_ORDER = Object.keys(NAV_INVENTORY);
  const NAV_EXTRA = new Set();   // hrefs the live site exposes that we don't inventory — persist across re-injects
  // default visible bar (Paul): Accueil, Torrents, Exclu, Chat, Wiki, Boutique, Mon compte, Stats, Mes uploads, Paramètres.
  const NAV_DEFAULT_HIDDEN = ['/communaute/forum', '/communaute/teams', '/communaute/demandes', '/migrations', '/achievements'];
  function navDefault() { return { v: 1, order: NAV_ORDER.slice(), hidden: NAV_DEFAULT_HIDDEN.slice() }; }
  // ---- drift-proof nav: the site adds new pages to the HOME TILE GRID first (/donation appeared
  // 2026-08 and our static inventory never learned it — user report). Harvest unknown routes from the
  // grid on every home tick, BEFORE hideRedundantHome masks it, persist them, and adopt them at boot
  // as full inventory citizens: they land in the bar AND in the nav editor like any built-in route.
  const NAV_HARVEST_MAX = 12;
  function navHarvested() { return store.get('t4x.navx', {}) || {}; }
  function adoptNavRoute(h, meta) {
    if (!NAV_INVENTORY[h]) NAV_INVENTORY[h] = meta;
    if (NAV_ORDER.indexOf(h) === -1) NAV_ORDER.push(h);
  }
  function harvestSiteNav() {
    if (location.pathname !== '/') return;
    let dirty = null;
    document.querySelectorAll('main nav a[href]').forEach((a) => {
      const h = a.getAttribute('href') || '';
      if (!/^\/[a-z0-9\-/]{2,38}$/i.test(h) || NAV_INVENTORY[h]) return;
      const ic = a.querySelector('.material-symbols-outlined, [class*="material-symbols"]');
      const icon = (ic && ic.textContent.trim()) || 'open_in_new';
      let label = (a.textContent || '').trim();
      if (ic) label = label.replace(ic.textContent.trim(), '').trim();
      label = label.slice(0, 24) || (h.split('/').filter(Boolean).pop() || 'Lien');
      const cur = dirty || navHarvested();
      if (!cur[h] && Object.keys(cur).length >= NAV_HARVEST_MAX) return;
      if (!cur[h] || cur[h].icon !== icon || cur[h].label !== label) { cur[h] = { icon, label }; dirty = cur; }
      adoptNavRoute(h, cur[h]);
    });
    if (dirty) { store.set('t4x.navx', dirty); try { const n = document.querySelector('#t4x-sitenav'); if (n) n.remove(); ensureSiteNav(); } catch (e) {} }
  }
  try { const hx = navHarvested(); for (const h in hx) adoptNavRoute(h, hx[h]); } catch (e) {}   // boot adoption
  function navMeta(h) { return NAV_INVENTORY[h] || { icon: 'chevron_right', label: (h.split('/').filter(Boolean).pop() || 'Lien') }; }
  // reconcile stored config against the known inventory (a new build may add/remove a route)
  function navConfig() {
    const cfg = pref0('navConfig', navDefault());
    const all = new Set(NAV_ORDER);
    const di = (h) => { const i = NAV_ORDER.indexOf(h); return i < 0 ? 1e9 : i; };
    let order = (cfg.order || []).filter((h) => all.has(h));
    const known = new Set(order);
    NAV_ORDER.filter((h) => !known.has(h)).sort((a, b) => di(a) - di(b)).forEach((h) => { let i = order.findIndex((x) => di(x) > di(h)); if (i < 0) i = order.length; order.splice(i, 0, h); });
    const hidden = (cfg.hidden || []).filter((h) => all.has(h));
    const next = { v: 1, order, hidden };
    if (JSON.stringify(next) !== JSON.stringify(cfg)) P = setPref('navConfig', next);   // refresh the P cache too
    return next;
  }
  function spaGo(href) {
    if (location.pathname === href) return;
    // tr4ker's router ignores a synthetic popstate (see navigate()): a pushState nudge would change the
    // URL + our active highlight while the page content never updates — a silent no-op. Do a real load.
    navigate(href);
  }
  let navPop = null;
  function closeNavPop() { if (navPop) { navPop.remove(); navPop = null; document.removeEventListener('pointerdown', onNavPopOutside, true); } }
  function onNavPopOutside(e) { const p = (e.composedPath && e.composedPath()) || []; if (navPop && p.indexOf(navPop) === -1) closeNavPop(); }
  function makeNavLink(href) {
    const m = navMeta(href);
    const a = document.createElement('a'); a.className = 't4x-snav-link'; a.href = href; a.dataset.href = href; a.setAttribute('aria-label', m.label); a.title = m.label;
    const ic = document.createElement('span'); ic.className = 'ic'; ic.textContent = m.icon; ic.setAttribute('aria-hidden', 'true');
    const lb = document.createElement('span'); lb.className = 't4x-lbl'; lb.textContent = m.label;
    a.append(ic, lb);
    a.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); closeNavPop(); spaGo(href); });
    return a;
  }
  function navBudget(header, left) {
    try {
      const hw = header.getBoundingClientRect().width;
      let others = 0; [...left.children].forEach((c) => { if (c.id !== 't4x-sitenav') others += c.getBoundingClientRect().width; });
      const rightEl = header.lastElementChild; const rw = rightEl ? rightEl.getBoundingClientRect().width : 90;
      const searchEl = header.querySelector('input[type="search"]');
      const searchMin = searchEl ? Math.min(280, Math.max(140, searchEl.getBoundingClientRect().width * 0.5)) : 40; // keep search usable, but don't over-reserve
      return Math.max(120, Math.floor(hw - others - rw - searchMin - 24));
    } catch (e) { return 480; }
  }
  function markNavActive(nav) {
    // longest-prefix match so a detail/sub-route (/torrents/123, /mon-compte/statistiques) still
    // lights its section; '/' (home) matches only exactly, else it would always win.
    const path = location.pathname;
    let best = '', bestLen = -1;
    nav.querySelectorAll('.t4x-snav-link').forEach((a) => {
      const h = a.dataset.href;
      const hit = h === '/' ? path === '/' : (path === h || path.startsWith(h + '/'));
      if (hit && h.length > bestLen) { best = h; bestLen = h.length; }
    });
    nav.querySelectorAll('.t4x-snav-link').forEach((a) => a.classList.toggle('t4x-active', a.dataset.href === best && bestLen >= 0));
  }
  let _navBusy = false;
  // fit as many links as possible: try full (icon+label); if that overflows the budget, go icon-only;
  // if STILL overflowing, spill the tail into the ⋯ menu. Measured against the real available track
  // (budget), not the content-tight nav width. The ⋯ button stays visible (it also opens the editor).
  function applyNavOverflow(nav, links, more, budget) {
    try {
      nav.classList.remove('t4x-compact');
      links.forEach((l) => { l.style.display = ''; });
      const moreW = (more.offsetWidth || 34) + 3;
      const sum = () => links.reduce((s, l) => s + (l.offsetWidth || 0) + 3, 0);
      if (sum() + moreW <= budget) return;              // full + ⋯ fits
      nav.classList.add('t4x-compact');                 // tier 2: icon-only labels
      if (sum() + moreW <= budget) return;              // icon-only + ⋯ fits
      // tier 3: spill the tail into ⋯. Read every width first, THEN write display in a second pass —
      // a display write inside the loop would invalidate layout and force a reflow on each next read.
      const widths = links.map((l) => (l.offsetWidth || 0) + 3);
      let used = 0;
      for (let i = 0; i < links.length; i++) { used += widths[i]; links[i].style.display = (used > budget - moreW) ? 'none' : ''; }
    } catch (e) {}
  }
  function renderSiteNav(nav, header, left, passthrough) {
    _navBusy = true;
    try {
      const cfg = navConfig(); const hidden = new Set(cfg.hidden);
      const visible = cfg.order.filter((h) => !hidden.has(h));
      // carry any link the site shows in its primary nav that we don't inventory (future routes), so
      // blanket-hiding the native nav never orphans a destination. NAV_EXTRA persists across re-injects,
      // so a passthrough link survives every subsequent render, not only the first.
      (passthrough || []).forEach((p) => { if (p.href) { if (!NAV_INVENTORY[p.href]) NAV_INVENTORY[p.href] = { icon: 'open_in_new', label: p.label || p.href }; NAV_EXTRA.add(p.href); } });
      NAV_EXTRA.forEach((h) => { if (!visible.includes(h) && !hidden.has(h)) visible.push(h); });
      nav.replaceChildren();
      const budget = navBudget(header, left);
      nav.style.maxWidth = budget + 'px';
      const links = visible.map(makeNavLink);
      links.forEach((l) => nav.appendChild(l));
      const more = document.createElement('button'); more.type = 'button'; more.className = 't4x-snav-more'; more.title = 'Plus / personnaliser';
      more.innerHTML = '<span class="ic">more_horiz</span>';
      more.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openNavMenu(more, visible, cfg); });
      nav.appendChild(more);
      requestAnimationFrame(() => applyNavOverflow(nav, links, more, budget));
      markNavActive(nav);
    } finally { queueMicrotask(() => { _navBusy = false; }); }
  }
  function openNavMenu(anchor, visible, cfg) {
    closeNavPop();
    ensureSiteCSS();
    const pop = document.createElement('div'); pop.className = 't4x-snav-pop';
    const hidden = new Set(cfg.hidden);
    // every enabled destination (so nothing is undiscoverable), active one marked
    cfg.order.filter((h) => !hidden.has(h)).forEach((href) => {
      const m = navMeta(href); const a = document.createElement('a'); a.href = href;
      const _ni = document.createElement('span'); _ni.className = 'ic'; _ni.textContent = m.icon; a.appendChild(_ni); const t = document.createElement('span'); t.textContent = m.label; a.appendChild(t);
      if (href === location.pathname) a.style.color = 'var(--primary,#93a5ff)';
      a.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); closeNavPop(); spaGo(href); });
      pop.appendChild(a);
    });
    const sep = document.createElement('div'); sep.style.cssText = 'height:1px;margin:5px 4px;background:var(--outline-variant,rgba(255,255,255,.12))'; pop.appendChild(sep);
    const edit = document.createElement('button'); edit.innerHTML = '<span class="ic">tune</span>'; const et = document.createElement('span'); et.textContent = 'Personnaliser la barre…'; edit.appendChild(et);
    edit.addEventListener('click', (e) => { e.preventDefault(); closeNavPop(); openNavEditor(); });
    pop.appendChild(edit);
    document.body.appendChild(pop);
    const r = anchor.getBoundingClientRect();
    pop.style.top = Math.round(r.bottom + 6) + 'px';
    pop.style.left = Math.round(Math.min(r.left, window.innerWidth - pop.offsetWidth - 12)) + 'px';
    navPop = pop;
    setTimeout(() => document.addEventListener('pointerdown', onNavPopOutside, true), 0);
  }
  let siteNavObs = null, _navScheduled = null;
  function ensureSiteNav() {
    if (!pref0('siteNav', true)) return;
    // on narrow viewports the site renders its own mobile menu; don't hide it or fight it — defer.
    if (window.innerWidth < 720) { teardownSiteNav(); return; }
    const header = document.querySelector('header[role="banner"]') || document.querySelector('header');
    if (!header) return;
    const logo = header.querySelector('a[href="/"]'); const left = (logo && logo.parentElement) || header.firstElementChild;
    if (!left) return;
    document.documentElement.classList.add('t4x-sitenav-on');
    let nav = left.querySelector('#t4x-sitenav');
    if (nav && nav.isConnected) { markNavActive(nav); return; }   // present → just refresh active (skip the harvest below — perf)
    // future-proofing: any href the site exposes in its own primary nav that we don't inventory (only for a fresh render)
    let passthrough = [];
    try { const nn = header.querySelector('nav[aria-label="Navigation principale"]'); if (nn) nn.querySelectorAll('a[href^="/"]').forEach((a) => { const h = (a.getAttribute('href') || '').split('?')[0]; if (h && !h.startsWith('//') && !NAV_INVENTORY[h]) passthrough.push({ href: h, label: (a.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24) }); }); } catch (e) {}
    nav = document.createElement('nav'); nav.id = 't4x-sitenav'; nav.setAttribute('aria-label', 'Navigation TR4KER+');
    left.appendChild(nav);
    renderSiteNav(nav, header, left, passthrough);
  }
  function teardownSiteNav() {
    document.documentElement.classList.remove('t4x-sitenav-on');
    const n = document.querySelector('#t4x-sitenav'); if (n) n.remove(); closeNavPop();
  }

  // ---- nav editor (in our shadow root) -------------------------------------------------------
  let navEditorClose = null;
  function openNavEditor() {
    ensureHost();
    if (navEditorClose) { navEditorClose(); return; }
    const cfg = navConfig(); const draft = { order: cfg.order.slice(), hidden: new Set(cfg.hidden) };
    const veil = document.createElement('div'); veil.className = 'veil on';
    const panel = document.createElement('div'); panel.className = 'pal on'; panel.style.maxWidth = '420px'; panel.style.top = '14vh';
    panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Personnaliser la barre');
    const h = document.createElement('header'); const g = document.createElement('div'); g.className = 'glyph';
    const ti = document.createElement('div'); ti.style.cssText = 'flex:1;font-size:15px;font-weight:600'; ti.textContent = 'Barre de navigation';
    const x = document.createElement('span'); x.className = 'hintk'; x.textContent = 'esc'; x.style.cursor = 'pointer';
    h.append(g, ti, x);
    const body = document.createElement('div'); body.className = 'list'; body.style.cssText = 'padding:8px 12px;max-height:60vh;overflow:auto';
    const hint = document.createElement('div'); hint.className = 'empty'; hint.style.textAlign = 'left'; hint.textContent = 'Coche pour afficher, flèches pour réordonner. La barre suit ton choix, sur toutes les pages (wiki inclus).';
    body.appendChild(hint);
    const listEl = document.createElement('div');
    const paint = () => {
      listEl.replaceChildren();
      draft.order.forEach((href, idx) => {
        const m = navMeta(href);
        const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--outline-variant,rgba(255,255,255,.06))';
        const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !draft.hidden.has(href);
        cb.addEventListener('change', () => { if (cb.checked) draft.hidden.delete(href); else draft.hidden.add(href); });
        const ic = document.createElement('span'); ic.className = 'ic'; ic.style.cssText = "font-family:'Material Symbols Outlined';font-size:18px;opacity:.85"; ic.textContent = m.icon;
        const lb = document.createElement('span'); lb.style.flex = '1'; lb.textContent = m.label;
        const up = document.createElement('button'); up.type = 'button'; up.textContent = '↑'; up.style.cssText = 'all:unset;cursor:pointer;padding:2px 7px;opacity:' + (idx === 0 ? '.25' : '.8');
        const dn = document.createElement('button'); dn.type = 'button'; dn.textContent = '↓'; dn.style.cssText = 'all:unset;cursor:pointer;padding:2px 7px;opacity:' + (idx === draft.order.length - 1 ? '.25' : '.8');
        up.addEventListener('click', () => { if (idx > 0) { const a = draft.order; [a[idx - 1], a[idx]] = [a[idx], a[idx - 1]]; paint(); } });
        dn.addEventListener('click', () => { if (idx < draft.order.length - 1) { const a = draft.order; [a[idx + 1], a[idx]] = [a[idx], a[idx + 1]]; paint(); } });
        row.append(cb, ic, lb, up, dn); listEl.appendChild(row);
      });
    };
    paint(); body.appendChild(listEl);
    const btns = document.createElement('div'); btns.style.cssText = 'display:flex;gap:8px;padding:12px 4px 4px';
    const bCss = 'padding:8px 14px;border-radius:999px;border:1px solid var(--outline-variant,#3a4056);cursor:pointer;font-size:13px;background:transparent;color:var(--on-surface,#e6e8f2)';
    const save = document.createElement('button'); save.textContent = 'Enregistrer'; save.style.cssText = bCss + ';border-color:var(--primary,#93a5ff)';
    const reset = document.createElement('button'); reset.textContent = 'Réinitialiser'; reset.style.cssText = bCss;
    save.addEventListener('click', () => { P = setPref('navConfig', { v: 1, order: draft.order.slice(), hidden: [...draft.hidden] }); const n = document.querySelector('#t4x-sitenav'); if (n) n.remove(); ensureSiteNav(); toast('Barre de navigation mise à jour'); close(); });
    reset.addEventListener('click', () => { draft.order = NAV_ORDER.slice(); draft.hidden = new Set(); paint(); });
    btns.append(save, reset); body.appendChild(btns);
    panel.append(h, body); root.append(veil, panel);
    const onEsc = (e) => { if (e.key === 'Escape') close(); };
    const close = () => { veil.remove(); panel.remove(); document.removeEventListener('keydown', onEsc, true); navEditorClose = null; };
    navEditorClose = close; veil.addEventListener('click', close); x.addEventListener('click', close);
    document.addEventListener('keydown', onEsc, true);
  }

  // ---- native composer augmentation ----------------------------------------------------------
  const COMPOSER_TOOLS = [
    { key: 'bold', icon: 'format_bold', label: 'Gras', run: (ta) => nativeWrap(ta, '[b]', '[/b]') },
    { key: 'spoiler', icon: 'visibility_off', label: 'Spoiler', run: (ta) => nativeWrap(ta, '[spoiler]', '[/spoiler]') },
    { key: 'link', icon: 'link', label: 'Lien', run: (ta) => { const u = prompt('URL du lien :', 'https://'); if (u) nativeWrap(ta, '[url=' + u + ']', '[/url]'); } },
    { key: 'phrases', icon: 'quickreply', label: 'Réponses rapides', run: (ta, grp) => openPhrasePanel(ta, grp) },
    { key: 'emoji', icon: 'mood', label: 'Emoji', run: (ta, grp) => toggleEmoji(ta, grp) },
    { key: 'gif', icon: 'gif_box', label: 'GIF animé', run: (ta, grp) => openGifPanel(ta, grp) },
    { key: 'image', icon: 'upload', label: 'Envoyer un fichier image (upload, ou colle / glisse-dépose)', run: (ta) => { const i = document.createElement('input'); i.type = 'file'; i.accept = 'image/*'; i.addEventListener('change', () => { const f = i.files && i.files[0]; if (f) handleImageFile(f, ta); }); i.click(); } },
  ];
  // T4CO : un composeur appartenant à une autre app coopérante ne doit jamais être augmenté — les
  // colonnes OmniChat utilisent le même placeholder « Message dans #… » et leur rendu n'applique pas
  // notre BBCode (issue #1). Repli sur les racines/classes OmniChat pré-T4CO (#t9p/.t9-col/t9-input).
  function composerForeign(el) {
    if (!el) return false;
    const owner = el.closest('[data-t4co-app],[data-t4co-selfmanaged]');
    if (owner && owner.getAttribute('data-t4co-app') !== 'tr4kerplus') return true;
    return !!(el.closest('#t9p,#t9b,.t9-col') || (el.classList && el.classList.contains('t9-input')));
  }
  function findComposerTA() {
    for (const ta of document.querySelectorAll('textarea[placeholder^="Message dans"], textarea[placeholder^="Message"]')) { if (!composerForeign(ta)) return ta; }
    return null;
  }
  function ensureComposerTools() {
    if (!pref0('siteComposer', true)) return;
    const ta = findComposerTA(); if (!ta) return;
    // (re)bind the paste-to-upload handler to the CURRENT textarea on every call — React can swap the
    // textarea node (keyed remount on channel switch) while our tools group survives; the button
    // handlers read the fresh node via grp._ta, but a once-bound paste listener would go stale.
    if (!ta.dataset.t4xPaste) {
      ta.dataset.t4xPaste = '1';
      ta.addEventListener('paste', (e) => { if (!pref0('siteComposer', true)) return; const items = (e.clipboardData && e.clipboardData.items) || []; for (const it of items) { if (it.type && it.type.indexOf('image') === 0) { const f = it.getAsFile(); if (f) { e.preventDefault(); handleImageFile(f, ta); return; } } } });
    }
    let imgBtn = document.querySelector('button[aria-label="Insérer une image"], button[title="Insérer une image"]');
    if (imgBtn && composerForeign(imgBtn)) imgBtn = null;
    const wrap = imgBtn ? (imgBtn.closest('div') || imgBtn.parentElement) : null;
    const field = wrap ? wrap.parentElement : ta.parentElement;
    if (!field) return;
    let grp = document.querySelector('#t4x-composer-tools');
    if (grp && grp.isConnected) {
      if (grp.parentElement === field) { grp._ta = ta; return; }
      grp.remove(); grp = null;   // barre échouée ailleurs (ex. colonne OmniChat) — reconstruite au bon endroit
    }
    ensureSiteCSS(); patchInsertForControlled();
    grp = document.createElement('div'); grp.id = 't4x-composer-tools'; grp._ta = ta;
    const btnFrom = (spec) => {
      let b;
      if (imgBtn) { b = imgBtn.cloneNode(true); b.setAttribute('type', 'button'); const glyph = b.querySelector('.material-symbols-outlined, [class*="material-symbols"]'); if (glyph) glyph.textContent = spec.icon; }
      else { b = document.createElement('button'); b.type = 'button'; b.style.cssText = 'all:unset;cursor:pointer;width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:8px;color:var(--on-surface-variant,#aab2c5)'; b.innerHTML = '<span class="material-symbols-outlined">' + spec.icon + '</span>'; }
      b.setAttribute('aria-label', spec.label); b.setAttribute('title', spec.label); b.dataset.t4x = spec.key;
      b.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); spec.run(grp._ta || ta, grp); (grp._ta || ta).focus(); });
      return b;
    };
    // "all in one" (Paul 2026-08-19): OUR emoji picker (standard + the tr4ker custom emotes + favourites/pin)
    // is the SINGLE emoji button — hide the site's plain native one so there's no duplicate. The tr4ker tab
    // (community emotes from the shared Klipy gateway) lives only in ours.
    try { document.documentElement.classList.add('t4x-emoji-native-off'); } catch (e) {}
    const order = pref0('composerTools', ['phrases', 'emoji', 'gif', 'image', 'bold', 'spoiler', 'link']).slice();
    if (order.indexOf('phrases') === -1) order.unshift('phrases');   // adopt new tools even for pre-existing frozen orders
    if (order.indexOf('emoji') === -1) order.splice(1, 0, 'emoji');
    order.forEach((k) => { const spec = COMPOSER_TOOLS.find((t) => t.key === k); if (!spec) return; if (k === 'gif' && !pref0('gif', true)) return; if (k === 'phrases' && !pref0('phrases', true)) return; if (k === 'emoji' && !pref0('emojiRecents', true) && !pref0('siteEmojis', true)) return; grp.appendChild(btnFrom(spec)); });
    if (wrap) field.insertBefore(grp, wrap); else field.appendChild(grp);
    wireNativeDrop(ta, field);
  }
  // drag-drop an image/GIF anywhere onto the native composer area → auto-upload (parity with our drawer).
  // The zone node can be remounted by React; the dataset flag prevents double-binding per node and the
  // augment scheduler re-wires new nodes. The drop resolves the CURRENT textarea (React may have swapped it).
  function wireNativeDrop(ta, field) {
    const zone = ta.closest('div[class*="inputWrapper"]') || ta.closest('div[class*="inputArea"]') || field || ta.parentElement;
    if (!zone || zone.dataset.t4xDrop) return;
    zone.dataset.t4xDrop = '1';
    const stop = (e) => { e.preventDefault(); e.stopPropagation(); };
    ['dragenter', 'dragover'].forEach((ev) => zone.addEventListener(ev, (e) => { if (!pref0('siteComposer', true)) return; stop(e); zone.classList.add('t4x-dropzone'); }));
    ['dragleave', 'drop'].forEach((ev) => zone.addEventListener(ev, (e) => { stop(e); zone.classList.remove('t4x-dropzone'); }));
    zone.addEventListener('drop', (e) => {
      if (!pref0('siteComposer', true)) return;
      const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
      if (f) handleImageFile(f, findComposerTA() || ta);
    });
  }
  function teardownComposerTools() { const g = document.querySelector('#t4x-composer-tools'); if (g) g.remove(); try { document.documentElement.classList.remove('t4x-emoji-native-off'); } catch (e) {} }

  // ---- /communication: resizable sidebar + per-channel "open as column" ------------------------
  // Anchors are STRUCTURAL (the Communication sidebar is the only <aside> without aria-label, its
  // section headers carry the visible labels, channel rows are div[role=button]) — never hashed classes.
  function commAside() {
    if (!/^\/communication(\/|$)/.test(location.pathname)) return null;
    const aside = document.querySelector('aside:not([aria-label])');
    if (!aside || !/Canaux|Messages priv/i.test(aside.textContent || '')) return null;
    return aside;
  }
  function ensureCommSidebar() {
    if (!pref0('commSide', true)) return;
    const aside = commAside(); if (!aside) return;
    const w = pref0('commSideW', null);
    if (!document.querySelector('.t4x-sideresize.on')) document.documentElement.style.setProperty('--t4x-commw', (w || 336) + 'px');   // don't fight a live drag
    aside.setAttribute('data-t4x-commside', '1');
    if (aside.querySelector('.t4x-sideresize')) return;
    const h = document.createElement('div'); h.className = 't4x-sideresize';
    h.title = 'Redimensionner la barre latérale (double-clic : réinitialiser)';
    aside.appendChild(h);
    h.addEventListener('dblclick', () => { P = setPref('commSideW', null); document.documentElement.style.setProperty('--t4x-commw', '336px'); });
    h.addEventListener('pointerdown', (e) => {
      try { e.preventDefault(); e.stopPropagation(); } catch (_) {}
      const sx = e.clientX, sw = aside.getBoundingClientRect().width;
      h.classList.add('on'); const prevSel = document.body.style.userSelect; document.body.style.userSelect = 'none';
      const mv = (ev) => { const nw = Math.max(200, Math.min(640, sw + (ev.clientX - sx))); document.documentElement.style.setProperty('--t4x-commw', nw + 'px'); h.__w = nw; };
      const up = () => {
        document.removeEventListener('pointermove', mv, true); document.removeEventListener('pointerup', up, true);
        h.classList.remove('on'); document.body.style.userSelect = prevSel;
        if (typeof h.__w === 'number') P = setPref('commSideW', Math.round(h.__w));
      };
      document.addEventListener('pointermove', mv, true); document.addEventListener('pointerup', up, true);
    });
  }
  function teardownCommSidebar() {
    document.querySelectorAll('aside[data-t4x-commside]').forEach((a) => a.removeAttribute('data-t4x-commside'));
    document.querySelectorAll('.t4x-sideresize').forEach((h) => h.remove());
  }
  // A visible launcher for the upload assistant, injected at the top of the /upload page (the natural home for
  // it). Also reachable from the command palette; this button is what makes it discoverable. Idempotent, and
  // removed when we leave /upload. Runs from scheduleAugment (SPA-nav + observer), so it survives re-renders.
  function ensureUploadLauncher() {
    try {
      if (!location.pathname.startsWith('/upload')) { const ex = document.getElementById('t4x-uplaunch'); if (ex) ex.remove(); return; }
      if (document.getElementById('t4x-uplaunch')) return;
      const anchor = document.querySelector('main') || document.querySelector('h1') || document.body; if (!anchor) return;
      const btn = document.createElement('button'); btn.id = 't4x-uplaunch'; btn.type = 'button';
      btn.style.cssText = 'display:flex;align-items:center;gap:12px;width:100%;box-sizing:border-box;margin:0 0 18px;padding:14px 16px;border:1px solid color-mix(in srgb,var(--primary,#93a5ff) 45%,transparent);border-radius:14px;background:color-mix(in srgb,var(--primary,#93a5ff) 12%,var(--surface-container,#141722));color:var(--on-surface,#e6e8f2);cursor:pointer;font:inherit;text-align:left;transition:filter .12s';
      const ic = document.createElement('span'); ic.textContent = '✨'; ic.style.cssText = 'font-size:22px;line-height:1;flex:0 0 auto';
      const col = document.createElement('span'); col.style.cssText = 'flex:1;min-width:0';
      const t1 = document.createElement('span'); t1.textContent = 'Assistant d’upload (IA)'; t1.style.cssText = 'display:block;font-weight:600;font-size:14px';
      const t2 = document.createElement('span'); t2.textContent = 'Détecte le doublon, récupère les métadonnées, rédige le NFO — tu valides et tu envoies'; t2.style.cssText = 'display:block;font-size:12px;color:var(--on-surface-variant,#aab2c5);margin-top:2px';
      col.append(t1, t2);
      const hint = document.createElement('span'); hint.textContent = '⌘K'; hint.style.cssText = 'flex:0 0 auto;font:11px/1 ui-monospace,monospace;color:var(--on-surface-variant,#8b93a7);border:1px solid var(--outline-variant,#3a4056);border-radius:6px;padding:4px 6px';
      btn.append(ic, col, hint);
      btn.addEventListener('mouseenter', () => { btn.style.filter = 'brightness(1.08)'; });
      btn.addEventListener('mouseleave', () => { btn.style.filter = ''; });
      btn.addEventListener('click', (e) => { e.preventDefault(); openUploadAssist(); });
      if (anchor.tagName === 'MAIN') anchor.insertBefore(btn, anchor.firstChild);
      else anchor.parentNode.insertBefore(btn, anchor);   // just before the <h1> if there's no <main>
    } catch (e) {}
  }
  function ensureChanSplit() {
    if (!pref0('chanSplit', true)) return;
    const aside = commAside(); if (!aside) return;
    const sec = Array.from(aside.querySelectorAll('section')).find((s) => {
      const b = s.querySelector('button[aria-expanded]');
      return b && /Canaux/i.test(b.textContent || '');
    });
    if (!sec) return;
    sec.querySelectorAll('div[role="button"]').forEach((row) => {
      if (row.id === 't4x-extchan' || row.querySelector('.t4x-splitbtn')) return;   // our own injected row opens as a column already
      const spans = row.querySelectorAll('span');
      const name = ((spans[1] && spans[1].textContent) || '').trim();
      if (!name) return;
      const b = document.createElement('button'); b.type = 'button'; b.className = 't4x-splitbtn';
      b.title = 'Ouvrir « ' + name + ' » dans une colonne TR4KER+ (plusieurs chats visibles à la fois)';
      const ic = document.createElement('span'); ic.className = 'material-symbols-outlined'; ic.textContent = 'splitscreen_right'; b.appendChild(ic);
      b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); chatSplitByName(name); });
      row.appendChild(b);
    });
  }
  function teardownChanSplit() { document.querySelectorAll('.t4x-splitbtn').forEach((b) => b.remove()); }
  // Extra "TR4KER+ (externe)" row at the bottom of the native CANAUX list — opens the t4health
  // shoutbox as a chat column. Styled by CLONING a sibling row's classes at runtime (never hardcoded
  // hashed classes); idempotent by id, re-added by the augment tick if React reconciles it away.
  function ensureExtChanRow() {
    if (!pref0('extChat', true) || !pref0('chat', true)) return;
    const aside = commAside(); if (!aside) return;
    const sec = Array.from(aside.querySelectorAll('section')).find((s) => {
      const b = s.querySelector('button[aria-expanded]');
      return b && /Canaux/i.test(b.textContent || '');
    });
    if (!sec || sec.querySelector('#t4x-extchan')) return;
    const anyRow = sec.querySelector('div[role="button"]'); if (!anyRow) return;
    const row = document.createElement('div'); row.id = 't4x-extchan'; row.className = anyRow.className;
    row.setAttribute('role', 'button'); row.tabIndex = 0;
    row.style.borderLeft = '4px solid var(--primary,#93a5ff)';
    const spans = anyRow.querySelectorAll('span');
    const ic = document.createElement('span'); ic.className = (spans[0] && spans[0].className) || ''; ic.textContent = '#';
    const nm = document.createElement('span'); nm.className = (spans[1] && spans[1].className) || ''; nm.textContent = 'TR4KER+ (externe)';
    row.append(ic, nm);
    row.title = 'Chat communautaire TR4KER+ — hébergé hors du site, il marche même quand TR4KER est down. Réservé aux membres qui ont l’extension.';
    row.addEventListener('click', async (e) => {
      e.preventDefault(); e.stopPropagation();
      await openDrawer(); addPane(EXT_CONV, true);
    });
    anyRow.parentElement.appendChild(row);
  }
  function teardownExtChanRow() { const r = document.getElementById('t4x-extchan'); if (r) r.remove(); }
  async function chatSplitByName(name) {
    if (!pref0('chat', true)) { toast('Le chat TR4KER+ est désactivé dans les réglages.'); return; }
    await loadChannels();
    const c = (chat.channels || []).find((x) => (x.name || '').trim().toLowerCase() === String(name).trim().toLowerCase());
    if (!c) { toast('Canal introuvable côté TR4KER+ : ' + name); return; }
    await openDrawer();
    addPane(c.id, true);
  }

  // ---- boot / re-inject on every render + SPA route change -----------------------------------
  let _augScheduled = null;
  function scheduleAugment() {
    if (_navBusy) return;
    if (_augScheduled) return;
    _augScheduled = setTimeout(() => { _augScheduled = null; try { harvestSiteNav(); } catch (e) {} try { ensureSiteNav(); } catch (e) {} try { ensureComposerTools(); } catch (e) {} try { ensureCommSidebar(); } catch (e) {} try { ensureChanSplit(); } catch (e) {} try { ensureExtChanRow(); } catch (e) {} try { ensureHomeDash(); } catch (e) {} try { ensureYtChips(); } catch (e) {} try { augmentUserPage(); } catch (e) {} try { ensureUploadLauncher(); } catch (e) {} }, 400);
  }
  let _obsHeader = null;
  function attachHeaderObs() {
    const header = document.querySelector('header[role="banner"]') || document.querySelector('header');
    if (!header) return false;
    if (siteNavObs && _obsHeader === header && header.isConnected) return true;   // re-observe if React swapped the header node
    // Scope the observer to the HEADER only — NOT document.body. The chat message list mutates
    // hundreds of times/second on /communication; a body-wide subtree observer would fire on every
    // message. The header churns rarely, so this catches React reconciling our nav away, cheaply.
    // Only the 9-function augment pass matters when React adds/removes ELEMENTS; a header text tick
    // (clock, unread count) mutates text nodes only and must not trigger the whole pass.
    const elementChurn = (recs) => {
      for (const r of recs) {
        for (const n of r.addedNodes) if (n.nodeType === 1) return true;
        for (const n of r.removedNodes) if (n.nodeType === 1) return true;
      }
      return false;
    };
    try { if (siteNavObs) siteNavObs.disconnect(); siteNavObs = new MutationObserver((recs) => { if (elementChurn(recs)) scheduleAugment(); }); siteNavObs.observe(header, { childList: true, subtree: true }); _obsHeader = header; } catch (e) {}
    return !!siteNavObs;
  }
  function initSiteAugment() {
    if (!pref0('siteNav', true) && !pref0('siteComposer', true) && !pref0('homeDash', true) && !pref0('commSide', true) && !pref0('chanSplit', true)) return;
    ensureSiteCSS(); watchDashTheme();
    scheduleAugment();
    attachHeaderObs();
    window.addEventListener('t4x:locationchange', () => { closeNavPop(); scheduleAugment(); const n = document.querySelector('#t4x-sitenav'); if (n) markNavActive(n); });
    window.addEventListener('popstate', () => { const n = document.querySelector('#t4x-sitenav'); if (n) markNavActive(n); });
    window.addEventListener('resize', t4xDebounce(() => { const n = document.querySelector('#t4x-sitenav'); if (n) { n.remove(); } ensureSiteNav(); }, 200));
    // header may mount after us; a modest idempotent tick attaches the observer once it exists and
    // re-injects the composer tools (only present on /communication) without a body-wide observer.
    setInterval(() => { if (document.hidden) return; attachHeaderObs(); scheduleAugment(); }, 2500);
  }

  // ==========================================================================
  // HOME DASHBOARD — injected on "/" only. A self-contained shadow (so the reused chat keeps its
  // styles) holding three cards: the real community chat mounted INLINE (relocated, not duplicated),
  // live service status from t4health (+ a latency sparkline from /api/history), and the daily/weekly
  // community recap from t4ai /v1/recap. Re-injected idempotently by the same scheduler as the navbar.
  // ==========================================================================
  // Design merged from two parallel design passes (opus MoE workflow + a Fable CLI pass). Key ideas:
  // dashboard-scoped accent tokens RE-STEPPED per theme (dark accents fail contrast on white — the
  // light-mode washout bug); figure/ground cards (gradient+inner-light dark / white+soft-shadow light);
  // a compact ME-stats strip on top (the void-killer) + an equal-height 3-card row where the chat gets
  // height via internal scroll (not by being tall); a bucketed monotone-cubic sparkline; uptime
  // micro-bars; a segmented recap control; first-paint skeletons + inline error/retry.
  const DASH_CSS = `
    :host{ --dp:#93a5ff; --dok:#4ade80; --dwarn:#fbbf24; --ddown:#ff6b6b;
      --dsurf:var(--surface-container,#141722); --dsurf2:var(--surface-container-high,#232838);
      --dtext:var(--on-surface,#e6e8f2); --dmut:var(--on-surface-variant,#aab2c5);
      --dline:color-mix(in oklab,var(--dtext) 10%,transparent);
      --dcard:linear-gradient(180deg,color-mix(in oklab,var(--dsurf),white 3%),var(--dsurf));
      --dsh:inset 0 1px 0 color-mix(in oklab,white 6%,transparent),0 10px 30px -20px rgba(0,0,0,.75); }
    /* light steps applied via a JS-detected data-dtheme on the host — we follow the SITE's actual theme,
       NOT the OS prefers-color-scheme (a dark site under a light-OS was rendering white cards). */
    :host([data-dtheme="light"]){ --dp:#4a5ad0; --dok:#15803d; --dwarn:#b45309; --ddown:#b3261e;
      --dtext:#191b24; --dmut:#575e72; --dsurf2:#edeff5;
      --dcard:#fff; --dsh:0 1px 2px rgb(16 24 40/.05),0 10px 26px -14px rgb(16 24 40/.16); --dline:rgb(16 24 40/.10); }
    .t4x-dash{display:flex;flex-direction:column;gap:12px;margin:18px 0 22px;font-family:'Geist Variable',system-ui,sans-serif;color:var(--dtext)}
    .t4x-me{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}
    .t4x-tile{background:var(--dcard);border:1px solid var(--dline);border-radius:12px;box-shadow:var(--dsh);padding:11px 13px;display:flex;flex-direction:column;gap:3px;min-width:0}
    .t4x-tile .k{font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--dmut)}
    .t4x-tile .v{font:700 20px/1.1 'JetBrains Mono',monospace;color:var(--dtext);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .t4x-tile .d{font:600 11px/1 'JetBrains Mono',monospace}
    .t4x-tile.fl{background:color-mix(in oklab,var(--dok) 12%,var(--dsurf));border-color:color-mix(in oklab,var(--dok) 30%,transparent)}
    .t4x-tile.fl .v{color:var(--dok)}
    .t4x-hero{display:flex;flex-wrap:wrap;gap:5px 22px;align-items:center;padding:0 4px 2px;color:var(--dmut);font-size:12px}
    .t4x-hero .s{display:inline-flex;align-items:center;gap:6px}
    .t4x-hero .s .ic{font-family:'Material Symbols Outlined';font-size:15px;color:var(--dp);opacity:.9}
    .t4x-hero b{color:var(--dtext);font:700 13px/1 'JetBrains Mono',monospace;font-variant-numeric:tabular-nums}
    .t4x-row{display:grid;grid-template-columns:minmax(340px,1.2fr) minmax(280px,1fr) minmax(280px,1fr);gap:12px;height:clamp(430px,54vh,580px)}
    @media(max-width:1160px){.t4x-row{grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);height:auto}.t4x-chatcard{grid-row:span 2;height:clamp(440px,58vh,600px)}}
    @media(max-width:760px){.t4x-row{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;scroll-padding:12px}.t4x-row>*{flex:0 0 min(86vw,340px);scroll-snap-align:start;height:360px}}
    .t4x-card{background:var(--dcard);border:1px solid var(--dline);border-radius:16px;box-shadow:var(--dsh);color:var(--dtext);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
    .t4x-card>.hd{display:flex;align-items:center;gap:9px;padding:13px 15px 9px;flex:0 0 auto}
    .t4x-card>.hd .hchip{width:28px;height:28px;border-radius:8px;display:flex;align-items:center;justify-content:center;background:color-mix(in oklab,var(--dp) 15%,transparent);color:var(--dp);flex:0 0 auto}
    .t4x-card>.hd .hchip .ic{font-family:'Material Symbols Outlined';font-size:17px}
    .t4x-card>.hd .ttl{font-size:13px;font-weight:600;color:var(--dtext);margin-right:auto}
    .t4x-card>.hd .sub{font-weight:500;font-size:11px;color:var(--dmut);font-variant-numeric:tabular-nums}
    .t4x-cex{all:unset;cursor:pointer;width:26px;height:26px;display:flex;align-items:center;justify-content:center;border-radius:7px;color:var(--dmut);flex:0 0 auto}
    .t4x-cex:hover{background:color-mix(in oklab,var(--dp) 14%,transparent);color:var(--dp)}
    .t4x-cex .ic{font-family:'Material Symbols Outlined';font-size:16px}
    .t4x-cmveil{position:fixed;inset:0;z-index:2147483050;background:rgba(0,0,0,.62);-webkit-backdrop-filter:blur(3px); backdrop-filter:blur(3px)}
    .t4x-cmbox{position:fixed;inset:4vh 4vw;z-index:2147483051;display:flex}
    .t4x-cmbox>.t4x-card,.t4x-card.t4x-cmcard{flex:1;height:100%!important;max-height:none;width:100%}
    .t4x-trendcard,.t4x-bountycard{min-height:210px}
    .t4x-card>.bd{flex:1;min-height:0;overflow-y:auto;padding:2px 15px 14px;scrollbar-width:thin}
    .t4x-chatcard>.bd{padding:0;display:flex;flex-direction:column;overflow:hidden}
    .t4x-chatcard .draw.t4x-inline .msgs{min-height:0}
    .t4x-rh{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--dp);margin:12px 0 4px}
    .t4x-rh:first-child{margin-top:0}
    .t4x-rli{position:relative;padding-left:14px;margin:0 0 5px;color:var(--dmut);font-size:12.5px;line-height:1.5}
    .t4x-rli::before{content:'';position:absolute;left:2px;top:8px;width:4px;height:4px;border-radius:50%;background:var(--dp);opacity:.6}
    .t4x-dmuted{font-size:12px;color:var(--dmut);text-align:center;padding:16px 8px;display:flex;flex-direction:column;align-items:center;gap:8px}
    .t4x-retry{all:unset;cursor:pointer;font-size:12px;padding:5px 12px;border-radius:999px;border:1px solid var(--dline);color:var(--dtext)}
    .t4x-skel{height:14px;border-radius:7px;background:color-mix(in oklab,var(--dtext) 8%,transparent);margin:9px 0;animation:t4xpulse 1.6s ease-in-out infinite}
    @keyframes t4xpulse{0%,100%{opacity:.5}50%{opacity:1}}
    .t4x-ovr{display:flex;align-items:center;gap:9px;font-weight:600;font-size:15px;margin:2px 0 10px}
    .t4x-dot{width:8px;height:8px;border-radius:50%;flex:0 0 auto}
    .t4x-ovr .t4x-dot{box-shadow:0 0 0 4px color-mix(in oklab,var(--oc,var(--dok)) 20%,transparent)}
    .t4x-spark{width:100%;height:46px;display:block;margin:0 0 12px}
    .t4x-srows{display:flex;flex-direction:column;gap:1px}
    .t4x-srow{display:grid;grid-template-columns:8px 1fr max-content 46px;align-items:center;gap:10px;height:30px;margin:0 -8px;padding:0 8px;border-radius:8px}
    .t4x-srow:hover{background:color-mix(in oklab,var(--dtext) 4%,transparent)}
    .t4x-sname{color:var(--dtext);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
    .t4x-slat{font:11px/1 'JetBrains Mono',monospace;color:var(--dmut);text-align:right;font-variant-numeric:tabular-nums}
    .t4x-upbar{height:4px;border-radius:2px;background:color-mix(in oklab,var(--dok) 15%,transparent);overflow:hidden}
    .t4x-upbar .f{display:block;height:100%;background:var(--dok);border-radius:2px}
    .t4x-seg{display:inline-flex;gap:2px;padding:2px;border-radius:9px;background:color-mix(in oklab,var(--dtext) 6%,transparent)}
    .t4x-seg button{all:unset;cursor:pointer;font-size:11px;padding:4px 10px;border-radius:7px;color:var(--dmut)}
    .t4x-seg button.on{background:var(--dsurf2);color:var(--dtext)}
    .t4x-rdate{font-size:11px;color:var(--dmut);letter-spacing:.02em;margin:0 0 8px}
    .t4x-rbody{font-size:12.5px;line-height:1.55;color:var(--dmut);max-width:64ch}
    .t4x-rbody p{margin:0 0 9px}.t4x-rbody p:last-child{margin:0}
    .t4x-rbody strong{color:var(--dtext);font-weight:600}
    .t4x-ritem{margin:0 0 11px}.t4x-ritem:last-child{margin:0}
    .t4x-ritem .rt{font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--dp);margin:0 0 3px;display:flex;align-items:center;gap:6px}
    .t4x-ritem .rt::before{content:'';width:3px;align-self:stretch;border-radius:2px;background:var(--dp);opacity:.6}
    .t4x-ritem .rd{font-size:12px;line-height:1.5;color:var(--dmut)}
    .t4x-rmore{all:unset;cursor:pointer;font-size:11.5px;color:var(--dp);margin-top:6px;display:inline-block}
    .t4x-rmore:hover{text-decoration:underline}
    .draw.t4x-inline{position:static!important;height:100%!important;width:100%!important;transform:none!important;box-shadow:none!important;border:0!important;border-radius:0!important;background:transparent!important;-webkit-backdrop-filter:none!important; backdrop-filter:none!important;display:flex;flex:1;min-height:0;resize:none!important;overflow:visible!important}
    .draw.t4x-inline .dh{display:none!important}
    .draw.t4x-inline .wgrip{display:none!important}
    /* inline (home card): show only the focused pane — the card is too narrow for a split */
    .draw.t4x-inline .pane{display:none}
    .draw.t4x-inline .pane.foc{display:flex}
    .draw.t4x-inline .pane .ph{display:none!important}
    .t4x-cmd{display:flex;gap:10px;align-items:center}
    .t4x-cmdsearch{all:unset;cursor:text;flex:1;display:flex;align-items:center;gap:9px;height:42px;padding:0 14px;border-radius:12px;background:var(--dcard);border:1px solid var(--dline);box-shadow:var(--dsh);color:var(--dmut);box-sizing:border-box}
    .t4x-cmdsearch:hover{border-color:color-mix(in oklab,var(--dp) 40%,var(--dline))}
    .t4x-cmdsearch .ic{font-family:'Material Symbols Outlined';font-size:19px}
    .t4x-cmdsearch .ph{flex:1;font-size:13px}
    .t4x-cmdsearch .kk{font:10px/1 'JetBrains Mono',monospace;padding:3px 7px;border-radius:6px;background:var(--dsurf2);color:var(--dmut)}
    .t4x-cmdacts{display:flex;gap:8px}
    .t4x-pill{all:unset;cursor:pointer;display:flex;align-items:center;gap:6px;height:42px;padding:0 14px;border-radius:12px;background:var(--dcard);border:1px solid var(--dline);box-shadow:var(--dsh);color:var(--dtext);font-size:12.5px;box-sizing:border-box}
    .t4x-pill:hover{background:color-mix(in oklab,var(--dp) 12%,transparent)}
    .t4x-pill .ic{font-family:'Material Symbols Outlined';font-size:18px;color:var(--dp)}
    @media(max-width:760px){.t4x-cmd{flex-wrap:wrap}.t4x-cmdsearch{flex:1 0 100%}}
    .t4x-row2{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;align-items:start}
    .t4x-hubgrid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px}
    .t4x-hubtile{background:var(--t4x-glass);border:1px solid var(--t4x-hair);border-radius:10px;padding:9px 11px}
    .t4x-hubtile .v{font:700 19px/1.2 ui-monospace,monospace;font-variant-numeric:tabular-nums}
    .t4x-hubtile .l{font-size:10.5px;opacity:.62;margin-top:2px}
    .t4x-hubspark{color:#5aa9ff;margin:2px 0 4px}
    .t4x-hubspark svg{width:100%;height:46px;display:block}
    .t4x-hubfoot{display:flex;align-items:center;gap:8px;margin-top:8px;font-size:12px}
    .t4x-hubdot{width:8px;height:8px;border-radius:50%;background:#8d8a81;display:inline-block;flex:none}
    .t4x-hubdot.up{background:#7fb069}.t4x-hubdot.slow,.t4x-hubdot.degraded{background:#e3b341}.t4x-hubdot.down{background:#e0654f}
    .t4x-hublink{margin-left:auto}
    @media(max-width:1100px){.t4x-row2{grid-template-columns:1fr 1fr}}
    @media(max-width:760px){.t4x-row2{grid-template-columns:1fr}}
    .t4x-trow,.t4x-brow{text-decoration:none;color:inherit;padding:8px;margin:1px -8px;border-radius:9px;display:block}
    .t4x-trow:hover,.t4x-brow:hover{background:color-mix(in oklab,var(--dp) 8%,transparent)}
    .t4x-trow .tt,.t4x-brow .tt{font-size:12.5px;color:var(--dtext);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .t4x-trow .mt{display:flex;gap:5px;flex-wrap:wrap;margin-top:4px}
    .t4x-chip{font:10px/1.5 'JetBrains Mono',monospace;padding:2px 7px;border-radius:6px;background:var(--dsurf2);color:var(--dmut);white-space:nowrap}
    .t4x-chip.seed{color:var(--dok)}
    .t4x-chip.fl{color:#141722;background:var(--dwarn);font-weight:700}
    .t4x-brow{display:grid;grid-template-columns:1fr max-content;align-items:center;gap:10px}
    .t4x-brow .amt{font:700 13px/1 'JetBrains Mono',monospace;color:var(--dp);background:color-mix(in oklab,var(--dp) 14%,transparent);padding:5px 9px;border-radius:8px;white-space:nowrap}
    .t4x-brow .pc{font:10px/1 'JetBrains Mono',monospace;color:var(--dmut);display:flex;align-items:center;gap:3px;margin-top:3px}
    .t4x-brow .pc .ic{font-family:'Material Symbols Outlined';font-size:13px}
    @media (prefers-reduced-motion: reduce){ .t4x-skel{animation:none} }`;
  function dEl(tag, cls, txt) { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
  // Follow the SITE's real theme (data-theme attr, else infer from the page background luminance) —
  // never the OS preference, which mismatched (dark site + light OS = white cards).
  function dashDetectTheme() {
    try {
      const dt = document.documentElement.getAttribute('data-theme');
      if (dt === 'light') return 'light'; if (dt === 'dark') return 'dark';
      const bg = getComputedStyle(document.body).backgroundColor || getComputedStyle(document.documentElement).backgroundColor;
      const m = (bg || '').match(/\d+(\.\d+)?/g);
      if (m && m.length >= 3) { const lum = 0.299 * +m[0] + 0.587 * +m[1] + 0.114 * +m[2]; return lum > 140 ? 'light' : 'dark'; }
    } catch (e) {}
    return 'dark';
  }
  function applyDashTheme() { if (homeMount) homeMount.setAttribute('data-dtheme', dashDetectTheme()); }
  // ---- perf: persistent cache so cards paint INSTANTLY from the last snapshot, then refresh silently ----
  function dashCacheGet(k) { try { const c = store.get('t4x.dash.' + k, null); return (c && 'v' in c) ? c.v : null; } catch (e) { return null; } }
  function dashCacheFresh(k, maxAge) { try { const c = store.get('t4x.dash.' + k, null); return !!(c && 'v' in c && Date.now() - (c.t || 0) < maxAge); } catch (e) { return false; } }   // perf: skip the network refresh on SPA remount when the snapshot is recent
  function dashCacheSet(k, v) { try { store.set('t4x.dash.' + k, { v: v, t: Date.now() }); } catch (e) {} }
  // instant theme flip: watch the site's data-theme instead of waiting for the 2.5 s tick
  let _dthemeObs = null;
  function watchDashTheme() {
    if (_dthemeObs) return;
    try { _dthemeObs = new MutationObserver(() => applyDashTheme()); _dthemeObs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] }); } catch (e) {}
    window.addEventListener('t4x:sitetheme', applyDashTheme);
  }
  // ---- expand any card into a large modal (moves the real node in, restores it on close) ----
  let cardModal = null;
  function closeCardModal() { if (cardModal) cardModal.remove(); }
  function expandCard(card) {
    if (cardModal) { closeCardModal(); return; }
    if (!homeRoot) return;
    const veil = dEl('div', 't4x-cmveil'); const box = dEl('div', 't4x-cmbox');
    card._home = { parent: card.parentNode, next: card.nextSibling };
    card.classList.add('t4x-cmcard'); box.appendChild(card);
    homeRoot.append(veil, box);
    const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeCardModal(); } };
    veil.addEventListener('click', closeCardModal);
    document.addEventListener('keydown', onKey, true);
    cardModal = { remove() { card.classList.remove('t4x-cmcard'); try { if (card._home && card._home.parent) card._home.parent.insertBefore(card, card._home.next); } catch (e) {} veil.remove(); box.remove(); document.removeEventListener('keydown', onKey, true); cardModal = null; } };
  }
  function dashCard(cls, icon, title) {
    const c = dEl('div', 't4x-card ' + cls); const hd = dEl('div', 'hd');
    const chip = dEl('span', 'hchip'); const ic = dEl('span', 'ic'); ic.textContent = icon; chip.appendChild(ic);
    const ex = dEl('button', 't4x-cex'); ex.type = 'button'; ex.title = 'Agrandir'; ex.setAttribute('aria-label', 'Agrandir'); ex.innerHTML = '<span class="ic">open_in_full</span>';
    ex.addEventListener('click', (e) => { e.stopPropagation(); expandCard(c); });
    hd.append(chip, dEl('span', 'ttl', title), ex);   // header extras (recap seg) insert before .t4x-cex
    const bd = dEl('div', 'bd'); c.append(hd, bd); return c;
  }
  function dashSkel(n) { const f = document.createDocumentFragment(); for (let i = 0; i < n; i++) { const s = dEl('div', 't4x-skel'); s.style.width = (60 + (i * 53) % 40) + '%'; f.appendChild(s); } return f; }
  function dashError(msg, retry) { const w = dEl('div', 't4x-dmuted'); w.append(dEl('div', '', msg)); const b = dEl('button', 't4x-retry', 'Réessayer'); b.addEventListener('click', retry); w.appendChild(b); return w; }
  const DST = { up: 'var(--dok)', warn: 'var(--dwarn)', down: 'var(--ddown)', unknown: 'var(--dmut)' };
  // monotone-cubic (Fritsch–Carlson) path — smooth without the overshoot/invented-dips of Catmull-Rom
  function monoCubicPath(xs, ys) {
    const n = xs.length; if (n < 2) return '';
    const dx = [], dy = [], m = [];
    for (let i = 0; i < n - 1; i++) { dx[i] = xs[i + 1] - xs[i] || 1e-6; dy[i] = ys[i + 1] - ys[i]; m[i] = dy[i] / dx[i]; }
    const t = new Array(n); t[0] = m[0]; t[n - 1] = m[n - 2];
    for (let i = 1; i < n - 1; i++) t[i] = (m[i - 1] * m[i] <= 0) ? 0 : (m[i - 1] + m[i]) / 2;
    for (let i = 0; i < n - 1; i++) { if (m[i] === 0) { t[i] = 0; t[i + 1] = 0; } else { const a = t[i] / m[i], b = t[i + 1] / m[i], h = Math.hypot(a, b); if (h > 3) { const s = 3 / h; t[i] = s * a * m[i]; t[i + 1] = s * b * m[i]; } } }
    let d = 'M' + xs[0].toFixed(2) + ',' + ys[0].toFixed(2);
    for (let i = 0; i < n - 1; i++) { const h = dx[i]; d += 'C' + (xs[i] + h / 3).toFixed(2) + ',' + (ys[i] + t[i] * h / 3).toFixed(2) + ' ' + (xs[i + 1] - h / 3).toFixed(2) + ',' + (ys[i + 1] - t[i + 1] * h / 3).toFixed(2) + ' ' + xs[i + 1].toFixed(2) + ',' + ys[i + 1].toFixed(2); }
    return d;
  }
  function dashSparkline(points) {
    const NS = 'http://www.w3.org/2000/svg';
    const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 't4x-spark'); svg.setAttribute('viewBox', '0 0 100 46'); svg.setAttribute('preserveAspectRatio', 'none'); svg.setAttribute('role', 'img');
    const raw = (points || []).filter((p) => p && p.lat != null);
    if (raw.length < 2) { const t = document.createElementNS(NS, 'text'); t.setAttribute('x', '50'); t.setAttribute('y', '26'); t.setAttribute('text-anchor', 'middle'); t.setAttribute('font-size', '9'); t.setAttribute('fill', 'var(--dmut)'); t.textContent = 'pas encore de données'; svg.appendChild(t); return svg; }
    // bucket to ~32 points (mean latency, worst state per bucket) → kills raw-density sawtooth
    const B = Math.min(32, raw.length); const buckets = [];
    for (let i = 0; i < B; i++) { const a = Math.floor(i * raw.length / B), b = Math.floor((i + 1) * raw.length / B); const seg = raw.slice(a, Math.max(a + 1, b)); const lat = seg.reduce((s, p) => s + p.lat, 0) / seg.length; const bad = seg.some((p) => p.state && p.state !== 'up'); buckets.push({ lat, bad }); }
    const lats = buckets.map((b) => b.lat); const min = Math.min(...lats), max = Math.max(...lats); const pad = (max - min) * 0.15 || 1;
    const lo = min - pad, hi = max + pad; const n = buckets.length;
    const xs = buckets.map((_, i) => i / (n - 1) * 100); const ys = buckets.map((b) => 40 - (b.lat - lo) / (hi - lo) * 34);
    const line = monoCubicPath(xs, ys);
    const gid = 't4xspk'; const grad = document.createElementNS(NS, 'linearGradient'); grad.setAttribute('id', gid); grad.setAttribute('x1', '0'); grad.setAttribute('y1', '0'); grad.setAttribute('x2', '0'); grad.setAttribute('y2', '1');
    const gs = (o, c) => { const s = document.createElementNS(NS, 'stop'); s.setAttribute('offset', o); s.setAttribute('stop-color', c); grad.appendChild(s); };
    gs('0', 'color-mix(in oklab,var(--dp) 26%,transparent)'); gs('1', 'transparent');
    const defs = document.createElementNS(NS, 'defs'); defs.appendChild(grad); svg.appendChild(defs);
    const area = document.createElementNS(NS, 'path'); area.setAttribute('d', line + ' L100,46 L0,46 Z'); area.setAttribute('fill', 'url(#' + gid + ')'); svg.appendChild(area);
    const p = document.createElementNS(NS, 'path'); p.setAttribute('d', line); p.setAttribute('fill', 'none'); p.setAttribute('stroke', 'var(--dp)'); p.setAttribute('stroke-width', '2'); p.setAttribute('stroke-linejoin', 'round'); p.setAttribute('stroke-linecap', 'round'); p.setAttribute('vector-effect', 'non-scaling-stroke'); svg.appendChild(p);
    buckets.forEach((b, i) => { if (b.bad) { const c = document.createElementNS(NS, 'circle'); c.setAttribute('cx', xs[i].toFixed(1)); c.setAttribute('cy', ys[i].toFixed(1)); c.setAttribute('r', '2.4'); c.setAttribute('fill', 'var(--ddown)'); svg.appendChild(c); } });
    const end = document.createElementNS(NS, 'circle'); end.setAttribute('cx', xs[n - 1].toFixed(1)); end.setAttribute('cy', ys[n - 1].toFixed(1)); end.setAttribute('r', '3'); end.setAttribute('fill', 'var(--dp)'); end.setAttribute('stroke', 'var(--dsurf)'); end.setAttribute('stroke-width', '2'); svg.appendChild(end);
    return svg;
  }
  function paintStatus(bd, data) {
    const d = data && data.status; if (!d) { bd.replaceChildren(dEl('div', 't4x-dmuted', 'Statut indisponible.')); return; }
    bd.replaceChildren();
    const ov = dEl('div', 't4x-ovr'); const dot = dEl('span', 't4x-dot'); dot.style.background = DST[d.overall] || DST.unknown; dot.style.setProperty('--oc', DST[d.overall] || DST.unknown);
    ov.append(dot, dEl('span', '', d.overall_label || STATE_FR[d.overall] || d.overall)); bd.appendChild(ov);
    const sr = (data.hist || {}).series || {}; const pts = ((sr.site || sr.api || {}).points) || []; bd.appendChild(dashSparkline(pts));
    const svcs = d.services || {}; const rows = dEl('div', 't4x-srows');
    Object.keys(svcs).forEach((k) => {
      const s = svcs[k]; const row = dEl('div', 't4x-srow');
      const dd = dEl('span', 't4x-dot'); dd.style.background = DST[s.state] || DST.unknown;
      const lat = dEl('span', 't4x-slat', s.latency_ms != null ? s.latency_ms + ' ms' : '—');
      const bar = dEl('span', 't4x-upbar'); const f = dEl('span', 'f'); const u = (typeof s.uptime_24h === 'number') ? s.uptime_24h : 100; f.style.width = Math.max(6, Math.min(100, (u - 99) * 100)) + '%'; bar.appendChild(f); bar.title = 'uptime 24 h ' + (u.toFixed ? u.toFixed(2) : u) + '%';
      row.append(dd, dEl('span', 't4x-sname', s.label || k), lat, bar); rows.appendChild(row);
    });
    bd.appendChild(rows);
  }
  async function renderDashStatus(card) {
    const bd = card.querySelector('.bd'); const cached = dashCacheGet('status');
    if (cached) paintStatus(bd, cached); else bd.replaceChildren(dashSkel(4));   // instant from cache, refresh below
    if (dashCacheFresh('status', 90000)) return;
    let d = null, hist = null;
    try { await refreshHealth(false); d = health.data; } catch (e) {}
    try { const h = await gifFetchJson(HEALTH_URL + '/api/history'); hist = h.body; } catch (e) {}
    if (d) { const data = { status: d, hist: hist }; dashCacheSet('status', data); paintStatus(bd, data); }
    else if (!cached) bd.replaceChildren(dashError('Statut indisponible.', () => renderDashStatus(card)));
  }
  function recapClean(md) { return String(md || '').replace(/^\s*#{1,6}\s*(.+?)\s*$/gm, '**$1**').replace(/\*\*\*+/g, '**').slice(0, 8000); }
  // Structured render of the full digest (headings, bulleted list items, paragraphs) so the expanded
  // recap is organised + indented instead of a wall of text. DOM-built (XSS-safe).
  function renderRecapFull(md) {
    const frag = document.createDocumentFragment();
    String(md || '').slice(0, 12000).split('\n').forEach((raw) => {
      const l = raw.trim(); if (!l) return;
      const h = l.match(/^#{1,6}\s*(.+)$/);
      if (h) { frag.appendChild(dEl('div', 't4x-rh', h[1].replace(/[*_`]/g, '').trim())); return; }
      const b = l.match(/^[-*•]\s+(.+)$/);
      if (b) { const li = dEl('div', 't4x-rli'); _inline(b[1].replace(/`/g, ''), li); frag.appendChild(li); return; }
      const p = document.createElement('p'); p.style.cssText = 'margin:0 0 8px'; _inline(l.replace(/`/g, ''), p); frag.appendChild(p);
    });
    return frag;
  }
  // On the home page the site renders its OWN global-stats strip + a tile-grid nav menu below our
  // dashboard — both now redundant (we show the hero stats + a full navbar). Hide them (keep the
  // Petites-Annonces panel). Re-run each tick since React can recreate them. Signature-matched, not
  // class-matched (site classes are hashed); the smallest matching container is hidden.
  function hideRedundantHome() {
    if (!pref0('hideRedundant', true)) return;
    try {
      const main = document.querySelector('main'); if (!main) return;
      if (main.querySelectorAll('[data-t4xhid]').length >= 2) return;   // perf: both strips already hidden -> skip the O(N) textContent scan every 2.5s
      const nodes = [...main.querySelectorAll('div,section,ul,nav')].filter((el) => !(el.closest && el.closest('#t4x-home')));
      const hideSmallest = (test) => { const ms = nodes.filter((el) => !el.hasAttribute('data-t4xhid') && test(el.textContent || '')); if (!ms.length) return; const best = ms.reduce((a, b) => (b.textContent.length < a.textContent.length ? b : a)); best.style.setProperty('display', 'none', 'important'); best.setAttribute('data-t4xhid', '1'); };
      hideSmallest((t) => t.length < 400 && /Inscrits/.test(t) && /Peers actifs/.test(t) && /Torrents en seed/.test(t));                       // global-stats strip
      hideSmallest((t) => t.length < 700 && /MON COMPTE/i.test(t) && /MES UPLOADS/i.test(t) && /MIGRATIONS/i.test(t) && /PARAM/i.test(t));      // tile-grid nav menu
    } catch (e) {}
  }
  // Condense the verbose digest into ≤4 short items (section heading + its first line) so the card is
  // digestible at a glance; the full text is one click away. (Paul: "pas digérable, plus court/condensé".)
  function recapItems(md) {
    const items = []; let cur = null;
    String(md || '').split('\n').forEach((raw) => {
      const l = raw.trim(); if (!l) return;
      const h = l.match(/^#{1,6}\s*(.+)$/);
      if (h) { if (cur && (cur.title || cur.detail)) items.push(cur); cur = { title: h[1].replace(/[*_`]/g, '').trim(), detail: '' }; }
      else if (cur && !cur.detail) cur.detail = l.replace(/[*_`]/g, '').replace(/^[-*•]\s*/, '');
      else if (!cur) cur = { title: '', detail: l.replace(/[*_`]/g, '') };
    });
    if (cur && (cur.title || cur.detail)) items.push(cur);
    return items.slice(0, 4).map((it) => ({ title: it.title, detail: (it.detail || '').replace(/\s+/g, ' ').slice(0, 150) }));
  }
  // interpreted site-changes feed (t4health change-tracker + hourly LLM reading) — cached 5 min
  async function fetchSiteChanges(force) {
    const cached = dashCacheGet('sitechanges');
    if (cached && !force && Date.now() - (cached.at || 0) < 300000) return cached;
    try {
      const r = await gifFetchJson(HEALTH_URL + '/api/changes');
      const j = r.body || {};
      const evs = (j.events || []).map((e) => ({ ts: e.ts, severity: e.severity, title: e.title, interp: e.interp || null }));
      const v = { at: Date.now(), reading: j.reading || null, events: evs.slice(0, 10) };
      dashCacheSet('sitechanges', v);
      return v;
    } catch (e) { return cached || null; }
  }
  async function renderDashRecap(card) {
    const bd = card.querySelector('.bd'); const cached = dashCacheGet('recap');
    if (cached) { cached.site = dashCacheGet('sitechanges'); paintRecap(card, cached); } else bd.replaceChildren(dashSkel(4));   // instant from cache, refresh below
    if (dashCacheFresh('recap', 90000)) return;
    let data = null; try { const r = await gifFetchJson('https://t4ai.ekaii.fr/v1/recap'); data = r.body; } catch (e) {}
    const site = await fetchSiteChanges();
    if (data) { dashCacheSet('recap', data); data.site = site; paintRecap(card, data); }
    else if (cached && site) { cached.site = site; paintRecap(card, cached); }
    else if (!cached) bd.replaceChildren(dashError('Récap indisponible.', () => renderDashRecap(card)));
  }
  function paintRecap(card, data) {
    const bd = card.querySelector('.bd');
    if (!data.daily && !data.weekly) { bd.replaceChildren(dEl('div', 't4x-dmuted', 'Aucun récap pour le moment.')); return; }
    const seg = dEl('div', 't4x-seg'); const hd = card.querySelector('.hd'); const oldSeg = hd.querySelector('.t4x-seg'); if (oldSeg) oldSeg.remove(); hd.insertBefore(seg, hd.querySelector('.t4x-cex'));
    const body = dEl('div', 't4x-rbody');
    // one digest section (date header + condensed items / full text) — the 24 h tab stacks today AND
    // the previous day (each once); a paintRecap re-run REPLACES the card body (the append variant
    // duplicated the whole digest on every cache-then-refresh repaint).
    const section = (item, expanded, onToggle, label) => {
      const frag = document.createDocumentFragment();
      if (!item || !item.md) return frag;
      if (label || item.date) frag.appendChild(dEl('div', 't4x-rdate', (label ? label + ' — ' : '') + (item.date || '')));
      if (expanded) {
        frag.appendChild(renderRecapFull(item.md));
        const less = dEl('button', 't4x-rmore', 'Réduire'); less.addEventListener('click', () => onToggle(false)); frag.appendChild(less);
      } else {
        recapItems(item.md).forEach((it) => { const ri = dEl('div', 't4x-ritem'); if (it.title) ri.appendChild(dEl('div', 'rt', it.title)); ri.appendChild(dEl('div', 'rd', it.detail)); frag.appendChild(ri); });
        const more = dEl('button', 't4x-rmore', 'Voir le récap complet →'); more.addEventListener('click', () => onToggle(true)); frag.appendChild(more);
      }
      return frag;
    };
    const state = { daily: false, prev: false, weekly: false };
    const show = (w) => {
      seg.querySelectorAll('button').forEach((b) => b.classList.toggle('on', b.dataset.w === w));
      body.replaceChildren();
      if (w === 'site') {
        const st = data.site || {};
        if (st.reading && st.reading.summary) {
          body.appendChild(dEl('div', 't4x-rdate', 'Lecture d’ensemble'));
          const ri = dEl('div', 't4x-ritem'); ri.appendChild(dEl('div', 'rd', st.reading.summary)); body.appendChild(ri);
        }
        const nn = (st.events || []).filter((e) => e.severity !== 'info');
        const list = (nn.length ? nn : (st.events || [])).slice(0, 6);
        if (list.length) body.appendChild(dEl('div', 't4x-rdate', 'Changements détectés (interprétés)'));
        const SEVC = { major: '#5aa9ff', notable: '#f5a623', info: '#6b7384' };
        for (const ev of list) {
          const ri = dEl('div', 't4x-ritem');
          const rt = dEl('div', 'rt');
          const dot = dEl('span'); dot.style.cssText = 'display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:7px;vertical-align:1px;background:' + (SEVC[ev.severity] || SEVC.info);
          rt.append(dot, document.createTextNode(ev.title || ''));
          ri.appendChild(rt);
          if (ev.interp) ri.appendChild(dEl('div', 'rd', ev.interp));
          body.appendChild(ri);
        }
        if (!list.length && !(st.reading && st.reading.summary)) body.appendChild(dEl('div', 't4x-dmuted', '—'));
        const more = dEl('button', 't4x-rmore', 'Tout voir sur t4health →');
        more.addEventListener('click', () => window.open(HEALTH_URL + '/changes', '_blank', 'noreferrer'));
        body.appendChild(more);
      } else if (w === 'weekly') {
        const item = data.weekly;
        if (!item || !item.md) { body.appendChild(dEl('div', 't4x-dmuted', '—')); }
        else body.appendChild(section(item, state.weekly, (v) => { state.weekly = v; show(w); }));
      } else {
        if (!data.daily || !data.daily.md) body.appendChild(dEl('div', 't4x-dmuted', '—'));
        else body.appendChild(section(data.daily, state.daily, (v) => { state.daily = v; show(w); }));
        if (data.daily_prev && data.daily_prev.md && (!data.daily || data.daily_prev.date !== data.daily.date)) {
          body.appendChild(section(data.daily_prev, state.prev, (v) => { state.prev = v; show(w); }, 'La veille'));
        }
      }
      body.style.opacity = '0'; requestAnimationFrame(() => { body.style.transition = 'opacity .16s'; body.style.opacity = '1'; });
    };
    [['daily', '24 h'], ['weekly', 'Semaine'], ['site', 'Site']].forEach(([w, label]) => { if (!data[w] || (w === 'site' && !(data.site.reading || (data.site.events || []).length))) return; const b = dEl('button', '', label); b.dataset.w = w; b.addEventListener('click', () => show(w)); seg.appendChild(b); });
    bd.replaceChildren(body);   // REPLACE, never append — repaints must not stack copies
    show(data.daily ? 'daily' : 'weekly');
  }
  // Complementary to the site's own left sidebar (ratio / upload / download / crédits) + our pill —
  // so we DON'T repeat those. Show only recent + live signals the sidebar lacks.
  async function renderDashMe(strip) {
    const cached = dashCacheGet('me');
    if (cached) paintMe(strip, cached); else { strip.replaceChildren(); for (let i = 0; i < 5; i++) { const t = dEl('div', 't4x-tile'); t.appendChild(dashSkel(2)); strip.appendChild(t); } }
    if (dashCacheFresh('me', 90000)) return;
    let m = null, st = null;
    try { m = await me(); } catch (e) {}
    try { st = await apiGet('/api/me/stats', { ttl: 60000 }); } catch (e) {}
    if (m || st) { dashCacheSet('me', { m: m, st: st }); paintMe(strip, { m: m, st: st }); }
    else if (!cached) { const t = dEl('div', 't4x-tile'); t.appendChild(dEl('div', 't4x-dmuted', 'Stats indisponibles.')); strip.replaceChildren(t); }
  }
  function paintMe(strip, data) {
    const m = data.m, st = data.st;
    const stat = (st && st.statistics) || {}; const dd = window.__t4x_delta || null;
    strip.replaceChildren();
    const cnum = (x) => Intl.NumberFormat('fr-FR', { notation: 'compact', maximumFractionDigits: 1 }).format(x || 0);
    const tile = (k, v, cls) => { const t = dEl('div', 't4x-tile' + (cls ? ' ' + cls : '')); t.append(dEl('div', 'k', k)); const vv = dEl('div', 'v'); vv.textContent = v; t.append(vv); return { el: t, v: vv }; };
    strip.appendChild(tile('Upload 24 h', fmtSize(stat.uploaded_last_24h || 0)).el);
    strip.appendChild(tile('Upload 7 j', fmtSize(stat.uploaded_last_7d || 0)).el);
    if (stat.torrents_seeding != null) strip.appendChild(tile('En seed', cnum(stat.torrents_seeding)).el);
    if (stat.total_seedtime_seconds != null) strip.appendChild(tile('Seedtime', Math.round(stat.total_seedtime_seconds / 86400) + ' j').el);
    if (dd && isFinite(dd.dUp) && Math.abs(dd.dUp) > 0) { const t = tile('Depuis ta visite', (dd.dUp >= 0 ? '+' : '−') + fmtSize(Math.abs(dd.dUp))); t.v.style.color = 'var(--dok)'; strip.appendChild(t.el); }
    // (no Freeleech tile — the site's top banner "FREELEECH GLOBAL ACTIF" already says it)
  }
  function chipEl(txt, cls) { const c = dEl('span', 't4x-chip' + (cls ? ' ' + cls : '')); c.textContent = txt; return c; }
  async function renderDashHero(band) {
    const cached = dashCacheGet('hero'); if (cached) paintHero(band, cached);
    if (dashCacheFresh('hero', 90000)) return;
    let s = null; try { s = await apiGet('/api/public/stats', { ttl: 300000 }); } catch (e) {}
    if (s) { dashCacheSet('hero', s); paintHero(band, s); }
  }
  function paintHero(band, s) {
    band.replaceChildren(); if (!s) return;
    const cnum = (x) => Intl.NumberFormat('fr-FR', { notation: 'compact', maximumFractionDigits: 1 }).format(x || 0);
    const item = (icon, val, label) => { const w = dEl('span', 's'); const i = dEl('span', 'ic'); i.textContent = icon; const b = dEl('b'); b.textContent = val; w.append(i, b, document.createTextNode(' ' + label)); return w; };
    band.append(
      item('group', cnum(s.users_count), 'membres'),
      item('swap_vert', cnum(s.peers_count), 'pairs actifs'),
      item('folder_open', cnum(s.seeded_count), 'en seed'),
      item('cloud_done', fmtSize(s.total_uploaded), 'partagés'),
    );
  }
  function renderDashCmd(band) {
    band.replaceChildren();
    const search = dEl('button', 't4x-cmdsearch'); search.type = 'button'; search.setAttribute('aria-label', 'Recherche universelle');
    const ic = dEl('span', 'ic'); ic.textContent = 'search'; const ph = dEl('span', 'ph', 'Rechercher torrents, membres, wiki…'); const kk = dEl('span', 'kk', (IS_MAC ? '⌘' : 'Ctrl') + ' K');
    search.append(ic, ph, kk); search.addEventListener('click', () => { try { openPalette(); } catch (e) {} });
    band.appendChild(search);
    const acts = dEl('div', 't4x-cmdacts');
    const pill = (icon, label, fn) => { const p = dEl('button', 't4x-pill'); p.type = 'button'; const i = dEl('span', 'ic'); i.textContent = icon; p.append(i, dEl('span', '', label)); p.addEventListener('click', fn); return p; };
    acts.append(pill('redeem', 'Demandes', () => openRequests('recent')), pill('upload', 'Uploader', () => navigate('/upload')));
    band.appendChild(acts);
  }
  // ---- carte « La communauté en chiffres » : consomme le hub unifié (1 seul fetch, cache serveur 30 s) ----
  const HUB_URL = 'https://t4hub.ekaii.fr';
  function hubGet(path) {
    return new Promise((res, rej) => {
      GM_xmlhttpRequest({ method: 'GET', url: HUB_URL + path, timeout: 12000,
        onload: (r) => { try { res(JSON.parse(r.responseText)); } catch (e) { rej(new Error('réponse invalide')); } },
        onerror: () => rej(new Error('injoignable')), ontimeout: () => rej(new Error('délai')) });
    });
  }
  async function renderDashHub(card) {
    const bd = card.querySelector('.bd'); const cached = dashCacheGet('hub');
    if (cached) paintHub(bd, cached); else bd.replaceChildren(dashSkel(5));
    if (dashCacheFresh('hub', 90000)) return;
    let o = null; try { o = await hubGet('/api/hub/overview'); } catch (e) {}
    if (o) { dashCacheSet('hub', o); paintHub(bd, o); }
    else if (!cached) bd.replaceChildren(dashError('t4hub injoignable.', () => renderDashHub(card)));
  }
  function paintHub(bd, o) {
    bd.replaceChildren();
    const t = (o.stats && o.stats.totals) || {};
    const grid = dEl('div', 't4x-hubgrid');
    [[t.messages, 'messages échangés'], [t.active_people, 'personnes actives'], [t.questions, 'questions posées'],
     [(o.voice && o.voice.total) || 0, 'en vocal maintenant']].forEach(([v, l]) => {
      const tile = dEl('div', 't4x-hubtile');
      tile.appendChild(dEl('div', 'v', v == null ? '—' : Number(v).toLocaleString('fr-FR')));
      tile.appendChild(dEl('div', 'l', l)); grid.appendChild(tile);
    });
    bd.appendChild(grid);
    const daily = (o.stats && o.stats.daily) || [];
    if (daily.length > 2) {
      const wrap = dEl('div', 't4x-hubspark');
      const W = 560, H = 46, vals = daily.map((d) => d.messages || 0), mx = Math.max.apply(null, vals.concat([1]));
      const pts = vals.map((v, i) => ((i / (vals.length - 1)) * W).toFixed(1) + ',' + (H - 3 - (v / mx) * (H - 8)).toFixed(1)).join(' ');
      wrap.innerHTML = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none"><polyline points="' + pts +
        '" fill="none" stroke="currentColor" stroke-width="2" opacity=".85"/><polygon points="0,' + H + ' ' + pts + ' ' + W + ',' + H + '" fill="currentColor" opacity=".07"/></svg>';
      bd.appendChild(wrap);
      bd.appendChild(dEl('div', 't4x-dmuted', 'messages par jour — 14 derniers jours · agrégats anonymes, personne n’est nommé'));
    }
    const foot = dEl('div', 't4x-hubfoot');
    const st = (o.health && o.health.overall) || 'unknown';
    foot.appendChild(dEl('span', 't4x-hubdot ' + st));
    foot.appendChild(dEl('span', '', (o.health && o.health.label) || 'état inconnu'));
    const a = dEl('a', 't4x-hublink', 'explorer t4hub →'); a.href = HUB_URL; a.target = '_blank'; a.rel = 'noopener';
    foot.appendChild(a); bd.appendChild(foot);
  }
  async function renderDashTrending(card) {
    const bd = card.querySelector('.bd'); const cached = dashCacheGet('trending');
    if (cached) paintTrending(bd, cached); else bd.replaceChildren(dashSkel(6));
    let arr = null; try { const r = await apiGet('/api/torrents?page=1', { ttl: 120000 }); arr = (r && r.torrents) || []; } catch (e) {}
    if (arr) { dashCacheSet('trending', arr); paintTrending(bd, arr); }
    else if (!cached) bd.replaceChildren(dashError('Torrents indisponibles.', () => renderDashTrending(card)));
  }
  function paintTrending(bd, list) {
    let arr = (list || []).slice().sort((a, b) => (Number(b.seeders) || 0) - (Number(a.seeders) || 0)).slice(0, 6);   // by activity — distinct from the chronological feed below
    bd.replaceChildren();
    if (!arr.length) { bd.appendChild(dEl('div', 't4x-dmuted', 'Rien à afficher.')); return; }
    arr.forEach((t) => {
      const row = dEl('a', 't4x-trow'); row.href = '/torrent/' + t.slug; row.title = t.name || '';
      row.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); navigate('/torrent/' + t.slug); });
      row.appendChild(dEl('div', 'tt', t.name || t.slug));
      const mt = dEl('div', 'mt'); mt.append(chipEl(fmtSize(Number(t.size_bytes) || 0), 'size'), chipEl('▲ ' + (Number(t.seeders) || 0), 'seed'));
      if (t.is_freeleech === true || t.is_freeleech === 'True' || t.is_freeleech === 'true') mt.appendChild(chipEl('FL', 'fl'));
      if (t.parent_cat_name) mt.appendChild(chipEl(t.parent_cat_name, 'cat'));
      row.appendChild(mt); bd.appendChild(row);
    });
  }
  async function renderDashBounties(card) {
    const bd = card.querySelector('.bd'); const cached = dashCacheGet('bounties');
    if (cached) paintBounties(bd, cached); else bd.replaceChildren(dashSkel(5));
    let arr = null; try { const r = await apiGet('/api/requests?limit=80', { ttl: 120000 }); arr = pickArray(r, ['requests', 'results', 'data', 'items']); } catch (e) {}
    if (arr) { const active = arr.filter((r) => (r.status || 'active') === 'active'); dashCacheSet('bounties', active); paintBounties(bd, active); }
    else if (!cached) bd.replaceChildren(dashError('Demandes indisponibles.', () => renderDashBounties(card)));
  }
  function paintBounties(bd, list) {
    const arr = (list || []).slice().sort((a, b) => reqBounty(b) - reqBounty(a)).slice(0, 5);
    bd.replaceChildren();
    if (!arr.length) { bd.appendChild(dEl('div', 't4x-dmuted', 'Aucune demande active.')); return; }
    const cnum = (x) => Intl.NumberFormat('fr-FR', { notation: 'compact', maximumFractionDigits: 1 }).format(x || 0);
    arr.forEach((r) => {
      const row = dEl('a', 't4x-brow'); const slug = (r.linked_torrent_slug && r.linked_torrent_slug !== 'None') ? r.linked_torrent_slug : null;
      const href = slug ? ('/torrent/' + slug) : '/communaute/demandes'; row.href = href; row.title = r.title || '';
      row.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); navigate(href); });
      const left = dEl('div'); left.appendChild(dEl('div', 'tt', r.title || '—'));
      const pc = dEl('div', 'pc'); const pi = dEl('span', 'ic'); pi.textContent = 'group'; pc.append(pi, document.createTextNode(String(Number(r.participant_count) || 0))); left.appendChild(pc);
      row.append(left, dEl('div', 'amt', cnum(reqBounty(r)))); bd.appendChild(row);
    });
  }
  let homeRoot = null, homeMount = null;
  function mountInlineChat(card) {
    try {
      if (!pref0('chat', true)) { card.querySelector('.bd').appendChild(dEl('div', 't4x-dmuted', 'Chat désactivé dans les réglages TR4KER+.')); return; }
      ensureOwnSocket();                                 // the send transport (openDrawer does this too); without it, receive works but you can't talk
      ensurePanes();
      if (!chat._sub) chat._sub = onFrame((f) => onChatFrame(f));
      chat.el.classList.add('t4x-inline'); chat.open = true; chat.inline = true;   // chat.inline keeps live frames flowing even if a closeDrawer path flips chat.open
      clearChatPush(); publishT4co();                    // inline = not docked: release the page + the T4CO gutter
      card.querySelector('.bd').appendChild(chat.el);    // relocate the REAL chat (no duplication)
      (async () => {
        if (!chat.channels) await loadChannels();
        try { const m = await me(); chat.myRole = m.role || 'user'; chat.myId = m.id; chat.myName = m.username; if (m.username) store.set('t4x.myname', m.username); } catch (e) {}
        try { refreshColsMode(); renderTabs(); renderComposer(); chat.panes.forEach((p) => loadPane(p)); refreshPillBadge(); } catch (e) {}
      })();
    } catch (e) { card.querySelector('.bd').appendChild(dEl('div', 't4x-dmuted', 'Chat indisponible.')); }
  }
  function ensureHomeDash() {
    if (!pref0('homeDash', true) || location.pathname !== '/') { teardownHomeDash(); return; }
    const main = document.querySelector('main');
    if (!main) return;
    if (homeMount && homeMount.isConnected && main.contains(homeMount)) { applyDashTheme(); hideRedundantHome(); return; }   // idempotent (+ keep theme & cleanup fresh)
    if (homeMount) teardownHomeDash();
    homeMount = dEl('section'); homeMount.id = 't4x-home'; homeMount.setAttribute('aria-label', 'Aperçu TR4KER+');
    homeRoot = homeMount.attachShadow({ mode: 'open' });
    homeRoot.appendChild(dEl('style')).textContent = chatCSS() + xtraCSSText() + DASH_CSS;
    const dash = dEl('div', 't4x-dash');
    const hero = dEl('div', 't4x-hero');                  // global site stats up top (logical hero line)
    const cmd = dEl('div', 't4x-cmd');                    // command bar: search + quick actions
    const meStrip = dEl('div', 't4x-me');                 // personal recent/live stats (complements the sidebar, no repeat)
    const row = dEl('div', 't4x-row');                    // equal-height row: chat (tall) · status · recap
    const chatCard = dashCard('t4x-chatcard', 'forum', 'Chat de la communauté');
    const statusCard = dashCard('t4x-statuscard', 'monitor_heart', 'État des services');
    const recapCard = dashCard('t4x-recapcard', 'event_note', 'Ce qui s’est passé');
    row.append(chatCard, statusCard, recapCard);
    const row2 = dEl('div', 't4x-row2');                  // trending · bounties
    const trendCard = dashCard('t4x-trendcard', 'trending_up', 'Tendances');
    const bountyCard = dashCard('t4x-bountycard', 'redeem', 'Demandes en cours');
    const hubCard = dashCard('t4x-hubcard', 'query_stats', 'La communauté en chiffres');
    row2.append(trendCard, bountyCard, hubCard);
    dash.append(hero, cmd, meStrip, row, row2); homeRoot.appendChild(dash);
    // mount INSIDE main (the site's scroll container) so the dashboard scrolls WITH the page content.
    main.insertBefore(homeMount, main.firstChild);
    applyDashTheme(); hideRedundantHome();
    mountInlineChat(chatCard);
    renderDashHero(hero); renderDashCmd(cmd); renderDashMe(meStrip);
    renderDashStatus(statusCard); renderDashRecap(recapCard);
    renderDashTrending(trendCard); renderDashBounties(bountyCard); renderDashHub(hubCard);
  }
  function teardownHomeDash() {
    if (!homeMount) return;
    try {
      if (chat.el && chat.el.classList.contains('t4x-inline')) {
        chat.el.classList.remove('t4x-inline'); chat.open = false; chat.inline = false;
        if (root) root.appendChild(chat.el);
        // the workspace is persistent: if it was open before the home page inlined it, reopen it
        if (pref0('chat', true) && chatLay().open) setTimeout(() => { try { openDrawer(); } catch (e) {} }, 80);
      }
    } catch (e) {}
    try { homeMount.remove(); } catch (e) {}
    homeMount = null; homeRoot = null;
  }

  // ==========================================================================
  // OFFLINE MODE — a full fallback interface when tr4ker itself is down (typically: the session
  // system dies and everyone looks logged out). Everything on it runs on OUR infra, so it stays
  // functional during an outage: live t4health status (services, latencies, sparkline, uptime),
  // the external community chat (t4health shoutbox, relocated inline like the home dashboard does),
  // and torrent search on our own Meilisearch index. Auto-opens when t4health says the site is not
  // 'up' AND /api/me fails; auto-closes (with a toast) when both recover. Manual entry point in the
  // launcher. Dismissing suppresses the auto-open until the next page load.
  // ==========================================================================
  const OFF_CSS = `
    .t4x-off{position:fixed;inset:0;z-index:2147483009;isolation:isolate;overflow-y:auto;background:color-mix(in oklab,var(--surface,#0b0d13) 92%,black);color:var(--dtext);font-family:'Geist Variable',system-ui,sans-serif}
    /* inline variant: lives IN the site's empty shell, under its native header (which stays usable) */
    .t4x-off.inline{position:static;inset:auto;z-index:auto;overflow:visible;background:transparent;min-height:60vh}
    .t4x-off.inline .wrap{padding-top:8px}
    .t4x-off .wrap{max-width:1240px;margin:0 auto;padding:26px 22px 60px;display:flex;flex-direction:column;gap:14px}
    .t4x-off .top{display:flex;align-items:center;gap:12px}
    .t4x-off .logo{font:800 15px/1 'JetBrains Mono',monospace;letter-spacing:.12em;color:var(--dp)}
    .t4x-off .ttl{font-size:20px;font-weight:700}
    .t4x-off .sub{color:var(--dmut);font-size:13px;line-height:1.5;max-width:70ch}
    .t4x-off .x{all:unset;cursor:pointer;margin-left:auto;width:34px;height:34px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--dmut);border:1px solid var(--dline)}
    .t4x-off .x:hover{color:var(--dtext);background:var(--dsurf2)}
    .t4x-off .x .ic{font-family:'Material Symbols Outlined';font-size:20px}
    .t4x-off .ochip{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;font:600 12px/1 'JetBrains Mono',monospace;background:color-mix(in oklab,var(--ddown) 14%,transparent);color:var(--ddown);border:1px solid color-mix(in oklab,var(--ddown) 35%,transparent)}
    .t4x-off .ochip.up{background:color-mix(in oklab,var(--dok) 14%,transparent);color:var(--dok);border-color:color-mix(in oklab,var(--dok) 35%,transparent)}
    .t4x-off .ochip.anon{background:color-mix(in oklab,var(--dwarn) 14%,transparent);color:var(--dwarn);border-color:color-mix(in oklab,var(--dwarn) 35%,transparent)}
    .t4x-off .cta{all:unset;cursor:pointer;font-size:13px;font-weight:700;padding:10px 20px;border-radius:12px;background:var(--dp);color:#0f1117}
    .t4x-off .cta:hover{filter:brightness(1.08)}
    .t4x-off .pstats{display:flex;flex-wrap:wrap;gap:6px 26px;align-items:center;padding:2px 4px;color:var(--dmut);font-size:12px}
    .t4x-off .pstats .s{display:inline-flex;align-items:center;gap:7px}
    .t4x-off .pstats b{color:var(--dtext);font:700 14px/1 'JetBrains Mono',monospace;font-variant-numeric:tabular-nums}
    .t4x-off .t4x-minecard .bd{padding:6px 15px 15px;display:flex;flex-direction:column;gap:13px;overflow-y:auto}
    .t4x-off .t4x-offsec-h{font:700 11px/1 'JetBrains Mono',monospace;letter-spacing:.06em;text-transform:uppercase;color:var(--dmut);margin:2px 0 7px}
    .t4x-off .t4x-minestats{display:flex;flex-wrap:wrap;gap:6px 22px}
    .t4x-off .t4x-minestats .s{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--dmut)}
    .t4x-off .t4x-minestats b{color:var(--dtext);font:700 15px/1 'JetBrains Mono',monospace;font-variant-numeric:tabular-nums}
    .t4x-off .t4x-offrow{display:flex;align-items:center;gap:10px;padding:4px 0;font-size:12.5px;border-top:1px solid var(--dline)}
    .t4x-off .t4x-offrow:first-of-type{border-top:none}
    .t4x-off .t4x-offrow .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dtext)}
    .t4x-off .t4x-offrow .bt{flex:0 0 auto;font:700 12px/1 'JetBrains Mono',monospace;color:var(--dp)}
    .t4x-off .grid{display:grid;grid-template-columns:minmax(320px,1fr) minmax(360px,1.1fr);gap:14px;align-items:stretch}
    .t4x-off .grid2{display:grid;grid-template-columns:minmax(360px,1.1fr) minmax(320px,1fr);gap:14px;align-items:stretch}
    @media(max-width:1080px){.t4x-off .grid,.t4x-off .grid2{grid-template-columns:1fr}}
    .t4x-off .t4x-card{min-height:300px;max-height:560px}
    .t4x-off .t4x-chatoff{height:520px}
    .t4x-off .aff{display:flex;align-items:center;gap:10px;padding:8px 15px 13px;flex:0 0 auto}
    .t4x-off .aff button{all:unset;cursor:pointer;font-size:12px;padding:7px 13px;border-radius:999px;border:1px solid color-mix(in oklab,var(--ddown) 45%,transparent);color:var(--ddown)}
    .t4x-off .aff button:hover{background:color-mix(in oklab,var(--ddown) 12%,transparent)}
    .t4x-off .aff .r{font-size:11px;color:var(--dmut)}
    .t4x-off .srch{display:flex;flex-direction:column;gap:8px;padding:4px 15px 13px;flex:1;min-height:0}
    .t4x-off .srch input{font:inherit;font-size:14px;padding:11px 14px;border-radius:12px;background:var(--dsurf2);color:var(--dtext);border:1px solid var(--dline);outline:none}
    .t4x-off .srch input:focus{border-color:var(--dp)}
    .t4x-off .srl{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:2px}
    .t4x-off .sr{display:flex;align-items:baseline;gap:10px;padding:7px 9px;border-radius:9px;text-decoration:none;color:var(--dtext)}
    .t4x-off .sr:hover{background:var(--dsurf2)}
    .t4x-off .sr .t{flex:1;min-width:0;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .t4x-off .sr .m{font:600 11px/1 'JetBrains Mono',monospace;color:var(--dmut);flex:0 0 auto}
    .t4x-off .sr .s{font:600 11px/1 'JetBrains Mono',monospace;color:var(--dok);flex:0 0 auto}
    .t4x-off .draw.t4x-inline .msgs{min-height:0}
    .t4x-off .stov{display:flex;align-items:center;gap:9px;padding:2px 15px 8px;font-size:14px;flex-wrap:wrap}
    .t4x-off .stov b{font-weight:700}
    .t4x-off .stov .rsn{color:var(--dmut);font-size:12px}
    .t4x-off .dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto}
    .t4x-off .dot.ok{background:var(--dok)} .t4x-off .dot.warn{background:var(--dwarn)} .t4x-off .dot.down{background:var(--ddown)}
    .t4x-off .stchart{display:block;width:calc(100% - 30px);height:auto;margin:0 15px;border-radius:10px;background:color-mix(in oklab,var(--dsurf2) 50%,transparent)}
    .t4x-off .stchart .grid{stroke:var(--dline);stroke-width:1}
    .t4x-off .stchart .lbl{fill:var(--dmut);font:9px 'JetBrains Mono',monospace}
    .t4x-off .stchart .downpt{fill:var(--ddown)}
    .t4x-off .stwrap{position:relative;margin:0 15px}
    .t4x-off .stwrap .stchart{width:100%;margin:0}
    .t4x-off .sttip{position:absolute;top:10px;pointer-events:none;background:color-mix(in oklab,var(--dsurf2) 96%,black);border:1px solid var(--dline);border-radius:10px;padding:8px 11px;font-size:11px;box-shadow:0 10px 30px rgba(0,0,0,.45);min-width:160px;z-index:3}
    .t4x-off .sttip .th{font:700 10px 'JetBrains Mono',monospace;color:var(--dmut);margin-bottom:5px;letter-spacing:.05em}
    .t4x-off .sttip .tr{display:flex;align-items:center;gap:7px;padding:1.5px 0;color:var(--dtext)}
    .t4x-off .sttip .tr .nm{flex:1;color:var(--dmut);white-space:nowrap}
    .t4x-off .sttip .tr b{font:700 11px 'JetBrains Mono',monospace}
    .t4x-off .sttip .tr b.down{color:var(--ddown)}
    .t4x-off .cdot{width:9px;height:3px;border-radius:2px;display:inline-block;flex:0 0 auto}
    .t4x-off .stchart .cross{stroke:var(--dmut);stroke-width:1;stroke-dasharray:3 3;opacity:.75}
    .t4x-off .strows{display:flex;flex-direction:column;gap:1px;padding:7px 15px 4px;flex:1;min-height:0;overflow-y:auto}
    .t4x-off .strow{display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:8px;font-size:13px}
    .t4x-off .strow:hover{background:var(--dsurf2)}
    .t4x-off .strow .nm{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .t4x-off .strow .ms{font:700 12px/1 'JetBrains Mono',monospace;color:var(--dtext);flex:0 0 auto}
    .t4x-off .strow .pc{font:10px/1 'JetBrains Mono',monospace;color:var(--dmut);flex:0 0 auto}
    .t4x-off .strow .upl{font:10px/1 'JetBrains Mono',monospace;color:var(--dmut);flex:0 0 auto;width:52px;text-align:right}
    .t4x-off .strow .ub{width:56px;height:4px;border-radius:99px;background:var(--dsurf2);overflow:hidden;flex:0 0 auto}
    .t4x-off .strow .ub .uf{display:block;height:100%;border-radius:99px;background:var(--dok)}
  `;
  let offMount = null, offRoot = null, offAuto = false, offMode = 'down', offlineDismissed = false, offStatusTimer = null, offKeepTimer = null;
  function offlineOpenNow() { return !!(offMount && offMount.isConnected); }
  function offOnAuthPage() { return /^\/(login|register|inscription|password|mot-de-passe|recover|reset|2fa)/.test(location.pathname); }
  // the SPA's own logged-out header (CONNEXION / link to /login) — an INSTANT, site-declared signal
  // that we're logged out; a slow logged-in load never renders it, so no grace delay is needed.
  function offLoggedOutHeader() {
    if (document.querySelector('header a[href^="/login"], a[href="/login"], a[href$="/login"]')) return true;   // login link, header or not (site may relabel/move it)
    return Array.from(document.querySelectorAll('header a, header button')).some((e) => /^(connexion|se connecter|login|sign in)$/i.test((e.textContent || '').trim()));
  }
  // the empty shell the site renders to anonymous visitors: the first child of #root, with no <main>
  function offShellHost() {
    if (document.querySelector('main')) return null;
    const r = document.getElementById('root');
    return (r && r.firstElementChild) || null;
  }
  function offRenderPublicStats(row) {
    GM_xmlhttpRequest({
      method: 'GET', url: HEALTH_URL + '/api/stats', timeout: 8000,
      onload: (r) => {
        try {
          const p = (JSON.parse(r.responseText) || {}).public; if (!p) return;
          row.replaceChildren();
          const add = (label, val) => { const s = dEl('span', 's'); const b = dEl('b', '', val); s.append(b, document.createTextNode(' ' + label)); row.appendChild(s); };
          if (p.users_count) add('membres', Math.round(p.users_count).toLocaleString('fr-FR'));
          if (p.peers_count) add('pairs', Math.round(p.peers_count).toLocaleString('fr-FR'));
          if (p.torrents_count) add('torrents', Math.round(p.torrents_count).toLocaleString('fr-FR'));
          if (p.seeded_count) add('en seed', Math.round(p.seeded_count).toLocaleString('fr-FR'));
          if (p.total_uploaded) add('partagés', fmtSize(p.total_uploaded));
        } catch (e) {}
      }, onerror: () => {}, ontimeout: () => {},
    });
  }
  // MoE Fix 6: personal + community data from the SWR cache (serve-stale) so the rescue dashboard shows the
  // user's OWN last-known ratio/upload, the top open bounties, and recent torrents even while the site is down.
  async function offRenderMine(card) {
    const bd = card.querySelector('.bd'); if (!bd) return; bd.replaceChildren();
    const section = (title) => { const s = dEl('div', 't4x-offsec'); s.appendChild(dEl('div', 't4x-offsec-h', title)); bd.appendChild(s); return s; };
    try {
      const m = await apiGet('/api/me', { ttl: 30000 });
      if (m && (m.uploaded != null || m.username)) {
        const s = section('Mon compte — dernières données'); const stats = dEl('div', 't4x-minestats');
        const add = (label, val) => { const it = dEl('span', 's'); it.append(dEl('b', '', val), document.createTextNode(' ' + label)); stats.appendChild(it); };
        const up = Number(m.uploaded) || 0, dn = Number(m.downloaded) || 0; const ratio = dn > 0 ? up / dn : (up > 0 ? Infinity : 0);
        add('ratio', ratio === Infinity ? '∞' : ratio.toFixed(2));
        if (m.uploaded != null) add('envoyé', fmtSize(up));
        const credits = m.money != null ? m.money : (m.bonus_upload != null ? m.bonus_upload : null);
        if (credits != null) add('crédits', Math.round(credits).toLocaleString('fr-FR'));
        s.appendChild(stats);
      }
    } catch (e) {}
    try {
      const j = await apiGet('/api/requests?limit=80', { ttl: 60000 });
      const arr = pickArray(j, ['requests', 'items', 'data']).slice().sort((a, b) => reqBounty(b) - reqBounty(a)).slice(0, 5);
      if (arr.length) { const s = section('Primes ouvertes'); arr.forEach((r) => { const row = dEl('div', 't4x-offrow'); const bt = reqBounty(r); row.append(dEl('span', 'nm', r.title || '(sans titre)'), dEl('span', 'bt', bt ? Intl.NumberFormat('fr-FR', { notation: 'compact' }).format(bt) : '')); s.appendChild(row); }); }
    } catch (e) {}
    try {
      const j = await apiGet('/api/torrents?page=1', { ttl: 60000 });
      const arr = pickArray(j, ['torrents', 'items', 'data']).slice(0, 5);
      if (arr.length) { const s = section('Torrents récents'); arr.forEach((t) => { const row = dEl('div', 't4x-offrow'); row.appendChild(dEl('span', 'nm', t.name || t.title || '(sans nom)')); s.appendChild(row); }); }
    } catch (e) {}
    if (!bd.children.length) bd.appendChild(dEl('div', 't4x-dmuted', 'Pas encore de données en cache — reviens une fois connecté pour les mémoriser.'));
  }
  try { if (window.__T4X_EXPOSE__) { window.__t4xOff = { open: openOffline, close: closeOffline, renderMine: offRenderMine, card: dashCard }; } } catch (e) {}
  function offMountChat(card) {
    const bd = card.querySelector('.bd');
    if (!pref0('chat', true) || !pref0('extChat', true)) { bd.appendChild(dEl('div', 't4x-dmuted', 'Chat externe désactivé dans les réglages TR4KER+.')); return; }
    try {
      try { teardownHomeDash(); } catch (e) {}
      ensureOwnSocket();
      ensurePanes();
      if (!chat._sub) chat._sub = onFrame((f) => onChatFrame(f));
      chat.el.classList.add('t4x-inline'); chat.open = true; chat.inline = true;
      clearChatPush(); publishT4co();
      bd.appendChild(chat.el);
      (async () => {
        if (!chat.channels) await loadChannels(); else ensureExtChannel(chat.channels);
        addPane(EXT_CONV, true);
        refreshColsMode(); renderTabs(); renderComposer(); chat.panes.forEach((q) => loadPane(q));
      })();
    } catch (e) { bd.appendChild(dEl('div', 't4x-dmuted', 'Chat indisponible.')); }
  }
  function offSearchCard() {
    const card = dashCard('t4x-searchcard', 'search', 'Recherche TR4KER+ (notre index, indépendant du site)');
    const bd = card.querySelector('.bd'); bd.classList.add('srch');
    const input = document.createElement('input'); input.type = 'search'; input.placeholder = 'Rechercher un torrent (345k titres, tolérant aux fautes)…';
    const note = dEl('div', 't4x-dmuted', 'Les fiches s’ouvriront quand le site sera de retour.');
    const list = dEl('div', 'srl');
    bd.append(input, note, list);
    let t = null, seq = 0;
    input.addEventListener('input', () => {
      clearTimeout(t);
      const q = input.value.trim();
      if (q.length < 2) { list.replaceChildren(); return; }
      t = setTimeout(async () => {
        const my = ++seq;
        let hits = await meiliSearch(q, { limit: 14 });
        if (my !== seq) return;
        let fromPack = false;
        if (!Array.isArray(hits) || !hits.length) {   // Meili down / empty → offline hot-pack (zero network)
          const ph = await packSearch(q, 14);
          if (my !== seq) return;
          if (ph.length) { hits = ph.map((h) => ({ title: h.name, size_bytes: h.size_bytes, seeders: h.seeders, _magnet: packMagnet(h) })); fromPack = true; }
        }
        list.replaceChildren();
        if (!Array.isArray(hits)) { list.appendChild(dEl('div', 't4x-dmuted', 'Recherche indisponible.')); return; }
        if (!hits.length) { list.appendChild(dEl('div', 't4x-dmuted', 'Aucun résultat.')); return; }
        if (fromPack) list.appendChild(dEl('div', 't4x-dmuted', 'Index local hors-ligne — liens magnet directs.'));
        for (const h of hits) {
          const a = dEl('a', 'sr'); a.href = h._magnet || (h.slug ? '/torrent/' + h.slug : '#');
          a.appendChild(dEl('span', 't', (h.title || '?') + (h.year ? ' (' + h.year + ')' : '')));
          if (h.size_bytes != null) a.appendChild(dEl('span', 'm', fmtSize(h.size_bytes)));
          if (h.seeders != null) a.appendChild(dEl('span', 's', h.seeders + '↑'));
          list.appendChild(a);
        }
      }, 260);
    });
    return card;
  }
  // t4health-style LIVE status: multi-series latency curves (real colors, real points), precise per-
  // service ping + p50/p95 + 24 h uptime. Refreshed every 15 s while the fallback is on screen.
  function offFmtHm(t) { try { const d = new Date(t); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); } catch (e) { return ''; } }
  // annex probes (torznab / our own search / SSL expiry) are noise when the question is "is the SITE down"
  const OFF_STATUS_HIDE = new Set(['torznab', 'search', 'cert']);
  async function offRenderStatus(card) {
    const bd = card.querySelector('.bd');
    try { await refreshHealth(false); } catch (e) {}
    const d = health.data;
    let hist = null;
    try { const h = await gifFetchJson(HEALTH_URL + '/api/history'); hist = h.body; } catch (e) {}
    if (!d) { bd.replaceChildren(dashError('Statut indisponible.', () => offRenderStatus(card))); return; }
    const frag = document.createDocumentFragment();
    const ov = dEl('div', 'stov');
    ov.append(dEl('span', 'dot ' + (d.overall === 'up' ? 'ok' : d.overall === 'down' ? 'down' : 'warn')),
              dEl('b', '', d.overall_label || d.overall || '?'));
    if (d.reason) ov.appendChild(dEl('span', 'rsn', d.reason));
    frag.appendChild(ov);
    const seriesColor = {};
    const series = (hist && hist.series)
      ? Object.entries(hist.series).filter(([k, sr]) => !OFF_STATUS_HIDE.has(k) && sr && sr.chart !== false && Array.isArray(sr.points) && sr.points.length > 1)
      : [];
    for (const [k, sr] of series) seriesColor[k] = sr.color || '#93a5ff';
    if (series.length) {
      const W = 640, H = 170, PX = 36, PT = 10, PB = 18;
      let tmin = Infinity, tmax = -Infinity, lmax = 0;
      for (const [, sr] of series) for (const pt of sr.points) { if (pt.t < tmin) tmin = pt.t; if (pt.t > tmax) tmax = pt.t; if (pt.lat != null && pt.lat > lmax) lmax = pt.lat; }
      lmax = Math.max(50, Math.ceil((lmax * 1.15) / 50) * 50);
      const X = (t) => PX + ((t - tmin) / Math.max(1, tmax - tmin)) * (W - PX - 8);
      const Y = (l) => PT + (1 - Math.min(l, lmax) / lmax) * (H - PT - PB);
      const NS = 'http://www.w3.org/2000/svg';
      const svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H); svg.setAttribute('class', 'stchart');
      [0, 0.5, 1].forEach((f) => {
        const y = Y(lmax * f);
        const ln = document.createElementNS(NS, 'line'); ln.setAttribute('x1', PX); ln.setAttribute('x2', W - 8); ln.setAttribute('y1', y); ln.setAttribute('y2', y); ln.setAttribute('class', 'grid'); svg.appendChild(ln);
        const tx = document.createElementNS(NS, 'text'); tx.setAttribute('x', PX - 5); tx.setAttribute('y', y + 3); tx.setAttribute('class', 'lbl'); tx.setAttribute('text-anchor', 'end'); tx.textContent = Math.round(lmax * f); svg.appendChild(tx);
      });
      [[tmin, PX, 'start'], [tmax, W - 8, 'end']].forEach(([t, x, a]) => {
        const tx = document.createElementNS(NS, 'text'); tx.setAttribute('x', x); tx.setAttribute('y', H - 4); tx.setAttribute('class', 'lbl'); tx.setAttribute('text-anchor', a); tx.textContent = offFmtHm(t); svg.appendChild(tx);
      });
      for (const [, sr] of series) {
        const pts = sr.points.filter((pt) => pt.lat != null);
        if (pts.length > 1) {
          const path = document.createElementNS(NS, 'path');
          path.setAttribute('d', monoCubicPath(pts.map((pt) => X(pt.t)), pts.map((pt) => Y(pt.lat))));
          path.setAttribute('fill', 'none'); path.setAttribute('stroke', sr.color || '#93a5ff');
          path.setAttribute('stroke-width', '1.6'); path.setAttribute('opacity', '.95');
          svg.appendChild(path);
        }
        for (const pt of sr.points) if (pt.state && pt.state !== 'up') {
          const c = document.createElementNS(NS, 'circle'); c.setAttribute('cx', X(pt.t)); c.setAttribute('cy', PT + 2); c.setAttribute('r', 2.4); c.setAttribute('class', 'downpt'); svg.appendChild(c);
        }
      }
      // hover: crosshair + per-service values at the hovered instant
      const cross = document.createElementNS(NS, 'line');
      cross.setAttribute('class', 'cross'); cross.setAttribute('y1', PT); cross.setAttribute('y2', H - PB); cross.style.display = 'none';
      svg.appendChild(cross);
      const wrap = dEl('div', 'stwrap');
      const tip = dEl('div', 'sttip'); tip.hidden = true;
      wrap.append(svg, tip);
      const nearest = (pts, t) => { let best = null, bd2 = Infinity; for (const pt of pts) { const dd = Math.abs(pt.t - t); if (dd < bd2) { bd2 = dd; best = pt; } } return best; };
      // Hoist the two getBoundingClientRect reads out of the per-pixel pointermove: measure once on enter, and
      // skip rebuilding the per-service tip rows when the pointer is still snapped to the same instant.
      let _box = null, _wb = null, _lastSnapT = null, _mAt = 0;
      const measure = () => { _box = svg.getBoundingClientRect(); _wb = wrap.getBoundingClientRect(); };
      svg.addEventListener('pointerenter', (e) => { measure(); _mAt = e.timeStamp; });
      svg.addEventListener('pointermove', (e) => {
        // v0.53.1: re-measure at most every 150ms so a scroll/zoom/layout-shift while the pointer stays inside
        // the svg cannot leave the crosshair snapping to a stale x. Per-pixel moves still reuse the cached rect.
        if (!_box || !_box.width || e.timeStamp - _mAt > 150) { measure(); _mAt = e.timeStamp; if (!_box.width) return; }
        const fx = (e.clientX - _box.left) * (W / _box.width);
        const t = tmin + Math.max(0, Math.min(1, (fx - PX) / (W - PX - 8))) * (tmax - tmin);
        const snap = nearest(series[0][1].points, t); if (!snap) return;
        if (snap.t !== _lastSnapT) {
          _lastSnapT = snap.t;
          cross.style.display = ''; cross.setAttribute('x1', X(snap.t)); cross.setAttribute('x2', X(snap.t));
          tip.replaceChildren(dEl('div', 'th', offFmtHm(snap.t)));
          for (const [k, sr] of series) {
            const pt = nearest(sr.points, snap.t); if (!pt) continue;
            const svc = (d.services || {})[k] || {};
            const tr = dEl('div', 'tr');
            const cd = dEl('span', 'cdot'); cd.style.background = sr.color || '#93a5ff';
            const val = dEl('b', pt.state && pt.state !== 'up' ? 'down' : '', pt.state && pt.state !== 'up' ? 'down' : (pt.lat != null ? pt.lat + ' ms' : '—'));
            tr.append(cd, dEl('span', 'nm', svc.label || k), val);
            tip.appendChild(tr);
          }
          tip.hidden = false;
        }
        const wb = _wb;   // set by measure() in the guard above (refreshed on the same 150ms cadence as _box)
        const cx = e.clientX - wb.left;
        if (cx > wb.width * 0.55) { tip.style.left = 'auto'; tip.style.right = Math.round(wb.width - cx + 14) + 'px'; }
        else { tip.style.right = 'auto'; tip.style.left = Math.round(cx + 14) + 'px'; }
      });
      svg.addEventListener('pointerleave', () => { tip.hidden = true; cross.style.display = 'none'; _lastSnapT = null; });
      frag.appendChild(wrap);
    }
    // per-service rows: the leading dot wears the SERIES COLOR (the legend, folded into the rows);
    // it only switches to amber/red when the service is degraded or down.
    const rows = dEl('div', 'strows');
    for (const k in (d.services || {})) {
      if (OFF_STATUS_HIDE.has(k)) continue;
      const sv = d.services[k] || {};
      const row = dEl('div', 'strow'); row.title = sv.detail || '';
      const dot = dEl('span', 'dot');
      if (sv.state === 'up' && seriesColor[k]) dot.style.background = seriesColor[k];
      else dot.classList.add(sv.state === 'up' ? 'ok' : sv.state === 'down' ? 'down' : 'warn');
      row.appendChild(dot);
      row.appendChild(dEl('span', 'nm', sv.label || k));
      const pct = sv.pct || {};
      if (pct.p50 != null) row.appendChild(dEl('span', 'pc', 'p50 ' + pct.p50 + ' · p95 ' + pct.p95));
      row.appendChild(dEl('span', 'ms', sv.latency_ms != null ? sv.latency_ms + ' ms' : '—'));
      const up = Number(sv.uptime_24h);
      if (!isNaN(up)) {
        row.appendChild(dEl('span', 'upl', (up >= 99.995 ? '100' : up.toFixed(2)) + ' %'));
        const ub = dEl('span', 'ub'); const uf = dEl('span', 'uf');
        uf.style.width = Math.max(2, Math.min(100, (up - 99) * 100)) + '%';   // 99-100 % remapped to full scale
        if (up < 99) uf.style.background = 'var(--ddown)';
        ub.appendChild(uf); row.appendChild(ub);
      }
      rows.appendChild(row);
    }
    frag.appendChild(rows);
    bd.replaceChildren(frag);
  }
  function offAffectedRow(card) {
    const row = dEl('div', 'aff');
    const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Je suis affecté aussi';
    const r = dEl('span', 'r', 'Signale la panne au compteur communautaire (t4health).');
    b.addEventListener('click', () => {
      b.disabled = true;
      GM_xmlhttpRequest({ method: 'POST', url: HEALTH_URL + '/api/affected', timeout: 8000,
        onload: (resp) => { let d = null; try { d = JSON.parse(resp.responseText); } catch (e) {} r.textContent = d && d.ok ? 'Merci — panne signalée.' : 'Déjà signalé récemment.'; },
        onerror: () => { r.textContent = 'Signalement impossible (réseau).'; b.disabled = false; } });
    });
    row.append(b, r); card.appendChild(row);
  }
  function openOffline(auto, mode) {
    if (offlineOpenNow()) return;
    ensureHost();
    offAuto = !!auto; offMode = mode || 'down';
    offMount = dEl('div'); offMount.id = 't4x-offmount';
    try { offMount.setAttribute('data-dtheme', dashDetectTheme()); } catch (e) {}   // re-stepped light accents (v0.25 contrast fix)
    offRoot = offMount.attachShadow({ mode: 'open' });
    offRoot.appendChild(dEl('style')).textContent = chatCSS() + xtraCSSText() + DASH_CSS + OFF_CSS;
    const off = dEl('div', 't4x-off'); off.setAttribute('role', 'dialog'); off.setAttribute('aria-label', 'Mode hors-ligne TR4KER+');
    const wrap = dEl('div', 'wrap');
    const top = dEl('div', 'top');
    const chip = dEl('span', 'ochip' + (offMode === 'anon' ? ' anon' : ''));
    chip.textContent = offMode === 'anon' ? 'Non connecté' : 'TR4KER indisponible';
    const cta = document.createElement('button'); cta.className = 'cta'; cta.textContent = 'Se connecter';
    cta.addEventListener('click', () => { offlineDismissed = true; closeOffline(); try { navigate('/login'); } catch (e) { location.assign('/login'); } });
    const x = document.createElement('button'); x.className = 'x'; x.title = 'Fermer'; x.innerHTML = '<span class="ic">close</span>';
    x.addEventListener('click', () => { offlineDismissed = true; closeOffline(); });
    top.append(dEl('span', 'logo', 'TR4KER+'), dEl('span', 'ttl', offMode === 'anon' ? 'En attendant la connexion' : 'Mode hors-ligne'), chip, cta, x);
    const sub = dEl('div', 'sub', offMode === 'anon'
      ? 'Le site n’affiche rien tant que tu n’es pas connecté (et quand ses sessions tombent, tout le monde atterrit exactement ici). En attendant, tout ce qui suit tourne sur l’infrastructure communautaire TR4KER+ et reste fonctionnel : état des services en direct, chat de secours, recherche sur notre index.'
      : 'Le site ne répond pas (les sessions sautent, tout le monde apparaît déconnecté). Tout ce qui suit tourne sur l’infrastructure communautaire TR4KER+ et reste fonctionnel : état des services en direct, chat de secours, recherche sur notre index. Cette page se fermera d’elle-même au retour du site.');
    const pstats = dEl('div', 'pstats'); offRenderPublicStats(pstats);
    const mineCard = dashCard('t4x-minecard', 'insights', 'Mes stats & la communauté (dernières données connues)');
    const grid = dEl('div', 'grid');
    const statusCard = dashCard('t4x-statuscard', 'monitor_heart', 'État des services (en direct)');
    offAffectedRow(statusCard);
    const chatCard = dashCard('t4x-chatoff', 'forum', 'Chat de secours — hébergé hors du site');
    grid.append(statusCard, chatCard);
    const grid2 = dEl('div', 'grid2');
    const searchCard = offSearchCard();
    const recapCard = dashCard('t4x-recapcard', 'event_note', 'Ce qui s’est passé');
    grid2.append(searchCard, recapCard);
    wrap.append(top, sub, pstats, mineCard, grid, grid2); off.appendChild(wrap); offRoot.appendChild(off);
    // placement: INLINE in the site's empty shell when there is one (its header stays visible and
    // usable above us); fixed overlay only when the page still has content (outage mid-browsing).
    const shell = offShellHost();
    if (shell) { off.classList.add('inline'); shell.appendChild(offMount); }
    else { root.appendChild(offMount); markOpen(off, true); }
    off._chip = chip;
    offRenderStatus(statusCard); renderDashRecap(recapCard); offMountChat(chatCard); offRenderMine(mineCard);
    if (offStatusTimer) clearInterval(offStatusTimer);
    offStatusTimer = setInterval(() => { try { if (!document.hidden && offlineOpenNow()) { offRenderStatus(statusCard); offMount.setAttribute('data-dtheme', dashDetectTheme()); } } catch (e) {} }, 15000);   // no re-render while backgrounded; visibilitychange catches up
    // keepalive: React may reconcile the shell and drop our INLINE node — re-seat the same node
    if (offKeepTimer) clearInterval(offKeepTimer);
    offKeepTimer = setInterval(() => {
      try {
        if (!offMount) return;
        if (offMount.isConnected) return;
        if (offOnAuthPage()) { closeOffline(); return; }
        const s = offShellHost();
        if (s && off.classList.contains('inline')) s.appendChild(offMount);
        else if (!s && !off.classList.contains('inline')) root.appendChild(offMount);
        else closeOffline();   // page shape changed (content appeared / shell gone) — stand down
      } catch (e) {}
    }, 4000);
  }
  function closeOffline() {
    if (offStatusTimer) { clearInterval(offStatusTimer); offStatusTimer = null; }
    if (offKeepTimer) { clearInterval(offKeepTimer); offKeepTimer = null; }
    try {
      if (chat.el && chat.el.classList.contains('t4x-inline') && offMount && offMount.shadowRoot && offMount.shadowRoot.contains(chat.el)) {
        chat.el.classList.remove('t4x-inline'); chat.open = false; chat.inline = false;
        if (root) root.appendChild(chat.el);
        if (pref0('chat', true) && chatLay().open) setTimeout(() => { try { openDrawer(); } catch (e) {} }, 80);
      }
    } catch (e) {}
    if (offMount) { try { offMount.remove(); } catch (e) {} }
    offMount = null; offRoot = null; offAuto = false;
  }
  // /api/me tri-state: 'ok' (session), 'unauth' (the API SAID 4xx — a definitive logged-out signal,
  // fast even on a cold cache since it's a tiny request), 'error' (network/timeout — inconclusive).
  async function meProbe() {
    // RAW fetch — apiGet's serve-stale turns a 401 / network-down into a cached 'ok', which kept the
    // rescue mode from ever opening (and self-closed the anon rescue on a false "back up").
    const ctl = new AbortController(); const to = setTimeout(() => ctl.abort(), 6000);
    try {
      const r = await fetch('/api/me', { credentials: 'include', headers: { accept: 'application/json' }, signal: ctl.signal });
      if (r.ok) { _apiDownUntil = 0; try { cache.set('/api/me', { t: Date.now(), v: await r.json() }); persistApiCache(); } catch (e) {} return 'ok'; }
      return r.status >= 400 && r.status < 500 ? 'unauth' : 'error';
    } catch (e) { return 'error'; }
    finally { clearTimeout(to); }
  }
  function offlineSyncCheck() {
    // DOM-only fast path — the anonymous shell (logged-out header + no content) is site-declared,
    // so the fallback appears the instant the SPA paints its header. Fed by timer ticks AND a
    // MutationObserver (a cold-cache SPA bundle can take 30 s+ to paint on a slow connection).
    if (!pref0('offline', true) || offlineOpenNow() || offlineDismissed || offOnAuthPage()) return;
    if (offShellHost() && offLoggedOutHeader()) openOffline(true, 'anon');
  }
  let _offObs = null;
  function offObserve() {
    if (_offObs) return;
    try {
      let t = null;
      _offObs = new MutationObserver(() => {
        if (t) return;
        t = setTimeout(() => {
          t = null;
          offlineSyncCheck();
          // logged-in content mounted → this page will never be the shell; stop observing
          if (document.querySelector('main') && !offLoggedOutHeader()) { try { _offObs.disconnect(); } catch (e) {} _offObs = null; }
        }, 250);
      });
      _offObs.observe(document.documentElement, { childList: true, subtree: true });
    } catch (e) {}
  }
  async function offlineCheck() {
    if (!pref0('offline', true)) return;
    offlineSyncCheck();
    if (performance.now() < 1200) return;   // the very first ticks stay DOM-only
    // async path: a 401 on /api/me is a definitive logged-out signal even while the SPA bundle is
    // still downloading — no need to wait for the header on slow connections / cold caches.
    const me = await meProbe();
    if (!offlineOpenNow() && !offlineDismissed && !offOnAuthPage() && me === 'unauth' && !document.querySelector('main')) {
      openOffline(true, 'anon');
    }
    try { await refreshHealth(false); } catch (e) {}
    const overall = health.data && health.data.overall;
    if (offlineOpenNow()) {
      const chip = offRoot && offRoot.querySelector('.ochip');
      if (chip && offMode !== 'anon') { chip.classList.toggle('up', overall === 'up'); chip.textContent = overall === 'up' ? 'TR4KER répond de nouveau' : 'TR4KER indisponible'; }
      if (offAuto && me === 'ok' && (overall === 'up' || offMode === 'anon')) { closeOffline(); toast('TR4KER est de retour — recharge la page pour te reconnecter.', 9000); }
      if (offOnAuthPage()) closeOffline();   // never cover the login/register forms
      return;
    }
    if (offlineDismissed || offOnAuthPage()) return;
    if (overall && overall !== 'up' && me !== 'ok') openOffline(true, 'down');
  }

  function menu() {
    try {
      GM_registerMenuCommand('Ouvrir la palette (Ctrl/⌘+K)', openPalette);
      GM_registerMenuCommand('Débloquer l’interface — fermer tous les panneaux (Ctrl/⌘+Maj+X)', () => closeAllOverlays());
      GM_registerMenuCommand('Vérifier les mises à jour de TR4KER+', () => checkUpdate(true));
      GM_registerMenuCommand('Réglages TR4KER+', openSettings);
      GM_registerMenuCommand('Réinitialiser la position de la pastille', resetPillPos);
      GM_registerMenuCommand('Personnaliser la barre de navigation', openNavEditor);
      GM_registerMenuCommand('Chat : mots-clés à surveiller', () => {
        const cur = pref0('chatKeywords', '');
        const v = prompt('Mots-clés séparés par des virgules — une alerte s’affiche quand ils apparaissent dans le chat (ton pseudo est déjà surveillé) :', cur);
        if (v !== null) { setPref('chatKeywords', v.trim()); toast('Surveillance du chat mise à jour'); }
      });
      GM_registerMenuCommand(P.enabled ? 'Désactiver TR4KER+' : 'Activer TR4KER+', () => {
        P = setPref('enabled', !P.enabled); toast('TR4KER+ ' + (P.enabled ? 'activé' : 'désactivé') + ' — rechargez la page'); });
    } catch (e) {}
  }

  function boot() {
    if (SAFE_BOOT) { log('safe boot — features off'); whenHtml(menu); return; }
    if (!P.enabled) { log('disabled'); whenHtml(menu); return; }
    patchWS();      // must precede the SPA creating its socket (we run at document-start)
    patchHistory(); // no DOM dependency
    whenHtml(() => {
      applyTheme();
      applySiteTheme();
      try { document.documentElement.classList.toggle('t4x-emoj-on', pref0('siteEmojis', true)); } catch (e) {}   // inline sizing for the site's own emotes
      // the site sets data-theme during its React init (after us) → re-apply our saved choice a few times
      [200, 700, 1600].forEach((d) => setTimeout(applySiteTheme, d));
      ensureHost();
      watchHost();
      try { wireNativeVignette(); } catch (e) {}   // vignette on the site's own result rows (not just our palette)
      document.addEventListener('keydown', hotkeys, true);
      // Safety net against invisible-but-clickable overlays (see OVERLAY INERTNESS). Cheap: a handful
      // of shadow-root children, and each pass only writes when the state actually changed.
      document.addEventListener('pointerdown', () => setTimeout(inertSweep, 0), true);   // perf: defer the style-flush off the click critical path (protects the NEXT click)
      setInterval(() => { if (!document.hidden) inertSweep(); }, 1500);
      window.addEventListener('t4x:locationchange', () => setTimeout(() => { updateTabTitle(_lastUnread); applySiteTheme(); }, 500)); // re-apply badge + theme after SPA sets title
      window.addEventListener('t4x:locationchange', () => scheduleSweep());
      // Subscribe to chat frames globally so @mention/keyword alerts + tab-title unread fire WITHOUT first opening
      // the drawer. We rely on the SPA's teed socket (no eager 2nd connection); warm identity for mention matching.
      if (pref0('chat', true) && !chat._sub) { chat._sub = onFrame((f) => onChatFrame(f)); ensureIdentity(); loadChannels(); }
      if (!chat._dmSub) { chat._dmSub = onFrame((f) => onDmFrame(f)); if (pref0('dmCard', true)) { try { loadChannels(); } catch (e) {} } }   // DM card needs the real channel list to tell DMs from public messages
      // Signaux: backfill the inbox from channel history once, ~5 s after boot (silent — no beeps)
      if (pref0('inbox', true)) {
        setTimeout(() => { try { ensureIdentity(); Promise.resolve(loadChannels()).then(() => { try { sweepChannelHistory({}); } catch (e) {} }); } catch (e) {} }, 5000);
        window.addEventListener('pagehide', inboxFlush);
        document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') inboxFlush(); });
        setTimeout(refreshSigBadge, 800);
      }
      if (pref0('hud', true)) { try { buildPill(); } catch (e) { log('pill err', e); } }
      // update check: once shortly after boot (cached 6 h), then every 6 h on long-lived tabs
      setTimeout(() => checkUpdate(false), 6000);
      setInterval(() => checkUpdate(false), UPDATE_EVERY);
      // service status: only the cheap banner poll on a timer; the full probe runs when the
      // member actually opens the panel, so a background tab never hammers the backends.
      if (pref0('statusBanner', true)) {
        setTimeout(() => refreshHealth(false), 4000);
        setInterval(() => { if (!document.hidden) refreshHealth(false); }, 120000);
      }
      document.addEventListener('keydown', torrentHotkeys, true);
      wireLightboxNative();   // native /communication images open in the in-page viewer (pref-gated, defers to other scripts)
      try { loadImgDomains(); } catch (e) {}   // image-domain allowlist for the IP-beacon guard in appendImage
      // one-shot: adopt the slimmer default nav for anyone still on the original all-visible default
      // (real customisations — a non-empty hidden set or a reordered list — are preserved).
      try {
        if (!store.get('t4x.mig.nav-default-v2', false)) {
          const cur = (store.get('t4x.prefs', {}) || {}).navConfig;
          const wasOldDefault = !cur || ((cur.hidden || []).length === 0 && JSON.stringify(cur.order || []) === JSON.stringify(NAV_ORDER));
          if (wasOldDefault) P = setPref('navConfig', navDefault());
          store.set('t4x.mig.nav-default-v2', true);
        }
      } catch (e) {}
      if (pref0('siteNav', true) || pref0('siteComposer', true) || pref0('homeDash', true) || pref0('commSide', true) || pref0('chanSplit', true)) { try { initSiteAugment(); } catch (e) { log('augment err', e); } }
      // the chat workspace survives page changes: if it was open, reopen it with the same columns/mode
      if (pref0('chat', true) && chatLay().open) setTimeout(() => { try { openDrawer(); } catch (e) {} }, 900);
      try { initBus(); } catch (e) { log('bus err', e); }   // T4CO phase 2 (hello only fires if pref 'bus' is on)
      // offline fallback: outage (t4health not-up AND /api/me fails) OR the logged-out empty shell.
      // The MutationObserver reacts the instant the SPA paints (even 30 s later on a cold cache);
      // the 1.5 s tick resolves logged-out via a fast 401 probe without waiting for the bundle.
      if (pref0('offline', true)) {
        offObserve();
        offlineSyncCheck();
        [500, 1500, 4000, 10000].forEach((d) => setTimeout(() => { try { offlineCheck(); } catch (e) {} }, d));
        setInterval(() => { if (document.hidden) return; try { offlineCheck(); } catch (e) {} }, 45000);
        window.addEventListener('t4x:locationchange', () => setTimeout(() => { try { offlineCheck(); } catch (e) {} }, 600));
        setTimeout(() => { try { warmPack(); } catch (e) {} }, 25000);   // pre-cache the offline catalogue pack to disk
        setTimeout(() => { try { apiGet('/api/public/stats', { ttl: 300000 }).catch(() => {}); apiGet('/api/torrents?page=1', { ttl: 120000 }).catch(() => {}); apiGet('/api/requests?limit=80', { ttl: 120000 }).catch(() => {}); } catch (e) {} }, 12000);   // warm the main-view endpoints onto disk for offline
      }
      menu();
      try { GM_addValueChangeListener('t4x.prefs', () => { P = prefs(); applyTheme(); try { applyPillPos(); } catch (e) {} }); } catch (e) {}   // multi-tab: reflect pill mode/pos changes from another tab
      try { GM_addValueChangeListener('t4x.users', () => { _usersC = null; }); GM_addValueChangeListener('t4x.chatHighlights', () => { _hlC = null; }); GM_addValueChangeListener('t4x.emofrec', () => { _emoC = null; _rxFavC = null; }); } catch (e) {}   // multi-tab: drop memoized maps when another tab writes
      // Greasemonkey 4 (promisified GM.*) lacks GM_xmlhttpRequest → all cross-origin dies silently.
      // We boot on the localStorage mirror, but Meili/GIF/upload/t4health won't work. Say so, once.
      try { if (typeof GM_xmlhttpRequest === 'undefined') { setTimeout(() => toast('TR4KER+ : ce gestionnaire ne fournit pas GM_xmlhttpRequest — recherche, GIF, upload et statut désactivés. Utilise Tampermonkey ou Violentmonkey.', 9000), 3000); } } catch (e) {}
      log('ready', VERSION);
    });
  }

  boot();
})();