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.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

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