CB User Quick Bio

lets you view a users bio content by clicking on the name in any room

Versão de: 16/10/2025. Veja: a última versão.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         CB User Quick Bio
// @namespace    aravvn.tools
// @author       aravvn
// @license      CC-BY-NC-SA-4.0
// @version      3.2.2
// @description  lets you view a users bio content by clicking on the name in any room
// @author       aravvn
// @match        https://chaturbate.com/*
// @match        https://*.chaturbate.com/*
// @run-at       document-idle
// @grant        none
// @noframes
// ==/UserScript==

(() => {
  'use strict';

  const MENU_SEL = '#user-context-menu[data-testid="user-context-menu"]';
  const LINK_SEL = 'a[data-testid="username"][href]';
  const PANEL_ID = 'cb-biox-phone';
  const BODY_ID  = 'cb-biox-body';
  const CACHE_TTL = 5 * 60 * 1000;

  let lastUser = '';
  let debTimer = 0;
  const cache = new Map();

  const debounce = (fn, ms=70) => (...a)=>{ clearTimeout(debTimer); debTimer=setTimeout(()=>fn(...a), ms); };
  const getUserFromHref = (href) => { try { const u=new URL(href, location.origin); return u.pathname.split('/').filter(Boolean)[0]||''; } catch { return (href||'').replace(/^\/+|\/+$/g,'').split('/')[0]||''; } };
  const isVisible = (el) => { if(!el) return false; const cs=getComputedStyle(el); if(cs.display==='none'||cs.visibility==='hidden'||+cs.opacity===0) return false; const r=el.getBoundingClientRect(); return r.width>0&&r.height>0; };
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  const absUrl = (u) => { try { return new URL(u, location.origin).href; } catch { return u; } };

  const sanitizeAndRestyleHTML = (dirty) => {
    const tmp = document.createElement('div');
    tmp.innerHTML = String(dirty || '');
    tmp.querySelectorAll('script,style,link,object,embed,meta,noscript').forEach(n=>n.remove());
    tmp.querySelectorAll('*').forEach(el=>{
      [...el.attributes].forEach(a=>{ if(/^on/i.test(a.name)) el.removeAttribute(a.name); });
      ['style','align','bgcolor','border','cellpadding','cellspacing','color','face','size','id'].forEach(attr=>el.removeAttribute(attr));
      if (el.tagName === 'FONT') { const p=el.parentNode; if(p){ while(el.firstChild) p.insertBefore(el.firstChild, el); el.remove(); } return; }
      if (el.tagName === 'A') { el.target = '_blank'; el.rel = 'noopener noreferrer'; }
      if (el.tagName === 'IMG' || el.tagName === 'VIDEO') { el.removeAttribute('width'); el.removeAttribute('height'); }
      if (el.tagName === 'IFRAME') { el.removeAttribute('width'); if(!el.getAttribute('height')) el.setAttribute('height','360'); }
    });
    tmp.innerHTML = tmp.innerHTML.replace(/(?:<br\s*\/?>\s*){3,}/gi, '<br><br>');
    tmp.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(h=>h.classList.add('hc-h'));
    tmp.querySelectorAll('p,li').forEach(e=>e.classList.add('hc-p'));
    tmp.querySelectorAll('blockquote').forEach(e=>e.classList.add('hc-quote'));
    tmp.querySelectorAll('pre,code').forEach(e=>e.classList.add('hc-code'));
    tmp.querySelectorAll('table').forEach(t=>t.classList.add('hc-table'));
    tmp.querySelectorAll('a').forEach(a=>a.classList.add('hc-a'));
    tmp.querySelectorAll('img').forEach(img=>img.classList.add('hc-img'));
    tmp.querySelectorAll('video').forEach(v=>{ v.classList.add('hc-video'); v.setAttribute('controls',''); });
    tmp.querySelectorAll('iframe').forEach(f=>f.classList.add('hc-iframe'));
    tmp.querySelectorAll('table, pre').forEach(el=>{
      if (!el.parentElement) return;
      if (!el.parentElement.classList.contains('hc-wrap-scroll')) {
        const w = document.createElement('div');
        w.className = 'hc-wrap-scroll';
        el.parentElement.insertBefore(w, el);
        w.appendChild(el);
      }
    });
    tmp.querySelectorAll('p, li, blockquote, code, pre, div').forEach(el=>{
      el.style.wordBreak = 'break-word';
      el.style.overflowWrap = 'anywhere';
    });
    const wrap = document.createElement('div');
    wrap.className = 'html-clean';
    wrap.append(...[...tmp.childNodes]);
    return wrap.outerHTML;
  };

  const ensurePanel = () => {
    let panel = document.getElementById(PANEL_ID);
    if (panel) return panel;
    panel = document.createElement('div');
    panel.id = PANEL_ID;
    panel.innerHTML = `
      <style>
        #${PANEL_ID}{
          position:fixed; right:16px; bottom:16px;
          width:min(420px,92vw); height:min(86vh,820px);
          z-index:2147483646; border-radius:22px;
          background:#0f1217; color:#e9eef5;
          box-shadow:0 20px 50px rgba(0,0,0,.55);
          border:1px solid rgba(255,255,255,.08);
          display:none; overflow:hidden;
        }
        #${PANEL_ID} *{ box-sizing:border-box }
        .ph-head{ height:56px; display:flex; align-items:center; gap:10px; padding:0 14px;
          border-bottom:1px solid rgba(255,255,255,.08);
          background:linear-gradient(0deg, rgba(255,255,255,.02), rgba(255,255,255,.06));
          user-select:none; cursor:move;
        }
        .ph-username{ font:800 16px/1.15 system-ui,-apple-system,Segoe UI,Roboto,Arial }
        .ph-body{ height:calc(100% - 56px - 48px); overflow:auto; padding:14px }
        .ph-tabs{ height:48px; display:flex; gap:8px; align-items:center; padding:0 10px 8px;
          border-top:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.06));
        }
        .ph-tab{ flex:1; text-align:center; padding:8px 10px; border-radius:12px;
          border:1px solid rgba(255,255,255,.10); cursor:pointer; user-select:none;
          font:600 12px/1 system-ui,-apple-system,Segoe UI,Roboto,Arial; opacity:.9;
        }
        .ph-tab.active{ background:#141924; border-color:rgba(255,255,255,.16) }

        .card{ background:#11161f; border:1px solid rgba(255,255,255,.10); border-radius:14px; padding:12px; margin-bottom:12px }
        .card h3{ margin:0 0 8px; font:700 13px/1.2 system-ui,-apple-system,Segoe UI,Roboto,Arial; color:#f3f6fb }

        .btns{ display:flex; gap:8px; flex-wrap:wrap; margin-top:8px }
        .btn{ padding:8px 12px; border-radius:10px; border:1px solid rgba(255,255,255,.14); background:#0f141e; color:#e9eef5; cursor:pointer; text-decoration:none; font-weight:600 }
        .btn:hover{ background:#141c29 }

        .chips{ display:flex; flex-wrap:wrap; gap:8px; margin-top:10px }
        .chip{ padding:4px 10px; border:1px solid rgba(255,255,255,.16); border-radius:999px; font-size:12px; opacity:.95 }

        .json{ white-space:pre; font:12px/1.35 ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;
          background:#0f141c; border:1px solid rgba(255,255,255,.10); border-radius:12px; padding:10px; overflow:auto; max-height:50vh;
        }

        /* Clean HTML Theme */
        .html-clean{ line-height:1.55; font-size:13px; color:#e9eef5 }
        .html-clean .hc-h{ margin:10px 0 6px; font-weight:800; color:#f3f6fb }
        .html-clean .hc-p{ margin:8px 0 }
        .html-clean .hc-a{ color:#9ac7ff; text-decoration:underline }
        .html-clean .hc-img, .html-clean .hc-video{ max-width:100% !important; height:auto !important; display:block; border-radius:8px; }
        .html-clean .hc-iframe{ width:100% !important; border:none; border-radius:8px; }
        .html-clean .hc-code{ background:#0b111a; border:1px solid rgba(255,255,255,.10); padding:10px; border-radius:10px; overflow-x:auto }
        .html-clean .hc-table{ width:100%; border-collapse:collapse; display:block; overflow-x:auto; }
        .html-clean .hc-table th, .html-clean .hc-table td{ padding:6px 8px; border:1px solid rgba(255,255,255,.12); vertical-align:top }
        .html-clean .hc-quote{ margin:10px 0; padding:8px 10px; border-left:3px solid #3a7bd5; background:#0f141c; border-radius:8px }
        .hc-wrap-scroll{ overflow-x:auto; max-width:100% }

        /* Media (phone style) */
        .ps-grid{ display:grid; grid-template-columns:1fr; gap:10px }
        .ps-card{ display:flex; gap:10px; border:1px solid rgba(255,255,255,.12); background:#0f141c; border-radius:12px; padding:8px }
        .ps-cover{ width:96px; height:72px; border-radius:8px; overflow:hidden; background:#0b0f15; border:1px solid rgba(255,255,255,.08) }
        .ps-cover img{ width:100%; height:100%; object-fit:cover }
        .ps-info{ flex:1; min-width:0 }
        .ps-name{ font-weight:800; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
        .ps-meta{ display:flex; gap:6px; flex-wrap:wrap; margin-top:4px }
        .badge{ padding:2px 6px; border-radius:999px; border:1px solid rgba(255,255,255,.20); font-size:11px }
        .price{ margin-left:auto; font-weight:800 }

        .flags{ margin-top:4px; display:flex; gap:6px; flex-wrap:wrap }

        /* prettier "Access" pill */
        .flag-access{
          display:inline-flex; align-items:center; gap:6px;
          padding:3px 10px; border-radius:999px;
          background:linear-gradient(135deg,#1e6b3c,#2fae62);
          color:#ecfff3; border:1px solid rgba(255,255,255,.12);
          box-shadow:0 2px 10px rgba(47,174,98,.35), inset 0 0 0 1px rgba(255,255,255,.08);
          font-size:12px; font-weight:800; letter-spacing:.02em;
          text-shadow:0 1px 0 rgba(0,0,0,.35);
        }
        .flag-access .ico{
          width:16px; height:16px; border-radius:50%;
          background:rgba(255,255,255,.18); display:inline-flex;
          align-items:center; justify-content:center; font-size:12px;
        }
        .flag-access .ico::before{ content:"✓"; line-height:1; }

        .flag-bought{
          padding:2px 8px; border:1px solid rgba(108,199,144,.45);
          color:#98e3b6; border-radius:8px; font-size:11px; background:rgba(108,199,144,.08);
        }

        .muted{ opacity:.75 }
      </style>
      <div class="ph-head" id="${PANEL_ID}-drag">
        <div class="ph-username">Quick Profile</div>
      </div>
      <div class="ph-body" id="${BODY_ID}"></div>
      <div class="ph-tabs" id="${PANEL_ID}-tabs"></div>
    `;
    document.body.appendChild(panel);

    // drag
    const handle = document.getElementById(`${PANEL_ID}-drag`);
    let sx,sy,sl,st,drag=false;
    handle.addEventListener('pointerdown',(e)=>{ drag=true; sx=e.clientX; sy=e.clientY; const r=panel.getBoundingClientRect(); sl=r.left; st=r.top; panel.setPointerCapture(e.pointerId); e.preventDefault(); });
    window.addEventListener('pointermove',(e)=>{ if(!drag) return; const nl=sl+(e.clientX-sx), nt=st+(e.clientY-sy); panel.style.left=Math.max(8,Math.min(window.innerWidth-panel.offsetWidth-8,nl))+'px'; panel.style.top=Math.max(8,Math.min(window.innerHeight-panel.offsetHeight-8,nt))+'px'; panel.style.right='auto'; panel.style.bottom='auto'; });
    window.addEventListener('pointerup',()=>drag=false);

    window.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && panel.style.display!=='none') closePanel(); });

    return panel;
  };

  const closePanel = () => {
    const panel = document.getElementById(PANEL_ID);
    const body  = document.getElementById(BODY_ID);
    if (panel) panel.style.display='none';
    if (body) body.innerHTML='';
    lastUser='';
  };

  const fetchBio = async (user) => {
    const url = new URL(`/api/biocontext/${encodeURIComponent(user)}/`, location.origin).href;
    const resp = await fetch(url, { credentials:'include' });
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    return resp.json();
  };
  const getBioCached = async (user) => {
    const now = Date.now();
    const hit = cache.get(user);
    if (hit && (now - hit.t) < CACHE_TTL) return hit.data;
    const data = await fetchBio(user);
    cache.set(user, { t: now, data });
    return data;
  };

  const chip = (t) => `<span class="chip">${esc(t)}</span>`;

  const renderTabs = (tabsEl, panes) => {
    tabsEl.innerHTML = panes.map(p => `<div class="ph-tab${p.active?' active':''}" data-tab="${p.id}">${p.label}</div>`).join('');
    tabsEl.querySelectorAll('.ph-tab').forEach(tab=>{
      tab.addEventListener('click', ()=>{
        const target = tab.dataset.tab;
        tabsEl.querySelectorAll('.ph-tab').forEach(t=>t.classList.toggle('active', t===tab));
        document.querySelectorAll('[data-pane]').forEach(p=> p.style.display = (p.dataset.pane===target)?'':'none');
        document.getElementById(BODY_ID)?.scrollTo({ top:0, behavior:'instant' });
      });
    });
  };

  const renderPhotoSets = (sets=[]) => {
    if (!Array.isArray(sets) || sets.length===0) return '<div class="muted">No photo/video sets.</div>';
    return `
      <div class="ps-grid">
        ${sets.map(s=>{
          const cover=s.cover_url?absUrl(s.cover_url):'';
          const name=s.name||`Set #${s.id??''}`;
          const tokens=Number.isFinite(s.tokens)?`${s.tokens} tokens`:'';
          const badges=[
            s.is_video?'VIDEO':'PHOTO',
            s.video_ready?'READY':'',
            s.video_has_sound?'SOUND':'',
            s.fan_club_only?'FC ONLY':(s.fan_club_unlock?'FC UNLOCK':''),
            s.label_text||''
          ].filter(Boolean);
          const canAccess = !!s.user_can_access;
          const purchased = !!s.user_has_purchased;
          return `
            <div class="ps-card">
              <div class="ps-cover">${cover?`<img src="${esc(cover)}" alt="">`:''}</div>
              <div class="ps-info">
                <div class="ps-name" title="${esc(name)}">${esc(name)}</div>
                <div class="ps-meta">
                  ${badges.map(b=>`<span class="badge">${esc(b)}</span>`).join('')}
                  ${tokens?`<span class="price">${esc(tokens)}</span>`:''}
                </div>
                <div class="flags">
                  ${canAccess ? `<span class="flag-access" title="You can access"><span class="ico" aria-hidden="true"></span><span>Access</span></span>` : ''}
                  ${purchased ? `<span class="flag-bought">Purchased ✓</span>` : ''}
                </div>
              </div>
            </div>
          `;
        }).join('')}
      </div>
    `;
  };

  const renderSocials = (socials=[]) => {
    if (!Array.isArray(socials) || socials.length===0) return '<div class="muted">No social offers.</div>';
    return `
      <div class="soc-list">
        ${socials.map(s=>{
          const title=s.title_name||s.name||'Social';
          const img=s.image_url?absUrl(s.image_url):'';
          const price=Number.isFinite(s.tokens)?`${s.tokens} tokens`:'';
          const link=s.link?absUrl(s.link):'#';
          const label=s.label_text?`<span class="soc-label"${s.label_color?` style="border-color:${esc(s.label_color)};color:${esc(s.label_color)}"`:''}>${esc(s.label_text)}</span>`:'';
          const flag=s.purchased?`<span class="soc-label" style="border-color:#6cc790;color:#6cc790">Purchased ✓</span>`:'';
          return `
            <a class="soc-item" href="${esc(link)}" target="_blank" rel="noopener">
              ${img?`<img class="soc-ico" src="${esc(img)}" alt="">`:`<div class="soc-ico"></div>`}
              <div>
                <div class="soc-title">${esc(title)}</div>
                <div class="soc-meta">
                  ${price?`<span class="soc-price">${esc(price)}</span>`:''}
                  ${label}${flag}
                </div>
              </div>
            </a>
          `;
        }).join('')}
      </div>
    `;
  };

  const timeAgo = (isoOrText) => {
    if (!isoOrText) return '';
    if (typeof isoOrText==='string' && /\bago\b/i.test(isoOrText)) return isoOrText;
    const d = new Date(isoOrText); if (isNaN(d)) return String(isoOrText);
    let s = Math.max(0,(Date.now()-d.getTime())/1000);
    for (const [lab,sec] of [['y',31536000],['mo',2592000],['d',86400],['h',3600],['m',60],['s',1]]) {
      const v = Math.floor(s/sec); if (v>=1) return `${v}${lab} ago`;
    }
    return 'just now';
  };

  const renderView = (username, data) => {
    const body = document.getElementById(BODY_ID);
    const panel= document.getElementById(PANEL_ID);
    const tabs = document.getElementById(`${PANEL_ID}-tabs`);
    const head = document.getElementById(`${PANEL_ID}-drag`);
    if (!body || !panel || !tabs || !head) return;

    head.querySelector('.ph-username').innerHTML = `@${esc(username)}`;

    const chips = [
      data.sex || data.subgender ? `⚧ ${data.sex || data.subgender}` : '',
      data.languages ? `🗣 ${data.languages}` : '',
      Array.isArray(data.interested_in) && data.interested_in.length ? `❤️ ${data.interested_in.join(', ')}` : '',
      data.room_status ? `● ${data.room_status}` : '',
      data.follower_count!=null ? `👥 ${data.follower_count.toLocaleString?.() ?? data.follower_count}` : '',
      data.time_since_last_broadcast ? `⏱ ${data.time_since_last_broadcast}` : (data.last_broadcast?`⏱ ${timeAgo(data.last_broadcast)}`:'')
    ].filter(Boolean).map(chip).join('');

    const filtered = { ...data };
    delete filtered.photo_sets;
    delete filtered.social_medias;
    delete filtered.about_me;
    delete filtered.wish_list;

    const profileUrl = `/${encodeURIComponent(username)}/`;
    const hasFanclub  = data.performer_has_fanclub === true;
    const fanLink     = data.fan_club_join_url || '';
    const fanCost     = data.fan_club_cost;

    const aboutHTML    = sanitizeAndRestyleHTML(data.about_me || '');
    const wishlistHTML = sanitizeAndRestyleHTML(data.wish_list || '');
    const photoSets    = Array.isArray(data.photo_sets) ? data.photo_sets : [];
    const socialsArr   = Array.isArray(data.social_medias) ? data.social_medias : [];
    const showMedia    = (photoSets && photoSets.length) || (socialsArr && socialsArr.length);

    body.innerHTML = `
      <div class="card">
        <div class="chips">${chips}</div>
        <div class="btns" style="margin-top:10px">
          <a class="btn" href="${esc(profileUrl)}" target="_blank" rel="noopener">Open profile ↗</a>
          ${ (hasFanclub && fanLink) ? `<a class="btn" href="${esc(fanLink)}" target="_blank" rel="noopener">Join Fan Club${Number.isFinite(fanCost)?` (${fanCost})`:''}</a>` : '' }
          <button class="btn" id="cb-biox-copy">⧉ Copy username</button>
        </div>
      </div>

      <div class="card" data-pane="overview">
        <h3>Overview (Raw)</h3>
        <div class="json" id="cb-biox-json">${esc(JSON.stringify(filtered, null, 2))}</div>
      </div>

      ${data.about_me ? `
      <div class="card" data-pane="about" style="display:none">
        <h3>About Me</h3>
        ${aboutHTML}
      </div>`:''}

      ${data.wish_list ? `
      <div class="card" data-pane="wishlist" style="display:none">
        <h3>Wishlist</h3>
        ${wishlistHTML}
      </div>`:''}

      ${showMedia ? `
      <div class="card" data-pane="media" style="display:none">
        <h3>Photo/Video Sets</h3>
        ${renderPhotoSets(photoSets)}
        <div style="height:10px"></div>
        <h3>Social Offers</h3>
        ${renderSocials(socialsArr)}
      </div>`:''}
    `; // <-- WICHTIG: richtiges Backtick schließt das Template

    const panes = [{ id:'overview', label:'Overview', active:true }];
    if (data.about_me)    panes.push({ id:'about',    label:'About',    active:false });
    if (data.wish_list)   panes.push({ id:'wishlist', label:'Wishlist', active:false });
    if (showMedia)        panes.push({ id:'media',    label:'Media',    active:false });
    renderTabs(tabs, panes);

    const copyBtn = body.querySelector('#cb-biox-copy');
    if (copyBtn) {
      copyBtn.addEventListener('click', async ()=>{
        try { await navigator.clipboard.writeText(username); copyBtn.textContent='✓ Copied'; }
        catch { copyBtn.textContent=username; }
        setTimeout(()=> copyBtn.textContent='⧉ Copy username', 900);
      });
    }
  };

  const processMenu = async () => {
    const menu = document.querySelector(MENU_SEL);
    if (!menu || !isVisible(menu)) { closePanel(); return; }
    const a = menu.querySelector(LINK_SEL);
    if (!a) return;

    const user = getUserFromHref(a.getAttribute('href')||'');
    if (!user || user === lastUser) return;
    lastUser = user;

    const panel = ensurePanel();
    const body  = document.getElementById(BODY_ID);

    const r = menu.getBoundingClientRect();
    panel.style.display = 'block';
    const left = Math.min(window.innerWidth - panel.offsetWidth - 8, Math.max(8, r.right + 12));
    const top  = Math.min(window.innerHeight - panel.offsetHeight - 8, Math.max(8, r.top));
    panel.style.left = left + 'px';
    panel.style.top  = top  + 'px';
    panel.style.right='auto'; panel.style.bottom='auto';

    if (body) body.innerHTML = `<div class="muted">Loading…</div>`;

    try {
      const data = await getBioCached(user);
      renderView(user, data);
      document.getElementById(BODY_ID)?.scrollTo({ top:0, behavior:'instant' });
    } catch (err) {
      if (body) body.innerHTML = `<div class="muted">Failed to load (${esc(String(err))}).</div>`;
    }
  };

  const debouncedProcess = debounce(processMenu, 80);

  const mo = new MutationObserver((muts)=>{
    let menuAdded=false, menuRemoved=false, menuChanged=false;
    for (const m of muts){
      if (m.type==='childList'){
        for (const n of m.addedNodes){ if(n instanceof HTMLElement && (n.matches?.(MENU_SEL)||n.querySelector?.(MENU_SEL))) menuAdded=true; }
        for (const n of m.removedNodes){ if(n instanceof HTMLElement && (n.matches?.(MENU_SEL)||n.querySelector?.(MENU_SEL))) menuRemoved=true; }
      } else if (m.type==='attributes'){
        const t=m.target; if(t instanceof HTMLElement && t.matches(MENU_SEL)) menuChanged=true;
      }
    }
    if (menuAdded || menuChanged){ const menu=document.querySelector(MENU_SEL); if(menu && isVisible(menu)) debouncedProcess(); }
    if (menuRemoved){ closePanel(); } else { const menu=document.querySelector(MENU_SEL); if(!menu || !isVisible(menu)) closePanel(); }
  });
  mo.observe(document.documentElement||document.body, {
    childList:true, subtree:true, attributes:true, attributeFilter:['style','class','data-testid','id']
  });

  const existing = document.querySelector(MENU_SEL);
  if (existing && isVisible(existing)) debouncedProcess();
})();