① Neopets — Core & Layout

Foundation script for the suite. Defines the common theme tokens (CSS vars + accent palette + button-icon themes), draws the fixed top bar (search, NST clock, quest log, alerts bell), wraps page content in a centered responsive frame, and hides ads / sidebars / native nav while leaving the underlying nodes intact so Neopets' own JS keeps working.

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

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 — Core & Layout
// @namespace    neopets-qol
// @version      3.28.2
// @author       marius@clraik
// @license      MIT
// @description  Foundation script for the suite. Defines the common theme tokens (CSS vars + accent palette + button-icon themes), draws the fixed top bar (search, NST clock, quest log, alerts bell), wraps page content in a centered responsive frame, and hides ads / sidebars / native nav while leaving the underlying nodes intact so Neopets' own JS keeps working.
// @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        none
// ==/UserScript==

(function () {
  'use strict';
  // Skip iframes (game.phtml, NCMall…). Without this, the top bar + frame
  // styling are injected into every embedded frame on the page and the
  // hover menus end up trapped inside iframe clipping rects.
  if (window.top !== window) return;
  if (document.getElementById('np-core-layout')) return;

  /* =========================================================
     PAGES WITH A NATIVE THEMED CONTENT BACKGROUND
     Some Neopets pages (inventory, bank, etc.) ship a gradient
     or themed background inside the content frame that we want
     to KEEP visible. We tag <html> early so the CSS below can
     skip the white-bg override on those paths only — without
     affecting any other page.
     Edit this list to add/remove pages.
  ========================================================= */
  const KEEP_NATIVE_BG_PATHS = [
    '/inventory.phtml',
    // '/bank.phtml',   // uncomment if you also want to keep Bank's native bg
  ];
  if (KEEP_NATIVE_BG_PATHS.some(p => location.pathname.startsWith(p))) {
    document.documentElement.classList.add('np-keep-native-bg');
  }

  /* =========================================================
     BUTTON-THEME API (icon set used by the top bar and HUD).
     Exposed early so any consumer script can read the list,
     read the current theme, or dispatch `np:btn-theme` to
     refresh icons.
  ========================================================= */
  (function exposeBtnThemes(){
    if (window.__npBtnThemesData) return;

    const BTN_THEMES = [
      { id:'winterholiday',     label:'Winter Holiday',     pattern:'https://images.neopets.com/themes/h5/winterholiday/images/{icon}-icon.png' },
      { id:'basic',             label:'Basic',              pattern:'https://images.neopets.com/themes/h5/basic/images/v3/{icon}-icon.svg' },
      { id:'premium',           label:'Premium',            pattern:'https://images.neopets.com/themes/h5/premium/images/{icon}-icon.svg' },
      { id:'altadorcup',        label:'Altador Cup',        pattern:'https://images.neopets.com/themes/h5/altadorcup/images/{icon}-icon.png' },
      { id:'constellations',    label:'Constellations',     pattern:'https://images.neopets.com/themes/h5/constellations/images/{icon}-icon.svg' },
      { id:'birthday',          label:'Birthday',           pattern:'https://images.neopets.com/themes/h5/birthday/images/{icon}-icon.png' },
      { id:'destroyedfestival', label:'Faerie Festival',    pattern:'https://images.neopets.com/themes/h5/destroyedfestival/images/{icon}-icon.svg' },
      { id:'neggs',             label:'Festival of Neggs',  pattern:'https://images.neopets.com/themes/h5/neggs/images/{icon}-icon.svg' },
      { id:'grey',              label:'Grey Day',           pattern:'https://images.neopets.com/themes/h5/grey/images/{icon}-icon.png' },
      { id:'newyears',          label:'New Year',           pattern:'https://images.neopets.com/themes/h5/newyears/images/{icon}-icon.png' },
      { id:'hauntedwoods',      label:'Haunted Woods',      pattern:'https://images.neopets.com/themes/h5/hauntedwoods/images/{icon}-icon.svg' },
      { id:'meridell',          label:'Meridell',           pattern:'https://images.neopets.com/themes/h5/meridell/images/{icon}-icon.svg' },
      { id:'mysteryisland',     label:'Mystery Island',     pattern:'https://images.neopets.com/themes/h5/mysteryisland/images/{icon}-icon.png' },
      { id:'neopiantimes',      label:'Neopian Times',      pattern:'https://images.neopets.com/themes/h5/neopiantimes/images/{icon}-icon.svg' },
      { id:'neggsneovia',       label:'Neovian Neggs',      pattern:'https://images.neopets.com/themes/h5/neggsneovia/images/{icon}-icon.svg' },
      { id:'tistheseason',      label:'Tis the Season',     pattern:'https://images.neopets.com/themes/h5/tistheseason/images/{icon}-icon.png' },
      { id:'tyrannia',          label:'Tyrannia',           pattern:'https://images.neopets.com/themes/h5/tyrannia/images/{icon}-icon.svg' },
      { id:'valentines',        label:'Valentines',         pattern:'https://images.neopets.com/themes/h5/valentines/images/{icon}-icon.svg' },
    ];
    const FALLBACK_PATTERN = 'https://images.neopets.com/themes/h5/basic/images/v3/{icon}-icon.svg';
    const LS_KEY = 'npqol_btn_theme_v1';
    const DEFAULT = 'constellations';

    window.__npBtnThemesData = { BTN_THEMES, FALLBACK_PATTERN, LS_KEY, DEFAULT };
    window.__npBtnThemes = BTN_THEMES.map(t => ({ id: t.id, label: t.label }));

    window.__npGetBtnTheme = () => {
      try { return localStorage.getItem(LS_KEY) || DEFAULT; }
      catch { return DEFAULT; }
    };
    // apply (no persist) → fires event so each script can refresh its icons.
    window.__npApplyBtnTheme = (id) => {
      document.dispatchEvent(new CustomEvent('np:btn-theme', { detail: { themeId: id } }));
    };
    // save (persist + apply)
    window.__npSaveBtnTheme = (id) => {
      try { localStorage.setItem(LS_KEY, id); } catch {}
      document.dispatchEvent(new CustomEvent('np:btn-theme', { detail: { themeId: id } }));
    };

    window.__npBtnIconUrl = (iconName, themeId) => {
      const t = BTN_THEMES.find(x => x.id === themeId) || BTN_THEMES.find(x => x.id === DEFAULT);
      return (t || { pattern: FALLBACK_PATTERN }).pattern.replace('{icon}', iconName);
    };
    window.__npBtnFallbackUrl = (iconName) => FALLBACK_PATTERN.replace('{icon}', iconName);

    /* Cascade fallback for icons. Neopets' themes aren't consistent:
       - basic/images/v3/*.svg has: profile, mypets, customise, inventory,
         safetydeposit, quickstock, transferlog, gallery, stamps, tradingcards,
         ncalbum, settings, signout, adoptpet, createpet…
       - basic/images/*.svg has different icons that v3 DOESN'T have:
         chamber, bookshelf, neopass, shopwizard…
       - basic/images/*.png is the last resort (shopwizard is PNG-only).
       So when a theme is missing an icon, fall back through this chain
       instead of a single URL — that's the only way `chamber`,`bookshelf`,
       `neopass` survive on themes that don't ship them. */
    window.__npBtnFallbackChain = [
      'https://images.neopets.com/themes/h5/basic/images/v3/{icon}-icon.svg',
      'https://images.neopets.com/themes/h5/basic/images/{icon}-icon.svg',
      'https://images.neopets.com/themes/h5/basic/images/{icon}-icon.png',
    ];
    window.__npAttachBtnFallback = (img, iconName) => {
      if (!img || !iconName) return;
      const tried = new Set();
      let extSwapped = false;
      let chainStep = 0;
      img.onerror = () => {
        const current = img.src || '';
        tried.add(current);

        // 1) Same theme, opposite extension. Grey Day's chamber/bookshelf/
        //    neopass live as SVG even though the theme's default pattern is
        //    PNG — without this step we'd skip straight to basic and show
        //    the wrong theme's icon. Generalized so any PNG/SVG mismatch
        //    in any future theme is auto-recovered.
        if (!extSwapped){
          extSwapped = true;
          const m = current.match(/^(.+\/)([^\/]+)-icon\.(svg|png)(\?.*)?$/i);
          if (m){
            const altExt = m[3].toLowerCase() === 'png' ? 'svg' : 'png';
            const altUrl = `${m[1]}${iconName}-icon.${altExt}`;
            if (!tried.has(altUrl)){
              img.src = altUrl;
              return;
            }
          }
        }

        // 2) Static cascade: basic/v3 → basic root SVG → basic root PNG.
        //    Dedup against `tried` so we never re-fetch an URL the browser
        //    has already 404'd on (otherwise the cascade would freeze on
        //    no-op src assignments).
        const chain = window.__npBtnFallbackChain || [];
        while (chainStep < chain.length){
          const next = chain[chainStep++].replace('{icon}', iconName);
          if (!tried.has(next)){
            img.src = next;
            return;
          }
        }
        img.onerror = null;
      };
    };
  })();

  /* =========================================================
     ACCENT API — single accent color shared by every script
     via CSS custom properties (--np-accent*). The picker in
     the BG script calls __npApplyAccent(hex) to re-tint the
     entire HUD on the fly.
  ========================================================= */
  (function exposeAccent(){
    if (window.__npAccentApi) return;

    const LS_KEY = 'npqol_accent_v1';
    const DEFAULT_HEX = '#dcb8ff';

    // Pastel rainbow + default lila. The hex value goes straight into
    // `--np-accent`; the derived bg/line/etc. variations come from rgba()
    // via hexToRgb() below.
    const PALETTE = [
      { id:'lila',    hex:'#dcb8ff', label:'Lila' },
      { id:'rose',    hex:'#ffb8d4', label:'Rose' },
      { id:'peach',   hex:'#ffc8a3', label:'Peach' },
      { id:'yellow',  hex:'#f5e07a', label:'Yellow' },
      { id:'mint',    hex:'#b8e6c5', label:'Mint' },
      { id:'cyan',    hex:'#a8e0ea', label:'Cyan' },
      { id:'blue',    hex:'#b8c8ff', label:'Blue' },
      { id:'black',   hex:'#000000', label:'Black' },
      { id:'white',   hex:'#ffffff', label:'White' },
    ];

    function hexToRgb(hex){
      let h = String(hex || '').replace('#','').trim();
      if (h.length === 3) h = h.split('').map(c => c+c).join('');
      const n = parseInt(h, 16);
      if (!Number.isFinite(n) || h.length !== 6) return { r:220, g:184, b:255 };
      return { r:(n>>16)&255, g:(n>>8)&255, b:n&255 };
    }
    const rgba = (hex, a) => { const {r,g,b} = hexToRgb(hex); return `rgba(${r},${g},${b},${a})`; };

    function setCSSVars(hex){
      const root = document.documentElement;
      root.style.setProperty('--np-accent',       hex);
      root.style.setProperty('--np-accent-bg',    rgba(hex, .14));
      root.style.setProperty('--np-accent-line',  rgba(hex, .45));
      root.style.setProperty('--np-accent-soft',  rgba(hex, .08));
      root.style.setProperty('--np-accent-hover', rgba(hex, .28));
      root.style.setProperty('--np-accent-r', String(hexToRgb(hex).r));
      root.style.setProperty('--np-accent-g', String(hexToRgb(hex).g));
      root.style.setProperty('--np-accent-b', String(hexToRgb(hex).b));
    }

    function getAccent(){
      try { return localStorage.getItem(LS_KEY) || DEFAULT_HEX; }
      catch { return DEFAULT_HEX; }
    }
    function applyAccent(hex){
      hex = (/^#[0-9a-fA-F]{3,8}$/.test(hex||'')) ? hex : DEFAULT_HEX;
      setCSSVars(hex);
      document.dispatchEvent(new CustomEvent('np:accent', { detail: { hex } }));
    }
    function saveAccent(hex){
      try { localStorage.setItem(LS_KEY, hex); } catch {}
      applyAccent(hex);
    }
    function resetAccent(){
      try { localStorage.removeItem(LS_KEY); } catch {}
      applyAccent(DEFAULT_HEX);
    }

    window.__npAccentApi     = true;
    window.__npAccentPalette = PALETTE;
    window.__npAccentDefault = DEFAULT_HEX;
    window.__npGetAccent     = getAccent;
    window.__npApplyAccent   = applyAccent;   // preview (no persist)
    window.__npSaveAccent    = saveAccent;    // persist + apply
    window.__npResetAccent   = resetAccent;

    // Apply at document-start so the rest of the page sees the color.
    applyAccent(getAccent());
  })();

  /* =========================================================
     FLOATING TABS API — shared dimensions + auto-stack.
     Each tab script (Quest Log, etc.) calls __npRegisterTab(id)
     at mount and gets the `bottom` offset to use:
       - 1st tab → at the very bottom-right
       - 2nd tab → stacked above
       - 3rd → above that
     Order = Tampermonkey execution order (alphabetical by default).
  ========================================================= */
  (function exposeTabsAPI(){
    if (window.__npTabConfig) return;
    window.__npTabConfig = {
      H: 110,           // tab height
      W: 220,           // tab width
      PEEK: 80,         // visible part when collapsed
      HOVER_PEEK: 220,  // visible part on hover (= W → tab fully out)
      BORDER: 3,
      GAP: 18,          // vertical gap between two stacked tabs
      BASE_BOTTOM: 30,  // bottom margin for the first tab
      ACCENT: '#dcb8ff',
      ZINDEX: 100000,
    };
    window.__npTabs = window.__npTabs || [];
    window.__npRegisterTab = function(id){
      const cfg = window.__npTabConfig;
      if (!window.__npTabs.includes(id)) window.__npTabs.push(id);
      const idx = window.__npTabs.indexOf(id);
      return cfg.BASE_BOTTOM + idx * (cfg.H + cfg.GAP);
    };
  })();

  /* =========================================================
     SHARED THEME TOKENS — also consumed by HUD and BACKGROUND
     scripts. Other scripts reference them via var(--np-*, fallback)
     so they keep working even if this script is disabled.
  ========================================================= */
  const THEME = `
    :root{
      --np-font: system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;

      /* Accent — overridden by exposeAccent() from localStorage.
         These values are just fallbacks (default lila). */
      --np-accent:#dcb8ff;
      --np-accent-bg:rgba(220,184,255,.14);
      --np-accent-line:rgba(220,184,255,.45);
      --np-accent-soft:rgba(220,184,255,.08);
      --np-accent-hover:rgba(220,184,255,.28);

      --np-ink:#fff;
      --np-ink-soft:rgba(255,255,255,.62);

      --np-glass:rgba(0,0,0,.22);
      --np-glass-hover:rgba(0,0,0,.30);
      --np-line:rgba(255,255,255,.16);
      --np-line-hover:rgba(255,255,255,.30);

      --np-btn:rgba(255,255,255,.06);
      --np-btn-hover:rgba(255,255,255,.10);

      --np-radius:14px;
      --np-radius-sm:10px;
      --np-shadow:0 8px 24px rgba(0,0,0,.28);
      --np-blur:8px;

      --np-gap:10px;
      --np-col-width:330px;          /* width of the left decor column */
      --np-hud-left:20px;            /* left margin inside the column */
      --np-hud-top:10px;
      --np-hud-bottom:10px;
      --np-hud-width:290px;          /* 20 + 290 + 20 = 330 → balanced margins */

      --np-z-bg:-2147483640;
      --np-z-bar:1000;
      --np-z-hud:1000;
      --np-z-menu:2000;
    }
  `;

  /* =========================================================
     LAYOUT (frame + kill native + offsets)
  ========================================================= */
  const BAR_H = 60;
  const FRAME = `
    :root{
      /* Responsive frame width: shrinks on narrow viewports, never wider than 1100px.
         User originally designed at 80% browser zoom; clamp lets the same
         layout work cleanly at 100% zoom on laptop screens too. */
      --np-frame-width: clamp(720px, 90vw, 1100px);
      --np-top-offset: 100px;     /* clearance below the top bar */
      --np-h5-gap: 0px;           /* 0 = BETA aligned with CLASSIC (top position) */
      --np-frame-radius: 30px;
      --np-frame-padding: 16px;
      --np-frame-border: 2px solid #000;
      --np-bottom-spacer: 10px;
    }

    /* CLASSIC: no H5 gap */
    body#neobdy{ --np-h5-gap: 0px; }

    /* Invisible scrollbars + no jump */
    html, body {
      -ms-overflow-style: none;
      scrollbar-width: none;
      scrollbar-gutter: stable both-edges;
      margin-top: 0 !important;
      padding-top: 0 !important;
      scroll-padding-top: var(--np-top-offset) !important;
      padding-bottom: var(--np-bottom-spacer) !important;
    }
    html::-webkit-scrollbar, body::-webkit-scrollbar { display: none; }
    body::after{ content:""; display:block; height:var(--np-bottom-spacer); }

    /* ===== HIDE NATIVE (nav + ads + footer + sidebar) ===== */
    #pushdown_banner,#ad-slug-wrapper,#ad-table,#ad-table-btf,
    .nl-instream-video,.nl-ads-leaderWrapper,#nl_ad_left,#nl_ad_right,
    [id^="pb-slot-"],#header,#navigation,#template_nav,#ban,#nst,
    #navtop__2020,#navbottom__2020,#footer,.footer,.footerNifty,
    .footer__2020,#nl_mobile_ad,.nl-ad-top,.nl-mobile-ad,.nl-ad-left,.nl-ad-right{
      display:none !important;
      visibility:hidden !important;
      height:0 !important;
      margin:0 !important;
      padding:0 !important;
      overflow:hidden !important;
    }

    /* Classic sidebar */
    td.sidebar, td.sidebar .sidebarModule{
      display:none !important;
      width:0 !important;
      height:0 !important;
      overflow:hidden !important;
    }

    /* Classic content full width */
    #content, td.content{ width:100% !important; }

    /* H5 subnav (favorites / NP / NC) */
    body .navsub-left__2020,
    body .navsub-right__2020,
    body .navsub-fav-icon__2020,
    body .bookmark-dropdown__2020,
    body .bookmark-dropdown-shade__2020{
      display:none !important;
      width:0 !important; height:0 !important;
      margin:0 !important; padding:0 !important;
      overflow:hidden !important;
    }
    body #navsub-buffer__2020{
      display:none !important;
      height:0 !important; margin:0 !important; padding:0 !important;
    }

    /* Main content frame — always centered on the viewport.
       If the HUD column overlaps the frame on narrow screens, the user can
       resize the HUD via its drag handle (added in the HUD script) rather
       than having us auto-push the frame around.
       Layout part (width/padding/border) always applies; the white-bg part
       below is gated so KEEP_NATIVE_BG_PATHS pages keep their themed gradient. */
    body :where(
      #container__2020,#content,#pageContent,#contentContainer,
      #main,main,.inner-body,.content,#np-content,#alpha-inner
    ){
      width:var(--np-frame-width) !important;
      max-width: var(--np-frame-width) !important;
      margin: var(--np-top-offset) auto 0 !important;
      padding:var(--np-frame-padding) !important;
      box-sizing:border-box !important;
      border:var(--np-frame-border) !important;
      border-radius:var(--np-frame-radius) !important;
      background-clip:padding-box !important;
    }
    html:not(.np-keep-native-bg) body :where(
      #container__2020,#content,#pageContent,#contentContainer,
      #main,main,.inner-body,.content,#np-content,#alpha-inner
    ){
      background-color:#fff !important;
      background-image:none !important;
    }

    /* Override Neopets' sand/cream gradient on H5 containers
       (.theme-bg, .container) — otherwise the background goes beige.
       Skipped on pages flagged via KEEP_NATIVE_BG_PATHS (inventory, etc.)
       so their native gradient stays visible. */
    html:not(.np-keep-native-bg) body .container.theme-bg,
    html:not(.np-keep-native-bg) body #container__2020.theme-bg,
    html:not(.np-keep-native-bg) body .container,
    html:not(.np-keep-native-bg) body .theme-bg{
      background-color:#fff !important;
      background-image:none !important;
    }

    /* BETA/H5 : adds the "removed bar" gap */
    body #container__2020{
      margin-top: calc(var(--np-top-offset) + var(--np-h5-gap)) !important;
    }

    /* Anti-blank top */
    body :where(#container__2020,#content,#pageContent,#contentContainer,#main,main,.inner-body,.content,#np-content,#alpha-inner) > :first-child{
      margin-top:0 !important; padding-top:0 !important;
    }
    body :where(#container__2020,#content,#pageContent,#contentContainer,#main,main,.inner-body,.content,#np-content,#alpha-inner) > br:first-child,
    body :where(#container__2020,#content,#pageContent,#contentContainer,#main,main,.inner-body,.content,#np-content,#alpha-inner) > hr:first-child{
      display:none !important;
    }
    body .page-title__2020, body .inv-title-container { margin-top:0 !important; padding-top:0 !important; }
    body .page-title__2020 :is(h1,h2,h3), body .inv-title-container :is(h1,h2,h3){ margin-top:0 !important; }

    /* Remove decorative borders / table chrome inside the frame.
       NOTE: background-image:none is gated behind :not(.np-keep-native-bg)
       below so themed pages (inventory, …) keep their native gradients
       even when those live on a <table>/<td>. */
    body :where(#container__2020,#content,#pageContent,#contentContainer,#main,main,.inner-body,.content,#np-content,#alpha-inner)
    :is(table,thead,tbody,tfoot,tr,td,th){
      border:0 !important; outline:0 !important; box-shadow:none !important;
      border-collapse:separate !important; border-spacing:0 !important;
    }
    html:not(.np-keep-native-bg) body :where(#container__2020,#content,#pageContent,#contentContainer,#main,main,.inner-body,.content,#np-content,#alpha-inner)
    :is(table,thead,tbody,tfoot,tr,td,th){
      background-image:none !important;
    }
    body :where(#container__2020,#content,#pageContent,#contentContainer,#main,main,.inner-body,.content,#np-content,#alpha-inner) img{
      border:0 !important; outline:0 !important; box-shadow:none !important;
    }

    /* CLASSIC: kill the 160px floated column blocking the content */
    body#neobdy td.content > div[style*="float: right"][style*="width: 160px"]{
      display:none !important;
    }
    /* CLASSIC: free the 635px-forced main block */
    body#neobdy td.content > div[style*="width: 635px"]{
      width:auto !important; max-width:100% !important;
      margin-left:auto !important; margin-right:auto !important;
    }
    /* CLASSIC: cleanup floats */
    body#neobdy td.content::after{ content:""; display:block; clear:both; }

    /* ===== Modern BETA pages: hide redundant chrome =====
       Recently rebuilt pages (SDB, Inventory, Closet, Quickstock…)
       duplicate things that already live in the HUD:
         - "Back to Neopia Central" button
         - inter-page nav (Inventory / Closet / SDB / ...)
         - verbose descriptions ("Your Safety Deposit Box holds...")
       Hide all of it so the nav stays clean.

       .inv-menubar is the parent of .inv-filtericons (NP/NC toggles,
       sort A-Z, stack/unstack). It can't be display:none without
       killing its useful children. Strategy:
         - keep .inv-menubar visible
         - hide ALL its direct children EXCEPT .inv-filtericons
       Same for .sdb-menubar and .closet-menubar.

       Specificity (0,1,2) with 'html body' beats the fallback
       [class$="-menubar"] (0,1,1) that comes later in source order. */
    html body .inv-menubar,
    html body .sdb-menubar,
    html body .closet-menubar,
    html body .quickstock-menubar,
    html body .gallery-menubar{
      display:flex !important;
      visibility:visible !important;
      opacity:1 !important;
      background:transparent !important;
      height:auto !important;
      width:auto !important;
      overflow:visible !important;
      pointer-events:auto !important;
    }
    /* Position the menubar absolutely (to the right of the page title)
       so it doesn't stack at the top-left. */
    html body .page-title__2020,
    html body .inv-title-container{
      position:relative !important;
    }
    html body .inv-menubar{
      position:absolute !important;
      top:50% !important;
      right:24px !important;
      transform:translateY(-50%) !important;
      gap:8px !important;
    }
    html body .inv-menubar > *:not(.inv-filtericons),
    html body .sdb-menubar > *:not(.sdb-filtericons),
    html body .closet-menubar > *:not(.closet-filtericons),
    html body .quickstock-menubar > *:not(.quickstock-filtericons),
    html body .gallery-menubar > *:not(.gallery-filtericons){
      display:none !important;
    }
    /* Force visibility of .inv-filtericons only. No display:flex on its
       children — let Neopets size the icons (containers + bg sizing). */
    html body .inv-filtericons{
      visibility:visible !important;
      opacity:1 !important;
      position:relative !important;
      z-index:50 !important;
    }

    body :is(
      .sdb-header-back, .sdb-header-description,
      .inv-header-back, .inv-header-description,
      .closet-header-back, .closet-header-description,
      .quickstock-header-back, .quickstock-menubar, .quickstock-header-description,
      .gallery-header-back, .gallery-menubar, .gallery-header-description,
      /* GENERIC "Back" button used by every rebuilt beta page */
      .back-button-circle__2020,
      /* per-page descriptions / instructions */
      #pageDesc, #qs-instructions,
      /* Battledome header (Board/FAQ + iframe FB) */
      #bdHeader,
      /* social widgets (FB Like, Twitter Share/Follow) */
      .social-links, .fb-like, .fb_iframe_widget, #fb-root,
      .twitter-share-button, .twitter-follow-button
    ){
      display:none !important;
    }
    /* social plugin iframes wherever they get injected */
    body iframe[src*="facebook.com/plugins"],
    body iframe[src*="platform.twitter.com"],
    body iframe[title*="Facebook Social Plugin" i],
    body iframe[title*="Twitter" i]{
      display:none !important;
    }
    /* Generic fallback: every "*-menubar"/"*-header-back"/"*-header-description"
       follows the same naming pattern. Covers beta pages we don't know
       about yet (closet, stamps, tcg, etc.). */
    body [class$="-menubar"],
    body [class$="-header-back"],
    body [class$="-header-description"]{
      display:none !important;
    }
    /* Remove the empty space left above the page title once Back and the
       menu are gone (the title-row has nothing left to balance). */
    body :is(.sdb-header, .inv-header, .closet-header, .quickstock-header, .qs-page-header){
      padding-top:0 !important;
      margin-top:0 !important;
    }
    body :is(.sdb-header-title-row, .inv-header-title-row, .closet-header-title-row, .qs-title-container){
      justify-content:center !important;
      padding-top:0 !important;
      margin-top:0 !important;
    }
    /* Frame: cancel the calc(2em+15px+2em) bottom padding from Neopets, but
       guarantee a min-height that covers the central icon-column area so it
       stays visually INSIDE the frame on short pages. */
    body :where(#container__2020,#content,#pageContent,#contentContainer,#main,main,.inner-body,.content,#np-content,#alpha-inner){
      min-height:calc(50vh + 80px) !important;
      padding-bottom:var(--np-frame-padding) !important;
    }

    /* CLASSIC: avoid the DOUBLE frame. #main stays as THE single frame (it
       carries the top offset). Nested elements get their border removed but
       keep their internal padding so the content breathes. */
    body#neobdy #main #content{
      border:0 !important;
      border-radius:0 !important;
      width:100% !important;
      max-width:none !important;
      margin:0 !important;
      padding:24px !important;
    }
    body#neobdy td.content{
      border:0 !important;
      border-radius:0 !important;
      width:100% !important;
      max-width:none !important;
      margin:0 !important;
      padding:0 !important;
    }

    /* Narrow viewports: reduce frame radius/padding so it doesn't look
       chunky at smaller widths. */
    @media (max-width: 1100px){
      :root{ --np-frame-radius: 20px; --np-frame-padding: 12px; }
    }
    @media (max-width: 900px){
      :root{ --np-frame-radius: 14px; --np-frame-padding: 10px; --np-top-offset: 80px; }
    }
  `;

  const style = document.createElement('style');
  style.id = 'np-core-layout';
  style.textContent = THEME + FRAME;
  (document.head || document.documentElement).appendChild(style);

  /* ===== HIDE NATIVE (CSS only — NOT removed from DOM) =====
     We don't .remove() these because Neopets scripts then try to
     query them (setDotOnBellIcon, manageProfileMenuSize2020, etc.)
     and crash when they're missing — which breaks page init
     (e.g. inventory NP/NC filters never bind).
     Solution: keep them in the DOM, just visually hidden.
  */
  const KILL = [
    '#pushdown_banner','#ad-slug-wrapper','#ad-table','#ad-table-btf',
    '.nl-instream-video','.nl-ads-leaderWrapper','#nl_ad_left','#nl_ad_right',
    '[id^="pb-slot-"]','#header','#navigation','#template_nav','#ban','#nst',
    '#navtop__2020','#navbottom__2020','#footer','.footer','.footerNifty','.footer__2020',
    '#nl_mobile_ad','.nl-ad-top','.nl-mobile-ad','.nl-ad-left','.nl-ad-right',
    'td.sidebar','td.sidebar .sidebarModule'
  ];
  const killStyle = document.createElement('style');
  killStyle.id = 'np-kill-native';
  killStyle.textContent = KILL.join(',\n') + `{
    display:none !important;
    visibility:hidden !important;
    height:0 !important;
    width:0 !important;
    overflow:hidden !important;
    pointer-events:none !important;
  }`;
  (document.head || document.documentElement).appendChild(killStyle);

  /* =========================================================
     TOP BAR (Shadow DOM, hover-open menus)
  ========================================================= */
  const mkLink = (attrs) => { const el = document.createElement('link'); Object.assign(el, attrs); (document.head || document.documentElement).append(el); };
  mkLink({ rel:'preconnect', href:'https://images.neopets.com', crossorigin:'' });
  mkLink({ rel:'dns-prefetch', href:'//images.neopets.com' });

  const host = document.createElement('div');
  host.id = 'npx-host';
  Object.assign(host.style, { position:'fixed', top:'0', left:'0', right:'0', zIndex:'1000' });
  const root = host.attachShadow({ mode:'open' });

  const barStyle = document.createElement('style');
  barStyle.textContent = `
    :host, :host * { box-sizing:border-box; }
    .bar, .bar *, .menu, .menu *{
      font-family: var(--np-font, system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif);
      font-size:15px; -webkit-font-smoothing:antialiased;
    }
    .bar{
      height:${BAR_H}px;
      background:transparent;
      display:flex; align-items:center; justify-content:center;
      overflow:visible;
    }
    .inner{
      display:grid;
      grid-template-columns: 1fr auto 1fr;
      align-items:center; width:100%;
      padding:0 10px; gap:10px;
    }
    .left{ grid-column:2; display:flex; align-items:center; gap:8px; justify-content:center; }
    .right{ grid-column:3; display:flex; align-items:center; gap:10px; justify-content:flex-end; }

    .btn{
      display:flex; align-items:center; gap:8px;
      text-decoration:none; cursor:pointer; user-select:none;
      padding:4px 8px; border-radius:10px;
      color:var(--np-accent, #dcb8ff); font-weight:700;
      text-shadow:0 1px 3px rgba(0,0,0,.9), 0 0 8px rgba(0,0,0,.5);
      transition: background .15s ease;
    }
    .btn:hover{ background:var(--np-accent-bg, var(--np-accent-bg)); }
    .btn img{ width:34px; height:34px; display:block; }
    .btn.fight img{ width:68px; height:68px; }

    .menu{
      position:fixed; top:${BAR_H + 6}px; left:0; display:none;
      background:rgba(20,14,32,.96);
      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,.16));
      border-radius:var(--np-radius, 14px);
      box-shadow:0 10px 28px rgba(0,0,0,.45);
      padding:8px; min-width:220px; max-width:min(640px,92vw);
      z-index:2000;
    }
    .menu a{
      display:flex; align-items:center; gap:10px;
      padding:8px 10px; border-radius:var(--np-radius-sm, 10px);
      text-decoration:none; color:var(--np-accent, #dcb8ff); font-weight:600;
    }
    .menu a:hover{ background:var(--np-accent-bg, var(--np-accent-bg)); color:#fff; }
    .menu a img{ width:24px; height:24px; display:block; flex:0 0 auto; }
    .menu a span{ flex:1 1 auto; white-space:nowrap; }
    .menu .menuSep{
      height:1px;
      background:rgba(255,255,255,.14);
      margin:6px 8px;
    }

    /* Trash button: hidden by default, appears on row hover */
    .menu a{ position:relative; }
    .menu a .menuTrash{
      position:absolute; top:50%; right:6px;
      transform:translateY(-50%);
      width:22px; height:22px; padding:0;
      border:0; border-radius:50%;
      background:rgba(255,80,80,.15);
      color:#ff8080; font-size:14px; font-weight:900;
      line-height:1; cursor:pointer;
      opacity:0; transition:opacity .12s ease;
      display:flex; align-items:center; justify-content:center;
    }
    .menu a:hover .menuTrash{ opacity:1; }
    .menu a .menuTrash:hover{
      background:#ff5a5a; color:#fff;
    }

    /* Add row: + button + inline form */
    .menu .menuAddRow{
      margin-top:6px;
      padding-top:6px;
      border-top:1px solid rgba(255,255,255,.10);
    }
    .menu .menuActions{
      display:flex; gap:6px;
    }
    .menu .menuActions[hidden]{ display:none; }
    .menu .menuAddBtn,
    .menu .menuResetBtn{
      flex:1; padding:6px 8px;
      border:1px dashed rgba(255,255,255,.28);
      background:transparent;
      color:rgba(255,255,255,.7); font:inherit; font-size:11px; font-weight:700;
      border-radius:var(--np-radius-sm, 10px);
      cursor:pointer;
      text-align:center;
      box-sizing:border-box;
    }
    .menu .menuAddBtn:hover{
      background:var(--np-accent-bg);
      color:#fff;
      border-color:var(--np-accent-line);
    }
    .menu .menuResetBtn:hover{
      background:rgba(255,180,180,.10);
      color:#ffb4b4;
      border-color:rgba(255,180,180,.45);
    }
    .menu .menuAddForm{
      display:flex; flex-direction:column; gap:6px;
      padding:6px;
    }
    .menu .menuAddForm[hidden]{ display:none; }
    .menu .menuAddForm input{
      width:100%; box-sizing:border-box;
      height:28px; padding:0 8px;
      border:1px solid rgba(255,255,255,.22);
      background:rgba(0,0,0,.22);
      color:#fff; font:inherit; font-size:12px;
      border-radius:8px; outline:0;
    }
    .menu .menuAddForm input:focus{ border-color:var(--np-accent, #dcb8ff); }
    .menu .menuAddForm input::placeholder{ color:rgba(255,255,255,.4); }
    .menu .menuAddBtns{ display:flex; gap:6px; }
    .menu .menuAddBtns button{
      flex:1; height:26px; padding:0 8px;
      border:1px solid rgba(255,255,255,.22);
      background:rgba(0,0,0,.18);
      color:#fff; font:inherit; font-weight:700; font-size:11px;
      border-radius:8px; cursor:pointer;
    }
    .menu .menuAddBtns button.menuAddOk{
      background:var(--np-accent, #dcb8ff);
      color:#1a1330;
      border-color:transparent;
    }
    .menu .menuAddBtns button:hover{ filter:brightness(1.1); }

    /* Top bar: search button (toggle) */
    .searchBtn{ cursor:pointer; }

    /* Search form: hidden by default, appears when the magnifier is clicked */
    .searchForm{
      display:none;
      align-items:center;
      height:34px; padding:0 4px 0 10px;
      border-radius:18px;
      background:rgba(255,255,255,.10);
      border:1px solid rgba(255,255,255,.20);
      transition:border-color .12s ease, background .12s ease;
    }
    .searchForm.open{ display:flex; }
    .searchForm:focus-within{
      border-color:var(--np-accent, #dcb8ff);
      background:rgba(255,255,255,.14);
    }
    .searchInput{
      width:200px; min-width:0; height:30px; padding:0 6px;
      border:0; background:transparent; outline:0;
      color:#fff; font:inherit; font-weight:500; font-size:13px;
    }
    .searchInput::placeholder{ color:rgba(255,255,255,.45); }
    .searchGo{
      height:26px; padding:0 12px;
      border:0; border-radius:13px;
      background:var(--np-accent, #dcb8ff);
      color:#1a1330; font:inherit; font-weight:800; font-size:12px;
      cursor:pointer;
    }
    .searchGo:hover{ filter:brightness(1.08); }

    /* Top bar: NST clock */
    .nst{
      display:flex; align-items:baseline; gap:4px;
      color:var(--np-accent, #dcb8ff); font-weight:600; font-size:13px;
      font-variant-numeric:tabular-nums;
      text-shadow:0 1px 3px rgba(0,0,0,.9);
      padding:0 4px; white-space:nowrap;
    }
    .nst .nstLabel{ font-size:11px; opacity:.7; }

    /* Bell button + notification badge */
    .bellBtn{ position:relative; cursor:pointer; }
    .bellBtn .bellDot{
      position:absolute; top:-2px; right:-2px;
      min-width:18px; height:18px; padding:0 5px;
      border-radius:10px; background:#ff5a5a; color:#fff;
      font-size:10px; font-weight:900; line-height:18px;
      text-align:center; box-shadow:0 1px 3px rgba(0,0,0,.6);
    }
    .bellBtn .bellDot[hidden]{ display:none !important; }

    /* Bell dropdown */
    .bellMenu{
      position:fixed; top:${BAR_H + 6}px; right:10px; display:none;
      background:rgba(20,14,32,.96);
      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,.16));
      border-radius:var(--np-radius, 14px);
      box-shadow:0 10px 28px rgba(0,0,0,.45);
      padding:8px; width:340px; max-width:92vw; max-height:60vh; overflow:auto;
      z-index:2000;
    }
    .bellMenu .bellHead{
      display:flex; justify-content:space-between; align-items:center;
      padding:4px 8px 8px 8px;
      border-bottom:1px solid rgba(255,255,255,.10);
      margin-bottom:6px;
    }
    .bellMenu .bellHead h3{
      margin:0; font:900 12px/1 system-ui,sans-serif;
      letter-spacing:.05em; text-transform:uppercase;
      color:rgba(255,255,255,.7);
    }
    .bellMenu .bellHead a{
      font-size:11px; color:var(--np-accent, #dcb8ff);
      text-decoration:none; padding:2px 6px; border-radius:6px;
    }
    .bellMenu .bellHead a:hover{ background:var(--np-accent-bg); }
    .bellMenu .bellList{ display:flex; flex-direction:column; gap:4px; }
    .bellMenu .bellItem{
      position:relative;
      display:grid;
      grid-template-columns: 32px 1fr;
      grid-template-rows: auto auto;
      grid-column-gap:10px;
      align-items:start;
      padding:8px 28px 8px 10px; border-radius:10px;
      text-decoration:none; color:#fff;
    }
    .bellMenu .bellItem:hover{ background:var(--np-accent-soft); }
    .bellMenu .bellItem .bellIcon{
      grid-row: 1 / span 2;
      width:32px; height:32px; align-self:center;
      background:center/contain no-repeat;
      filter: drop-shadow(0 1px 2px rgba(0,0,0,.5));
    }
    .bellMenu .bellItem .bellTitle{
      font-size:13px; font-weight:800; color:#fff;
      line-height:1.2;
      margin:0;
    }
    .bellMenu .bellItem .bellText{
      font-size:11px; color:rgba(255,255,255,.78);
      line-height:1.3; margin:2px 0 0;
    }
    .bellMenu .bellItem .bellTime{
      grid-column: 2; margin:3px 0 0;
      font-size:10px; font-weight:600;
      color:var(--np-accent);
      letter-spacing:.02em;
    }
    .bellMenu .bellItem .bellX{
      position:absolute; top:6px; right:6px;
      width:22px; height:22px; padding:0; border:0;
      background:rgba(255,255,255,.06);
      color:rgba(255,255,255,.75);
      border-radius:6px; cursor:pointer;
      display:flex; align-items:center; justify-content:center;
      opacity:.55; transition:opacity .12s ease, background .12s ease, color .12s ease;
    }
    .bellMenu .bellItem:hover .bellX{ opacity:1; }
    .bellMenu .bellItem .bellX:hover{
      background:#ff5a5a; color:#fff;
    }
    .bellMenu .bellItem .bellX svg{
      width:13px; height:13px; display:block;
      pointer-events:none;
    }
    .bellMenu .bellEmpty{
      padding:18px 8px; text-align:center;
      color:rgba(255,255,255,.55); font-size:12px;
    }

    /* Smaller top bar on narrow screens */
    @media (max-width: 900px){
      .left{ gap:6px; }
      .right{ gap:8px; }
      .nst{ display:none; }  /* drop the clock first */
      .btn span{ display:none; } /* keep only icons */
    }
    @media (max-width: 700px){
      .btn img{ width:28px; height:28px; }
      .searchInput{ width:120px; }
    }
  `;
  root.appendChild(barStyle);

  // Mapping key → Neopets icon name (used in data-icon + URL pattern).
  // Structure mirrors the original Neopets nav (Community / Games /
  // Explore / Shop + Premium on the right, Quests + Bell at the far right).
  const TOPBAR_ICON_NAMES = {
    community: 'community',
    games:     'games',
    explore:   'explore',
    shop:      'shop',
    premium:   'premium',
    quests:    'quests',
    bell:      'bell',
    search:    'search',
  };

  const _initialTheme = (typeof window.__npGetBtnTheme === 'function') ? window.__npGetBtnTheme() : 'constellations';
  const _iconUrl = (typeof window.__npBtnIconUrl === 'function')
    ? window.__npBtnIconUrl
    : (name, theme) => `https://images.neopets.com/themes/h5/${theme}/images/${name}-icon.svg`;
  const icons = Object.fromEntries(
    Object.entries(TOPBAR_ICON_NAMES).map(([key, iconName]) => [key, _iconUrl(iconName, _initialTheme)])
  );
  // Each item: [href, label, iconSpec].
  // Special element: '---' = visual separator between groups.
  // iconSpec:
  //   - "name"        → follows current theme via __npBtnIconUrl
  //   - "basic:name"  → fixed icon basic/images/<name>-icon.png (sub-icons)
  //   - "basicsvg:name" → fixed icon basic/images/<name>-icon.svg
  //   - "https://..." → fixed URL, ignored by theme switching
  const BASIC_ICON_BASE = 'https://images.neopets.com/themes/h5/basic/images/';
  const menus = {
    community:[
      ['/community/index.phtml','Community Central','basic:communitycentral'],
      ['/neomessages.phtml','Neomail','neomail'],
      ['/neoboards/index.phtml','Neoboards','basic:neoboards'],
      ['/contests.phtml','Spotlights','basic:spotlights'],
      ['/guilds/index.phtml','Guilds','basic:guilds'],
    ],
    games:[
      ['/games/','Games Room','basicsvg:gamesroom'],
      ['/dome/','Battledome','basic:battledome'],
      ['/games/hiscores.phtml','High Scores','games'],
      ['/faeriefragments/','Faerie Fragments','basic:fragments'],
      ['/talesofdacardia/','Tales of Dacardia','basic:tada'],
    ],
    explore:[
      ['/explore.phtml','Explore Neopia','basicsvg:explore'],
      ['/tvw/','The Void Within','basic:tvw'],
      ['/giftsfromtherift/index.phtml','Gifts from the Rift','https://images.neopets.com/plots/tvw/nc/gifts-from-rift-icon.png'],
      ['/prizepass/adventure/','Adventure Pass','basic:prizepass'],
    ],
    shop:[
      // NP section
      ['/shops/wizard.phtml','Shop Wizard','basic:shopwizard'],
      ['/market.phtml?type=your','My Shop','basic:myshop'],
      ['/auctions.phtml','Auctions','basic:auction'],
      ['/island/tradingpost.phtml','Trading Post','basic:tradingpost'],
      ['/bank.phtml','Bank','shop'],
      ['/space/warehouse/prizecodes.phtml','Redeem Codes','basic:redeemcode'],
      '---',
      // NC section
      ['https://ncmall.neopets.com/mall/shop.phtml?layout=new','NC Mall','basic:ncmall'],
      ['https://secure.nc.neopets.com/get-neocash','Buy NC','basic:buync'],
      ['https://secure.nc.neopets.com/redeemnc','Redeem NC Cards','basic:redeemnc'],
      ['https://shop.neopets.com/','Merch Shop','basic:merch'],
      ['/shopping/index.phtml','Merch Partners','basic:merchshop'],
      ['/games/we/','Shenanigifts','basic:shenanigifts'],
      ['/mall/stylingstudio/','Styling Studio','basic:stylingstudio'],
      ['https://ncmall.neopets.com/mall/shop.phtml?page=wonderclaw','Wonderclaw','basic:wonderclaw'],
    ],
  };

  function resolveMenuIconSrc(spec, themeId){
    if (typeof spec !== 'string') return '';
    if (spec.startsWith('http')) return spec;
    if (spec.startsWith('basicsvg:')) return BASIC_ICON_BASE + spec.slice(9) + '-icon.svg';
    if (spec.startsWith('basic:'))    return BASIC_ICON_BASE + spec.slice(6) + '-icon.png';
    return _iconUrl(spec, themeId);
  }
  function isFixedIconSpec(spec){
    return typeof spec === 'string' && (
      spec.startsWith('http') ||
      spec.startsWith('basic:') ||
      spec.startsWith('basicsvg:')
    );
  }

  const bar = document.createElement('div');
  bar.className = 'bar';
  bar.innerHTML = `
    <div class="inner">
      <div class="left">
        <a class="btn cat" data-id="community"><img src="${icons.community}" data-icon="community" alt=""><span>Community</span></a>
        <a class="btn cat" data-id="games"><img src="${icons.games}" data-icon="games" alt=""><span>Games</span></a>
        <a class="btn cat" data-id="explore"><img src="${icons.explore}" data-icon="explore" alt=""><span>Explore</span></a>
        <a class="btn cat" data-id="shop"><img src="${icons.shop}" data-icon="shop" alt=""><span>Shop</span></a>
        <a class="btn" href="https://nc.neopets.com/membership/"><img src="${icons.premium}" data-icon="premium" alt=""><span>Premium</span></a>
      </div>
      <div class="right">
        <div class="btn searchBtn" id="npxSearchToggle" title="Search ( / )">
          <img src="${icons.search}" data-icon="search" alt="Search">
        </div>
        <form class="searchForm" id="npxSearch" action="https://www.neopets.com/search.phtml" method="get" role="search">
          <input class="searchInput" id="npxSearchInput" name="string" autocomplete="off" placeholder="Search Neopets…">
          <button class="searchGo" type="submit" aria-label="Go">Go</button>
        </form>
        <div class="nst" id="npxNst"><span class="nstTime">--:--:--</span> <span class="nstLabel">NST</span></div>
        <a class="btn" href="https://www.neopets.com/questlog/" title="Quest Log"><img src="${icons.quests}" data-icon="quests" alt="Quests"></a>
        <div class="btn bellBtn" id="npxBell" title="Alerts">
          <img src="${icons.bell}" data-icon="bell" alt="Alerts">
          <span class="bellDot" id="npxBellDot" hidden>0</span>
        </div>
      </div>
    </div>
  `;
  root.appendChild(bar);

  /* =========================================================
     TOP-BAR MENU CUSTOMIZATION (per category):
       - users can remove an item (trash on hover)
       - users can add a custom link (+ at the end of the menu)
     Persisted in localStorage as
       { [catId]: { removed:[href], added:[{href,label,iconSpec}] } }
  ========================================================= */
  const TOPBAR_CUSTOM_LS = 'npqol_topbar_custom_v1';
  function loadAllCustom(){
    try {
      const raw = localStorage.getItem(TOPBAR_CUSTOM_LS);
      return raw ? JSON.parse(raw) : {};
    } catch { return {}; }
  }
  function saveAllCustom(data){
    try { localStorage.setItem(TOPBAR_CUSTOM_LS, JSON.stringify(data)); } catch {}
  }
  function getCustom(catId){
    const all = loadAllCustom();
    return all[catId] || { removed: [], added: [] };
  }
  function setCustom(catId, custom){
    const all = loadAllCustom();
    all[catId] = custom;
    saveAllCustom(all);
  }
  function effectiveMenuEntries(catId){
    const defaults = menus[catId] || [];
    const custom = getCustom(catId);
    const removed = new Set(custom.removed || []);
    const kept = defaults.filter(e => {
      if (e === '---') return true;
      return !removed.has(e[0]);
    });
    const added = (custom.added || []).map(it => [it.href, it.label, it.iconSpec || catId]);
    if (!added.length) return kept;
    const needSep = kept.length && kept[kept.length - 1] !== '---';
    return needSep ? [...kept, '---', ...added] : [...kept, ...added];
  }

  function buildMenuHTML(catId){
    const entries = effectiveMenuEntries(catId);
    const itemsHTML = entries.map(entry => {
      if (entry === '---') return `<div class="menuSep"></div>`;
      const [href, label, iconSpec] = entry;
      const safeLabel = String(label).replace(/[<>]/g, '');
      const safeHref = String(href).replace(/"/g, '&quot;');
      const fixed = isFixedIconSpec(iconSpec);
      const src = resolveMenuIconSrc(iconSpec, _initialTheme);
      const attr = fixed
        ? `data-icon-fixed="${String(iconSpec).replace(/"/g, '&quot;')}"`
        : `data-icon="${iconSpec}"`;
      return `
        <a href="${safeHref}" data-href="${safeHref}">
          <img src="${src}" ${attr} alt="">
          <span>${safeLabel}</span>
          <button type="button" class="menuTrash" title="Remove this link" aria-label="Remove">×</button>
        </a>`;
    }).join('');
    return itemsHTML + `
      <div class="menuAddRow">
        <div class="menuActions">
          <button type="button" class="menuResetBtn" data-cat="${catId}" title="Restore the default links">Reset</button>
          <button type="button" class="menuAddBtn" data-cat="${catId}" title="Add a link">+ Add link</button>
        </div>
        <form class="menuAddForm" data-cat="${catId}" hidden>
          <input type="text" name="label" placeholder="Link name" required>
          <input type="url" name="href" placeholder="https://www.neopets.com/..." required>
          <div class="menuAddBtns">
            <button type="button" class="menuAddCancel">Cancel</button>
            <button type="submit" class="menuAddOk">Add</button>
          </div>
        </form>
      </div>
    `;
  }

  function rerenderMenu(catId){
    const m = dd[catId];
    if (!m) return;
    m.innerHTML = buildMenuHTML(catId);
    wireMenuActions(catId);
  }

  function wireMenuActions(catId){
    const m = dd[catId];
    if (!m) return;
    m.querySelectorAll('.menuTrash').forEach(btn => {
      btn.addEventListener('click', (ev) => {
        ev.preventDefault(); ev.stopPropagation();
        const a = btn.closest('a[data-href]');
        if (!a) return;
        const href = a.getAttribute('data-href');
        const custom = getCustom(catId);
        // If the item is a default → add it to "removed".
        // If it's a previously added item → drop it from "added".
        const inAdded = (custom.added || []).findIndex(x => x.href === href);
        if (inAdded >= 0){
          custom.added.splice(inAdded, 1);
        } else {
          custom.removed = custom.removed || [];
          if (!custom.removed.includes(href)) custom.removed.push(href);
        }
        setCustom(catId, custom);
        rerenderMenu(catId);
      });
    });
    const actions = m.querySelector('.menuActions');
    const addBtn  = m.querySelector('.menuAddBtn');
    const resetBtn = m.querySelector('.menuResetBtn');
    const form    = m.querySelector('.menuAddForm');
    const cancel  = m.querySelector('.menuAddCancel');

    if (addBtn && form && actions){
      addBtn.addEventListener('click', (ev) => {
        ev.preventDefault(); ev.stopPropagation();
        actions.hidden = true;
        form.hidden = false;
        form.querySelector('input[name="label"]')?.focus();
      });
    }
    if (resetBtn){
      resetBtn.addEventListener('click', (ev) => {
        ev.preventDefault(); ev.stopPropagation();
        const custom = getCustom(catId);
        const hasChanges = (custom.removed?.length || 0) + (custom.added?.length || 0) > 0;
        if (!hasChanges) return;
        if (!confirm(`Restore the default links for "${catId}"?\nThis will also remove the custom links you added.`)) return;
        setCustom(catId, { removed: [], added: [] });
        rerenderMenu(catId);
      });
    }
    if (cancel && actions){
      cancel.addEventListener('click', (ev) => {
        ev.preventDefault(); ev.stopPropagation();
        form.hidden = true;
        actions.hidden = false;
        form.reset();
      });
    }
    if (form){
      form.addEventListener('submit', (ev) => {
        ev.preventDefault(); ev.stopPropagation();
        const label = form.querySelector('input[name="label"]').value.trim();
        const href  = form.querySelector('input[name="href"]').value.trim();
        if (!label || !href) return;
        // Whitelist URL schemes: only http(s) absolute URLs or site-relative paths.
        // Blocks javascript:, data:, vbscript:, file:, etc. which would otherwise
        // run with the page's privileges when the user clicks the saved link.
        if (!/^(https?:\/\/|\/)/i.test(href)){
          alert('Link URL must start with http://, https://, or / (e.g. /shops/wizard.phtml).');
          return;
        }
        const custom = getCustom(catId);
        custom.added = custom.added || [];
        custom.added.push({ href, label, iconSpec: catId });
        setCustom(catId, custom);
        rerenderMenu(catId);
      });
    }
  }

  const dd = {};
  Object.keys(menus).forEach(id => {
    const m = document.createElement('div');
    m.className = 'menu';
    m.id = `menu-${id}`;
    m.innerHTML = buildMenuHTML(id);
    dd[id] = m;
    root.appendChild(m);
    wireMenuActions(id);
  });

  document.documentElement.appendChild(host);

  /* =========================================================
     NST CLOCK — Neopian Standard Time (America/Los_Angeles),
     ticks every second in the top bar.
  ========================================================= */
  (function nstClock(){
    const nstEl = root.querySelector('#npxNst');
    if (!nstEl) return;
    const timeEl  = nstEl.querySelector('.nstTime');
    const labelEl = nstEl.querySelector('.nstLabel');
    const fmt = new Intl.DateTimeFormat('en-US', {
      hour:'numeric', minute:'2-digit', second:'2-digit',
      hour12:true, timeZone:'America/Los_Angeles'
    });
    function tick(){
      const parts = fmt.formatToParts(new Date());
      let h='--', m='--', s='--', dp='am';
      for (const p of parts){
        if (p.type === 'hour') h = p.value;
        else if (p.type === 'minute') m = p.value;
        else if (p.type === 'second') s = p.value;
        else if (p.type === 'dayPeriod') dp = String(p.value || 'am').toLowerCase();
      }
      timeEl.textContent = `${h}:${m}:${s}`;
      labelEl.textContent = `${dp} NST`;
    }
    tick();
    setInterval(tick, 1000);
  })();

  /* =========================================================
     SEARCH — toggle on magnifier click + "/" keyboard shortcut.
  ========================================================= */
  (function searchToggle(){
    const toggle = root.querySelector('#npxSearchToggle');
    const form   = root.querySelector('#npxSearch');
    const inp    = root.querySelector('#npxSearchInput');
    if (!toggle || !form || !inp) return;

    let open = false;
    function setOpen(o){
      open = !!o;
      form.classList.toggle('open', open);
      if (open){
        setTimeout(() => { inp.focus(); inp.select(); }, 0);
      } else {
        inp.blur();
      }
    }
    toggle.addEventListener('click', (e) => {
      e.preventDefault(); e.stopPropagation();
      setOpen(!open);
    });
    inp.addEventListener('keydown', (e) => {
      if (e.key === 'Escape'){ e.preventDefault(); setOpen(false); }
    });
    document.addEventListener('pointerdown', (ev) => {
      if (!open) return;
      const path = ev.composedPath ? ev.composedPath() : [];
      if (path.some(n => n === toggle || n === form)) return;
      setOpen(false);
    }, true);
    document.addEventListener('keydown', (e) => {
      if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey) return;
      const t = e.target;
      const isField = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable);
      if (isField) return;
      e.preventDefault();
      setOpen(true);
    }, true);
  })();

  /* =========================================================
     BELL DROPDOWN — beta-style icon + title + text + time + X.
     Sources tried in order:
       1) Current DOM: #alerts li / #alertstab__2020 ul li (META — reliable)
       2) Fetch /nf.phtml (sometimes empty depending on context)
       3) Fetch / (H5 homepage, contains #alerts if logged in)
     5-minute cache in sessionStorage. Up to 8 alerts. X = local dismiss
     + tries to click the native .alert-x if present.
  ========================================================= */
  (function bellNotif(){
    const CACHE_KEY  = 'npqol_notifs_v2';
    const HIDDEN_KEY = 'npqol_notifs_hidden_v1';
    const CACHE_TTL  = 5 * 60 * 1000;
    const MAX_ITEMS  = 8;
    const FALLBACK_ICON = 'https://images.neopets.com/themes/h5/basic/images/bell-icon.svg';
    const bell = root.querySelector('#npxBell');
    const dot  = root.querySelector('#npxBellDot');
    if (!bell || !dot) return;

    const menu = document.createElement('div');
    menu.className = 'bellMenu';
    menu.innerHTML = `
      <div class="bellHead">
        <h3>Alerts</h3>
        <a href="https://www.neopets.com/allevents.phtml">See all</a>
      </div>
      <div class="bellList" id="npxBellList">
        <div class="bellEmpty">Loading…</div>
      </div>
    `;
    root.appendChild(menu);

    function cleanText(t){
      return (t||'')
        .replace(/»\s*See all alerts\s*«/i,'')
        .replace(/\s+/g,' ').trim();
    }
    function esc(s){
      return String(s||'').replace(/[&<>"']/g, c => (
        { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]
      ));
    }
    function readHidden(){
      try { return new Set(JSON.parse(sessionStorage.getItem(HIDDEN_KEY) || '[]')); }
      catch { return new Set(); }
    }
    function writeHidden(set){
      try { sessionStorage.setItem(HIDDEN_KEY, JSON.stringify([...set])); } catch {}
    }
    function alertKey(a){
      return a.delid || a.href || (a.title + '|' + a.text);
    }
    /* Adds &delevent=yes (or ?delevent=yes) to a Neopets href —
       that's the native CLASSIC mechanism to delete the event when
       the user follows the link. */
    function withDelevent(href){
      if (!href || /[?&]delevent=yes\b/i.test(href)) return href;
      const sep = href.includes('?') ? '&' : '?';
      return href + sep + 'delevent=yes';
    }
    /* Dismiss an alert server-side — best-effort.
       Strategies tried in parallel (cheap, silent):
        - GET <href>&delevent=yes  (classic — also works on META)
        - If data-delid is present: click the native
          .alert-x[data-delid=...] element (on META, that triggers
          the official AJAX handler) */
    function dismissOnServer(a){
      try {
        const rawHref = a.href.startsWith('http') ? a.href : ('https://www.neopets.com' + (a.href.startsWith('/') ? '' : '/') + a.href);
        // Only fire the dismiss request against neopets.com itself — otherwise
        // a malformed/injected href could be turned into an outbound request
        // against an arbitrary domain.
        const u = new URL(rawHref);
        if (/(^|\.)neopets\.com$/i.test(u.hostname)){
          fetch(withDelevent(rawHref), { credentials:'same-origin', cache:'no-store', method:'GET' })
            .catch(() => {});
        }
      } catch {}
      if (a.delid){
        try {
          // CSS.escape guards against attribute injection if delid contains quotes.
          const native = document.querySelector(`.alert-x[data-delid="${CSS.escape(a.delid)}"]`);
          if (native) native.click();
        } catch {}
      }
    }
    const TRASH_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M9 3h6a1 1 0 0 1 1 1v1h4a1 1 0 1 1 0 2h-1.08l-1.2 12.03A3 3 0 0 1 14.74 22H9.26a3 3 0 0 1-2.98-2.97L5.08 7H4a1 1 0 1 1 0-2h4V4a1 1 0 0 1 1-1Zm1 2h4V4h-4v1Zm-2.92 2l1.17 11.77A1 1 0 0 0 9.26 20h5.48a1 1 0 0 0 1-1.03L16.92 7H7.08ZM10 9a1 1 0 0 1 1 1v7a1 1 0 1 1-2 0v-7a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v7a1 1 0 1 1-2 0v-7a1 1 0 0 1 1-1Z"/></svg>';

    /* Extract a <li> into an alert object.
       `live=true` → we're reading the current page: we can use
       getComputedStyle to grab the real icon URL (beta icons
       come from background-image on the element). */
    function extractAlertFromLi(li, live){
      const a    = li.querySelector('a[href]');
      const h4   = li.querySelector('h4');
      const p    = li.querySelector('p');
      const h5   = li.querySelector('h5');
      const iconEl = li.querySelector('.alerts-tab-item-icon__2020, [class*="alerts-tab-eventcode-"]');
      const xEl  = li.querySelector('.alert-x');

      let iconUrl = '';
      if (iconEl && live){
        const bg = getComputedStyle(iconEl).backgroundImage || '';
        const m  = bg.match(/url\((['"]?)(.*?)\1\)/);
        if (m && m[2] && m[2] !== 'none') iconUrl = m[2];
      }
      if (!iconUrl && iconEl){
        const titleStr = (h4?.textContent || '').trim().toLowerCase();
        const known = ['neomail','events','battledome','guild','trade','auction','council'];
        const hit = known.find(k => titleStr.includes(k));
        if (hit) iconUrl = `https://images.neopets.com/alerts/${hit}.png`;
      }

      return {
        href:  a?.getAttribute('href') || 'https://www.neopets.com/allevents.phtml',
        title: (h4?.textContent || 'Notification').trim(),
        text:  cleanText(p?.textContent || ''),
        time:  (h5?.textContent || '').trim(),
        icon:  iconUrl || FALLBACK_ICON,
        delid: xEl?.getAttribute('data-delid') || '',
      };
    }

    function readFromCurrentPage(){
      const lis = document.querySelectorAll('#alerts li, #alertstab__2020 ul li');
      if (!lis.length) return null;
      return [...lis].slice(0, MAX_ITEMS).map(li => extractAlertFromLi(li, true));
    }
    function readFromClassicPage(){
      // CLASSIC only exposes a single alert (the last one) via td.eventIcon.
      const td = document.querySelector('td.eventIcon, td.eventIcon.sf');
      if (!td) return null;
      const a   = td.querySelector('a[href]');
      const img = td.querySelector('img');
      const bld = td.querySelector('b');
      const raw = (td.innerText || '');
      const text = cleanText(raw.replace(bld?.textContent || '', '').replace(/»\s*See all events\s*«/i,''));
      return [{
        href:  a?.getAttribute('href') || 'https://www.neopets.com/allevents.phtml',
        title: (bld?.textContent || 'Notification').trim(),
        text:  text,
        time:  '',
        icon:  img?.getAttribute('src') || FALLBACK_ICON,
        delid: '',
      }];
    }
    function parseHtmlForAlerts(html){
      const dom = new DOMParser().parseFromString(html, 'text/html');
      const lis = dom.querySelectorAll('#alerts li, #alertstab__2020 ul li');
      if (!lis.length) return null;
      return [...lis].slice(0, MAX_ITEMS).map(li => extractAlertFromLi(li, false));
    }
    async function fetchUrl(url){
      try {
        const res = await fetch(url, { credentials:'same-origin', cache:'no-store' });
        if (!res.ok) return null;
        return await res.text();
      } catch { return null; }
    }
    async function fetchNotifs(){
      let html = await fetchUrl('/nf.phtml');
      let alerts = html ? parseHtmlForAlerts(html) : null;
      if (alerts && alerts.length) return alerts;
      html = await fetchUrl('/');
      alerts = html ? parseHtmlForAlerts(html) : null;
      return alerts || [];
    }

    function readCache(){
      try {
        const raw = sessionStorage.getItem(CACHE_KEY);
        if (!raw) return null;
        const j = JSON.parse(raw);
        if (!j?.t || (Date.now() - j.t) > CACHE_TTL) return null;
        return j.alerts;
      } catch { return null; }
    }
    function writeCache(alerts){
      try { sessionStorage.setItem(CACHE_KEY, JSON.stringify({ t: Date.now(), alerts })); } catch {}
    }

    function render(alerts){
      const list = menu.querySelector('#npxBellList');
      const hidden = readHidden();
      const visible = (alerts || []).filter(a => !hidden.has(alertKey(a)));

      if (!visible.length){
        list.innerHTML = `<div class="bellEmpty">No notifications.</div>`;
        dot.hidden = true;
        return;
      }
      list.innerHTML = visible.map((a, i) => `
        <a class="bellItem" href="${esc(withDelevent(a.href))}" data-idx="${i}">
          <div class="bellIcon" style="background-image:url('${esc(a.icon)}')"></div>
          <h4 class="bellTitle">${esc(a.title)}</h4>
          <p class="bellText">${esc(a.text)}</p>
          ${a.time ? `<h5 class="bellTime">${esc(a.time)}</h5>` : ''}
          <button type="button" class="bellX" title="Dismiss this notification" aria-label="Dismiss">${TRASH_SVG}</button>
        </a>
      `).join('');
      dot.textContent = String(visible.length);
      dot.hidden = false;

      list.querySelectorAll('.bellItem').forEach(item => {
        const idx = +item.dataset.idx;
        const alert = visible[idx];
        item.querySelector('.bellX').addEventListener('click', (e) => {
          e.preventDefault(); e.stopPropagation();
          dismissOnServer(alert);
          const h = readHidden();
          h.add(alertKey(alert));
          writeHidden(h);
          try { sessionStorage.removeItem(CACHE_KEY); } catch {}
          item.remove();
          if (!list.querySelector('.bellItem')){
            render(alerts);
          } else {
            const left = list.querySelectorAll('.bellItem').length;
            if (left > 0){ dot.textContent = String(left); }
            else { dot.hidden = true; }
          }
        });
      });
    }

    async function refresh(force = false){
      // 1) Current page (META)
      const fromPage = readFromCurrentPage();
      if (fromPage && fromPage.length){
        writeCache(fromPage);
        render(fromPage);
        return;
      }
      // 2) Current page (CLASSIC, single top alert)
      const fromClassic = readFromClassicPage();
      if (fromClassic && fromClassic.length){
        // Not written to cache (incomplete), just rendered.
        render(fromClassic);
        // Continue in the background to fetch the full list.
      }
      // 3) Cache
      if (!force){
        const cached = readCache();
        if (cached && cached.length){
          if (!fromClassic) render(cached);
          return;
        }
      }
      // 4) Network
      const alerts = await fetchNotifs();
      writeCache(alerts);
      render(alerts);
    }

    let open = false;
    function setOpen(o){
      open = !!o;
      menu.style.display = open ? 'block' : 'none';
      if (open) refresh(false);
    }
    bell.addEventListener('click', (e) => {
      e.preventDefault(); e.stopPropagation();
      setOpen(!open);
    });
    document.addEventListener('pointerdown', (ev) => {
      if (!open) return;
      const path = ev.composedPath ? ev.composedPath() : [];
      if (path.some(n => n === bell || n === menu)) return;
      setOpen(false);
    }, true);

    refresh(false);
  })();

  /* =========================================================
     Theme listener → refresh top-bar icons.
     <img data-icon="..."> tags get their src swapped; <img> tags
     without data-icon (e.g. fight = battledome) stay untouched.
  ========================================================= */
  function applyTopbarTheme(themeId){
    const iconUrl      = window.__npBtnIconUrl;
    const attachFb     = window.__npAttachBtnFallback;
    if (typeof iconUrl !== 'function') return;
    const all = root.querySelectorAll('img[data-icon]');
    all.forEach(img => {
      const name = img.dataset.icon;
      if (!name) return;
      if (typeof attachFb === 'function') attachFb(img, name);
      img.src = iconUrl(name, themeId);
    });
  }
  document.addEventListener('np:btn-theme', (ev) => {
    const id = ev?.detail?.themeId;
    if (id) applyTopbarTheme(id);
  });

  function positionMenuUnder(btnEl, menuEl){
    menuEl.style.display = 'block';
    menuEl.style.visibility = 'hidden';
    const mw = menuEl.getBoundingClientRect().width || 220;
    const b  = btnEl.getBoundingClientRect();
    const br = host.getBoundingClientRect();
    const vw = window.innerWidth;
    const pad = 8;
    let left = Math.round(b.left + b.width/2 - mw/2);
    if (left < pad) left = pad;
    if (left + mw > vw - pad) left = vw - mw - pad;
    menuEl.style.left = left + 'px';
    menuEl.style.top  = Math.round(br.bottom) + 'px';
    menuEl.style.visibility = '';
  }
  function closeAll(exceptId){
    Object.keys(dd).forEach(id => { if (id !== exceptId) dd[id].style.display = 'none'; });
  }
  let closeTimer = null;
  function bindHover(btn){
    const id = btn.getAttribute('data-id');
    const menu = dd[id]; if (!menu) return;
    const open = () => { closeAll(id); positionMenuUnder(btn, menu); menu.style.display='block'; };
    const scheduleClose = () => { clearTimeout(closeTimer); closeTimer = setTimeout(() => { menu.style.display='none'; }, 140); };
    const cancelClose = () => { clearTimeout(closeTimer); };
    btn.addEventListener('pointerenter', open);
    btn.addEventListener('pointerleave', scheduleClose);
    menu.addEventListener('pointerenter', cancelClose);
    menu.addEventListener('pointerleave', scheduleClose);
  }
  root.querySelectorAll('.btn.cat').forEach(bindHover);
  window.addEventListener('resize', () => closeAll());
})();