Greasy Fork is available in English.

BetterChatPop

Better chat UI for gpop.io rooms (scrollback, copy/select, collapsible + unread badge, draggable, resizable, customizable)

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         BetterChatPop
// @namespace    https://gpop.io
// @icon         https://www.google.com/s2/favicons?sz=64&domain=gpop.io
// @version      1.2.7
// @description  Better chat UI for gpop.io rooms (scrollback, copy/select, collapsible + unread badge, draggable, resizable, customizable)
// @author       Purrfect
// @match        https://gpop.io
// @match        https://gpop.io/room/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function () {
  "use strict";

  // ---------------------------
  // Keys / Storage
  // ---------------------------
  const MOD_FLAG = "__betterchatpop_loaded__";

  const STORE_HISTORY = "__betterchatpop_history_v3";
  const STORE_SETTINGS = "__betterchatpop_settings_v3";
  const STORE_UI = "__betterchatpop_ui_v3"; // x + bottom + w + bodyH + split + collapsed + scrollTop

  const MAX_WAIT_MS = 30000;
  const TICK_MS = 200;

  const MAX_MESSAGES = 1400;
  const PERSIST_MESSAGES = 900;
  const INPUT_MAXLEN = 300;

  // ---------------------------
  // Defaults
  // ---------------------------
  const DEFAULTS = Object.freeze({
    keys: {
      toggleHide: "z",
      openChat: "t",
      toggleLock: "", // unbound by default
    },
    behavior: {
      scrollToBottomOnOpenIfUnread: false,
      lockUI: false,
      showJoinLeave: true,
      showServerNotices: true,
      blurAfterSend: true,
    },
    ui: {
      fontSize: 12, // px
    },
    theme: {
      bg: "rgba(20, 20, 24, 0.86)",
      bg2: "rgba(28, 28, 36, 0.82)",
      border: "rgba(255,255,255,0.12)",
      border2: "rgba(255,255,255,0.18)",
      text: "rgba(248,255,253,0.90)",
      dim: "rgba(248,255,253,0.65)",
      accent: "rgba(180, 200, 255, 0.80)",
      badgeBg: "rgba(255, 70, 90, 0.95)",
      badgeText: "rgba(255,255,255,0.95)",
    },
    colors: {
      selfName: null,
      selfColor: "#a8c5ff",
      nameColors: {},
      defaultNameColor: "#a8c5ff",
      defaultTextColor: "rgba(248,255,253,0.88)",
    },
  });

  // ---------------------------
  // Utilities
  // ---------------------------

    function extractPlayerId(payload) {
        return (
            payload?.shortid ??
            payload?.player?.shortid ??
            payload?.player?.id ??
            payload?.player?.pid ??
            payload?.id ??
            payload?.pid ??
            payload?.playerId ??
            payload?.userId ??
            null
        );
    }

  const now = () => Date.now();
  const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
  const micro = typeof queueMicrotask === "function" ? queueMicrotask : (f) => setTimeout(f, 0);

  const safeRead = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
  const safeWrite = (k, v) => { try { localStorage.setItem(k, String(v)); } catch {} };

  function deepMerge(base, patch) {
    if (!patch || typeof patch !== "object") return base;
    const out = Array.isArray(base) ? base.slice() : { ...base };
    for (const k of Object.keys(patch)) {
      const pv = patch[k];
      const bv = out[k];
      if (pv && typeof pv === "object" && !Array.isArray(pv) && bv && typeof bv === "object" && !Array.isArray(bv)) {
        out[k] = deepMerge(bv, pv);
      } else {
        out[k] = pv;
      }
    }
    return out;
  }

  function loadSettings() {
    try {
      const raw = safeRead(STORE_SETTINGS);
      if (!raw) return JSON.parse(JSON.stringify(DEFAULTS));
      const parsed = JSON.parse(raw);
      return deepMerge(JSON.parse(JSON.stringify(DEFAULTS)), parsed);
    } catch {
      return JSON.parse(JSON.stringify(DEFAULTS));
    }
  }

  function saveSettings(s) {
    try { safeWrite(STORE_SETTINGS, JSON.stringify(s)); } catch {}
  }

  function loadUI() {
    try {
      const raw = safeRead(STORE_UI);
      if (!raw) {
        return { x: 16, bottom: 14, w: 380, bodyH: 290, split: 200, collapsed: false, scrollTop: 0 };
      }
      const o = JSON.parse(raw);
      return {
        x: Number.isFinite(+o?.x) ? +o.x : 16,
        bottom: Number.isFinite(+o?.bottom) ? +o.bottom : 14,
        w: Number.isFinite(+o?.w) ? +o.w : 380,
        bodyH: Number.isFinite(+o?.bodyH) ? +o.bodyH : 290,
        split: Number.isFinite(+o?.split) ? +o.split : 200,
        collapsed: !!o?.collapsed,
        scrollTop: Number.isFinite(+o?.scrollTop) ? +o.scrollTop : 0,
      };
    } catch {
      return { x: 16, bottom: 14, w: 380, bodyH: 290, split: 200, collapsed: false, scrollTop: 0 };
    }
  }

  function saveUI(ui) {
    try {
      safeWrite(STORE_UI, JSON.stringify({
        x: ui.x, bottom: ui.bottom, w: ui.w, bodyH: ui.bodyH, split: ui.split,
        collapsed: !!ui.collapsed,
        scrollTop: Number.isFinite(+ui.scrollTop) ? +ui.scrollTop : 0
      }));
    } catch {}
  }

  function loadHistory() {
    try {
      const raw = safeRead(STORE_HISTORY);
      if (!raw) return [];
      const arr = JSON.parse(raw);
      if (!Array.isArray(arr)) return [];
      return arr.filter(x => x && typeof x === "object");
    } catch {
      return [];
    }
  }

  function saveHistory(arr) {
    try { safeWrite(STORE_HISTORY, JSON.stringify(arr.slice(-PERSIST_MESSAGES))); } catch {}
  }

  function waitFor(pred, timeoutMs = MAX_WAIT_MS) {
    const start = now();
    return new Promise((resolve, reject) => {
      (function tick() {
        let ok = false;
        try { ok = !!pred(); } catch {}
        if (ok) return resolve(true);
        if (now() - start > timeoutMs) return reject(new Error("timeout"));
        setTimeout(tick, TICK_MS);
      })();
    });
  }

  function el(tag, props = {}, children = []) {
    const n = document.createElement(tag);
    for (const [k, v] of Object.entries(props)) {
      if (k === "style") n.style.cssText = String(v);
      else if (k === "class") n.className = String(v);
      else if (k.startsWith("on") && typeof v === "function") n.addEventListener(k.slice(2), v);
      else if (v !== undefined && v !== null) n.setAttribute(k, String(v));
    }
    for (const c of children) n.appendChild(c);
    return n;
  }

  function parseKeyString(s) {
    if (typeof s !== "string") return null;
    const t = s.trim();
    if (!t) return null;
    return t.length === 1 ? t.toLowerCase() : t;
  }

  function keyLabel(k) {
    const s = String(k || "").trim();
    return s ? s.toUpperCase() : "—";
  }

  function isTypingTarget(e) {
    const t = e?.target;
    if (!t) return false;
    const tag = (t.tagName || "").toLowerCase();
    return tag === "input" || tag === "textarea" || tag === "select" || t.isContentEditable;
  }

    function toPlainText(v) {
        try {
            if (v == null) return "";
            if (typeof v === "string") return v;
            if (typeof v === "number" || typeof v === "boolean") return String(v);

            // DOM element / node
            if (typeof Element !== "undefined" && v instanceof Element) {
                return String(v.textContent || v.innerText || "").trim();
            }
            if (typeof Node !== "undefined" && v instanceof Node) {
                return String(v.textContent || "").trim();
            }

            // common shapes
            if (typeof v === "object") {
                const tc = v.textContent ?? v.innerText ?? v.text ?? v.message ?? v.msg;
                if (typeof tc === "string") return tc.trim();
            }

            return String(v);
        } catch {
            return "";
        }
    }

  // ---------------------------
  // Color helpers (rgba <-> hex)
  // ---------------------------
  function normalizeHex(hex) {
    if (typeof hex !== "string") return null;
    let h = hex.trim();
    if (!h) return null;
    if (h[0] !== "#") h = "#" + h;
    if (/^#[0-9a-f]{3}$/i.test(h)) {
      const r = h[1], g = h[2], b = h[3];
      return ("#" + r + r + g + g + b + b).toLowerCase();
    }
    if (/^#[0-9a-f]{6}$/i.test(h)) return h.toLowerCase();
    return null;
  }

  function rgbaToParts(rgba) {
    if (typeof rgba !== "string") return null;
    const s = rgba.trim().toLowerCase();
    const m = s.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)\s*(?:,\s*([0-9.]+)\s*)?\)$/);
    if (!m) return null;
    const r = clamp(Math.round(+m[1]), 0, 255);
    const g = clamp(Math.round(+m[2]), 0, 255);
    const b = clamp(Math.round(+m[3]), 0, 255);
    const a = (typeof m[4] === "undefined") ? 1 : clamp(+m[4], 0, 1);
    return { r, g, b, a };
  }

  function partsToHex({ r, g, b }) {
    const to2 = (n) => n.toString(16).padStart(2, "0");
    return ("#" + to2(r) + to2(g) + to2(b)).toLowerCase();
  }

  function hexToParts(hex) {
    const h = normalizeHex(hex);
    if (!h) return null;
    const r = parseInt(h.slice(1, 3), 16);
    const g = parseInt(h.slice(3, 5), 16);
    const b = parseInt(h.slice(5, 7), 16);
    return { r, g, b };
  }

  function partsToRgba({ r, g, b, a }) {
    const aa = Math.round(clamp(a, 0, 1) * 1000) / 1000;
    return `rgba(${r}, ${g}, ${b}, ${aa})`;
  }

  function resolveRgbaToPicker(rgbaString, fallbackRgba) {
    const p = rgbaToParts(rgbaString) || rgbaToParts(fallbackRgba) || { r: 255, g: 255, b: 255, a: 1 };
    return { hex: partsToHex(p), a: p.a };
  }

  // ---------------------------
  // CSS
  // ---------------------------
  function ensureCSS() {
    if (document.getElementById("__betterchatpop_css")) return;

    const css = `
      :root{
        --bcp-bg: rgba(20, 20, 24, 0.86);
        --bcp-bg2: rgba(28, 28, 36, 0.82);
        --bcp-border: rgba(255,255,255,0.12);
        --bcp-border2: rgba(255,255,255,0.18);
        --bcp-text: rgba(248,255,253,0.90);
        --bcp-dim: rgba(248,255,253,0.65);
        --bcp-accent: rgba(180, 200, 255, 0.80);
        --bcp-badge-bg: rgba(255, 70, 90, 0.95);
        --bcp-badge-text: rgba(255,255,255,0.95);

        --bcp-author-default: #a8c5ff;
        --bcp-text-default: rgba(248,255,253,0.88);

        --bcp-font: 12px;
      }

      #__betterchatpop_root{
        position: fixed;
        left: 16px;
        bottom: 14px;
        z-index: 2147483646;
        width: 380px;
        max-width: calc(100vw - 16px);
        font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Apple Color Emoji", "Segoe UI Emoji";
        color: var(--bcp-text);
        pointer-events: auto;
        user-select: text;
      }

      #__betterchatpop_panel{
        background: var(--bcp-bg);
        border: 1px solid var(--bcp-border);
        border-bottom: 3px solid var(--bcp-border2);
        border-radius: 16px;
        box-shadow: 0 14px 40px rgba(0,0,0,0.35);
        overflow: hidden;
        backdrop-filter: blur(8px);
        position: relative;
      }

      #__betterchatpop_header{
        display:flex;
        align-items:center;
        justify-content:space-between;
        gap:10px;
        padding: 10px 12px;
        background: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.02));
        border-bottom: 1px solid rgba(255,255,255,0.10);
        user-select: none;
      }
      .bcp-draggable #__betterchatpop_header{ cursor: grab; }
      .bcp-draggable #__betterchatpop_header:active{ cursor: grabbing; }
      .bcp-locked #__betterchatpop_header{ cursor: default; }

      #__betterchatpop_title{
        display:flex;
        align-items:center;
        gap:10px;
        font-weight: 750;
        letter-spacing: 0.2px;
        font-size: 13px;
      }

      #__betterchatpop_badge{
        display:none;
        min-width: 18px;
        height: 18px;
        padding: 0 6px;
        border-radius: 999px;
        background: var(--bcp-badge-bg);
        color: var(--bcp-badge-text);
        font-weight: 900;
        font-size: 12px;
        line-height: 18px;
        text-align: center;
        box-shadow: 0 0 0 2px rgba(0,0,0,0.25);
      }

      .bcp-btn{
        all: unset;
        cursor: pointer;
        padding: 6px 10px;
        border-radius: 999px;
        background: rgba(255,255,255,0.10);
        border: 1px solid rgba(255,255,255,0.10);
        color: rgba(248,255,253,0.88);
        font-size: 12px;
        font-weight: 700;
        user-select: none;
        line-height: 1;
      }
      .bcp-btn:hover{ background: rgba(255,255,255,0.14); }
      .bcp-btn:active{ transform: translateY(1px); }

      .bcp-btn-danger{
        background: rgba(255, 70, 90, 0.22) !important;
        border-color: rgba(255, 70, 90, 0.55) !important;
        color: rgba(255, 210, 215, 0.96) !important;
      }

      #__betterchatpop_body{
        display:flex;
        flex-direction:column;
        gap:8px;
        padding: 10px;
      }

      #__betterchatpop_scroller{
        overflow: auto;
        padding: 8px 8px;
        border-radius: 12px;
        background: var(--bcp-bg2);
        border: 1px solid rgba(255,255,255,0.10);
      }

      #__betterchatpop_scroller::-webkit-scrollbar { width: 10px; height: 10px; }
      #__betterchatpop_scroller::-webkit-scrollbar-track { background: rgba(0,0,0,0); }
      #__betterchatpop_scroller::-webkit-scrollbar-thumb { background: rgba(72, 67, 86, 1); border-radius: 999px }
      #__betterchatpop_scroller::-webkit-scrollbar-thumb:hover { background: rgba(72, 67, 86, 0.90); }

      .bcp-line{
        display:flex;
        gap:8px;
        padding: 3px 2px;
        line-height: 1.25;
        font-size: var(--bcp-font);
        white-space: pre-wrap;
        word-break: break-word;
      }
      .bcp-author{
        color: var(--bcp-author-default);
        font-weight: 800;
        flex: 0 0 auto;
        max-width: 45%;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      .bcp-text{
        color: var(--bcp-text-default);
        flex: 1 1 auto;
      }
      .bcp-notice{
        color: rgba(248,255,253,0.70);
        font-weight: 700;
      }
      .bcp-notice2{
        color: rgba(255, 210, 120, 0.90);
        font-weight: 900;
      }

      /* Splitter between scroller and input */
      #__betterchatpop_split{
        height: 8px;
        border-radius: 999px;
        background: rgba(255,255,255,0.06);
        border: 1px solid rgba(255,255,255,0.10);
        cursor: ns-resize;
        user-select:none;
      }
      #__betterchatpop_split:hover{ background: rgba(255,255,255,0.09); }

      /* Keep split usable even when locked */
      .bcp-locked #__betterchatpop_split{ cursor: ns-resize; opacity: 1; }

      #__betterchatpop_inputWrap{
        display:flex;
        gap:8px;
        align-items:flex-end;
        min-height: 44px;
      }

      #__betterchatpop_input{
        flex: 1 1 auto;
        width: 100%;
        resize: none;
        min-height: 38px;
        max-height: 160px;
        padding: 10px 10px;
        border-radius: 12px;
        border: 1px solid rgba(255,255,255,0.12);
        background: rgba(0,0,0,0.22);
        color: rgba(248,255,253,0.92);
        outline: none;
        font-size: var(--bcp-font);
        line-height: 1.25;
        overflow:auto;

        /* Hide textarea scrollbars (still scrolls) */
        scrollbar-width: none;
        -ms-overflow-style: none;
      }
      #__betterchatpop_input::-webkit-scrollbar{
        width: 0 !important;
        height: 0 !important;
      }
      #__betterchatpop_input::placeholder{ color: rgba(248,255,253,0.55); }

      /* collapsed mode */
      .bcp-collapsed #__betterchatpop_body{ display:none; }
      .bcp-collapsed #__betterchatpop_panel{ border-bottom-width: 2px; }
      .bcp-collapsed .bcp-hide-when-collapsed{ display:none !important; }

      /* settings overlay blocks play */
      #__betterchatpop_overlay{
        position: fixed;
        inset: 0;
        z-index: 2147483647;
        background: rgba(0,0,0,0.45);
        backdrop-filter: blur(4px);
        display:none;
      }

      #__betterchatpop_modal{
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        width: 560px;
        max-width: calc(100vw - 28px);
        background: var(--bcp-bg);
        border: 1px solid var(--bcp-border);
        border-bottom: 3px solid var(--bcp-border2);
        border-radius: 16px;
        box-shadow: 0 20px 70px rgba(0,0,0,0.55);
        overflow: hidden;
      }

      #__betterchatpop_modalHeader{
        display:flex;
        align-items:center;
        justify-content:space-between;
        padding: 12px 12px;
        border-bottom: 1px solid rgba(255,255,255,0.10);
        background: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.02));
        user-select:none;
      }

      #__betterchatpop_modalTitle{
        display:flex;
        align-items:center;
        gap:10px;
        font-weight: 900;
        letter-spacing: 0.3px;
        font-size: 13px;
        color: rgba(248,255,253,0.92);
      }

      #__betterchatpop_modalBody{
        padding: 12px;
        display:flex;
        flex-direction:column;
        gap: 12px;
        max-height: min(72vh, 560px);
        overflow:auto;
      }

      .bcp-card{
        background: var(--bcp-bg2);
        border: 1px solid rgba(255,255,255,0.10);
        border-radius: 14px;
        padding: 10px;
      }

      .bcp-row{
        display:flex;
        gap:10px;
        align-items:center;
        justify-content:space-between;
        margin: 6px 0;
      }

      .bcp-label{
        font-size: 12px;
        font-weight: 800;
        color: rgba(248,255,253,0.82);
      }

      .bcp-sub{
        font-size: 11px;
        color: rgba(248,255,253,0.62);
        margin-top: 2px;
      }

      .bcp-input{
        all: unset;
        padding: 7px 10px;
        border-radius: 10px;
        border: 1px solid rgba(255,255,255,0.12);
        background: rgba(0,0,0,0.22);
        color: rgba(248,255,253,0.92);
        font-size: 12px;
        min-width: 180px;
        text-align: center;
      }

      .bcp-color{
        width: 44px;
        height: 30px;
        border: 1px solid rgba(255,255,255,0.14);
        background: rgba(0,0,0,0.25);
        border-radius: 10px;
        padding: 0;
        cursor: pointer;
      }

      .bcp-divider{
        height: 1px;
        background: rgba(255,255,255,0.10);
        margin: 10px 0;
      }

      /* Toggle */
      .bcp-toggle{
        position: relative;
        width: 44px;
        height: 24px;
        border-radius: 999px;
        background: rgba(255,255,255,0.10);
        border: 1px solid rgba(255,255,255,0.12);
        cursor:pointer;
        flex: 0 0 auto;
      }
      .bcp-toggle::after{
        content:"";
        position:absolute;
        top: 3px;
        left: 3px;
        width: 18px;
        height: 18px;
        border-radius: 999px;
        background: rgba(248,255,253,0.85);
        box-shadow: 0 6px 14px rgba(0,0,0,0.25);
        transition: transform 140ms ease, background 140ms ease;
      }
      .bcp-toggle[data-on="1"]{
        background: rgba(180,200,255,0.22);
        border-color: rgba(180,200,255,0.35);
      }
      .bcp-toggle[data-on="1"]::after{
        transform: translateX(20px);
        background: rgba(180,200,255,0.95);
      }

      /* Color popup */
      #__betterchatpop_colorPopup{
        position: fixed;
        inset: 0;
        z-index: 2147483647;
        display:none;
        background: rgba(0,0,0,0.40);
        backdrop-filter: blur(3px);
      }
      #__betterchatpop_colorPopupBox{
        position:absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        width: 360px;
        max-width: calc(100vw - 28px);
        background: var(--bcp-bg);
        border: 1px solid var(--bcp-border);
        border-bottom: 3px solid var(--bcp-border2);
        border-radius: 16px;
        box-shadow: 0 20px 70px rgba(0,0,0,0.55);
        overflow:hidden;
      }
      #__betterchatpop_colorPopupHeader{
        display:flex;
        align-items:center;
        justify-content:space-between;
        padding: 12px;
        border-bottom: 1px solid rgba(255,255,255,0.10);
        background: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.02));
        user-select:none;
      }
      #__betterchatpop_colorPopupTitle{
        font-weight: 900;
        font-size: 13px;
        color: rgba(248,255,253,0.92);
      }
      #__betterchatpop_colorPopupBody{
        padding: 12px;
        display:flex;
        flex-direction:column;
        gap: 12px;
      }
      .bcp-alphaRow{
        display:flex;
        gap:10px;
        align-items:center;
        justify-content:space-between;
      }
      .bcp-range{ width: 220px; }
      .bcp-preview{
        height: 34px;
        border-radius: 12px;
        border: 1px solid rgba(255,255,255,0.14);
        background: rgba(0,0,0,0.25);
      }

      /* resize handles */
      .bcp-resize-r, .bcp-resize-b, .bcp-resize-br{
        position:absolute;
        z-index: 5;
        background: transparent;
      }
      .bcp-resize-r{ top: 0; right: 0; width: 10px; height: 100%; cursor: ew-resize; }
      .bcp-resize-b{ left: 0; bottom: 0; width: 100%; height: 10px; cursor: ns-resize; }
      .bcp-resize-br{ right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; }
      .bcp-locked .bcp-resize-r,
      .bcp-locked .bcp-resize-b,
      .bcp-locked .bcp-resize-br{ cursor: default; }

      /* hide original chat visuals */
      .__betterchatpop_hideOldChat .chat{ display:none !important; }
    `;

    const style = document.createElement("style");
    style.id = "__betterchatpop_css";
    style.textContent = css;
    document.head.appendChild(style);
  }

  function applyVars(settings) {
    const t = settings.theme;
    const c = settings.colors;
    const root = document.documentElement;

    root.style.setProperty("--bcp-bg", String(t.bg));
    root.style.setProperty("--bcp-bg2", String(t.bg2));
    root.style.setProperty("--bcp-border", String(t.border));
    root.style.setProperty("--bcp-border2", String(t.border2));
    root.style.setProperty("--bcp-text", String(t.text));
    root.style.setProperty("--bcp-dim", String(t.dim));
    root.style.setProperty("--bcp-accent", String(t.accent));
    root.style.setProperty("--bcp-badge-bg", String(t.badgeBg));
    root.style.setProperty("--bcp-badge-text", String(t.badgeText));

    root.style.setProperty("--bcp-author-default", String(normalizeHex(c.defaultNameColor) || "#a8c5ff"));
    root.style.setProperty("--bcp-text-default", String(c.defaultTextColor || "rgba(248,255,253,0.88)"));

    const fs = clamp(+settings.ui.fontSize || 12, 9, 20);
    root.style.setProperty("--bcp-font", fs + "px");
  }

  // ---------------------------
  // Main
  // ---------------------------
  function install() {
    if (window[MOD_FLAG]) return;
    Object.defineProperty(window, MOD_FLAG, { value: true, enumerable: false });

    ensureCSS();

    let settings = loadSettings();
    let ui = loadUI();
    applyVars(settings);

    let history = loadHistory().slice(-MAX_MESSAGES);

    let unread = 0;
    let hadUnreadWhileCollapsed = false;

    const playerNameById = new Map(); // id -> name

    // UI refs
    let root, panel, header, badge, scroller, splitBar, body, inputWrap, input;
    let btnHide, btnClear, btnGear;

    let overlay, modal;
    let colorPopup, colorPopupTitle, colorPicker, alphaRange, alphaLabel, previewBox;

    // confirm clear
    let clearArmed = false;
    let clearDisarmTimer = 0;

    // drag / resize / split drag
    let dragging = false;
    let dragStart = null;

    let resizing = false;
    let resizeMode = null; // "r" | "b" | "br"
    let resizeStart = null;

    let splitting = false;
    let splitStart = null;

    // key capture in settings
    let captureTarget = null;
    let captureHintEl = null;

    // hooks installed flags
    let roomInitHooked = false;

    function hideOldChat() {
      try { document.body.classList.add("__betterchatpop_hideOldChat"); } catch {}
    }

    function isSettingsOpen() {
      return !!overlay && overlay.style.display === "block";
    }

    function isColorPopupOpen() {
      return !!colorPopup && colorPopup.style.display === "block";
    }

    function disarmClear() {
      clearArmed = false;
      if (btnClear) {
        btnClear.classList.remove("bcp-btn-danger");
        btnClear.textContent = "Clear";
      }
      if (clearDisarmTimer) {
        clearTimeout(clearDisarmTimer);
        clearDisarmTimer = 0;
      }
    }

    function armClear() {
      clearArmed = true;
      if (btnClear) {
        btnClear.classList.add("bcp-btn-danger");
        btnClear.textContent = "Confirm";
      }
      if (clearDisarmTimer) clearTimeout(clearDisarmTimer);
      clearDisarmTimer = setTimeout(() => disarmClear(), 2500);
    }

    function doClear() {
      history = [];
      saveHistory(history);
      if (scroller) scroller.textContent = "";
      unread = 0;
      hadUnreadWhileCollapsed = false;
      setBadge();
      disarmClear();
    }

    function isAtBottom() {
      if (!scroller) return true;
      const near = 18;
      return scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - near;
    }

    function scrollToBottom() {
      if (!scroller) return;
      scroller.scrollTop = scroller.scrollHeight + 999999;
    }

    function setBadge() {
      if (!badge) return;
      if (ui.collapsed && unread > 0) {
        badge.style.display = "inline-block";
        badge.textContent = "!";
      } else {
        badge.style.display = "none";
      }
    }

    function setCollapsed(v) {
      const next = !!v;
      if (ui.collapsed === next) return;

      if (next && scroller) {
        ui.scrollTop = scroller.scrollTop;
        saveUI(ui);
      }

      ui.collapsed = next;
      saveUI(ui);

      panel?.classList.toggle("bcp-collapsed", ui.collapsed);

      if (btnHide) btnHide.textContent = ui.collapsed ? "Open" : "Hide";
      if (btnClear) btnClear.style.display = ui.collapsed ? "none" : "inline-block";

      if (!ui.collapsed) {
        if (hadUnreadWhileCollapsed) {
          if (settings.behavior.scrollToBottomOnOpenIfUnread) {
            micro(scrollToBottom);
          } else {
            if (scroller) scroller.scrollTop = ui.scrollTop || 0;
          }
        }
        unread = 0;
        hadUnreadWhileCollapsed = false;
        setBadge();
      } else {
        setBadge();
      }
    }

    function setLocked(v) {
      const next = !!v;
      settings.behavior.lockUI = next;
      saveSettings(settings);

      if (panel) {
        panel.classList.toggle("bcp-locked", next);
        panel.classList.toggle("bcp-draggable", !next);
      }

      if (isSettingsOpen()) rebuildSettingsUI();
    }

    function setRootPosFromUI() {
      if (!root || !body || !scroller) return;

      root.style.left = `${clamp(ui.x, 0, Math.max(0, window.innerWidth - 80))}px`;
      root.style.bottom = `${clamp(ui.bottom, 0, Math.max(0, window.innerHeight - 80))}px`;

      root.style.width = `${clamp(ui.w, 260, Math.max(260, window.innerWidth - 16))}px`;

      const maxBodyH = Math.max(160, window.innerHeight - 140);
      ui.bodyH = clamp(ui.bodyH, 160, maxBodyH);
      body.style.height = `${ui.bodyH}px`;

      const minInputArea = 70;
      const maxScroller = ui.bodyH - minInputArea - 8;
      ui.split = clamp(ui.split, 90, Math.max(90, maxScroller));
      scroller.style.height = `${ui.split}px`;

      saveUI(ui);
    }

    function ensureSelfNameBestEffort() {
      if (settings.colors.selfName) return;
      try {
        const s = window?.SERVERDATA?.username;
        if (typeof s === "string" && s.trim()) {
          settings.colors.selfName = s.trim();
          saveSettings(settings);
        } else if (typeof localStorage?.username === "string" && localStorage.username.trim()) {
          settings.colors.selfName = localStorage.username.trim();
          saveSettings(settings);
        }
      } catch {}
    }

    function getAuthorColor(name) {
      const nm = String(name || "").trim();
      if (!nm) return normalizeHex(settings.colors.defaultNameColor) || null;

      const self = settings.colors.selfName;
      if (self && nm.toLowerCase() === String(self).toLowerCase()) {
        return normalizeHex(settings.colors.selfColor) || normalizeHex(settings.colors.defaultNameColor) || null;
      }
      const map = settings.colors.nameColors || {};
      return normalizeHex(map[nm]) || normalizeHex(settings.colors.defaultNameColor) || null;
    }

    function appendLine(msg) {
      if (!scroller) return;

      const atBottom = isAtBottom();

      const line = document.createElement("div");
      line.className = "bcp-line";

      if (msg.type === "notice") {
        const t = document.createElement("div");
        t.className = "bcp-text bcp-notice";
        t.textContent = msg.text;
        line.appendChild(t);
      } else if (msg.type === "notice2") {
        const t = document.createElement("div");
        t.className = "bcp-text bcp-notice2";
        t.textContent = msg.text;
        line.appendChild(t);
      } else {
        const a = document.createElement("div");
        a.className = "bcp-author";
        a.textContent = msg.author || "Unknown";
        const col = getAuthorColor(a.textContent);
        if (col) a.style.color = col;

        const t = document.createElement("div");
        t.className = "bcp-text";
        t.textContent = msg.text || "";

        line.appendChild(a);
        line.appendChild(t);
      }

      scroller.appendChild(line);

      while (scroller.childNodes.length > MAX_MESSAGES) {
        scroller.removeChild(scroller.firstChild);
      }

      if (!ui.collapsed && atBottom) scrollToBottom();
    }

    function renderAll() {
      if (!scroller) return;
      scroller.textContent = "";
      for (const m of history) appendLine(m);
      scrollToBottom();
    }

    function pushMessage(msg) {
      history.push(msg);
      if (history.length > MAX_MESSAGES) history = history.slice(-MAX_MESSAGES);
      saveHistory(history);

      if (panel) {
        appendLine(msg);
        if (ui.collapsed) {
          unread++;
          hadUnreadWhileCollapsed = true;
          setBadge();
        }
      }
    }

    // ---------------------------
    // Notice filtering
    // ---------------------------
    function isServerNoticeText(t) {
      const s = String(t || "");
      return /connected to server|welcome to gamepop|loading level data|disconnected from room|cannot connect to the server|reconnect/i.test(s);
    }

    function isJoinedLeftText(t) {
      const s = String(t || "");
      return /\sjoined\.\s*$|\sleft\.\s*$/i.test(s);
    }

    function shouldShowNotice(text) {
      if (!settings.behavior.showServerNotices) {
        if (isServerNoticeText(text)) return false;
        if (isJoinedLeftText(text)) return false;
        return false;
      }
      return true;
    }

    // ---------------------------
    // Color popup (color + alpha)
    // ---------------------------
    let colorCommitFn = null;

    function openColorPopup(title, rgbaValue, fallbackRgba, onCommit) {
      if (!colorPopup) return;

      const pick = resolveRgbaToPicker(rgbaValue, fallbackRgba);

      colorPopupTitle.textContent = title;
      colorPicker.value = pick.hex;
      alphaRange.value = String(Math.round(pick.a * 100));
      alphaLabel.textContent = `${Math.round(pick.a * 100)}%`;

      const updatePreview = () => {
        const p = hexToParts(colorPicker.value) || { r: 255, g: 255, b: 255 };
        const a = clamp(+alphaRange.value / 100, 0, 1);
        previewBox.style.background = partsToRgba({ ...p, a });
      };

      alphaRange.oninput = () => {
        alphaLabel.textContent = `${clamp(+alphaRange.value, 0, 100)}%`;
        updatePreview();
      };
      colorPicker.oninput = updatePreview;

      updatePreview();

      colorCommitFn = () => {
        const p = hexToParts(colorPicker.value) || { r: 255, g: 255, b: 255 };
        const a = clamp(+alphaRange.value / 100, 0, 1);
        const rgba = partsToRgba({ ...p, a });
        onCommit?.(rgba);
      };

      colorPopup.style.display = "block";
    }

    function closeColorPopup(commit = false) {
      if (!colorPopup) return;
      if (commit && typeof colorCommitFn === "function") colorCommitFn();
      colorCommitFn = null;
      colorPopup.style.display = "none";
    }

    // ---------------------------
    // Settings Modal
    // ---------------------------
    function openSettings() {
      if (!overlay) return;
      overlay.style.display = "block";
      rebuildSettingsUI();
    }

    function closeSettings() {
      if (!overlay) return;
      overlay.style.display = "none";
      captureTarget = null;
      if (captureHintEl) captureHintEl.textContent = "";
      captureHintEl = null;
      refreshPlaceholder();
    }

    function makeToggle(on, onChange) {
      const t = el("div", { class: "bcp-toggle" });
      t.dataset.on = on ? "1" : "0";
      t.addEventListener("click", (e) => {
        e.preventDefault();
        e.stopPropagation();
        const next = t.dataset.on !== "1";
        t.dataset.on = next ? "1" : "0";
        onChange(!!next);
      });
      return t;
    }

    function rebuildSettingsUI() {
      if (!modal) return;
      const mb = modal.querySelector("#__betterchatpop_modalBody");
      if (!mb) return;

      ensureSelfNameBestEffort();
      mb.textContent = "";

      // ---- Keys
      const keysCard = el("div", { class: "bcp-card" });
      keysCard.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Keys")]));
      keysCard.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode("Click Set and press a key. While settings are open, hotkeys are blocked.")]));

      const capHint = el("div", { class: "bcp-sub", style: "color: rgba(255,210,120,0.95); font-weight:900; min-height:16px;" });
      captureHintEl = capHint;

      function keyRow(label, keyName, allowEmpty = false) {
        const row = el("div", { class: "bcp-row" });
        const left = el("div", {});
        left.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode(label)]));
        const right = el("div", { style: "display:flex; gap:8px; align-items:center;" });

        const display = el("div", { class: "bcp-input", style: "min-width: 90px;" });
        display.textContent = keyLabel(settings.keys[keyName]);

        const btnSet = el("button", { class: "bcp-btn", type: "button" });
        btnSet.textContent = "Set";
        btnSet.addEventListener("click", () => {
          captureTarget = keyName;
          capHint.textContent = `Press a key for "${label}"...`;
        });

        right.append(display, btnSet);

        if (allowEmpty) {
          const btnClearKey = el("button", { class: "bcp-btn bcp-btn-danger", type: "button" });
          btnClearKey.textContent = "Unbind";
          btnClearKey.addEventListener("click", () => {
            settings.keys[keyName] = "";
            saveSettings(settings);
            capHint.textContent = `"${label}" unbound.`;
            rebuildSettingsUI();
            refreshPlaceholder();
          });
          right.append(btnClearKey);
        }

        row.append(left, right);
        return row;
      }

      keysCard.appendChild(keyRow("Toggle hide/show", "toggleHide"));
      keysCard.appendChild(keyRow("Open chat (focus input)", "openChat"));
      keysCard.appendChild(keyRow("Toggle lock (optional)", "toggleLock", true));
      keysCard.appendChild(capHint);

      // ---- Behavior
      const behCard = el("div", { class: "bcp-card" });
      behCard.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Behavior")]));

      function behaviorRow(label, sub, value, onChange) {
        const row = el("div", { class: "bcp-row" });
        const left = el("div", {});
        left.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode(label)]));
        if (sub) left.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode(sub)]));
        const tog = makeToggle(!!value, onChange);
        row.append(left, tog);
        return row;
      }

      behCard.appendChild(behaviorRow(
        "Scroll to bottom on open if unread",
        "If messages arrived while hidden, opening chat can jump to the latest line.",
        settings.behavior.scrollToBottomOnOpenIfUnread,
        (v) => { settings.behavior.scrollToBottomOnOpenIfUnread = v; saveSettings(settings); }
      ));

      behCard.appendChild(behaviorRow(
        "Lock chat position/size",
        "Prevents dragging and resizing (split still works).",
        settings.behavior.lockUI,
        (v) => { setLocked(v); }
      ));

      behCard.appendChild(behaviorRow(
        "Show join/leave messages",
        `Shows "… joined." and "… left." (generated client-side).`,
        settings.behavior.showJoinLeave,
        (v) => { settings.behavior.showJoinLeave = v; saveSettings(settings); }
      ));

      behCard.appendChild(behaviorRow(
        "Show server notices",
        "Examples: Connected / Loading / Welcome. Also hides private-server join/leave notices when off.",
        settings.behavior.showServerNotices,
        (v) => { settings.behavior.showServerNotices = v; saveSettings(settings); }
      ));

      behCard.appendChild(behaviorRow(
        "Return to game after sending",
        "After pressing Enter, the input will blur (like ESC). Turn off to keep typing.",
        settings.behavior.blurAfterSend,
        (v) => { settings.behavior.blurAfterSend = v; saveSettings(settings); }
      ));

      // ---- UI
      const uiCard = el("div", { class: "bcp-card" });
      uiCard.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("UI")]));
      uiCard.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode("Font size is stored locally.")]));

      const fsRow = el("div", { class: "bcp-row" });
      const fsLeft = el("div", {});
      fsLeft.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Font size (px)")]));
      const fsInput = el("input", { class: "bcp-input", type: "text", value: String(clamp(+settings.ui.fontSize || 12, 9, 20)), style: "min-width: 90px;" });
      fsInput.addEventListener("input", () => {
        const n = clamp(parseInt(fsInput.value, 10) || 12, 9, 20);
        settings.ui.fontSize = n;
        saveSettings(settings);
        applyVars(settings);
      });
      fsRow.append(fsLeft, fsInput);
      uiCard.appendChild(fsRow);

      // ---- Theme
      const themeCard = el("div", { class: "bcp-card" });
      themeCard.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Theme")]));
      themeCard.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode("Click Edit to adjust (includes alpha).")]));

      function themeColorRow(label, key, fallback) {
        const row = el("div", { class: "bcp-row" });
        const left = el("div", {});
        left.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode(label)]));
        const btn = el("button", { class: "bcp-btn", type: "button" });
        btn.textContent = "Edit";
        btn.addEventListener("click", () => {
          openColorPopup(label, settings.theme[key], fallback, (rgba) => {
            settings.theme[key] = rgba;
            saveSettings(settings);
            applyVars(settings);
          });
        });
        row.append(left, btn);
        return row;
      }

      themeCard.appendChild(themeColorRow("Panel background", "bg", DEFAULTS.theme.bg));
      themeCard.appendChild(themeColorRow("Inner background", "bg2", DEFAULTS.theme.bg2));
      themeCard.appendChild(themeColorRow("Panel border", "border", DEFAULTS.theme.border));
      themeCard.appendChild(themeColorRow("Panel border (bottom)", "border2", DEFAULTS.theme.border2));
      themeCard.appendChild(el("div", { class: "bcp-divider" }));
      themeCard.appendChild(themeColorRow("UI text color", "text", DEFAULTS.theme.text));
      themeCard.appendChild(themeColorRow("Dim text color", "dim", DEFAULTS.theme.dim));
      themeCard.appendChild(themeColorRow("Accent color (fallback name color)", "accent", DEFAULTS.theme.accent));
      themeCard.appendChild(el("div", { class: "bcp-divider" }));
      themeCard.appendChild(themeColorRow("Unread badge background", "badgeBg", DEFAULTS.theme.badgeBg));
      themeCard.appendChild(themeColorRow("Unread badge text", "badgeText", DEFAULTS.theme.badgeText));

      // ---- Name & text colors
      const colorCard = el("div", { class: "bcp-card" });
      colorCard.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Name & text colors")]));
      colorCard.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode("Self + per-user colors are hex. Default message text uses popup (rgba+alpha).")]));

      const selfRow = el("div", { class: "bcp-row" });
      const selfLeft = el("div", {});
      selfLeft.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Your name (for self color)")]));
      selfLeft.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode("Auto-detected, but you can override.")]));
      const selfInp = el("input", { class: "bcp-input", type: "text", value: settings.colors.selfName || "" });
      selfInp.addEventListener("input", () => {
        settings.colors.selfName = selfInp.value.trim() || null;
        saveSettings(settings);
      });
      selfRow.append(selfLeft, selfInp);
      colorCard.appendChild(selfRow);

      const selfColRow = el("div", { class: "bcp-row" });
      const selfColLeft = el("div", {});
      selfColLeft.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Your name color")]));
      const selfCol = el("input", { class: "bcp-color", type: "color" });
      selfCol.value = normalizeHex(settings.colors.selfColor) || "#a8c5ff";
      selfCol.addEventListener("input", () => {
        settings.colors.selfColor = selfCol.value;
        saveSettings(settings);
        if (scroller) { const old = scroller.scrollTop; renderAll(); scroller.scrollTop = old; }
      });
      selfColRow.append(selfColLeft, selfCol);
      colorCard.appendChild(selfColRow);

      const defNameRow = el("div", { class: "bcp-row" });
      const defNameLeft = el("div", {});
      defNameLeft.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Default name color")]));
      const defName = el("input", { class: "bcp-color", type: "color" });
      defName.value = normalizeHex(settings.colors.defaultNameColor) || "#a8c5ff";
      defName.addEventListener("input", () => {
        settings.colors.defaultNameColor = defName.value;
        saveSettings(settings);
        applyVars(settings);
        if (scroller) { const old = scroller.scrollTop; renderAll(); scroller.scrollTop = old; }
      });
      defNameRow.append(defNameLeft, defName);
      colorCard.appendChild(defNameRow);

      const defTextRow = el("div", { class: "bcp-row" });
      const defTextLeft = el("div", {});
      defTextLeft.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Default message text color")]));
      const defTextBtn = el("button", { class: "bcp-btn", type: "button" });
      defTextBtn.textContent = "Edit";
      defTextBtn.addEventListener("click", () => {
        openColorPopup("Default message text color", settings.colors.defaultTextColor, DEFAULTS.colors.defaultTextColor, (rgba) => {
          settings.colors.defaultTextColor = rgba;
          saveSettings(settings);
          applyVars(settings);
          if (scroller) { const old = scroller.scrollTop; renderAll(); scroller.scrollTop = old; }
        });
      });
      defTextRow.append(defTextLeft, defTextBtn);
      colorCard.appendChild(defTextRow);

      colorCard.appendChild(el("div", { class: "bcp-divider" }));

      const assignTitle = el("div", { class: "bcp-label" }, [document.createTextNode("Assign user color")]);
      const assignSub = el("div", { class: "bcp-sub" }, [document.createTextNode("Type a username exactly as shown in chat, pick a color, then Add/Update.")]);
      colorCard.append(assignTitle, assignSub);

      const assignRow = el("div", { class: "bcp-row" });
      const nameInp = el("input", { class: "bcp-input", type: "text", value: "", placeholder: "Username" });
      const colInp = el("input", { class: "bcp-color", type: "color" });
      colInp.value = "#a8c5ff";
      const btnAdd = el("button", { class: "bcp-btn", type: "button" });
      btnAdd.textContent = "Add/Update";
      btnAdd.addEventListener("click", () => {
        const nm = nameInp.value.trim();
        const col = normalizeHex(colInp.value);
        if (!nm || !col) return;
        if (!settings.colors.nameColors) settings.colors.nameColors = {};
        settings.colors.nameColors[nm] = col;
        saveSettings(settings);
        rebuildSettingsUI();
        if (scroller) { const old = scroller.scrollTop; renderAll(); scroller.scrollTop = old; }
      });
      assignRow.append(nameInp, colInp, btnAdd);
      colorCard.appendChild(assignRow);

      const map = settings.colors.nameColors || {};
      const keys = Object.keys(map);
      if (keys.length) {
        const list = el("div", { class: "bcp-card", style: "margin-top:10px; padding:10px;" });
        list.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Saved user colors")]));
        for (const k of keys.sort((a, b) => a.localeCompare(b))) {
          const row = el("div", { class: "bcp-row", style: "margin: 8px 0;" });
          const left = el("div", { style: "display:flex; align-items:center; gap:10px;" });
          const sw = el("div", { style: `width:18px;height:18px;border-radius:999px;background:${map[k]};border:1px solid rgba(255,255,255,0.18);` });
          const nm = el("div", { class: "bcp-label", style: "font-weight:900;" }, [document.createTextNode(k)]);
          left.append(sw, nm);

          const right = el("div", { style: "display:flex; align-items:center; gap:8px;" });
          const btnDel = el("button", { class: "bcp-btn bcp-btn-danger", type: "button" });
          btnDel.textContent = "Remove";
          btnDel.addEventListener("click", () => {
            delete settings.colors.nameColors[k];
            saveSettings(settings);
            rebuildSettingsUI();
            if (scroller) { const old = scroller.scrollTop; renderAll(); scroller.scrollTop = old; }
          });

          right.append(btnDel);
          row.append(left, right);
          list.appendChild(row);
        }
        colorCard.appendChild(list);
      }

      mb.append(keysCard, behCard, uiCard, themeCard, colorCard);
    }

    // ---------------------------
    // Placeholder helper (dynamic)
    // ---------------------------
    function refreshPlaceholder() {
      if (!input) return;
      const k = String(settings.keys.openChat || "t").trim() || "t";
      input.placeholder = `Press ${k.toUpperCase()} to chat.`;
    }

    // ---------------------------
    // Build UI
    // ---------------------------
    function ensureUI() {
      if (root && document.body.contains(root)) return;

      root = el("div", { id: "__betterchatpop_root" });
      panel = el("div", { id: "__betterchatpop_panel" });

      header = el("div", { id: "__betterchatpop_header" });
      const titleWrap = el("div", { id: "__betterchatpop_title" });
      badge = el("div", { id: "__betterchatpop_badge" });
      const title = document.createElement("div");
      title.textContent = "Chat";
      titleWrap.append(title, badge);

      const btnWrap = el("div", { style: "display:flex;gap:8px;align-items:center;" });

      btnClear = el("button", { class: "bcp-btn bcp-hide-when-collapsed", type: "button" });
      btnClear.textContent = "Clear";
      btnClear.addEventListener("click", (e) => {
        e.stopPropagation();
        if (ui.collapsed) return;

        if (!clearArmed) { armClear(); return; }
        doClear();
      });

      btnHide = el("button", { class: "bcp-btn", type: "button" });
      btnHide.textContent = ui.collapsed ? "Open" : "Hide";
      btnHide.addEventListener("click", (e) => {
        e.stopPropagation();
        disarmClear();
        setCollapsed(!ui.collapsed);
      });

      btnGear = el("button", { class: "bcp-btn", type: "button", title: "Settings" });
      btnGear.textContent = "⚙";
      btnGear.addEventListener("click", (e) => {
        e.stopPropagation();
        disarmClear();
        openSettings();
      });

      btnWrap.append(btnClear, btnHide, btnGear);
      header.append(titleWrap, btnWrap);

      body = el("div", { id: "__betterchatpop_body" });

      scroller = el("div", { id: "__betterchatpop_scroller" });
      scroller.addEventListener("scroll", () => { if (clearArmed) disarmClear(); });

      splitBar = el("div", { id: "__betterchatpop_split" });

      inputWrap = el("div", { id: "__betterchatpop_inputWrap" });

      input = el("textarea", {
        id: "__betterchatpop_input",
        rows: "2",
        maxlength: String(INPUT_MAXLEN),
      });

      refreshPlaceholder();

      inputWrap.append(input);

      body.append(scroller, splitBar, inputWrap);

      // resize handles
      const hr = el("div", { class: "bcp-resize-r" });
      const hb = el("div", { class: "bcp-resize-b" });
      const hbr = el("div", { class: "bcp-resize-br" });

      panel.append(header, body, hr, hb, hbr);
      root.append(panel);
      document.body.appendChild(root);

      // Settings overlay + modal
      overlay = el("div", { id: "__betterchatpop_overlay" });
      modal = el("div", { id: "__betterchatpop_modal" });

      const mh = el("div", { id: "__betterchatpop_modalHeader" });
      const mt = el("div", { id: "__betterchatpop_modalTitle" });
      mt.appendChild(document.createTextNode("BetterChatPop Settings"));

      const closeBtn = el("button", { class: "bcp-btn", type: "button" });
      closeBtn.textContent = "Close";
      closeBtn.addEventListener("click", () => closeSettings());

      mh.append(mt, closeBtn);

      const mb = el("div", { id: "__betterchatpop_modalBody" });
      modal.append(mh, mb);
      overlay.append(modal);
      overlay.addEventListener("click", (e) => { if (e.target === overlay) closeSettings(); });
      document.body.appendChild(overlay);

      // Block page hotkeys while Settings overlay is open, but still allow typing inside the modal.
        function blockOverlayHotkeys(e) {
            // When capturing a new hotkey in settings:
            if (captureTarget) {
                e.preventDefault();
                e.stopPropagation();

                const k = parseKeyString(e.key);
                if (!k) return;

                settings.keys[captureTarget] = k;
                saveSettings(settings);

                if (captureHintEl) captureHintEl.textContent = `Set to ${keyLabel(k)}.`;
                captureTarget = null;

                rebuildSettingsUI();
                refreshPlaceholder();
                return;
            }

            // Close settings with ESC
            if (e.key === "Escape") {
                e.preventDefault();
                e.stopPropagation();
                closeSettings();
                return;
            }

  // Otherwise: allow the key to be typed, but prevent it from bubbling to page hotkeys
  e.stopPropagation();
}

overlay.addEventListener("keydown", blockOverlayHotkeys, false);
overlay.addEventListener("keypress", blockOverlayHotkeys, false);
overlay.addEventListener("keyup", blockOverlayHotkeys, false);

      // Color popup
      colorPopup = el("div", { id: "__betterchatpop_colorPopup" });
      const colorPopupBox = el("div", { id: "__betterchatpop_colorPopupBox" });

      const cph = el("div", { id: "__betterchatpop_colorPopupHeader" });
      colorPopupTitle = el("div", { id: "__betterchatpop_colorPopupTitle" });
      const cpClose = el("button", { class: "bcp-btn", type: "button" });
      cpClose.textContent = "Close";
      cpClose.addEventListener("click", () => closeColorPopup(false));
      cph.append(colorPopupTitle, cpClose);

      const cpb = el("div", { id: "__betterchatpop_colorPopupBody" });
      colorPicker = el("input", { class: "bcp-color", type: "color", style: "width: 100%; height: 44px; border-radius: 12px;" });

      const alphaRow = el("div", { class: "bcp-alphaRow" });
      const alphaLeft = el("div", { class: "bcp-label" });
      alphaLeft.textContent = "Alpha";
      alphaRange = el("input", { class: "bcp-range", type: "range", min: "0", max: "100", value: "100" });
      alphaLabel = el("div", { class: "bcp-label", style: "min-width:48px; text-align:right;" });
      alphaLabel.textContent = "100%";
      alphaRow.append(alphaLeft, alphaRange, alphaLabel);

      previewBox = el("div", { class: "bcp-preview" });

      const cpActions = el("div", { style: "display:flex; gap:10px; justify-content:flex-end;" });
      const cpCancel = el("button", { class: "bcp-btn", type: "button" });
      cpCancel.textContent = "Cancel";
      cpCancel.addEventListener("click", () => closeColorPopup(false));

      const cpSave = el("button", { class: "bcp-btn", type: "button" });
      cpSave.textContent = "Save";
      cpSave.addEventListener("click", () => closeColorPopup(true));

      cpActions.append(cpCancel, cpSave);

      cpb.append(colorPicker, alphaRow, previewBox, cpActions);
      colorPopupBox.append(cph, cpb);
      colorPopup.append(colorPopupBox);
      colorPopup.addEventListener("click", (e) => { if (e.target === colorPopup) closeColorPopup(false); });
      document.body.appendChild(colorPopup);

      // state apply
      panel.classList.toggle("bcp-collapsed", ui.collapsed);
      if (btnClear) btnClear.style.display = ui.collapsed ? "none" : "inline-block";
      setBadge();

      // lock UI visual
      panel.classList.toggle("bcp-locked", !!settings.behavior.lockUI);
      panel.classList.toggle("bcp-draggable", !settings.behavior.lockUI);

      // apply position / sizes
      setRootPosFromUI();
      renderAll();
      hideOldChat();

      // textarea autosize
      function autoResizeTextarea(reset = false) {
        if (!input) return;
        if (reset) {
          input.style.height = "0px";
          input.style.height = "38px";
          return;
        }
        input.style.height = "0px";
        const h = clamp(input.scrollHeight, 38, 160);
        input.style.height = h + "px";
      }

      input.addEventListener("input", () => autoResizeTextarea(false));

      function doSend() {
        const text = (input.value || "").trim().slice(0, INPUT_MAXLEN);
        if (!text) return false;

        try {
          if (window.chat && typeof window.chat._$1I === "function") {
            window.chat._$1I(text);
          }
        } catch {}

        input.value = "";
        autoResizeTextarea(true);
        return true;
      }

      input.addEventListener("keydown", (e) => {
        if (e.key === "Escape") {
          e.preventDefault();
          e.stopPropagation();
          try { input.blur(); } catch {}
          return;
        }

        if (e.key === "Enter" && !e.shiftKey) {
          e.preventDefault();
          e.stopPropagation();
          disarmClear();
          const sent = doSend();
          if (sent && settings.behavior.blurAfterSend) {
            try { input.blur(); } catch {}
          }
          return;
        }

        e.stopPropagation();
      }, true);

      input.addEventListener("keypress", (e) => e.stopPropagation(), true);
      input.addEventListener("keyup", (e) => e.stopPropagation(), true);

      micro(() => autoResizeTextarea(true));

      // ---------------------------
      // Dragging (bottom anchored)
      // ---------------------------
      header.addEventListener("mousedown", (e) => {
        if (settings.behavior.lockUI) return;
        const target = e.target;
        if (target && (target.closest?.("button") || target.closest?.("input") || target.closest?.("textarea"))) return;

        dragging = true;
        disarmClear();

        const rect = root.getBoundingClientRect();
        dragStart = {
          mx: e.clientX,
          my: e.clientY,
          left: rect.left,
          bottom: window.innerHeight - rect.bottom
        };

        e.preventDefault();
        e.stopPropagation();
      });

      // ---------------------------
      // Resizing panel (width + body height)
      // ---------------------------
      function beginResize(mode, e) {
        if (settings.behavior.lockUI) return;

        resizing = true;
        resizeMode = mode;
        disarmClear();

        const rect = root.getBoundingClientRect();
        resizeStart = { mx: e.clientX, my: e.clientY, w: rect.width, bodyH: ui.bodyH };

        e.preventDefault();
        e.stopPropagation();
      }

      hr.addEventListener("mousedown", (e) => beginResize("r", e));
      hb.addEventListener("mousedown", (e) => beginResize("b", e));
      hbr.addEventListener("mousedown", (e) => beginResize("br", e));

      // ---------------------------
      // Split drag (ALWAYS allowed, even when locked)
      // ---------------------------
      splitBar.addEventListener("mousedown", (e) => {
        splitting = true;
        disarmClear();

        splitStart = { my: e.clientY, split: ui.split };

        e.preventDefault();
        e.stopPropagation();
      });

      // ---------------------------
      // Global mouse move/up
      // ---------------------------
      window.addEventListener("mousemove", (e) => {
        if (dragging && dragStart) {
          const dx = e.clientX - dragStart.mx;
          const dy = e.clientY - dragStart.my;

          ui.x = clamp(dragStart.left + dx, 0, Math.max(0, window.innerWidth - 80));
          ui.bottom = clamp(dragStart.bottom - dy, 0, Math.max(0, window.innerHeight - 80));

          saveUI(ui);
          setRootPosFromUI();
          return;
        }

        if (resizing && resizeStart) {
          const dx = e.clientX - resizeStart.mx;
          const dy = e.clientY - resizeStart.my;

          if (resizeMode === "r" || resizeMode === "br") {
            ui.w = clamp(resizeStart.w + dx, 260, Math.max(260, window.innerWidth - 16));
          }
          if (resizeMode === "b" || resizeMode === "br") {
            const maxBodyH = Math.max(160, window.innerHeight - 140);
            ui.bodyH = clamp(resizeStart.bodyH + (-dy), 160, maxBodyH);
            ui.split = clamp(ui.split, 90, ui.bodyH - 70);
          }

          saveUI(ui);
          setRootPosFromUI();
          return;
        }

        if (splitting && splitStart) {
          const dy = e.clientY - splitStart.my;
          ui.split = clamp(splitStart.split + dy, 90, ui.bodyH - 70);
          saveUI(ui);
          setRootPosFromUI();
        }
      }, { passive: true });

      window.addEventListener("mouseup", () => {
        dragging = false; dragStart = null;
        resizing = false; resizeMode = null; resizeStart = null;
        splitting = false; splitStart = null;
      });

      window.addEventListener("mousedown", (e) => {
        if (!clearArmed) return;
        if (btnClear && (e.target === btnClear || btnClear.contains(e.target))) return;
        disarmClear();
      }, true);

      window.addEventListener("resize", () => {
        ui.x = clamp(ui.x, 0, Math.max(0, window.innerWidth - 80));
        ui.bottom = clamp(ui.bottom, 0, Math.max(0, window.innerHeight - 80));
        ui.w = clamp(ui.w, 260, Math.max(260, window.innerWidth - 16));
        const maxBodyH = Math.max(160, window.innerHeight - 140);
        ui.bodyH = clamp(ui.bodyH, 160, maxBodyH);
        ui.split = clamp(ui.split, 90, ui.bodyH - 70);
        saveUI(ui);
        setRootPosFromUI();
      });

      rebuildSettingsUI();
    }

    // ---------------------------
    // Key handling
    // ---------------------------
    function handleKeys(e) {
      if (isColorPopupOpen()) {
        if (e.key === "Escape") {
          e.preventDefault();
          e.stopPropagation();
          e.stopImmediatePropagation?.();
          closeColorPopup(false);
          return;
        }
        e.stopPropagation();
        e.stopImmediatePropagation?.();
        return;
      }

      if (isSettingsOpen()) {
        if (captureTarget) {
          e.preventDefault();
          e.stopPropagation();
          e.stopImmediatePropagation?.();
          const k = parseKeyString(e.key);
          if (!k) return;

          settings.keys[captureTarget] = k;
          saveSettings(settings);

          if (captureHintEl) captureHintEl.textContent = `Set to ${keyLabel(k)}.`;
          captureTarget = null;

          rebuildSettingsUI();
          refreshPlaceholder();
          return;
        }

        e.stopPropagation();
        e.stopImmediatePropagation?.();

        if (e.key === "Escape") {
          e.preventDefault();
          closeSettings();
        }
        return;
      }

      if (isTypingTarget(e)) return;

      const key = parseKeyString(e.key);
      if (!key) return;

      const toggleKey = parseKeyString(settings.keys.toggleHide);
      const openKey = parseKeyString(settings.keys.openChat);
      const lockKey = parseKeyString(settings.keys.toggleLock);

      if (toggleKey && key === toggleKey) {
        ensureUI();
        hideOldChat();
        e.preventDefault();
        e.stopPropagation();
        disarmClear();
        setCollapsed(!ui.collapsed);
        return;
      }

      if (openKey && key === openKey) {
        ensureUI();
        hideOldChat();
        e.preventDefault();
        e.stopPropagation();
        disarmClear();
        setCollapsed(false);
        unread = 0;
        hadUnreadWhileCollapsed = false;
        setBadge();
        micro(() => { try { input?.focus(); } catch {} });
        return;
      }

      if (lockKey && key === lockKey) {
        ensureUI();
        hideOldChat();
        e.preventDefault();
        e.stopPropagation();
        setLocked(!settings.behavior.lockUI);
        return;
      }
    }

    // ---------------------------
    // Player name helpers (fix Unknown left for pre-existing players)
    // ---------------------------
    function getNameFromPayload(payload) {
      const n = String(payload?.player?.username ?? payload?.player?.name ?? payload?.username ?? payload?.name ?? payload?.n ?? "").trim();
      return n || "Unknown";
    }

    function getIdFromPayload(payload) {
      const id =
        payload?.player?.sid ?? payload?.player?.id ?? payload?.sid ?? payload?.id ?? payload?.playerId ?? payload?.player_id;
      const s = (id === 0 || id) ? String(id) : "";
      return s || "";
    }

    function rememberPlayer(payload) {
      try {
        const sid = getIdFromPayload(payload);
        const name = getNameFromPayload(payload);
        if (sid && name && name !== "Unknown") playerNameById.set(sid, name);
      } catch {}
    }

    function resolveNameForLeave(payload) {
      const sid = getIdFromPayload(payload);
      const direct = getNameFromPayload(payload);
      if (direct && direct !== "Unknown") return direct;
      if (sid && playerNameById.has(sid)) return playerNameById.get(sid);
      return "Unknown";
    }

    // Hook room init to fill playerNameById from initial player list
      function hookRoomInitForPlayerMap() {
          if (roomInitHooked) return true;

          try {
              if (!window.RoomController || !window.RoomController.prototype) return false;

              const proto = window.RoomController.prototype;
              const orig = proto._$a2;
              if (typeof orig !== "function") return false;

              proto._$a2 = function (payload) {
                  try {
                      const players = payload?.players;
                      if (players && typeof players === "object") {
                          for (const id of Object.keys(players)) {
                              const p = players[id];
                              const name = String(p?.username || p?.name || "").trim();
                              if (id && name) {
                                  playerNameById.set(String(id), name);
                              }
                          }
                      }
                  } catch {}

                  return orig.apply(this, arguments);
              };

              roomInitHooked = true;
              return true;
          } catch {
              return false;
          }
      }


    // ---------------------------
    // Hook chat messages from the game
    // ---------------------------
      function hookChatReceive() {
          try {
              const chat = window.chat;
              if (!chat || chat.__bcpHooked) return false;

              // Simple dedupe to avoid double-captures within the same tick
              let lastSig = "";
              let lastTs = 0;
              const shouldAccept = (sig) => {
                  const t = now();
                  if (sig && sig === lastSig && (t - lastTs) < 250) return false;
                  lastSig = sig;
                  lastTs = t;
                  return true;
              };

              // ---- Hook normal chat messages: _$62(author, text)
              if (typeof chat._$62 === "function" && !chat._$62.__bcpWrapped) {
                  const orig62 = chat._$62;
                  chat._$62 = function (author, text) {
                      try {
                          const a = toPlainText(author).trim() || "Unknown";
                          const m = toPlainText(text).trim();

                          if (m) {
                              const sig = `m|${a}|${m}`;
                              if (shouldAccept(sig)) {
                                  ensureUI();
                                  hideOldChat();
                                  pushMessage({ type: "chat", author: a, text: m, ts: now() });
                              }
                          }
                      } catch {}
                      return orig62.apply(this, arguments);
                  };
                  chat._$62.__bcpWrapped = true;
              }

              // ---- Hook notices: _$8o(text)
              if (typeof chat._$8o === "function" && !chat._$8o.__bcpWrapped) {
                  const orig8o = chat._$8o;
                  chat._$8o = function (text) {
                      try {
                          const t = toPlainText(text).trim();
                          if (t) {
                              const sig = `n|${t}`;
                              if (shouldAccept(sig)) {
                                  // respect your server notice toggle
                                  if (shouldShowNotice(t)) {
                                      ensureUI();
                                      hideOldChat();
                                      pushMessage({ type: "notice", text: t, ts: now() });
                                  }
                              }
                          }
                      } catch {}
                      return orig8o.apply(this, arguments);
                  };
                  chat._$8o.__bcpWrapped = true;
              }

              // ---- Optional: keep compatibility with gpop's focus helper (press T)
              if (typeof chat._$8b === "function" && !chat._$8b.__bcpWrapped) {
                  const orig8b = chat._$8b;
                  chat._$8b = function (evt) {
                      try {
                          ensureUI();
                          hideOldChat();
                          disarmClear();
                          setCollapsed(false);
                          unread = 0;
                          hadUnreadWhileCollapsed = false;
                          setBadge();
                          micro(() => { try { input?.focus(); } catch {} });
                          try { if (evt?.preventDefault) evt.preventDefault(); } catch {}
                      } catch {}
                      try { return orig8b.apply(this, arguments); } catch { return 0; }
                  };
                  chat._$8b.__bcpWrapped = true;
              }

              Object.defineProperty(chat, "__bcpHooked", { value: true, enumerable: false });
              return true;
          } catch {
              return false;
          }
      }



    // ---------------------------
    // Hook socket join/leave events (best effort)
    // ---------------------------
      function hookJoinLeave() {
          try {
              const s = window.socket || window.SOCKET || window.ioSocket;
              if (!s) return false;
              if (s.__bcpJL) return true;
              if (typeof s.on !== "function") return false;

              const getId = (payload) => {
                  const id =
                        payload?.shortid ??
                        payload?.player?.shortid ??
                        payload?.player?.id ??
                        payload?.player?.pid ??
                        payload?.id ??
                        payload?.pid ??
                        payload?.playerId ??
                        payload?.userId ??
                        null;
                  return (id === 0 || id) ? String(id) : "";
              };

              const getName = (payload) => {
                  const n = String(
                      payload?.player?.username ??
                      payload?.player?.name ??
                      payload?.username ??
                      payload?.name ??
                      payload?.player?.n ??
                      payload?.n ??
                      ""
                  ).trim();
                  return n;
              };

              // JOIN
              s.on("playerjoin", function (payload) {
                  try {
                      const sid = getId(payload);
                      const name = getName(payload) || "Unknown";

                      if (sid) playerNameById.set(sid, name);

                      if (!settings.behavior.showJoinLeave) return;
                      ensureUI();
                      hideOldChat();
                      pushMessage({ type: "notice", text: `${name} joined.`, ts: now() });
                  } catch {}
              });

              // LEAVE
              s.on("playerleave", function (payload) {
                  try {
                      const sid = getId(payload);

                      // Prefer cache, because payload often misses name on leave
                      let name = "";
                      if (sid) name = String(playerNameById.get(sid) || "").trim();

                      // Fallback to payload name if cache empty
                      if (!name) name = getName(payload);

                      if (!name) name = "Unknown";

                      // remove from cache
                      if (sid) playerNameById.delete(sid);

                      if (!settings.behavior.showJoinLeave) return;
                      ensureUI();
                      hideOldChat();
                      pushMessage({ type: "notice", text: `${name} left.`, ts: now() });
                  } catch {}
              });

              Object.defineProperty(s, "__bcpJL", { value: true, enumerable: false });
              return true;
          } catch {
              return false;
          }
      }



    // ---------------------------
    // Init
    // ---------------------------
    function boot() {
      // key listeners
      window.addEventListener("keydown", handleKeys, true);

      // install tick hooks
      const started = now();

      (function tick() {
        // stop trying after a while
        if (now() - started > MAX_WAIT_MS) return;

        try { hookRoomInitForPlayerMap(); } catch {}
        try { hookChatReceive(); } catch {}
        try { hookJoinLeave(); } catch {}

        setTimeout(tick, TICK_MS);
      })();

      // Ensure UI exists lazily: only when messages arrive or user presses keys.
    }

    boot();
  }

  try { install(); } catch {}
})();