TR4KER+

Suite communautaire pour tr4ker.net — thème "Encre & Signal", palette (Ctrl/Cmd+K), recherche instantanée (Meilisearch 345k, corrige la recherche du site), chat sur toutes les pages, salon vocal, HUD ratio + évolution, compteur de non-lus dans l'onglet, assistant IA optionnel (désactivé par défaut). Outil non officiel.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         TR4KER+
// @namespace    https://t4x.ekaii.fr
// @version      0.18.0
// @description  Suite communautaire pour tr4ker.net — thème "Encre & Signal", palette (Ctrl/Cmd+K), recherche instantanée (Meilisearch 345k, corrige la recherche du site), chat sur toutes les pages, salon vocal, HUD ratio + évolution, compteur de non-lus dans l'onglet, assistant IA optionnel (désactivé par défaut). Outil 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
// @connect      t4search.ekaii.fr
// @connect      t4ai.ekaii.fr
// @connect      t4x.ekaii.fr
// @connect      forgejo.ekaii.fr
// @connect      voice.ekaii.fr
// @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.18.0';
  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
    accent: 'indigo',   // indigo | cyan | amber | rose
  };
  // 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};`;
    return `:root{${dark}} :root[data-theme=dark]{${dark}} [data-theme=light]{${light}}`;
  }

  // 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(--surface-container-highest) transparent; }
    ::-webkit-scrollbar{ width:10px; height:10px; }
    ::-webkit-scrollbar-thumb{ background:var(--surface-container-highest); border-radius:999px; border:2px solid transparent; background-clip:content-box; }
    ::-webkit-scrollbar-thumb:hover{ background: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 {
        const mo = new MutationObserver(() => { if (styleEl.__t4xLive) place(); });
        mo.observe(target, { childList: true });
        styleEl.__t4xMo = mo;
      } catch (e) {}
    }
    styleEl.__t4xLive = true;
  }

  let themeStyle = null;
  function applyTheme() {
    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';
      host.style.cssText = 'all:initial; position:fixed; inset:0 auto auto 0; width:0; height:0; z-index:2147483000;';
      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; }
    .veil{ position:fixed; inset:0; background:rgba(5,7,12,.55); 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;
      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:2147483010; 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); 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; }
    .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();
  async function apiGet(path, { ttl = 15000, timeout = 5000 } = {}) {
    const now = Date.now();
    const c = cache.get(path);
    if (c && now - c.t < ttl) return c.v;
    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();
        cache.set(path, { t: Date.now(), v: j });
        return j;
      })
      .finally(() => { clearTimeout(to); inflight.delete(path); });
    inflight.set(path, p);
    return p;
  }
  // never cache identity
  async function me() { return apiGet('/api/me', { ttl: 30000 }); }

  // ---- 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', 'size_bytes', 'category_label', 'year'] };
      if (opts.filter) body.filter = opts.filter;
      if (opts.sort) body.sort = opts.sort;
      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, _meili: true };
  }
  async function getTorrents(q, facets) {
    const o = { limit: 8 };
    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);
    // 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 }); return pickArray(j, ['torrents', 'items', 'data']).slice(0, 8); }
    catch (e) { return []; }
  }

  function fmtSize(b) {
    if (b == null) return '';
    const u = ['o', 'Ko', 'Mo', 'Go', 'To']; 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;
  function toast(msg, ms = 3800) {
    ensureHost();
    if (!toastWrap) { toastWrap = document.createElement('div'); toastWrap.className = 'toasts'; root.appendChild(toastWrap); }
    const t = document.createElement('div'); t.className = 'toast'; t.textContent = msg;
    toastWrap.appendChild(t);
    requestAnimationFrame(() => t.classList.add('on'));
    setTimeout(() => { t.classList.remove('on'); setTimeout(() => t.remove(), 260); }, ms);
  }

  // ==========================================================================
  // 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;
    for (const el of root.children) {
      if (!el.style || el.tagName === 'STYLE') continue;
      if (el.dataset && el.dataset.t4open === '1') {
        if (el.__t4pe !== 'auto') { el.style.pointerEvents = ''; el.__t4pe = '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) { el.style.pointerEvents = dead ? '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(() => { 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.');
  }

  // ==========================================================================
  // 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.'); }
  }

  // ==========================================================================
  // 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; } catch (e) {}
  }
  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));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));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: '/communaute', 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: 'Demandes : les plus récentes', ic: 'schedule', run: () => openRequests('recent') },
      { t: 'Demandes : les miennes', ic: 'person', run: () => openRequests('mine') },
      { t: 'Ouvrir le chat', ic: 'forum', run: openDrawer },
      { t: 'Salon vocal (bêta)', ic: 'mic', run: openVoice },
      { 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);
    }
  }
  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) {
    const 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; 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))';
    });
  }

  function openPalette() {
    if (!P.palette) return;
    if (!pal) buildPalette();
    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;
    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 = '';
  }

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

  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') {
      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 = '/u/' + it.name; closePalette(); navigate(path); return; }
    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) {
      el.addEventListener('mousemove', () => { activeIdx = selectable.indexOf(sel); selectable.forEach((it, i) => it.el.setAttribute('aria-selected', i === activeIdx ? 'true' : 'false')); });
      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 render(q, remote) {
    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) move(1);
  }

  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 };
    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 (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); }
    el.append(icon(catIcon(t)), main, chips); frag.appendChild(el); selectable.push(it);
  }
  function catIcon(t) {
    const s = (t.parent_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';
  }
  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'] },
  ];
  const chat = {
    open: false, channels: null, active: 1, myRole: 'user', myName: null,
    seen: {}, replyTo: null, lastId: {}, typingTimer: null,
    el: null, listEl: null, tabsEl: null, composerEl: null, inputEl: null, unread: {},
  };
  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(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); backdrop-filter:blur(18px) saturate(160%); color:var(--on-surface,#e6e8f2);
      transform:translateX(100%); transition:transform .22s cubic-bezier(.3,0,0,1); z-index:2147483001; }
    .draw.on{ transform:translateX(0); }
    .draw .dh{ display:flex; align-items:center; gap:8px; padding:12px 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); }
    .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 .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:8px; padding:5px 6px; border-radius:10px; }
    .m:hover{ background:var(--surface-container,#1a1e2b); }
    .m .av{ width:28px; height: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:13px; font-weight:600; }
    .m .tm{ font:11px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); }
    .m .bd{ font-size:13px; line-height:1.4; word-wrap:break-word; overflow-wrap:anywhere; white-space:pre-wrap; }
    .m .pq{ font-size:11px; 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:6px; align-items:center; transition:opacity .12s; }
    .m:hover .acts{ opacity:1; }
    .m .acts span{ cursor:pointer; font-family:'Material Symbols Outlined'; font-size:16px; color:var(--on-surface-variant,#b8bdd1); }
    .rx{ display:inline-flex; gap:3px; align-items:center; font-size:11px; padding:2px 6px; border-radius:999px;
      background:var(--surface-container-high,#232838); margin-right:4px; }
    .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); 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 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; }
    /* 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; }

  async function loadChannels() {
    if (chat.channels) return chat.channels;
    try { const j = await apiGet('/api/channels', { ttl: 3600000 }); chat.channels = pickArray(j, ['channels']); }
    catch (e) { chat.channels = CHANNELS_FALLBACK; }
    if (!chat.channels.length) chat.channels = CHANNELS_FALLBACK;
    return chat.channels;
  }

  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';
    const x = document.createElement('span'); x.className = 'x'; x.textContent = 'close'; x.title = 'Fermer (Échap)'; x.addEventListener('click', closeDrawer);
    dh.append(b, x);
    const tabs = document.createElement('div'); tabs.className = 'tabs';
    const typing = document.createElement('div'); typing.className = 'typing';
    const list = document.createElement('div'); list.className = 'msgs'; list.setAttribute('role', 'log'); list.setAttribute('aria-live', 'polite');
    const comp = document.createElement('div'); comp.className = 'comp';
    el.append(dh, tabs, typing, list, comp);
    root.appendChild(el);
    chat.el = el; chat.tabsEl = tabs; chat.listEl = list; chat.composerEl = comp; chat.typingEl = typing;
    xtraStyle();
    el.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDrawer(); }, true);
  }

  function renderTabs() {
    chat.tabsEl.replaceChildren();
    for (const c of chat.channels) {
      const t = document.createElement('div'); t.className = 'tab' + (c.id === chat.active ? ' act' : '');
      t.textContent = c.name; t.addEventListener('click', () => 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);
    }
  }

  function renderComposer() {
    const c = (chat.channels || []).find(x => x.id === chat.active) || {};
    chat.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).'; chat.composerEl.appendChild(ro); return; }
    if (chat.replyTo) {
      const rc = document.createElement('div'); rc.className = 'rc';
      const label = document.createElement('span'); label.textContent = '↳ ' + (chat.replyTo.sender || '') + ' : ' + String(chat.replyTo.body || '').slice(0, 60);
      const x = document.createElement('span'); x.className = 'x'; x.textContent = '✕'; x.title = 'Annuler'; x.addEventListener('click', () => { chat.replyTo = null; renderComposer(); });
      label.style.flex = '1'; label.style.overflow = 'hidden'; label.style.whiteSpace = 'nowrap'; label.style.textOverflow = 'ellipsis';
      rc.append(label, x); chat.composerEl.appendChild(rc);
    }
    const ta = document.createElement('textarea'); ta.rows = 1; ta.placeholder = 'Message dans #' + (c.slug || '') + '…'; ta.setAttribute('aria-label', 'Écrire un message');
    ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px'; onTyping(); });
    ta.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); const v = ta.value.trim(); if (v) sendMessage(v); } // clears only on success (renderComposer)
    });
    // 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));
    chat._imgBtn = iconBtn('image', 'Envoyer une image (ou colle / glisse-dépose)', () => { if (!uploading) fileInput.click(); });
    iconBtn('visibility_off', 'Spoiler', () => wrapSel(ta, '[spoiler]', '[/spoiler]'));
    iconBtn('format_bold', 'Gras', () => wrapSel(ta, '**', '**'));
    iconBtn('link', 'Lien', () => wrapSel(ta, 'https://', ''));
    chat._upStatus = document.createElement('div'); chat._upStatus.className = 't4x-upstatus'; chat._upStatus.style.display = 'none'; bar.appendChild(chat._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; } } } });
    chat.composerEl.append(bar, fileInput, ta); chat.inputEl = ta;
    // drag-drop image (wired once on the persistent composer container)
    if (!chat._dropWired) {
      chat._dropWired = true;
      const stop = (e) => { e.preventDefault(); e.stopPropagation(); };
      ['dragenter', 'dragover'].forEach((ev) => chat.composerEl.addEventListener(ev, (e) => { stop(e); chat.composerEl.classList.add('t4x-drop'); }));
      ['dragleave', 'drop'].forEach((ev) => chat.composerEl.addEventListener(ev, (e) => { stop(e); chat.composerEl.classList.remove('t4x-drop'); }));
      chat.composerEl.addEventListener('drop', (e) => { const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) handleImageFile(f, chat.inputEl); });
    }
  }

  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)); }
  function appendMention(parent, s, myName) {
    if (!myName) { appendText(parent, s); return; }
    const re = new RegExp('(^|[^\\w@])(@?' + escRe(myName) + ')(?![\\w])', 'gi');
    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));
  }
  function appendStyled(parent, s, myName) {
    const BOLD = /\*\*([^*\n]+)\*\*|\[b\]([\s\S]*?)\[\/b\]/gi; 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);
  }
  function appendImage(parent, url) {
    if (!/^https?:\/\//i.test(url)) { appendText(parent, '[img]' + url + '[/img]'); 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.addEventListener('click', () => window.open(url, '_blank', 'noreferrer'));
    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 = /\[spoiler\]([\s\S]*?)\[\/spoiler\]|\[img\]\s*([^\[\]\s]+?)\s*\[\/img\]/gi;
    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 (/\.(png|jpe?g|gif|webp|avif)(\?|#|$)/i.test(a.href)) { const holder = document.createElement('span'); a.replaceWith(holder); appendImage(holder, a.href); }
    });
  }
  // test-only hook: exposed solely when a harness sets window.__T4X_EXPOSE__ (never set in Tampermonkey).
  try { if (window.__T4X_EXPOSE__) window.__t4xRB = renderBody; } catch (e) {}
  // watchlist: does an incoming body mention me or a saved keyword?
  function mentionHit(body) {
    const b = String(body || '').toLowerCase();
    if (chat.myName && b.includes('@' + chat.myName.toLowerCase())) return chat.myName;
    const kw = String(pref0('chatKeywords', '') || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
    for (const k of kw) if (k && b.includes(k)) return k;
    return null;
  }
  function xtraStyle() {
    if (chat._xtra || !root) return; chat._xtra = true;
    const st = document.createElement('style');
    st.textContent = '.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));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))}';
    root.appendChild(st);
  }
  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 = '😀 😂 😅 😍 😎 🤨 🤔 😭 😡 😴 🥳 😏 🙃 👍 👎 🙏 👏 🔥 💯 🎉 ❤️ 💔 💀 👀 🤝 🙌 ✅ ❌ ⚡ ⭐ 🚀 🎬 📺 🎵 📚 🎮 💾 ⬇️ 🌱 🤖'.split(' ');
  let emojiPop = null;
  function toggleEmoji(ta, bar) {
    if (emojiPop) { emojiPop.remove(); emojiPop = null; return; }
    emojiPop = document.createElement('div');
    emojiPop.style.cssText = 'display:grid;grid-template-columns:repeat(8,1fr);gap:2px;padding:8px;margin-bottom:6px;background:var(--surface-container-high,#1a1d26);border:1px solid var(--outline-variant,rgba(255,255,255,.15));border-radius:10px;max-height:150px;overflow-y:auto';
    EMOJIS.forEach((em) => {
      const b = document.createElement('button'); b.type = 'button'; b.textContent = em;
      b.style.cssText = 'all:unset;cursor:pointer;font-size:18px;text-align:center;padding:4px;border-radius:6px';
      b.addEventListener('mouseenter', () => b.style.background = 'var(--secondary-container,#2b3050)');
      b.addEventListener('mouseleave', () => b.style.background = 'transparent');
      b.addEventListener('click', () => { wrapSel(ta, em, ''); emojiPop.remove(); emojiPop = null; });
      emojiPop.appendChild(b);
    });
    if (bar.parentNode) bar.parentNode.insertBefore(emojiPop, bar);
  }
  // ---- 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';
  }
  function setComposerUploading(on) {
    if (chat._imgBtn) { chat._imgBtn.disabled = on; chat._imgBtn.classList.toggle('busy', on); }
    if (chat._upStatus) { chat._upStatus.style.display = on ? 'flex' : 'none'; chat._upStatus.replaceChildren(); if (on) { const sp = document.createElement('span'); sp.className = 'spin'; chat._upStatus.append(sp, document.createTextNode('Envoi de l’image…')); } }
  }
  async function handleImageFile(file, ta) {
    if (!file || !/^image\//.test(file.type || '')) 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);
    try {
      const url = await uploadImage(await readAsDataURL(file));
      insertAtCursor(ta || chat.inputEl, '[img]' + url + '[/img] ');
    } catch (e) { toast('Envoi de l’image : ' + (e.message || 'échec')); }
    uploading = false; setComposerUploading(false);
    try { (ta || chat.inputEl) && (ta || chat.inputEl).focus(); } catch (_) {}
  }

  function mkMsg(m) {
    const el = document.createElement('div'); el.className = 'm'; el.dataset.id = m.id;
    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 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 = document.createElement('span'); rep.textContent = 'reply'; rep.title = 'Répondre'; rep.addEventListener('click', () => { chat.replyTo = { id: m.id, sender: m.sender, body: m.body }; renderComposer(); chat.inputEl && chat.inputEl.focus(); });
    const lk = document.createElement('span'); lk.textContent = 'add_reaction'; lk.title = 'Réagir 👍'; lk.addEventListener('click', () => sendFrame({ type: 'reaction.add', message_id: m.id, emoji: '👍' }));
    acts.append(rep, lk); mh.appendChild(acts);
    mc.appendChild(mh);
    if (m.parent && (m.parent.body || m.parent.sender)) { const pq = document.createElement('div'); pq.className = 'pq'; pq.textContent = '↳ ' + (m.parent.sender ? m.parent.sender + ' : ' : '') + String(m.parent.body || ''); mc.appendChild(pq); }
    const bd = document.createElement('div'); bd.className = 'bd'; renderBody(bd, m.body, chat.myName); mc.appendChild(bd);
    if (m.sender_id !== chat.myId && mentionHit(m.body)) el.classList.add('ment-hit');
    if (Array.isArray(m.reactions) && m.reactions.length) {
      const rr = document.createElement('div');
      for (const rx of m.reactions) { const s = document.createElement('span'); s.className = 'rx'; s.textContent = (rx.emoji || '👍') + ' ' + (rx.count || rx.users && rx.users.length || 1); rr.appendChild(s); }
      mc.appendChild(rr);
    }
    el.append(av, mc);
    return el;
  }

  function atBottom() { const l = chat.listEl; return l && (l.scrollHeight - l.scrollTop - l.clientHeight < 80); }
  function scrollBottom() { if (chat.listEl) chat.listEl.scrollTop = chat.listEl.scrollHeight; }
  // look up a message row by id — CSS.escape so a crafted remote id can't break the selector
  function msgEl(id) { if (!chat.listEl || id == null) return null; try { return chat.listEl.querySelector('.m[data-id="' + CSS.escape(String(id)) + '"]'); } catch (e) { return null; } }

  async function selectChannel(id) {
    chat.active = id; chat.replyTo = null; chat.unread[id] = 0;
    renderTabs(); renderComposer(); refreshPillBadge(); // reflect the just-read channel in the pill badge immediately
    chat.listEl.replaceChildren();
    const load = document.createElement('div'); load.className = 'ro'; load.textContent = 'Chargement…'; chat.listEl.appendChild(load);
    try {
      const j = await apiGet('/api/conversations/' + id + '/messages?limit=50', { ttl: 8000 });
      const msgs = pickArray(j, ['messages']).slice().reverse(); // API is newest-first → chrono
      chat.listEl.replaceChildren();
      for (const m of msgs) chat.listEl.appendChild(mkMsg(m));
      if (msgs.length) chat.lastId[id] = msgs[msgs.length - 1].id;
      scrollBottom();
    } catch (e) { chat.listEl.replaceChildren(); const er = document.createElement('div'); er.className = 'ro'; er.textContent = 'Impossible de charger (' + e.message + ').'; chat.listEl.appendChild(er); }
  }

  function onChatFrame(f) {
    if (f.type === 'msg.received') {
      // watchlist alert (self-mention or saved keyword) — fires even for background channels
      if (f.sender_id !== chat.myId && pref0('chatAlerts', true) && mentionHit(f.body)) {
        const ch = (chat.channels || []).find(x => x.id === f.conv_id);
        toast((f.sender || 'Quelqu’un') + ' t’a mentionné' + (ch ? ' dans #' + (ch.slug || ch.name) : ''), 6000);
      }
      if (f.conv_id === chat.active && chat.open) {
        const stick = atBottom(); chat.listEl.appendChild(mkMsg(f)); chat.lastId[f.conv_id] = f.id; if (stick) scrollBottom();
      } else if (f.sender_id !== chat.myId) {
        chat.unread[f.conv_id] = (chat.unread[f.conv_id] || 0) + 1; if (chat.open) renderTabs(); refreshPillBadge();
      }
    } else if (f.type === 'msg.deleted') {
      const el = msgEl(f.message_id); if (el) { el.querySelector('.bd').textContent = '(supprimé)'; el.style.opacity = '.5'; }
    } else if (f.type === 'msg.edited') {
      const el = msgEl(f.message_id); if (el) renderBody(el.querySelector('.bd'), f.body, chat.myName);
    } else if (f.type === 'typing') {
      if (f.conv_id === chat.active && f.user_id !== chat.myId && chat.typingEl) { chat.typingEl.textContent = (f.user || 'Quelqu’un') + ' écrit…'; clearTimeout(chat._typClr); chat._typClr = setTimeout(() => { chat.typingEl.textContent = ''; }, 3000); }
    }
  }

  let typingSent = 0;
  function onTyping() { const now = Date.now(); if (now - typingSent > 3500) { typingSent = now; sendFrame({ type: 'typing', conv_id: chat.active }); } }
  function sendMessage(body) {
    const frame = { type: 'msg.send', conv_id: chat.active, body };
    if (chat.replyTo) frame.parent_id = chat.replyTo.id;
    const ok = sendFrame(frame);
    if (ok) { chat.replyTo = null; renderComposer(); } // success rebuilds an empty composer; on failure the text is kept
    return ok;
  }

  async function openDrawer() {
    if (!pref0('chat', true)) return;
    ensureOwnSocket();
    if (!chat.el) buildDrawer();
    if (!chat.channels) { await loadChannels(); try { const m = await me(); chat.myRole = m.role || 'user'; chat.myId = m.id; chat.myName = m.username; } catch (e) {} }
    chat.open = true; chat.el.classList.add('on'); markOpen(chat.el, true);
    if (!chat._sub) chat._sub = onFrame((f) => onChatFrame(f));
    renderTabs(); renderComposer();
    await selectChannel(chat.active);
    refreshPillBadge();
  }
  function closeDrawer() { chat.open = false; if (chat.el) { chat.el.classList.remove('on'); markOpen(chat.el, false); } }
  function toggleDrawer() { chat.open ? closeDrawer() : openDrawer(); }

  // ==========================================================================
  // STATUS / HUD PILL
  // ==========================================================================
  let pillEl = null, pillUnreadEl = 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';
    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 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); });
    p.append(rs, cs, pillFlEl, sep, bk, bc, ba, bu, bv, bm);
    root.appendChild(p); pillEl = p;
    refreshPill();
    setInterval(refreshPill, 90000);
    setInterval(refreshPillBadge, 60000);
  }
  // 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],
      ['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],
      ['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));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(); } };
  }
  async function refreshPill() {
    try {
      const m = await me();
      if (m) {
        const ratio = m.downloaded > 0 ? (m.uploaded / m.downloaded) : 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';
        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() {
    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);
  }
  let _lastUnread = 0;
  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() {
    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 (pillUnreadEl) { if (n > 0) { pillUnreadEl.textContent = n > 99 ? '99+' : n; pillUnreadEl.style.display = ''; } else pillUnreadEl.style.display = 'none'; }
    _lastUnread = n; updateTabTitle(n);   // unread count in the browser tab (community request)
  }

  // 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); 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;
    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; render(); },
      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);
        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
      },
    });
  }

  function streamAi(payload, cb) {
    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 PANEL (minimal for v0.1)
  // ==========================================================================
  let settingsEl = null;
  function openSettings() {
    ensureHost();
    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'; panel.style.maxWidth = '440px'; panel.style.top = '18vh';
    panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Réglages TR4KER+');
    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 = 'TR4KER+ ' + VERSION;
    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.padding = '10px 14px';
    const toggles = [['theme', 'Thème « Encre & Signal »'], ['polish', 'Finitions (scrollbars, focus, chiffres)'], ['palette', 'Palette de commandes (Ctrl/⌘+K)'], ['chat', 'Chat sur toutes les pages (Ctrl/⌘+J)'], ['hud', 'HUD ratio / crédits (pastille)'], ['ai', 'Assistant IA (t4ai) — désactivé par défaut'], ['updates', 'Me prévenir des mises à jour de TR4KER+']];
    for (const [k, label] of toggles) {
      const rowd = document.createElement('label'); rowd.style.cssText = 'display:flex;align-items:center;gap:10px;padding:10px 4px;cursor:pointer;font-size:14px;color:var(--on-surface,#e6e8f2)';
      const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!P[k];
      cb.addEventListener('change', () => {
        P = setPref(k, cb.checked); applyTheme();
        // apply feature toggles live (no reload needed)
        if (k === 'hud') { if (cb.checked) { try { buildPill(); } catch (e) {} } else if (pillEl) { pillEl.remove(); pillEl = null; pillAiBtn = null; pillUpBtn = null; pillVoiceBtn = null; } }
        else if (k === 'chat' && !cb.checked) { closeDrawer(); }
        else if (k === 'ai') { if (!cb.checked) closeAi(); if (pillAiBtn) pillAiBtn.style.display = cb.checked ? '' : 'none'; }
        else if (k === 'updates' && cb.checked) { checkUpdate(true); }
      });
      const sp = document.createElement('span'); sp.textContent = label; sp.style.flex = '1';
      rowd.append(cb, sp); body.appendChild(rowd);
    }
    const accRow = document.createElement('div'); accRow.style.cssText = 'display:flex;gap:8px;padding:12px 4px;align-items:center;flex-wrap:wrap';
    const accLbl = document.createElement('span'); accLbl.textContent = 'Accent'; accLbl.style.cssText = 'font-size:14px;color:var(--on-surface,#e6e8f2);flex:1 0 100%';
    accRow.appendChild(accLbl);
    ['indigo', 'cyan', 'amber', 'rose'].forEach(name => {
      const b = document.createElement('button'); b.textContent = name;
      b.style.cssText = 'padding:7px 12px;border-radius:999px;border:1px solid var(--outline-variant,#3a4056);cursor:pointer;font-size:12px;background:' + (P.accent === name ? 'var(--secondary-container,#2b3050)' : 'transparent') + ';color:var(--on-surface,#e6e8f2)';
      b.addEventListener('click', () => { P = setPref('accent', name); applyTheme(); openSettings(); openSettings(); });
      accRow.appendChild(b);
    });
    body.appendChild(accRow);
    // version + on-demand update check (Tampermonkey's own polling is daily at best)
    const verRow = document.createElement('div'); verRow.style.cssText = 'display:flex;align-items:center;gap:8px;padding:12px 4px 4px;font-size:13px;color:var(--on-surface,#e6e8f2);border-top:1px solid var(--outline-variant,rgba(255,255,255,.08));margin-top:6px;flex-wrap:wrap';
    const verTxt = document.createElement('span'); verTxt.style.flex = '1 0 auto';
    const paintVer = () => {
      verTxt.textContent = updateState.available
        ? 'Version ' + VERSION + ' — v' + updateState.latest + ' disponible'
        : 'Version ' + VERSION + (updateState.latest ? ' — à jour' : '');
      verTxt.style.color = updateState.available ? 'var(--t4x-seed,#4ade80)' : '';
    };
    paintVer();
    const btnCss = 'padding:7px 12px;border-radius:999px;border:1px solid var(--outline-variant,#3a4056);cursor:pointer;font-size:12px;background:transparent;color:var(--on-surface,#e6e8f2)';
    const bChk = document.createElement('button'); bChk.textContent = 'Vérifier'; bChk.style.cssText = btnCss;
    bChk.addEventListener('click', () => { bChk.textContent = '…'; checkUpdate(true); setTimeout(() => { bChk.textContent = 'Vérifier'; paintVer(); bUp.style.display = updateState.available ? '' : 'none'; }, 1600); });
    const bUp = document.createElement('button'); bUp.textContent = 'Mettre à jour'; bUp.style.cssText = btnCss + ';border-color:var(--primary,#93a5ff)';
    bUp.style.display = updateState.available ? '' : 'none';
    bUp.addEventListener('click', openUpdate);
    verRow.append(verTxt, bChk, bUp);
    body.appendChild(verRow);
    const note = document.createElement('div'); note.className = 'empty'; note.style.textAlign = 'left';
    note.textContent = 'Outil communautaire non officiel — non affilié au staff TR4KER.';
    body.appendChild(note);
    panel.append(h, 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);
  }

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

  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('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();
      // 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();
      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', inertSweep, true);
      setInterval(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 (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);
      menu();
      try { GM_addValueChangeListener('t4x.prefs', () => { P = prefs(); applyTheme(); }); } catch (e) {}
      log('ready', VERSION);
    });
  }

  boot();
})();