③ Neopets — Background & Decor

Dark/lila background + left decor column + DTI background picker (search, favorites, hide). Includes button-theme + accent-color pickers. Settings persist locally.

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.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

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!)

// ==UserScript==
// @name         ③ Neopets — Background & Decor
// @namespace    neopets-qol
// @version      4.8.1
// @author       marius@clraik
// @license      MIT
// @description  Dark/lila background + left decor column + DTI background picker (search, favorites, hide). Includes button-theme + accent-color pickers. Settings persist locally.
// @match        *://*.neopets.com/*
// @exclude      https://www.neopets.com/userlookup.phtml?user=*
// @exclude      https://www.neopets.com/petlookup.phtml*
// @exclude      https://www.neopets.com/~*
// @exclude      https://www.neopets.com/games/game.phtml?*
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      impress.openneo.net
// @connect      impress-2020.openneo.net
// ==/UserScript==

(() => {
  'use strict';
  if (window.top !== window) return;
  if (document.documentElement.dataset.npBgBoot === '1') return;
  document.documentElement.dataset.npBgBoot = '1';

  // Cross-script access: the HUD script exposes globals from page context.
  // Use unsafeWindow to read them when we're in a sandboxed Tampermonkey scope.
  const W = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

  // ---------- Layout ----------
  const LEFT_BAR_PX = 330;
  const TOP_BAR_PX  = 0;

  // ---------- Storage keys ----------
  const LS = {
    mainBgId:  'npqol_bg_main_v3',     // DTI URL or '' (transparent)
    leftBarBg: 'npqol_bg_left_v3',     // DTI URL or '' (transparent)
    favorites: 'npqol_bg_favs_v1',     // JSON array of DTI ids
    hidden:    'npqol_bg_hidden_v1',   // JSON array of DTI ids
  };

  // ---------- Background base color ----------
  const BG_COLOR = '#000';

  // ---------- Right-side picker UI ----------
  const UI_Z = 2147483000;
  const HANDLE_W = 20;
  const HANDLE_H = 90;
  const SIDEBAR_W = 320;

  // ---------- Utils ----------
  const safeUrl = (u) => String(u || '').replace(/["\\\n\r]/g, '');
  const clamp01 = (x) => Math.max(0, Math.min(1, x));
  const LSget = (k, fallback = '') => { try { return localStorage.getItem(k) ?? fallback; } catch { return fallback; } };
  const LSset = (k, v) => { try { localStorage.setItem(k, String(v)); } catch {} };

  // ---------- DTI background list (GraphQL on impress-2020.openneo.net) ----------
  const DTI_CACHE_KEY = 'npqol_dti_bgs_v6';
  const DTI_CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
  const DTI_PAGE_SIZE = 30;
  // Hard safety cap — the loop in fetchDTIBackgrounds breaks naturally when a
  // page returns fewer items than DTI_PAGE_SIZE (last page reached), so this
  // value is only here to prevent an infinite loop if the DTI API ever
  // misbehaves. 30 × 5000 = 150 000 backgrounds, way above what DTI hosts.
  const DTI_MAX_PAGES = 5000;
  const DTI_ENDPOINT  = 'https://impress-2020.openneo.net/api/graphql';

  const gqlQuery = (query, variables = {}) => new Promise((resolve, reject) => {
    const body = JSON.stringify({ query, variables });
    if (typeof GM_xmlhttpRequest === 'function') {
      GM_xmlhttpRequest({
        method: 'POST',
        url: DTI_ENDPOINT,
        headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
        data: body,
        timeout: 20000,
        onload: (r) => {
          try { resolve(JSON.parse(r.responseText)); }
          catch (e) { reject(new Error('JSON parse: ' + e.message)); }
        },
        onerror: () => reject(new Error('network')),
        ontimeout: () => reject(new Error('timeout')),
      });
    } else {
      fetch(DTI_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })
        .then(r => r.json()).then(resolve).catch(reject);
    }
  });

  const DTI_QUERY = `
    query SearchBgs($query: String!, $offset: Int!, $limit: Int!) {
      itemSearch(query: $query, offset: $offset, limit: $limit) {
        items {
          id
          name
          thumbnailUrl
          appearanceOn(speciesId: 1, colorId: 8) {
            layers {
              id
              zone { id label }
              imageUrl(size: SIZE_600)
            }
          }
        }
      }
    }
  `;

  async function fetchDTIBackgrounds(progressCb){
    const all = [];
    const seen = new Set();
    let skippedNoImage = 0;
    for (let p = 0; p < DTI_MAX_PAGES; p++){
      progressCb?.(p + 1, all.length);
      const offset = p * DTI_PAGE_SIZE;
      let resp;
      try {
        resp = await gqlQuery(DTI_QUERY, { query: 'background', offset, limit: DTI_PAGE_SIZE });
      } catch (e) {
        if (p === 0) throw e;
        break;
      }
      if (resp?.errors?.length){
        throw new Error('GraphQL: ' + (resp.errors[0]?.message || JSON.stringify(resp.errors[0])));
      }
      const items = resp?.data?.itemSearch?.items || [];
      if (!items.length) break;
      for (const it of items){
        if (seen.has(it.id)) continue;
        seen.add(it.id);
        const layers = it.appearanceOn?.layers || [];
        const pickBg = layers.find(l => (l?.zone?.label || '').toLowerCase().includes('background')) || layers[0];
        const url = pickBg?.imageUrl || '';
        if (!url){ skippedNoImage++; continue; }
        all.push({
          id: String(it.id),
          name: it.name,
          thumbnailUrl: it.thumbnailUrl || '',
          url,
        });
      }
      if (items.length < DTI_PAGE_SIZE) break;
    }
    if (skippedNoImage) console.info(`[BG] DTI fetch: ${all.length} usable backgrounds, ${skippedNoImage} skipped (no PNG render).`);
    return all;
  }

  function readDTICached(){
    try {
      const raw = localStorage.getItem(DTI_CACHE_KEY);
      if (!raw) return null;
      const obj = JSON.parse(raw);
      if (!obj || !Array.isArray(obj.items) || (Date.now() - obj.ts) > DTI_CACHE_TTL) return null;
      return obj.items;
    } catch { return null; }
  }
  function writeDTICached(items){
    try { localStorage.setItem(DTI_CACHE_KEY, JSON.stringify({ ts: Date.now(), items })); } catch {}
  }

  // ---------- Favorites + hidden (DTI id sets) ----------
  function readIdSet(key){
    try {
      const raw = LSget(key, '');
      if (!raw) return new Set();
      const arr = JSON.parse(raw);
      return new Set(Array.isArray(arr) ? arr.map(String) : []);
    } catch { return new Set(); }
  }
  function writeIdSet(key, set){
    try { LSset(key, JSON.stringify([...set])); } catch {}
  }
  const readFavs    = () => readIdSet(LS.favorites);
  const writeFavs   = (s) => writeIdSet(LS.favorites, s);
  const readHidden  = () => readIdSet(LS.hidden);
  const writeHidden = (s) => writeIdSet(LS.hidden, s);

  // ---------- Apply (bare URLs — no opacity/tint) ----------
  function applyMainBg(url){
    if (!url){
      document.documentElement.style.setProperty('--npWallImage', 'none');
      document.documentElement.style.setProperty('--npMainSolid', 'transparent');
      return;
    }
    document.documentElement.style.setProperty('--npMainSolid', BG_COLOR);
    document.documentElement.style.setProperty('--npWallImage', `url("${safeUrl(url)}")`);
  }
  function applyLeftBg(url){
    if (!url){
      document.documentElement.style.setProperty('--npLeftBarImg', 'none');
      document.documentElement.style.setProperty('--npLeftBarSolid', 'transparent');
      document.documentElement.style.setProperty('--npLeftBarShadeA', '0');
      return;
    }
    document.documentElement.style.setProperty('--npLeftBarImg', `url("${safeUrl(url)}")`);
    document.documentElement.style.setProperty('--npLeftBarSolid', '#000');
    document.documentElement.style.setProperty('--npLeftBarShadeA', '1');
  }

  // Apply saved values right away, before any rendering.
  applyMainBg(LSget(LS.mainBgId, ''));
  applyLeftBg(LSget(LS.leftBarBg, ''));

  // ---------- CSS (early) ----------
  const style = document.createElement('style');
  style.textContent = `
    :root{
      --npWallImage:none;
      --npMainSolid:${BG_COLOR};

      --npLeftBarImg:none;
      --npLeftBarSolid:#000;
      --npLeftBarShadeA:1;
    }

    html,body{
      background:transparent!important;
      background-image:none!important;
    }

    :root::before{
      content:"";
      position:fixed;
      /* Anchored to the (user-resizable) decor column right edge. */
      inset:${TOP_BAR_PX}px 0 0 var(--np-col-width, ${LEFT_BAR_PX}px);
      z-index:-2147483647;
      pointer-events:none;

      background-color:var(--npMainSolid)!important;
      background-image:var(--npWallImage) !important;
      background-repeat:no-repeat!important;
      background-position:right top!important;
      background-size:cover!important;
    }

    .nav-top-pattern__2020,.nav-bottom-pattern__2020,.footer-pattern__2020{
      background:transparent!important;
      background-image:none!important;
    }

    /* Left decor column. visibility:visible escapes a body{visibility:hidden}
       anti-FOUC injected by some other extensions (e.g. Stylus). */
    #npLeftBar{
      position:fixed!important; top:0; left:0;
      /* Tracks --np-col-width set by the HUD resize handle. */
      width: var(--np-col-width, ${LEFT_BAR_PX}px); height:100vh;
      z-index:18;
      pointer-events:none;
      overflow:hidden;
      box-sizing:border-box;
      border:2px solid #000;
      background:transparent;
      contain:paint;
      visibility:visible!important;
      opacity:1!important;
    }
    #npLeftBarBg{
      position:absolute; inset:0;
      background-color:var(--npLeftBarSolid);
      background-image:var(--npLeftBarImg);
      background-size:cover;
      background-position:center;
      opacity:1!important;
      visibility:visible!important;
    }
    #npLeftBarShade{
      position:absolute; inset:0;
      background:linear-gradient(180deg, rgba(0,0,0,.10), rgba(0,0,0,.28));
      opacity:var(--npLeftBarShadeA);
      visibility:visible!important;
    }

    /* The decor column tracks --np-col-width (user-controlled via the HUD
       resize handle), so no automatic shrink rules — only hide on tiny
       screens where the HUD itself is hidden. */
    @media (max-width: 900px){
      #npLeftBar{ display:none; }
      :root::before{ inset:${TOP_BAR_PX}px 0 0 0; }
    }

    /* Right-side BG picker — slide-in panel */
    #npBgHandle{
      position:fixed;
      right:0;
      top:calc(${TOP_BAR_PX}px + (100vh - ${TOP_BAR_PX}px - ${HANDLE_H}px)/2);
      width:${HANDLE_W}px;
      height:${HANDLE_H}px;
      border-radius:12px 0 0 12px;
      background: var(--np-glass, rgba(0,0,0,.45));
      backdrop-filter: blur(var(--np-blur, 8px));
      -webkit-backdrop-filter: blur(var(--np-blur, 8px));
      border:1px solid var(--np-line, rgba(255,255,255,.18));
      border-right:0;
      color:#fff;
      display:flex; align-items:center; justify-content:center;
      font-weight:700; font-size:14px; line-height:1;
      z-index:${UI_Z};
      cursor:pointer;
      pointer-events:auto;
      box-shadow:none;
      user-select:none;
      transition: background .12s ease;
    }
    #npBgHandle:hover{ background: rgba(20,14,32,.7); }
    #npBgHandle span{ transform:translateX(-1px); }

    #npBgSidebar{
      position:fixed;
      right:0;
      top:${TOP_BAR_PX}px;
      width:${SIDEBAR_W}px;
      max-width: min(${SIDEBAR_W}px, 90vw);
      height:calc(100vh - ${TOP_BAR_PX}px);
      background:var(--np-glass, rgba(0,0,0,.22));
      backdrop-filter:blur(var(--np-blur, 8px));
      -webkit-backdrop-filter:blur(var(--np-blur, 8px));
      box-shadow:var(--np-shadow, -2px 0 10px rgba(0,0,0,.28));
      z-index:${UI_Z-2};
      transition:transform .20s ease;
      transform:translateX(100%);
      display:flex;
      flex-direction:column;
      overflow:hidden;
      box-sizing:border-box;
    }

    #npBgBody{
      flex:1 1 auto;
      min-height:0;
      overflow:auto;
      padding:12px;
      box-sizing:border-box;
      scrollbar-width:thin;
      scrollbar-color: rgba(255,255,255,.35) rgba(0,0,0,.12);
    }
    #npBgBody::-webkit-scrollbar{ width:10px; }
    #npBgBody::-webkit-scrollbar-thumb{
      background:rgba(255,255,255,.28);
      border-radius:10px;
      border:2px solid rgba(0,0,0,.12);
    }
    #npBgBody::-webkit-scrollbar-track{ background:rgba(0,0,0,.10); }

    #npBgFooter{
      flex:0 0 auto;
      padding:10px 12px;
      display:flex; gap:8px;
      border-top:1px solid rgba(255,255,255,.18);
      background:rgba(0,0,0,.22);
    }
    .npFooterBtn{
      flex:1;
      border-radius:10px;
      border:1px solid rgba(255,255,255,.32);
      background:rgba(0,0,0,.22);
      color:#fff; font-weight:900;
      padding:10px 8px; cursor:pointer; font-size:12px;
    }
    .npFooterBtn:hover{ filter:brightness(1.08); }
    .npFooterBtn.primary{
      background:var(--np-accent, #dcb8ff);
      color:#1a1330;
      border-color:transparent;
    }

    .npThemeBlock{ margin:0 0 10px 0; }
    .npThemeSelect{
      width:100%;
      height:32px;
      border-radius:8px;
      border:1px solid rgba(255,255,255,.32);
      background:rgba(0,0,0,.22);
      color:#fff; font-weight:700; font-size:12px;
      padding:0 10px;
      cursor:pointer;
      appearance:none;
      -webkit-appearance:none;
      background-image:linear-gradient(45deg, transparent 50%, rgba(255,255,255,.6) 50%),
                       linear-gradient(135deg, rgba(255,255,255,.6) 50%, transparent 50%);
      background-position:calc(100% - 14px) center, calc(100% - 9px) center;
      background-size:5px 5px;
      background-repeat:no-repeat;
    }
    .npThemeSelect:focus{ outline:2px solid var(--np-accent, #dcb8ff); }
    .npThemeSelect option{ background:#1a1330; color:#fff; }

    .npAccentBlock{ margin:0 0 12px 0; }
    .npAccentPastilles{
      display:flex; flex-wrap:wrap; gap:8px;
      padding:6px 2px 2px;
    }
    .npAccentDot{
      width:24px; height:24px; flex:0 0 24px;
      border-radius:50%; cursor:pointer;
      border:2px solid rgba(255,255,255,.18);
      box-shadow: 0 1px 3px rgba(0,0,0,.5), inset 0 0 0 1px rgba(0,0,0,.2);
      transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease;
      padding:0;
    }
    .npAccentDot:hover{
      transform: scale(1.12);
      border-color: rgba(255,255,255,.55);
    }
    .npAccentDot.isActive{
      border-color: #fff;
      box-shadow: 0 0 0 2px rgba(0,0,0,.55), 0 1px 6px rgba(0,0,0,.55), inset 0 0 0 1px rgba(0,0,0,.2);
      transform: scale(1.1);
    }

    .npBgSearch{
      display:flex; gap:6px; padding:2px;
    }
    .npBgSearch input{
      flex:1; min-width:0; height:28px; padding:0 8px;
      border:1px solid rgba(255,255,255,.32);
      background:rgba(0,0,0,.18); color:#fff;
      border-radius:8px; outline:none; font-size:12px;
    }
    .npBgSearch input::placeholder{ color:rgba(255,255,255,.45); }
    .npBgSearch input:focus{ border-color:rgba(255,255,255,.55); }
    .npBgSearch button{
      width:32px; height:28px; padding:0;
      border:1px solid rgba(255,255,255,.32);
      background:rgba(0,0,0,.18); color:#fff;
      border-radius:8px; cursor:pointer; font-size:14px;
    }
    .npBgSearch button.isOn{
      background:var(--np-accent, #dcb8ff); color:#1a1330;
      border-color:transparent;
    }
    #npBgList{
      display:flex; flex-direction:column; gap:6px; margin-top:8px;
    }
    .npBgItem{
      display:grid; grid-template-columns:44px 1fr auto auto auto auto;
      gap:6px; align-items:center;
      padding:4px 6px; border-radius:10px;
      background:rgba(0,0,0,.12);
      border:1px solid rgba(255,255,255,.12);
    }
    .npBgItem.isHidden{
      opacity:.55;
      background:rgba(255,80,80,.06);
      border-color:rgba(255,80,80,.18);
    }
    .npBgThumb{
      width:44px; height:44px; border-radius:6px;
      background:#fff no-repeat center/cover;
      border:1px solid rgba(255,255,255,.16);
    }
    .npBgName{
      color:rgba(255,255,255,.92); font-size:11px; line-height:1.2;
      overflow:hidden; text-overflow:ellipsis; display:-webkit-box;
      -webkit-line-clamp:2; -webkit-box-orient:vertical; word-break:break-word;
    }
    .npBgBtn{
      width:30px; height:28px; padding:0;
      border:1px solid rgba(255,255,255,.32);
      background:rgba(0,0,0,.20); color:#fff; font-weight:900;
      border-radius:8px; cursor:pointer; font-size:12px;
    }
    .npBgBtn:hover{ filter:brightness(1.08); }
    .npBgBtn.isActive{ outline:2px solid var(--np-accent, #dcb8ff); }
    .npFavBtn, .npHideBtn{
      width:24px; height:28px; padding:0;
      border:none; background:transparent;
      color:rgba(255,255,255,.45); cursor:pointer; font-size:14px;
      line-height:1;
    }
    .npFavBtn:hover, .npHideBtn:hover{ color:rgba(255,255,255,.85); }
    .npFavBtn.isOn{ color:#ffd166; }
    .npHideBtn.isOn{ color:#ff6b6b; }
    .npFavBtn{ font-size:16px; }
    #npBgStatus{
      padding:6px 4px; font-size:11px; color:rgba(255,255,255,.7);
    }

    .npSmall{
      font:800 10px/1.2 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
      color:rgba(255,255,255,.82);
      letter-spacing:.04em;
      margin-bottom:6px;
    }
  `;
  (document.head || document.documentElement).appendChild(style);

  // ---------- Left decor column mount ----------
  function mountLeftBar(){
    if (document.getElementById('npLeftBar')) return;
    const bar = document.createElement('div');
    bar.id = 'npLeftBar';
    bar.innerHTML = `<div id="npLeftBarBg"></div><div id="npLeftBarShade"></div>`;
    (document.body || document.documentElement).appendChild(bar);
  }
  if (document.body) mountLeftBar();
  else new MutationObserver((_, o) => { if (document.body){ mountLeftBar(); o.disconnect(); } })
    .observe(document.documentElement, { childList:true, subtree:true });

  // ---------- Right-side picker (slide-in) ----------
  function onReady(fn){
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once:true });
    else fn();
  }

  onReady(() => {
    if (document.getElementById('npBgHandle')) return;

    const handle = document.createElement('div');
    handle.id = 'npBgHandle';
    handle.title = 'Click to open';
    handle.innerHTML = '<span>❮</span>';

    const sidebar = document.createElement('div');
    sidebar.id = 'npBgSidebar';
    sidebar.innerHTML = `
      <div id="npBgBody">
        <div class="npThemeBlock">
          <div class="npSmall" style="margin-bottom:4px">Button style</div>
          <select id="npBtnTheme" class="npThemeSelect"></select>
        </div>

        <div class="npAccentBlock">
          <div class="npSmall" style="margin-bottom:4px">Accent color</div>
          <div class="npAccentPastilles" id="npAccentPastilles"></div>
        </div>

        <div class="npSmall" id="npBgStatus">Empty list — please wait while it loads…</div>
        <div class="npBgSearch">
          <input id="npBgQuery" type="text" placeholder="Filter by name…" />
          <button id="npBgFavOnly" type="button" title="Show favorites only">★</button>
          <button id="npBgHiddenOnly" type="button" title="Show hidden backgrounds (to restore them)">🗑</button>
          <button id="npBgReload" type="button" title="Reload from DTI">↻</button>
        </div>
        <div id="npBgList"></div>
      </div>
      <div id="npBgFooter">
        <button class="npFooterBtn" id="npBgCancel" type="button">Cancel</button>
        <button class="npFooterBtn primary" id="npBgSave" type="button">Save</button>
      </div>
    `;

    (document.body || document.documentElement).append(sidebar, handle);

    const OPEN_X = 'translateX(0)';
    const CLOSED_X = 'translateX(100%)';

    let built = false;
    let isOpen = false;
    let snapshot = null; // LS state captured on open
    let session  = null; // live preview state during editing

    function openBar(){
      if (isOpen) return;
      isOpen = true;
      snapshot = {
        mainBg:   LSget(LS.mainBgId, ''),
        leftBg:   LSget(LS.leftBarBg, ''),
        btnTheme: (typeof W.__npGetBtnTheme === 'function') ? W.__npGetBtnTheme() : '',
        accent:   (typeof W.__npGetAccent === 'function') ? W.__npGetAccent() : '#dcb8ff',
      };
      session = { ...snapshot };
      sidebar.style.transform = OPEN_X;
      if (!built) buildUI();
      refreshActives();
      refreshThemeSelect();
      refreshAccentPastilles();
    }

    function closeBar(){
      isOpen = false;
      sidebar.style.transform = CLOSED_X;
      snapshot = null;
      session = null;
    }

    function saveAndClose(){
      if (!session) { closeBar(); return; }
      LSset(LS.mainBgId,  session.mainBg);
      LSset(LS.leftBarBg, session.leftBg);
      if (session.btnTheme && typeof W.__npSaveBtnTheme === 'function'){
        W.__npSaveBtnTheme(session.btnTheme);
      }
      if (session.accent && typeof W.__npSaveAccent === 'function'){
        W.__npSaveAccent(session.accent);
      }
      closeBar();
    }

    function cancelAndClose(){
      if (snapshot){
        applyMainBg(snapshot.mainBg);
        applyLeftBg(snapshot.leftBg);
        if (snapshot.btnTheme && typeof W.__npApplyBtnTheme === 'function'){
          W.__npApplyBtnTheme(snapshot.btnTheme);
        }
        if (snapshot.accent && typeof W.__npApplyAccent === 'function'){
          W.__npApplyAccent(snapshot.accent);
        }
      }
      closeBar();
    }

    handle.addEventListener('click', (ev) => {
      ev.preventDefault(); ev.stopPropagation();
      openBar();
    });

    // Click anywhere outside the panel (and not on the handle) reverts + closes.
    document.addEventListener('pointerdown', (ev) => {
      if (!isOpen) return;
      const t = ev.target;
      if (!t) return;
      if (t.closest && (t.closest('#npBgSidebar') || t.closest('#npBgHandle'))) return;
      cancelAndClose();
    }, true);

    function refreshActives(){
      if (!session) return;
      sidebar.querySelectorAll('.npBgR').forEach(b => {
        b.classList.toggle('isActive', b.getAttribute('data-url') === session.mainBg);
      });
      sidebar.querySelectorAll('.npBgL').forEach(b => {
        b.classList.toggle('isActive', b.getAttribute('data-url') === session.leftBg);
      });
    }

    // (Re)populate the button-theme <select> — runs on each open so we pick up
    // the API in case the HUD script wasn't loaded yet on first build.
    function refreshThemeSelect(){
      const sel = sidebar.querySelector('#npBtnTheme');
      if (!sel) return;
      const themes = Array.isArray(W.__npBtnThemes) ? W.__npBtnThemes : [];
      if (!themes.length){
        sel.innerHTML = `<option value="">(HUD script not loaded)</option>`;
        sel.disabled = true;
        return;
      }
      sel.disabled = false;
      if (sel.options.length !== themes.length || sel.options[0]?.value === ''){
        sel.innerHTML = themes.map(t => `<option value="${t.id}">${t.label}</option>`).join('');
      }
      const cur = (session && session.btnTheme)
        || (typeof W.__npGetBtnTheme === 'function' ? W.__npGetBtnTheme() : '');
      if (cur) sel.value = cur;
    }

    function refreshAccentPastilles(){
      const box = sidebar.querySelector('#npAccentPastilles');
      if (!box) return;
      const palette = Array.isArray(W.__npAccentPalette) ? W.__npAccentPalette : [];
      if (!palette.length){
        box.innerHTML = `<div class="npSmall" style="opacity:.6">(Core script not loaded)</div>`;
        return;
      }
      if (box.children.length !== palette.length){
        box.innerHTML = palette.map(p =>
          `<button type="button" class="npAccentDot" data-hex="${p.hex}" title="${p.label}" style="background:${p.hex}"></button>`
        ).join('');
      }
      const cur = (session && session.accent)
        || (typeof W.__npGetAccent === 'function' ? W.__npGetAccent() : '#dcb8ff');
      box.querySelectorAll('.npAccentDot').forEach(el => {
        el.classList.toggle('isActive', el.getAttribute('data-hex').toLowerCase() === String(cur).toLowerCase());
      });
    }

    function buildUI(){
      built = true;

      const bgList       = sidebar.querySelector('#npBgList');
      const bgStatus     = sidebar.querySelector('#npBgStatus');
      const bgQuery      = sidebar.querySelector('#npBgQuery');
      const bgReload     = sidebar.querySelector('#npBgReload');
      const bgFavOnly    = sidebar.querySelector('#npBgFavOnly');
      const bgHiddenOnly = sidebar.querySelector('#npBgHiddenOnly');
      const saveBtn      = sidebar.querySelector('#npBgSave');
      const cancelBtn    = sidebar.querySelector('#npBgCancel');
      const bodyEl       = sidebar.querySelector('#npBgBody');
      const themeSel     = sidebar.querySelector('#npBtnTheme');

      themeSel?.addEventListener('change', () => {
        if (!session) return;
        session.btnTheme = themeSel.value;
        if (typeof W.__npApplyBtnTheme === 'function') W.__npApplyBtnTheme(themeSel.value);
      });
      refreshThemeSelect();

      const accentBox = sidebar.querySelector('#npAccentPastilles');
      accentBox?.addEventListener('click', (e) => {
        const btn = e.target.closest('.npAccentDot');
        if (!btn || !session) return;
        const hex = btn.getAttribute('data-hex');
        if (!hex) return;
        session.accent = hex;
        if (typeof W.__npApplyAccent === 'function') W.__npApplyAccent(hex);
        accentBox.querySelectorAll('.npAccentDot').forEach(el => {
          el.classList.toggle('isActive', el === btn);
        });
      });
      refreshAccentPastilles();

      saveBtn  .addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); saveAndClose(); });
      cancelBtn.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); cancelAndClose(); });

      let dtiAll = [];
      let dtiFiltered = [];
      let renderCount = 0;
      const RENDER_STEP = 30;
      let favs   = readFavs();
      let hidden = readHidden();
      let favOnly    = false;
      let hiddenOnly = false;

      function syncFilterBtns(){
        bgFavOnly.classList.toggle('isOn', favOnly);
        bgHiddenOnly.classList.toggle('isOn', hiddenOnly);
      }

      function renderMore(){
        const slice = dtiFiltered.slice(renderCount, renderCount + RENDER_STEP);
        for (const it of slice){
          const row = document.createElement('div');
          row.className = 'npBgItem';
          const isFav = favs.has(it.id);
          const isHid = hidden.has(it.id);
          if (isHid) row.classList.add('isHidden');
          row.innerHTML = `
            <div class="npBgThumb" style="background-image:url('${safeUrl(it.thumbnailUrl)}')"></div>
            <div class="npBgName" title="${it.name.replace(/"/g,'&quot;')}">${it.name}</div>
            <button type="button" class="npFavBtn ${isFav?'isOn':''}" title="Favorite">★</button>
            <button type="button" class="npBgBtn npBgL" data-url="${safeUrl(it.url)}" title="Use on LEFT decor column">L</button>
            <button type="button" class="npBgBtn npBgR" data-url="${safeUrl(it.url)}" title="Use as MAIN background">M</button>
            <button type="button" class="npHideBtn ${isHid?'isOn':''}" title="${isHid?'Restore':'Hide this background'}">🗑</button>
          `;
          const favBtn  = row.querySelector('.npFavBtn');
          const btnL    = row.querySelector('.npBgL');
          const btnR    = row.querySelector('.npBgR');
          const hideBtn = row.querySelector('.npHideBtn');
          favBtn.addEventListener('click', (ev) => {
            ev.preventDefault(); ev.stopPropagation();
            if (favs.has(it.id)) favs.delete(it.id); else favs.add(it.id);
            writeFavs(favs);
            favBtn.classList.toggle('isOn', favs.has(it.id));
            if (favOnly) applyFilter();
          });
          hideBtn.addEventListener('click', (ev) => {
            ev.preventDefault(); ev.stopPropagation();
            if (hidden.has(it.id)) hidden.delete(it.id); else hidden.add(it.id);
            writeHidden(hidden);
            applyFilter();
          });
          btnL.addEventListener('click', (ev) => {
            ev.preventDefault(); ev.stopPropagation();
            if (!session) return;
            session.leftBg = it.url;
            applyLeftBg(it.url);
            refreshActives();
          });
          btnR.addEventListener('click', (ev) => {
            ev.preventDefault(); ev.stopPropagation();
            if (!session) return;
            session.mainBg = it.url;
            applyMainBg(it.url);
            refreshActives();
          });
          bgList.appendChild(row);
        }
        renderCount += slice.length;
        refreshActives();
      }

      function applyFilter(){
        const q = (bgQuery.value || '').trim().toLowerCase();
        dtiFiltered = dtiAll.filter(it => {
          const isHid = hidden.has(it.id);
          if (hiddenOnly){
            if (!isHid) return false;
          } else {
            if (isHid) return false;
            if (favOnly && !favs.has(it.id)) return false;
          }
          if (q && !it.name.toLowerCase().includes(q)) return false;
          return true;
        });
        bgList.innerHTML = '';
        renderCount = 0;
        renderMore();
        updateStatus();
      }

      function updateStatus(){
        if (!dtiAll.length){
          bgStatus.textContent = 'No backgrounds loaded. Click ↻ to fetch from DTI.';
        } else if (!dtiFiltered.length){
          if (hiddenOnly) bgStatus.textContent = '0 hidden backgrounds (trash is empty).';
          else if (favOnly) bgStatus.textContent = `0 favorite${favs.size ? ' matching' : ''} (out of ${dtiAll.length} backgrounds).`;
          else bgStatus.textContent = `No results (out of ${dtiAll.length} cached backgrounds).`;
        } else {
          const note = hiddenOnly ? ' (trash)' : (favOnly ? ' (favorites)' : '');
          bgStatus.textContent = `${dtiFiltered.length} background${dtiFiltered.length > 1 ? 's' : ''}${note} shown (${Math.min(renderCount, dtiFiltered.length)} rendered).`;
        }
      }

      bodyEl.addEventListener('scroll', () => {
        if (renderCount >= dtiFiltered.length) return;
        if (bodyEl.scrollTop + bodyEl.clientHeight >= bodyEl.scrollHeight - 120) renderMore();
      });

      bgQuery.addEventListener('input', applyFilter);
      bgFavOnly.addEventListener('click', (ev) => {
        ev.preventDefault(); ev.stopPropagation();
        favOnly = !favOnly;
        if (favOnly) hiddenOnly = false;
        syncFilterBtns();
        applyFilter();
      });
      bgHiddenOnly.addEventListener('click', (ev) => {
        ev.preventDefault(); ev.stopPropagation();
        hiddenOnly = !hiddenOnly;
        if (hiddenOnly) favOnly = false;
        syncFilterBtns();
        applyFilter();
      });

      async function loadDTI(force = false){
        if (!force){
          const cached = readDTICached();
          if (cached && cached.length){
            dtiAll = cached;
            applyFilter();
            return;
          }
        }
        bgStatus.textContent = 'Loading from DTI…';
        try {
          const items = await fetchDTIBackgrounds((page, count) => {
            bgStatus.textContent = `Loading from DTI… (page ${page}, ${count} backgrounds)`;
          });
          if (items.length){
            dtiAll = items;
            writeDTICached(items);
            applyFilter();
            bgStatus.textContent = `${items.length} backgrounds loaded from DTI.`;
          } else {
            bgStatus.textContent = 'No backgrounds returned by DTI.';
          }
        } catch (err) {
          bgStatus.textContent = 'DTI fetch failed: ' + (err.message || err);
        }
      }

      bgReload.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); loadDTI(true); });

      const cached = readDTICached();
      if (cached && cached.length){
        dtiAll = cached;
        applyFilter();
      } else {
        updateStatus();
        setTimeout(() => loadDTI(false), 60);
      }
    }
  });

})();