Greasy Fork is available in English.

Perplexity Thread ToC Sidebar

Adds a toggleable floating Table of Contents sidebar showing interleaved prompts and AI responses in a Perplexity thread

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Advertisement:

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

Advertisement:

// ==UserScript==
// @name         Perplexity Thread ToC Sidebar
// @version      2.3.0
// @description  Adds a toggleable floating Table of Contents sidebar showing interleaved prompts and AI responses in a Perplexity thread
// @author       Sergio Dias
// @match        *://www.perplexity.ai/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// @namespace https://github.com/sergiodias/sergios-userscripts
// ==/UserScript==

(function () {
  'use strict';

  const TRUNCATE_LEN = 200;
  const STORAGE_KEY_MODE = 'ptoc-display-mode';
  const DEBOUNCE_MS = 300;
  const RETRY_INTERVAL_MS = 500;
  const RETRY_MAX = 20;
  const SIDEBAR_MIN_WIDTH = 280;
  const SIDEBAR_GAP = 16;
  const STORAGE_KEY_AUTOLOAD = 'ptoc-autoload';
  const AUTOLOAD_SCROLL_DELAY = 250;
  const AUTOLOAD_SCROLL_FACTOR = 0.8;

  // ── CSS ──────────────────────────────────────────────────────────────────

  const CSS = `
    #ptoc-toggle {
      position: fixed;
      top: 80px;
      right: 20px;
      z-index: 99999;
      width: 36px;
      height: 36px;
      border-radius: 50%;
      background: oklch(20.09% 0.003 67.68);
      color: oklch(87.35% 0.002 67.8);
      border: 1px solid #3a3a3a;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 18px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.4);
      transition: background 0.15s;
    }
    #ptoc-toggle:hover {
      background: #2e2e2e;
    }
    #ptoc-sidebar {
      position: fixed;
      top: 128px;
      right: 20px;
      min-width: 280px;
      max-height: calc(100vh - 128px - 20px);
      z-index: 99998;
      background: oklch(20.09% 0.003 67.68);
      color: oklch(87.35% 0.002 67.8);
      box-shadow: 0 4px 16px rgba(0,0,0,0.25);
      border: 1px solid #3a3a3a;
      border-radius: 12px;
      display: flex;
      flex-direction: column;
      overflow: hidden;
      font-family: system-ui, sans-serif;
      font-size: 15px;
      transition: opacity 0.15s ease, transform 0.15s ease;
    }
    #ptoc-sidebar.ptoc-hidden {
      opacity: 0;
      transform: translateY(-8px);
      pointer-events: none;
    }
    #ptoc-header {
      padding: 12px 16px 10px;
      border-radius: 12px 12px 0 0;
      font-weight: 600;
      font-size: 12px;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: #888;
      border-bottom: 1px solid #2e2e2e;
      flex-shrink: 0;
      display: flex;
      align-items: center;
      justify-content: space-between;
    }
    #ptoc-mode {
      cursor: pointer;
      font-size: 11px;
      padding: 2px 8px;
      border-radius: 4px;
      background: #2e2e2e;
      color: #aaa;
      text-transform: uppercase;
      letter-spacing: 0.05em;
      user-select: none;
      transition: background 0.15s;
    }
    #ptoc-mode:hover {
      background: #3a3a3a;
    }
    #ptoc-header-controls {
      display: flex;
      gap: 6px;
      align-items: center;
    }
    #ptoc-autoload {
      cursor: pointer;
      font-size: 11px;
      padding: 2px 8px;
      border-radius: 4px;
      background: #2e2e2e;
      color: #aaa;
      text-transform: uppercase;
      letter-spacing: 0.05em;
      user-select: none;
      transition: background 0.15s, box-shadow 0.15s;
    }
    #ptoc-autoload:hover {
      background: #3a3a3a;
    }
    #ptoc-autoload.ptoc-on {
      background: #252525;
      color: #ccc;
      box-shadow: inset 0 1px 3px rgba(0,0,0,0.4);
    }
    @keyframes ptoc-spin {
      0%   { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }
    .ptoc-spinner {
      display: inline-block;
      width: 10px;
      height: 10px;
      vertical-align: middle;
      border: 2px solid transparent;
      border-top-color: #aaa;
      border-radius: 50%;
      animation: ptoc-spin 0.6s linear infinite;
    }
    #ptoc-list {
      overflow-y: auto;
      flex: 1;
      padding: 8px 0;
      list-style: none;
      margin: 0;
    }
    #ptoc-list::-webkit-scrollbar {
      width: 4px;
    }
    #ptoc-list::-webkit-scrollbar-thumb {
      background: #3a3a3a;
      border-radius: 2px;
    }
    .ptoc-entry {
      display: flex;
      align-items: flex-start;
      gap: 8px;
      padding: 6px 16px;
      cursor: pointer;
      transition: background 0.1s;
    }
    .ptoc-entry:hover {
      background: #2e2e2e;
    }
    .ptoc-entry.ptoc-active {
      background: #3a3a3a;
    }
    .ptoc-num {
      color: #666;
      min-width: 20px;
      flex-shrink: 0;
      padding-top: 2px;
    }
    .ptoc-body {
      flex: 1;
      min-width: 0;
      overflow: hidden;
    }
    .ptoc-prompt {
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
      color: oklch(87.35% 0.002 67.8);
      font-size: 14px;
      line-height: 1.3;
    }
    .ptoc-response {
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
      color: #a0a0a0;
      font-style: italic;
      font-size: 12px;
      line-height: 1.3;
    }
    .ptoc-skeleton {
      height: 12px;
      width: 60%;
      border-radius: 4px;
      background: #2e2e2e;
      animation: ptoc-pulse 1.5s ease-in-out infinite;
      margin-top: 2px;
    }
    @keyframes ptoc-pulse {
      0%, 100% { opacity: 0.4; }
      50% { opacity: 0.8; }
    }
    .ptoc-skipped {
      opacity: 0.6;
    }
    #ptoc-empty {
      padding: 16px;
      color: #666;
      font-style: italic;
    }
  `;

  // ── State ─────────────────────────────────────────────────────────────────

  let sidebar, list, toggleBtn, modeBtn;
  let activeElements = []; // parallel array: DOM nodes for scroll targets (may be null for virtualized items)
  let cachedPairs = [];    // stable text-only cache; only grows within a navigation
  let displayMode = localStorage.getItem(STORAGE_KEY_MODE) || 'both'; // 'both' | 'prompts' | 'responses'
  let debounceTimer = null;
  let mutationObs = null;
  let intersectionObs = null;
  let intersectionPaused = false;
  let userHidden = false;
  let currentHref = location.href;
  let autoLoadEnabled = localStorage.getItem(STORAGE_KEY_AUTOLOAD) === 'true';
  let autoLoadAborted = false;
  let autoLoadRunning = false;
  let autoLoadBtn = null;

  // ── Init ──────────────────────────────────────────────────────────────────

  function injectStyles() {
    const style = document.createElement('style');
    style.textContent = CSS;
    document.head.appendChild(style);
  }

  function buildUI() {
    // Toggle button
    toggleBtn = document.createElement('button');
    toggleBtn.id = 'ptoc-toggle';
    toggleBtn.title = 'Toggle Table of Contents';
    toggleBtn.innerHTML = '☰'; // ☰
    toggleBtn.style.visibility = 'hidden'; // hidden until scan() finds pairs
    toggleBtn.addEventListener('click', () => {
      userHidden = !userHidden;
      sidebar.classList.toggle('ptoc-hidden', userHidden);
    });

    // Sidebar
    sidebar = document.createElement('div');
    sidebar.id = 'ptoc-sidebar';
    sidebar.classList.add('ptoc-hidden');

    const header = document.createElement('div');
    header.id = 'ptoc-header';

    const titleSpan = document.createElement('span');
    titleSpan.textContent = 'Thread';

    const MODE_LABELS = { both: 'Q+A', prompts: 'Q', responses: 'A' };
    const MODES = ['both', 'prompts', 'responses'];
    modeBtn = document.createElement('span');
    modeBtn.id = 'ptoc-mode';
    modeBtn.textContent = MODE_LABELS[displayMode];
    modeBtn.addEventListener('click', () => {
      displayMode = MODES[(MODES.indexOf(displayMode) + 1) % MODES.length];
      modeBtn.textContent = MODE_LABELS[displayMode];
      localStorage.setItem(STORAGE_KEY_MODE, displayMode);
      renderList(cachedPairs);
    });

    autoLoadBtn = document.createElement('span');
    autoLoadBtn.id = 'ptoc-autoload';
    autoLoadBtn.textContent = 'Auto';
    if (autoLoadEnabled) autoLoadBtn.classList.add('ptoc-on');
    autoLoadBtn.addEventListener('click', () => {
      autoLoadEnabled = !autoLoadEnabled;
      localStorage.setItem(STORAGE_KEY_AUTOLOAD, autoLoadEnabled);
      autoLoadBtn.classList.toggle('ptoc-on', autoLoadEnabled);
      if (autoLoadEnabled) {
        startAutoLoad();
      } else {
        autoLoadAborted = true;
        autoLoadBtn.classList.remove('ptoc-loading');
      }
    });

    const controls = document.createElement('span');
    controls.id = 'ptoc-header-controls';
    controls.appendChild(modeBtn);
    controls.appendChild(autoLoadBtn);

    header.appendChild(titleSpan);
    header.appendChild(controls);

    list = document.createElement('ul');
    list.id = 'ptoc-list';

    sidebar.appendChild(header);
    sidebar.appendChild(list);

    document.body.appendChild(toggleBtn);
    document.body.appendChild(sidebar);
  }

  // ── Auto-load (full-page sweep) ───────────────────────────────────────────

  function updateAutoLoadBtn(state) {
    if (!autoLoadBtn) return;
    if (state === 'idle') {
      autoLoadBtn.classList.remove('ptoc-loading', 'ptoc-done');
      autoLoadBtn.textContent = 'Auto';
    } else if (state === 'loading') {
      autoLoadBtn.classList.add('ptoc-loading');
      autoLoadBtn.classList.remove('ptoc-done');
      autoLoadBtn.innerHTML = "Auto <span class='ptoc-spinner'></span>";
    } else if (state === 'done') {
      autoLoadBtn.classList.remove('ptoc-loading');
      autoLoadBtn.classList.add('ptoc-done');
      autoLoadBtn.textContent = 'Auto \u2713';
    }
  }

  function finishAutoLoad() {
    autoLoadRunning = false;
    intersectionPaused = false;
    const container = document.querySelector('.scrollable-container');
    if (container) container.scrollTop = container.scrollHeight;
    updateAutoLoadBtn('done');
    rebindIntersectionObserver();
  }

  function cleanupAutoLoad(restoreScrollTop) {
    autoLoadRunning = false;
    autoLoadAborted = false;
    intersectionPaused = false;
    const container = document.querySelector('.scrollable-container');
    if (container && restoreScrollTop != null) container.scrollTop = restoreScrollTop;
    updateAutoLoadBtn('idle');
    rebindIntersectionObserver();
  }

  function startAutoLoad() {
    const container = document.querySelector('.scrollable-container');
    if (!container || autoLoadRunning) return;

    autoLoadRunning = true;
    autoLoadAborted = false;
    intersectionPaused = true;
    updateAutoLoadBtn('loading');

    const savedScrollTop = container.scrollTop;
    container.scrollTop = 0;
    scan();

    let lastScrollTop = -1;

    function step() {
      if (autoLoadAborted) {
        cleanupAutoLoad(savedScrollTop);
        return;
      }

      const { scrollTop, clientHeight, scrollHeight } = container;
      const atBottom = scrollTop + clientHeight >= scrollHeight - 5;
      const stalled = scrollTop === lastScrollTop;

      if (atBottom || stalled) {
        finishAutoLoad();
        return;
      }

      lastScrollTop = scrollTop;
      container.scrollTop += clientHeight * AUTOLOAD_SCROLL_FACTOR;
      scan();
      setTimeout(step, AUTOLOAD_SCROLL_DELAY);
    }

    setTimeout(step, AUTOLOAD_SCROLL_DELAY);
  }

  // ── Scan & render ─────────────────────────────────────────────────────────

  let lastKey = '';

  function isAnswerSkipped(promptEl) {
    // Walk up from the group/title element to find the turn-level container,
    // then look for a leaf <div> with exact "Answer skipped" text.
    let container = promptEl;
    for (let i = 0; i < 6; i++) {
      if (!container.parentElement) break;
      container = container.parentElement;
      if (container.classList.contains('scrollable-container')) return false;
    }
    const divs = container.getElementsByTagName('div');
    for (let i = 0; i < divs.length; i++) {
      if (divs[i].childElementCount === 0 && divs[i].textContent.trim() === 'Answer skipped') {
        return true;
      }
    }
    return false;
  }

  function scan() {
    const promptWrappers = Array.from(document.querySelectorAll('[class*="group/title"]'));
    const responseContainers = Array.from(document.querySelectorAll('[id^="markdown-content-"]'));

    // Tag each node with its type and sort by DOM order
    const allNodes = [
      ...promptWrappers.map(el => ({ el, type: 'prompt' })),
      ...responseContainers.map(el => ({ el, type: 'response' })),
    ];

    allNodes.sort((a, b) => {
      const pos = a.el.compareDocumentPosition(b.el);
      return pos & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
    });

    // Build livePairs from what's currently in the DOM
    const livePairs = [];
    for (let i = 0; i < allNodes.length; i++) {
      const node = allNodes[i];
      if (node.type !== 'prompt') continue;

      const spanEl = node.el.querySelector('span[class*="select-text"]');
      const promptText = spanEl ? (spanEl.innerText || spanEl.textContent || '').trim() : '';
      if (!promptText) continue;

      let response = null;
      let _order = null;

      if (i + 1 < allNodes.length && allNodes[i + 1].type === 'response') {
        const respNode = allNodes[i + 1];
        const orderMatch = respNode.el.id.match(/^markdown-content-(\d+)$/);
        if (orderMatch) _order = parseInt(orderMatch[1], 10);

        const p = respNode.el.querySelector('p:first-of-type');
        const fullText = p ? (p.innerText || p.textContent || '').trim() : '';
        if (fullText) {
          const truncated = fullText.length > TRUNCATE_LEN
            ? fullText.slice(0, TRUNCATE_LEN) + '\u2026'
            : fullText;
          response = { text: truncated };
        }
        i++; // always skip the response node when present
      }

      const _skipped = !response && isAnswerSkipped(node.el);
      livePairs.push({ prompt: { text: promptText }, response, _order, _promptEl: node.el, _skipped });
    }

    // Merge livePairs into cachedPairs (cache only grows, never shrinks within a navigation).
    // PRIMARY: use _order (from markdown-content-N IDs) for stable positioning across disjoint
    // virtualization windows. FALLBACK: bi-directional neighbor lookup for prompts without _order.
    for (let liveIdx = 0; liveIdx < livePairs.length; liveIdx++) {
      const live = livePairs[liveIdx];
      const existingIdx = cachedPairs.findIndex(c => c.prompt.text === live.prompt.text);

      if (existingIdx !== -1) {
        if (live.response) cachedPairs[existingIdx].response = live.response;
        cachedPairs[existingIdx]._promptEl = live._promptEl;
        if (live._order != null) cachedPairs[existingIdx]._order = live._order;
        if (live._skipped) cachedPairs[existingIdx]._skipped = true;
      } else {
        let insertAt = cachedPairs.length;

        if (live._order != null) {
          // PRIMARY: find first cached item with a higher _order and insert before it
          for (let k = 0; k < cachedPairs.length; k++) {
            if (cachedPairs[k]._order != null && cachedPairs[k]._order > live._order) {
              insertAt = k;
              break;
            }
          }
        } else {
          // FALLBACK: neighbor lookup — forward first, then backward
          for (let j = liveIdx + 1; j < livePairs.length; j++) {
            const nextIdx = cachedPairs.findIndex(c => c.prompt.text === livePairs[j].prompt.text);
            if (nextIdx !== -1) { insertAt = nextIdx; break; }
          }
          if (insertAt === cachedPairs.length) {
            for (let j = liveIdx - 1; j >= 0; j--) {
              const prevIdx = cachedPairs.findIndex(c => c.prompt.text === livePairs[j].prompt.text);
              if (prevIdx !== -1) { insertAt = prevIdx + 1; break; }
            }
          }
        }

        cachedPairs.splice(insertAt, 0, live);
      }
    }

    // Rebuild activeElements from cache (null for virtualized-away items)
    activeElements = cachedPairs.map(p => p._promptEl || null);
    rebindIntersectionObserver();  // always rebind — DOM refs may have changed

    // Change detection on stable cachedPairs (never shrinks, so key never shrinks)
    const newKey = cachedPairs.map(p => p.prompt.text + (p._skipped ? '\x01skip' : '') + (p.response?.text ?? '')).join('|');
    if (newKey === lastKey) return;

    lastKey = newKey;
    renderList(cachedPairs);
    updateSidebarWidth();
  }

  function setActive(idx) {
    list.querySelectorAll('.ptoc-entry').forEach((el) => el.classList.remove('ptoc-active'));
    const activeEl = list.querySelector(`.ptoc-entry[data-index="${idx}"]`);
    if (activeEl) activeEl.classList.add('ptoc-active');
  }

  function doScroll(el, idx) {
    intersectionPaused = true;
    setActive(idx);
    el.scrollIntoView({ behavior: 'instant', block: 'start' });
    setTimeout(() => { intersectionPaused = false; }, 400);
  }

  const PROG_MAX = 10;
  const PROG_DELAY = 300;

  function progressiveScroll(targetIdx, attempt) {
    if (attempt >= PROG_MAX) return;

    // Find nearest connected element to scroll toward target
    let nearest = -1;
    let nearestDist = Infinity;
    for (let i = 0; i < activeElements.length; i++) {
      const el = activeElements[i];
      if (el && el.isConnected) {
        const dist = Math.abs(i - targetIdx);
        if (dist < nearestDist) { nearestDist = dist; nearest = i; }
      }
    }

    if (nearest === -1) return;

    intersectionPaused = true;
    setActive(targetIdx);
    activeElements[nearest].scrollIntoView({ behavior: 'instant', block: 'start' });

    setTimeout(() => {
      scan(); // force DOM refresh so virtualizer-loaded elements are picked up
      const target = activeElements[targetIdx];
      if (target && target.isConnected) {
        doScroll(target, targetIdx);
      } else {
        progressiveScroll(targetIdx, attempt + 1);
      }
    }, PROG_DELAY);
  }

  function scrollToEntry(idx) {
    const target = activeElements[idx];
    if (target && target.isConnected) {
      doScroll(target, idx);
    } else {
      progressiveScroll(idx, 0);
    }
  }

  function setUIVisible(visible) {
    toggleBtn.style.visibility = visible ? '' : 'hidden';
    if (visible && !userHidden) {
      sidebar.classList.remove('ptoc-hidden');
      updateSidebarWidth();
    } else if (!visible) {
      sidebar.classList.add('ptoc-hidden');
    }
  }

  function updateSidebarWidth() {
    const threadBody = document.querySelector('[class*="max-w-threadContentWidth"]');
    if (!threadBody) return;
    const threadRight = threadBody.getBoundingClientRect().right;
    const available = window.innerWidth - threadRight - 20 - SIDEBAR_GAP;
    sidebar.style.width = Math.max(SIDEBAR_MIN_WIDTH, available) + 'px';
  }

  function renderList(pairs) {
    list.innerHTML = '';

    if (pairs.length < 1) {
      setUIVisible(false);
      return;
    }

    setUIVisible(true);

    pairs.forEach(({ prompt, response, _skipped }, i) => {
      const li = document.createElement('li');
      li.className = 'ptoc-entry';
      li.dataset.index = i;

      const num = document.createElement('span');
      num.className = 'ptoc-num';
      num.textContent = i + 1;

      const body = document.createElement('div');
      body.className = 'ptoc-body';

      if (displayMode === 'both' || displayMode === 'prompts') {
        const promptEl = document.createElement('div');
        promptEl.className = 'ptoc-prompt';
        promptEl.textContent = prompt.text;
        body.appendChild(promptEl);
      }

      if (displayMode === 'both' || displayMode === 'responses') {
        if (response) {
          const responseEl = document.createElement('div');
          responseEl.className = 'ptoc-response';
          responseEl.textContent = response.text;
          body.appendChild(responseEl);
        } else if (_skipped) {
          const skippedEl = document.createElement('div');
          skippedEl.className = 'ptoc-response ptoc-skipped';
          skippedEl.textContent = 'Skipped';
          body.appendChild(skippedEl);
        } else if (displayMode === 'responses') {
          // Skeleton only in responses-only mode; 'both' with no response shows nothing
          const skeleton = document.createElement('div');
          skeleton.className = 'ptoc-skeleton';
          body.appendChild(skeleton);
        }
      }

      li.appendChild(num);
      li.appendChild(body);

      li.addEventListener('click', () => scrollToEntry(i));

      list.appendChild(li);
    });
  }

  // ── Active entry via IntersectionObserver ─────────────────────────────────

  function rebindIntersectionObserver() {
    if (intersectionObs) intersectionObs.disconnect();
    const nonNullElements = activeElements.filter(el => el !== null);
    if (nonNullElements.length === 0) return;

    intersectionObs = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (!intersectionPaused && entry.isIntersecting) {
            const idx = activeElements.indexOf(entry.target);
            if (idx !== -1) setActive(idx);
          }
        });
      },
      { threshold: 0.3 }
    );

    nonNullElements.forEach((el) => intersectionObs.observe(el));
  }

  // ── MutationObserver on thread container ──────────────────────────────────

  function startMutationObserver(container) {
    if (mutationObs) mutationObs.disconnect();

    mutationObs = new MutationObserver(() => {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(scan, DEBOUNCE_MS);
    });

    mutationObs.observe(container, { childList: true, subtree: true });
  }

  // ── Wait for container with retry ─────────────────────────────────────────

  function waitForContainer(attempts) {
    const container = document.querySelector('.scrollable-container') || document.body;
    if (container !== document.body || attempts <= 0) {
      startMutationObserver(container);
      scan();
      if (autoLoadEnabled) setTimeout(startAutoLoad, 300);
      return;
    }
    setTimeout(() => waitForContainer(attempts - 1), RETRY_INTERVAL_MS);
  }

  // ── SPA navigation detection ──────────────────────────────────────────────

  function onNavigation() {
    autoLoadAborted = true;
    userHidden = false;
    lastKey = '';
    cachedPairs = [];
    setUIVisible(false); // hide immediately; scan() re-shows only if it finds pairs
    waitForContainer(RETRY_MAX); // retries until .scrollable-container is in the DOM
  }

  function watchNavigation() {
    // Watch title changes as a proxy for SPA navigation
    const titleObs = new MutationObserver(() => {
      if (location.href !== currentHref) {
        currentHref = location.href;
        setTimeout(onNavigation, 500); // brief delay for new DOM to settle
      }
    });

    const titleEl = document.querySelector('title');
    if (titleEl) {
      titleObs.observe(titleEl, { childList: true });
    }

    // Also intercept pushState / replaceState
    const origPush = history.pushState.bind(history);
    const origReplace = history.replaceState.bind(history);

    history.pushState = function (...args) {
      origPush(...args);
      if (location.href !== currentHref) {
        currentHref = location.href;
        setTimeout(onNavigation, 500);
      }
    };

    history.replaceState = function (...args) {
      origReplace(...args);
      if (location.href !== currentHref) {
        currentHref = location.href;
        setTimeout(onNavigation, 500);
      }
    };

    window.addEventListener('popstate', () => {
      if (location.href !== currentHref) {
        currentHref = location.href;
        setTimeout(onNavigation, 500);
      }
    });
  }

  // ── Bootstrap ─────────────────────────────────────────────────────────────

  function init() {
    injectStyles();
    buildUI();
    waitForContainer(RETRY_MAX);
    watchNavigation();
    window.addEventListener('resize', updateSidebarWidth);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();