WhatsApp Collapsible Sidebars

Collapse/expand the WhatsApp Web icon rail AND the chat list with clickable carets (chat-list caret on by default; falls back to hover show/hide when disabled), plus a draggable handle to resize the chat list width.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name        WhatsApp Collapsible Sidebars
// @namespace   JackMBurch
// @version     1.2
// @grant       none
// @license     GNU GPLv3
// @author      JackMBurch
// @contributor imxitiz (original author)
// @match       https://web.whatsapp.com/
// @description Collapse/expand the WhatsApp Web icon rail AND the chat list with clickable carets (chat-list caret on by default; falls back to hover show/hide when disabled), plus a draggable handle to resize the chat list width.
// ==/UserScript==

(() => {
    "use strict";

    // ============================================================
    // DEBUG SYSTEM — Toggle with localStorage.setItem('WHC_DEBUG','true')
    // ============================================================
    const DEBUG = localStorage.getItem("WHC_DEBUG") === "true";
    const log = (...args) => DEBUG && console.log("[WHC]", ...args);
    const warn = (...args) => DEBUG && console.warn("[WHC]", ...args);
    const error = (...args) => console.error("[WHC]", ...args);

    log("Script loaded. DEBUG mode:", DEBUG);
    log("Tip: Set localStorage.setItem('WHC_DEBUG','true') and reload for detailed logs.");

    // ============================================================
    // WHY THIS SCRIPT IS SO CAREFUL ABOUT DOM MUTATIONS
    // ============================================================
    // WhatsApp Web runs its OWN MutationObserver ("trackElements") and performs
    // IndexedDB work during its initial load/sync. If a userscript mutates the
    // DOM heavily *during* that window, WhatsApp's observer fires IndexedDB
    // operations against a transaction that has already closed, throwing
    // "TransactionInactiveError" and freezing the whole app on the loading
    // screen.
    //
    // To avoid this we:
    //   1. Do NOTHING until WhatsApp is fully loaded AND the DOM has gone quiet
    //      (see whenReady()).
    //   2. Drive show/hide/resize by rewriting a <style> element in <head>
    //      (which WhatsApp's app-subtree observer never sees) instead of
    //      mutating #side and its thousands of descendants.
    //   3. Add only two DOM nodes total (a caret + a resize handle), once,
    //      after WhatsApp has settled.
    // ============================================================

    // ============================================================
    // SELECTOR STRATEGY
    // ============================================================
    // SIDEBAR: the chat list pane (#side) — the element we grow/shrink.
    // HEADER / ICON RAIL: the leftmost vertical navigation rail holding the
    //   Chats / Status / Channels / Communities / Meta AI / settings / profile
    //   buttons. In the current DOM this is the <header data-testid=
    //   "chatlist-header"> (the direct <header> child of div.two).
    const Selectors = {
        sidebar: [
            "div.two > div:has(> #side)",
            "#side",
        ],
        header: [
            'header[data-testid="chatlist-header"]',
            "div.two > header",
            "header",
        ],
    };

    const foundElements = {};
    // Cache resolved elements so we don't re-run expensive selectors (notably
    // the :has() one) on every mousemove. Auto-invalidated when detached.
    const elementCache = {};

    function queryFirst(selectorList, label) {
        const cached = elementCache[label];
        if (cached && cached.isConnected) {
            return cached;
        }
        for (let i = 0; i < selectorList.length; i++) {
            const sel = selectorList[i];
            try {
                const el = document.querySelector(sel);
                if (el) {
                    if (!foundElements[label]) {
                        foundElements[label] = true;
                        log(`Found "${label}" with: "${sel}"`);
                    }
                    elementCache[label] = el;
                    return el;
                }
            } catch (e) {
                warn(`Invalid selector "${sel}" for "${label}":`, e.message);
            }
        }
        warn(`No selector matched for "${label}" — tried:`, selectorList);
        return null;
    }

    // ============================================================
    // SETTINGS
    // ============================================================
    // USE_CHATLIST_CARET: when true (default), the chat list is controlled by a
    // dedicated clickable caret (a second handle, next to the icon-rail caret)
    // instead of by mouse hover/placement. Set to false to restore the old
    // behavior where the chat list reveals on hover near the left edge.
    const USE_CHATLIST_CARET = true;

    // ============================================================
    // TUNING — Hover behavior (used only when USE_CHATLIST_CARET is false)
    // ============================================================
    // OPEN_BUFFER: width (px) of the grace zone along the LEFT edge of the
    //   screen that OPENS the chat list when the pointer enters it. This lets
    //   you reveal the chat list without slamming the cursor into the very
    //   edge of the screen (especially when the icon rail is collapsed).
    //   Larger = bigger, easier-to-hit open zone.
    const OPEN_BUFFER = 120;
    // CLOSE_BUFFER: once the chat list is open, how many px to the RIGHT of its
    //   edge the pointer may stray before it starts to hide. Smaller = the chat
    //   list collapses sooner after you move away from it.
    const CLOSE_BUFFER = 30;
    // HIDE_DELAY: grace period (ms) after the pointer leaves before the chat
    //   list actually hides, so brief overshoots don't collapse it.
    const HIDE_DELAY = 450;
    // Color of the resize handle before the first resize. Kept subtle so it
    // blends into WhatsApp instead of being a glaring red bar.
    const HANDLE_COLOR = "rgba(134, 150, 160, 0.35)";

    // Readiness gate: require the chat list pane to exist, then wait for the
    // DOM to go quiet (SETTLE_MS) before doing anything — capped by MAX_WAIT_MS.
    const SETTLE_MS = 700;
    const MAX_WAIT_MS = 5000;

    // ============================================================
    // STATE
    // ============================================================
    let hasInitialized = false;
    let eventParent;
    let isResizing = false;
    let userDefinedFlexBasis =
        parseFloat(getLocalStorageItem("userDefinedFlexBasis")) || 30;
    let userResizedOnce =
        getSessionStorageItem("userResizedOnce") === "true" || false;
    let chatListVisible = false;
    let hideTimer = null;
    let lastVisibility = null;
    let railCollapsed = getLocalStorageItem("railCollapsed") === "true";
    // Persisted collapsed state of the chat list (only used in caret mode).
    // Default false → chat list shown on load.
    let chatListCollapsed = getLocalStorageItem("chatListCollapsed") === "true";
    // Handle for the requestAnimationFrame loop that keeps the caret(s) glued
    // to the moving panel edges during collapse/expand animations.
    let caretRaf = null;
    // The <style> element whose contents we rewrite to show/hide/resize the
    // chat list without mutating WhatsApp's own DOM nodes.
    let dynamicStyleEl = null;

    // ============================================================
    // STORAGE HELPERS
    // ============================================================
    function setSessionStorageItem(key, value) {
        sessionStorage.setItem(key, value);
    }
    function getSessionStorageItem(key) {
        return sessionStorage.getItem(key);
    }
    function setLocalStorageItem(key, value) {
        localStorage.setItem(key, value);
    }
    function getLocalStorageItem(key) {
        return localStorage.getItem(key);
    }

    // ============================================================
    // CHAT LIST VISIBILITY (CSS-driven — no #side DOM mutation)
    // ============================================================
    /**
     * Pixel width for the chat list based on userDefinedFlexBasis (percentage).
     * flex-basis percentages are unreliable in some flex contexts, so we
     * compute pixels from the parent's width.
     */
    function getSidebarPixelWidth(sidebar) {
        const parentWidth = sidebar.parentElement
            ? sidebar.parentElement.clientWidth
            : window.innerWidth;
        return Math.round(parentWidth * (userDefinedFlexBasis / 100));
    }

    /**
     * Rewrite the dynamic <style> element to reflect the current visibility,
     * width and resize state. This mutates a <style> node in <head> only —
     * WhatsApp's app-subtree observer never sees it, so it can't trigger the
     * IndexedDB crash.
     */
    function renderChatListStyle() {
        if (!dynamicStyleEl) return;
        const sidebar = queryFirst(Selectors.sidebar, "sidebar");
        const px = sidebar ? getSidebarPixelWidth(sidebar) : 0;
        const transition = isResizing
            ? "none"
            : "flex-basis .35s ease, max-width .35s ease, min-width .35s ease, width .35s ease";

        let css =
            `.whc-chatlist{position:relative!important;overflow:hidden!important;` +
            `transition:${transition};}`;

        if (chatListVisible) {
            css +=
                `.whc-chatlist{flex:0 0 ${px}px!important;` +
                `max-width:${px}px!important;min-width:${px}px!important;}`;
        } else {
            // A 0-width box still renders its borders as a 1px line, so we must
            // explicitly zero them out — otherwise a stray "edge" line lingers
            // where the chat list used to be.
            css +=
                `.whc-chatlist{flex:0 0 0!important;width:0!important;` +
                `max-width:0!important;min-width:0!important;padding:0!important;` +
                `border:0!important;}`;
            // Clip descendants WhatsApp sets to overflow:visible so nothing
            // bleeds out of the 0-width pane. Scope to #side only — not
            // #main / the conversation compose box (outside .whc-chatlist).
            css +=
                `.whc-chatlist #side,` +
                `.whc-chatlist #side *{overflow:hidden!important;}`;
        }

        // DRAWER DIVIDERS: WhatsApp places two empty drawer holders
        // (data-testid="drawer-left" and "drawer-middle") between the icon rail
        // and the chat list. Each one carries a 1px border-inline-start. Those
        // borders normally hug the rail's right edge, but when the rail is
        // collapsed (or the chat list is hidden) they float free and show up as
        // two stray grey vertical lines. Zero them out in those states.
        if (railCollapsed || !chatListVisible) {
            css +=
                `[data-testid="drawer-left"],[data-testid="drawer-middle"]` +
                `{border-inline-start-width:0!important;}`;
        }

        dynamicStyleEl.textContent = css;
    }

    function setChatListVisible(show) {
        chatListVisible = show;
        if (lastVisibility !== show) {
            lastVisibility = show;
            log("Visibility:", show ? "SHOW" : "HIDE");
        }
        renderChatListStyle();
    }

    /**
     * Show/hide with a grace period. Showing is immediate; hiding is deferred
     * by HIDE_DELAY so brief pointer overshoots don't snap it shut. Any "show"
     * cancels a pending hide. On steady state this performs NO mutation.
     */
    function requestVisibility(show) {
        if (show) {
            if (hideTimer) {
                clearTimeout(hideTimer);
                hideTimer = null;
            }
            if (!chatListVisible) {
                setChatListVisible(true);
            }
        } else if (chatListVisible && !hideTimer) {
            hideTimer = setTimeout(() => {
                hideTimer = null;
                setChatListVisible(false);
            }, HIDE_DELAY);
        }
    }

    // ============================================================
    // RESIZE HANDLE
    // ============================================================
    function createResizeHandle() {
        try {
            const sidebar = queryFirst(Selectors.sidebar, "sidebar");
            if (!sidebar) throw new Error("Sidebar element not found");
            if (document.getElementById("resize-handle")) return;

            const resizeHandle = document.createElement("div");
            resizeHandle.id = "resize-handle";
            resizeHandle.style.width = "10px";
            resizeHandle.style.height = "100%";
            resizeHandle.style.position = "absolute";
            resizeHandle.style.top = "0";
            resizeHandle.style.right = "0";
            resizeHandle.style.cursor = "ew-resize";
            resizeHandle.style.backgroundColor = userResizedOnce
                ? "transparent"
                : HANDLE_COLOR;
            resizeHandle.style.zIndex = "1000";
            sidebar.appendChild(resizeHandle);

            resizeHandle.addEventListener("mousedown", (e) => {
                e.preventDefault();
                isResizing = true;
                renderChatListStyle(); // disable transition during the drag
                document.addEventListener("mousemove", resizeChatList);
                document.addEventListener("mouseup", stopResize);
            });
            log("Resize handle created");
        } catch (err) {
            error("Error creating resize handle:", err);
        }
    }

    function resizeChatList(e) {
        if (!isResizing) return;
        const sidebar = queryFirst(Selectors.sidebar, "sidebar");
        if (!sidebar) return;
        const containerWidth = sidebar.parentElement.clientWidth;
        const newFlexBasis =
            ((e.clientX - sidebar.getBoundingClientRect().left) /
                containerWidth) *
            100;

        if (newFlexBasis >= 5 && newFlexBasis <= 80) {
            userDefinedFlexBasis = newFlexBasis;
            renderChatListStyle();
        }

        if (!userResizedOnce) {
            userResizedOnce = true;
            setSessionStorageItem("userResizedOnce", "true");
            const handle = document.getElementById("resize-handle");
            if (handle) handle.style.backgroundColor = "transparent";
        }
        setLocalStorageItem("userDefinedFlexBasis", userDefinedFlexBasis);
    }

    function stopResize() {
        isResizing = false;
        renderChatListStyle(); // restore transition
        document.removeEventListener("mousemove", resizeChatList);
        document.removeEventListener("mouseup", stopResize);
    }

    // ============================================================
    // UTILITY
    // ============================================================
    function isMouseOver(element) {
        return element && eventParent && element.contains(eventParent.target);
    }

    // ============================================================
    // LEFT ICON RAIL — collapse/expand with a caret toggle
    // ============================================================
    function applyRailState() {
        const rail = queryFirst(Selectors.header, "header");
        if (!rail) return;
        rail.style.transition =
            "width .25s ease, min-width .25s ease, opacity .25s ease";
        if (railCollapsed) {
            rail.style.width = "0px";
            rail.style.minWidth = "0px";
            rail.style.maxWidth = "0px";
            rail.style.overflow = "hidden";
            rail.style.opacity = "0";
            rail.style.pointerEvents = "none";
            // A 0-width box still paints its borders as a 1px line; zero them
            // so no "edge" line lingers where the rail used to be.
            rail.style.borderWidth = "0px";
            // The rail has horizontal padding (~12px each side). With the
            // default content-box sizing, max-width:0 only zeroes the CONTENT
            // box — the ~24px of padding remains and keeps pushing the chat
            // list ~24px to the right. Zero the padding so the rail truly
            // collapses to 0 and the chat list sits flush at the left edge.
            rail.style.padding = "0px";
            // WhatsApp reserves the rail's space via the --navbar-width CSS
            // variable (default 64px on :root): drawer-left's margin, the
            // conversation pane width calc, etc. all derive from it. Collapsing
            // the rail ELEMENT to 0 doesn't change that variable, so the chat
            // list and everything to its right stay offset ~64px. Override the
            // variable to 0 so the reserved space collapses with the rail. Set
            // on <html> (outside <body>) to stay clear of WhatsApp's observer.
            document.documentElement.style.setProperty("--navbar-width", "0px");
        } else {
            rail.style.width = "";
            rail.style.minWidth = "";
            rail.style.maxWidth = "";
            rail.style.overflow = "";
            rail.style.opacity = "";
            rail.style.pointerEvents = "";
            rail.style.borderWidth = "";
            rail.style.padding = "";
            document.documentElement.style.removeProperty("--navbar-width");
        }
        // The collapsed/expanded rail state changes whether the drawer divider
        // lines should be hidden, so refresh the dynamic stylesheet too.
        renderChatListStyle();
        refreshCaretIcons();
        positionCarets();
    }

    function toggleRail() {
        railCollapsed = !railCollapsed;
        setLocalStorageItem("railCollapsed", railCollapsed);
        log("Icon rail collapsed:", railCollapsed);
        applyRailState();
        // Track the panel edges through the rail's width animation (.25s).
        animateCarets(420);
    }

    function createCaretButton() {
        if (document.getElementById("whc-rail-toggle")) return;
        const btn = document.createElement("button");
        btn.id = "whc-rail-toggle";
        btn.type = "button";
        btn.setAttribute("aria-label", "Collapse or expand the icon rail");
        btn.addEventListener("click", (e) => {
            e.preventDefault();
            e.stopPropagation();
            toggleRail();
        });
        document.body.appendChild(btn);
        log("Rail toggle caret created");
    }

    // ============================================================
    // CHAT LIST — collapse/expand with a caret toggle (caret mode)
    // ============================================================
    function createChatListCaret() {
        if (document.getElementById("whc-chatlist-toggle")) return;
        const btn = document.createElement("button");
        btn.id = "whc-chatlist-toggle";
        btn.type = "button";
        btn.setAttribute("aria-label", "Collapse or expand the chat list");
        btn.addEventListener("click", (e) => {
            e.preventDefault();
            e.stopPropagation();
            toggleChatList();
        });
        document.body.appendChild(btn);
        log("Chat list toggle caret created");
    }

    function toggleChatList() {
        chatListCollapsed = !chatListCollapsed;
        setLocalStorageItem("chatListCollapsed", chatListCollapsed);
        log("Chat list collapsed:", chatListCollapsed);
        setChatListVisible(!chatListCollapsed);
        refreshCaretIcons();
        // Track the chat list's right edge through its width animation (.35s).
        animateCarets(420);
    }

    // ============================================================
    // CARET RENDERING
    // ============================================================
    // Set the arrow glyph + tooltip. Each caret points toward the action it
    // performs: collapse (◂, points toward where the panel goes) when open,
    // expand (▸) when collapsed.
    function refreshCaretIcons() {
        const railBtn = document.getElementById("whc-rail-toggle");
        if (railBtn) {
            railBtn.textContent = railCollapsed ? "\u25B8" : "\u25C2";
            railBtn.title = railCollapsed ? "Show icon rail" : "Hide icon rail";
        }
        const chatBtn = document.getElementById("whc-chatlist-toggle");
        if (chatBtn) {
            chatBtn.textContent = chatListVisible ? "\u25C2" : "\u25B8";
            chatBtn.title = chatListVisible
                ? "Hide chat list"
                : "Show chat list";
        }
    }

    // Position both carets by LIVE-measuring the chat list container's edges:
    //   - rail caret  → container's LEFT edge  (= the icon rail's right edge)
    //   - chat caret  → container's RIGHT edge (= the chat list's right edge;
    //                    collapses to the left edge when the chat list is hidden)
    // Measuring live (each animation frame, via animateCarets) keeps the carets
    // perfectly glued to the panels regardless of which animation is running or
    // its easing/duration — no CSS `left` transition is used on the carets.
    function positionCarets() {
        const container = queryFirst(Selectors.sidebar, "sidebar");
        if (!container) return;
        const rect = container.getBoundingClientRect();
        const railBtn = document.getElementById("whc-rail-toggle");
        if (railBtn) {
            railBtn.style.left = `${Math.max(0, Math.round(rect.left))}px`;
        }
        const chatBtn = document.getElementById("whc-chatlist-toggle");
        if (chatBtn) {
            chatBtn.style.left = `${Math.max(0, Math.round(rect.right))}px`;
        }
    }

    // Run a short rAF loop that re-positions the carets every frame for the
    // duration of a collapse/expand animation, so they stay glued to the
    // moving edges (works for both the rail's .25s and the chat list's .35s
    // animations without any easing/duration matching).
    function animateCarets(duration) {
        if (caretRaf) cancelAnimationFrame(caretRaf);
        const start = performance.now();
        const step = (now) => {
            positionCarets();
            if (now - start < duration) {
                caretRaf = requestAnimationFrame(step);
            } else {
                caretRaf = null;
                positionCarets();
            }
        };
        caretRaf = requestAnimationFrame(step);
    }

    // ============================================================
    // MOUSEMOVE — hover show/hide (only active after setup)
    // ============================================================
    function updateSidebarVisibility() {
        const sidebar = queryFirst(Selectors.sidebar, "sidebar");
        const inboxSwitcher = queryFirst(Selectors.header, "header");
        if (!sidebar || !inboxSwitcher) return;

        // Re-assert our class if WhatsApp re-rendered #side (cheap read; only
        // mutates on the rare re-render).
        if (!sidebar.classList.contains("whc-chatlist")) {
            sidebar.classList.add("whc-chatlist");
            renderChatListStyle();
        }
        // Keep the rail in its persisted state across re-renders.
        if (railCollapsed && inboxSwitcher.style.width !== "0px") {
            applyRailState();
        }

        // In caret mode the chat list is driven solely by its caret — skip all
        // hover-based show/hide. We still ran the cheap re-assert above so the
        // chat list / rail survive WhatsApp re-renders.
        if (USE_CHATLIST_CARET) return;

        const x = eventParent.clientX;
        const overSidebar =
            isMouseOver(sidebar) || isMouseOver(inboxSwitcher);

        let show;
        if (overSidebar || isResizing || x <= OPEN_BUFFER) {
            show = true;
        } else if (
            chatListVisible &&
            x <= sidebar.getBoundingClientRect().right + CLOSE_BUFFER
        ) {
            show = true;
        } else {
            show = false;
        }

        requestVisibility(show);
    }

    function onMouseMove(event) {
        eventParent = event;
        try {
            updateSidebarVisibility();
        } catch (err) {
            error("updateSidebarVisibility failed:", err);
        }
    }

    // ============================================================
    // STYLES
    // ============================================================
    function injectStyles() {
        if (!document.getElementById("whc-static-style")) {
            const staticStyle = document.createElement("style");
            staticStyle.id = "whc-static-style";
            staticStyle.textContent = `
                #whc-rail-toggle,
                #whc-chatlist-toggle {
                    position: fixed;
                    transform: translateY(-50%);
                    left: 0;
                    z-index: 100000;
                    width: 24px;
                    height: 56px;
                    padding: 0;
                    border: none;
                    border-radius: 0 12px 12px 0;
                    background: rgba(0, 0, 0, 0.35);
                    color: #fff;
                    font-size: 18px;
                    line-height: 56px;
                    cursor: pointer;
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    opacity: 0.5;
                    /* No 'left' transition: position is driven per-frame by
                       requestAnimationFrame so the carets stay glued to the
                       moving panel edges. */
                    transition: background .2s ease, opacity .2s ease;
                }
                /* Stack the two carets vertically so they never overlap when
                   the chat list is hidden and both sit at the rail's edge. */
                #whc-rail-toggle { top: calc(50% - 32px); }
                #whc-chatlist-toggle { top: calc(50% + 32px); }
                /* The chat-list caret stacks a small chat-bubble glyph above
                   its arrow so it's obvious which caret controls the chat
                   list. The arrow itself is the button's textContent (updated
                   by refreshCaretIcons); the bubble is a generated ::before
                   flex item sitting above it. */
                #whc-chatlist-toggle {
                    flex-direction: column;
                    /* Center the bubble+arrow stack as a group so the midpoint
                       between them lands at the button's center. */
                    justify-content: center;
                    align-items: center;
                    gap: 8px;
                    line-height: 1;
                }
                #whc-chatlist-toggle::before {
                    content: "\\1F4AC";
                    font-size: 11px;
                    line-height: 1;
                }
                #whc-rail-toggle:hover,
                #whc-chatlist-toggle:hover {
                    background: rgba(0, 0, 0, 0.65);
                    opacity: 1;
                }
                /* Subtle resize handle that brightens on hover instead of a
                   permanent red bar. */
                #resize-handle:hover {
                    background-color: rgba(134, 150, 160, 0.6) !important;
                }
                /* Conversation compose: keep typed text the theme default
                   (white in dark mode). Scoped to #main footer so the chat
                   list search box keeps its own placeholder styling. */
                #main div[data-lexical-editor="true"],
                #main footer div[contenteditable="true"][role="textbox"] {
                    color: var(--WDS-content-default) !important;
                    caret-color: var(--WDS-content-default) !important;
                }
                #main span[data-lexical-text="true"] {
                    color: inherit !important;
                }
            `;
            document.head.appendChild(staticStyle);
        }
        if (!dynamicStyleEl) {
            dynamicStyleEl = document.createElement("style");
            dynamicStyleEl.id = "whc-dynamic-style";
            document.head.appendChild(dynamicStyleEl);
        }
    }

    // ============================================================
    // SETUP — runs ONCE, only after WhatsApp has loaded and settled
    // ============================================================
    function setup() {
        if (hasInitialized) return;
        const sidebar = queryFirst(Selectors.sidebar, "sidebar");
        if (!sidebar) {
            // pane-side existed but #side isn't resolvable yet; try shortly.
            setTimeout(setup, 500);
            return;
        }
        hasInitialized = true;
        log("Setting up — WhatsApp is loaded & settled.");

        injectStyles();
        // Tag the chat list element; all sizing is driven via this class in the
        // dynamic stylesheet (single attribute mutation, done once).
        sidebar.classList.add("whc-chatlist");

        // In caret mode, restore the persisted chat list state (default shown).
        // In hover mode, start hidden and let the pointer reveal it.
        chatListVisible = USE_CHATLIST_CARET ? !chatListCollapsed : false;
        renderChatListStyle();

        createResizeHandle();
        createCaretButton();
        if (USE_CHATLIST_CARET) {
            createChatListCaret();
        }
        applyRailState();
        refreshCaretIcons();
        positionCarets();
        // applyRailState() may kick off a collapse animation (when the rail
        // starts collapsed on load), so the panel edges keep moving for a beat
        // after this point. A single positionCarets() above would measure the
        // pre-animation (open-rail) edge and leave the carets floating. Track
        // the edges across the animation so they settle flush against it.
        animateCarets(420);

        // The mousemove listener is always attached: in hover mode it drives
        // show/hide; in caret mode it only re-asserts our state across WhatsApp
        // re-renders (it returns early before any hover logic).
        document.addEventListener("mousemove", onMouseMove);
        window.addEventListener("resize", positionCarets);
        log("Setup complete.");
    }

    // ============================================================
    // READINESS GATE — wait for full load + a quiet DOM before doing anything
    // ============================================================
    function whenReady(cb) {
        let settleTimer = null;
        let paneSeenAt = 0;
        let done = false;

        const finish = (reason) => {
            if (done) return;
            done = true;
            if (settleTimer) clearTimeout(settleTimer);
            observer.disconnect();
            log("Ready (", reason, ") — initializing.");
            cb();
        };

        const check = () => {
            // The chat list pane only exists once WhatsApp is past its initial
            // load/sync (and the user is logged in). Until then, do nothing.
            if (!document.getElementById("pane-side")) return;
            if (!paneSeenAt) paneSeenAt = Date.now();
            if (Date.now() - paneSeenAt >= MAX_WAIT_MS) {
                finish("max-wait");
                return;
            }
            if (settleTimer) clearTimeout(settleTimer);
            settleTimer = setTimeout(() => finish("settled"), SETTLE_MS);
        };

        const observer = new MutationObserver(check);
        observer.observe(document.body, { childList: true, subtree: true });
        check();
    }

    // ============================================================
    // BOOT
    // ============================================================
    function init() {
        log("=== INIT START ===");
        whenReady(setup);
        log("Waiting for WhatsApp to finish loading…");
    }

    function safeInit() {
        try {
            init();
        } catch (err) {
            error("init failed:", err);
        }
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", safeInit);
    } else {
        safeInit();
    }
})();