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, assistant IA, HUD ratio + évolution, compteur de non-lus dans l'onglet. Outil non officiel.

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         TR4KER+
// @namespace    https://t4x.ekaii.fr
// @version      0.16.1
// @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, assistant IA, HUD ratio + évolution, compteur de non-lus dans l'onglet. 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      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.16.1';
  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: true,           // AI assistant dock (t4ai)
    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; }
  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{ position:fixed; right:18px; bottom:130px; z-index:2147483010; display:flex; flex-direction:column; gap:10px; align-items:flex-end; }
    .toast{ 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);
  }

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

  // ==========================================================================
  // 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, 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);
    }
  }
  async function joinVoice(room) {
    if (voice.win && !voice.win.closed) { try { voice.win.focus(); } catch (e) {} return; }
    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();
    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-voiceveil';
    const panel = ceReq('div', 'position:fixed;z-index:2147483001;bottom:20px;right:20px;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;transform:translateY(8px);transition:opacity .15s,transform .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(veil, panel);
    veil.addEventListener('click', closeVoice);
    document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && voice.panel && voice.panel.veil.style.opacity === '1') { e.preventDefault(); closeVoice(); } }, true);
    voice.panel = { veil, 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();
    requestAnimationFrame(() => { voice.panel.veil.style.opacity = '1'; voice.panel.veil.style.pointerEvents = 'auto'; voice.panel.panel.style.opacity = '1'; voice.panel.panel.style.pointerEvents = 'auto'; voice.panel.panel.style.transform = 'translateY(0)'; });
  }
  function closeVoice() { if (voice.panel) { voice.panel.veil.style.opacity = '0'; voice.panel.veil.style.pointerEvents = 'none'; voice.panel.panel.style.opacity = '0'; voice.panel.panel.style.pointerEvents = 'none'; voice.panel.panel.style.transform = 'translateY(8px)'; } }

  // ==========================================================================
  // 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 [
      { 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) },
      { 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');
    palInput.value = ''; render('');
    setTimeout(() => palInput.focus(), 10);
  }
  function closePalette() {
    palOpen = false;
    if (pal) { pal.classList.remove('on'); pal.__veil.classList.remove('on'); }
  }
  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; } }
    @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');
    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'); }
  function toggleDrawer() { chat.open ? closeDrawer() : openDrawer(); }

  // ==========================================================================
  // STATUS / HUD PILL
  // ==========================================================================
  let pillEl = null, pillUnreadEl = null, pillRatioEl = null, pillCreditEl = null, pillFlEl = null, pillVoiceBtn = 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);
    const bv = document.createElement('button'); bv.title = 'Salon vocal'; bv.innerHTML = '<span class="ic">mic</span>'; bv.addEventListener('click', openVoice); pillVoiceBtn = bv;
    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, 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],
      ['auto_awesome', 'Assistant IA', '', openAi],
      ['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, 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 .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 x = document.createElement('span'); x.className = 'x'; x.textContent = 'close'; x.title = 'Fermer'; x.addEventListener('click', closeAi);
    h.append(g, b, 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);
    _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', true)) return;
    if (!ai.el) buildAiDock();
    ai.open = true; ai.el.classList.add('on');
    setTimeout(() => ai.inputEl && ai.inputEl.focus(), 20);
  }
  function closeAi() { ai.open = false; if (ai.el) ai.el.classList.remove('on'); }
  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;
  }

  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 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) => { 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) => { body.classList.remove('cursorblink'); const e = document.createElement('div'); e.className = 'am err'; e.textContent = '⚠ ' + (msg || 'erreur'); ai.listEl.appendChild(e); },
      onDone: () => { body.classList.remove('cursorblink'); ai.busy = false; if (!acc.trim() && !body.textContent) body.textContent = '(pas de réponse)'; },
    });
  }

  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)']];
    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; } }
        else if (k === 'chat' && !cb.checked) { closeDrawer(); }
        else if (k === 'ai' && !cb.checked) { closeAi(); }
      });
      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);
    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();
    }
  }

  function menu() {
    try {
      GM_registerMenuCommand('Ouvrir la palette (Ctrl/⌘+K)', openPalette);
      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);
      window.addEventListener('t4x:locationchange', () => setTimeout(() => { updateTabTitle(_lastUnread); applySiteTheme(); }, 500)); // re-apply badge + theme after SPA sets title
      // 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); } }
      menu();
      try { GM_addValueChangeListener('t4x.prefs', () => { P = prefs(); applyTheme(); }); } catch (e) {}
      log('ready', VERSION);
    });
  }

  boot();
})();