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 {}
})();