Hue Shift

Replaces Torn's blue nav attention indicators with a configurable colour. Settings appear in the Chat settings panel.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         Hue Shift
// @namespace    torn
// @version      1.2
// @description  Replaces Torn's blue nav attention indicators with a configurable colour. Settings appear in the Chat settings panel.
// @author       FatherOooogaboo [3269781]
// @match        https://www.torn.com/*
// @supportURL   https://www.torn.com/profiles.php?XID=3269781
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  const STORAGE_KEY = 'hue_shift_v1';
  const STYLE_ID    = 'hue-shift-style';
  const SETTINGS_ID = 'hue-shift-settings';

  const OG_MAIN  = '#8aa32e';
  const OG_LIGHT = '#c8e87a';
  const OG_FILL  = '#7a9216';

  const BLUES = ['#74c0fc', '#d0ebff', '#515dd3'];

  const SPECTRUM_PERIOD_MS = 2667;

  const MODES = [
    { value: 'torn-og',  label: 'Torn OG'         },
    { value: 'original', label: 'Original'         },
    { value: 'custom',   label: 'Custom Colour'    },
    { value: 'spectrum', label: 'Spectrum Cycling' },
  ];

  // ── Persistence ────────────────────────────────────────────────────────────

  function loadPrefs() {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (raw) return JSON.parse(raw);
    } catch (_) {}
    return { mode: 'torn-og', hsv: { h: 74, s: 68, v: 64 }, dotColour: true, dotHide: false };
  }

  function savePrefs() {
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); } catch (_) {}
  }

  let prefs = loadPrefs();

  // ── Colour utilities ────────────────────────────────────────────────────────

  function hsvToRgb(h, s, v) {
    s /= 100; v /= 100;
    const c = v * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = v - c;
    let r = 0, g = 0, b = 0;
    if      (h < 60)  { r=c; g=x; b=0; }
    else if (h < 120) { r=x; g=c; b=0; }
    else if (h < 180) { r=0; g=c; b=x; }
    else if (h < 240) { r=0; g=x; b=c; }
    else if (h < 300) { r=x; g=0; b=c; }
    else              { r=c; g=0; b=x; }
    return {
      r: Math.round((r + m) * 255),
      g: Math.round((g + m) * 255),
      b: Math.round((b + m) * 255),
    };
  }

  function rgbToHex({ r, g, b }) {
    return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
  }

  function lightenHex(hex, amt = 40) {
    return '#' + [1,3,5].map(i =>
      Math.min(255, parseInt(hex.slice(i, i+2), 16) + amt).toString(16).padStart(2,'0')
    ).join('');
  }

  function darkenHex(hex, amt = 20) {
    return '#' + [1,3,5].map(i =>
      Math.max(0, parseInt(hex.slice(i, i+2), 16) - amt).toString(16).padStart(2,'0')
    ).join('');
  }

  function getColours(hueOverride = null) {
    if (prefs.mode === 'original') return null;
    if (prefs.mode === 'torn-og')  return { main: OG_MAIN, light: OG_LIGHT, fill: OG_FILL };
    const h = hueOverride !== null ? hueOverride : prefs.hsv.h;
    const main = rgbToHex(hsvToRgb(h, prefs.hsv.s, prefs.hsv.v));
    return { main, light: lightenHex(main, 40), fill: darkenHex(main, 20) };
  }

  // ── CSS engine ─────────────────────────────────────────────────────────────

  function buildCSS(colours) {
    if (!colours) return '';
    const { main, light, fill } = colours;
    const dotBg  = prefs.dotHide ? 'transparent' : prefs.dotColour ? main : 'rgb(116,192,252)';
    const dotShadow = prefs.dotHide ? 'none' : prefs.dotColour ? `0 0 4px ${main}` : 'none';
    return `
      [class*="area-mobile"][class*="attention"] a,
      [class*="area-mobile"][class*="attention"] span { color: ${main} !important; }

      [class*="area-mobile"][class*="in-jail"][class*="attention"] a,
      [class*="area-mobile"][class*="in-hospital"][class*="attention"] a { color: ${light} !important; }

      .mobile___VS3O5.blue___OP4c8 svg,
      .mobile___VS3O5.hospital___dRL57.blue___OP4c8 svg,
      .mobile___VS3O5.jail___WfCx3.blue___OP4c8 svg,
      .mobile___VS3O5.travel___VRg89.blue___OP4c8 svg,
      .mobile___VS3O5.blue___OP4c8.active___b9F_C svg,
      .mobile___VS3O5.hospital___dRL57.blue___OP4c8.active___b9F_C svg,
      .mobile___VS3O5.jail___WfCx3.blue___OP4c8.active___b9F_C svg,
      .mobile___VS3O5.travel___VRg89.blue___OP4c8.active___b9F_C svg { fill: ${fill} !important; }

      [class*="area-mobile"][class*="attention"] {
        --sidebar-status-attention-dot-color: ${dotBg} !important;
        --sidebar-status-attention-dot-box-shadow: ${dotShadow} !important;
        --sidebar-status-attention-color: ${main} !important;
      }

      [class*="area-mobile"][class*="attention"]::after,
      .area-mobile___sx8BQ.attention___Pu8s3::after {
        background: ${dotBg} !important;
        box-shadow: ${dotShadow} !important;
        ${prefs.dotHide ? 'display: none !important;' : ''}
      }

      ${buildDiscoveredCSS(colours)}
    `;
  }

  function applyCSS(hueOverride = null) {
    const colours = getColours(hueOverride);
    let el = document.getElementById(STYLE_ID);
    if (!el) {
      el = document.createElement('style');
      el.id = STYLE_ID;
      (document.head || document.documentElement).appendChild(el);
    }
    el.textContent = buildCSS(colours);
    updateInlinePatcher(colours);
    patchSVGGradients();
    patchNavSVGs();
  }

  // Force-patch all SVG elements inside attention nav tabs directly
  function patchNavSVGs() {
    const colours = getColours();
    if (!colours) return;
    for (const tab of document.querySelectorAll('[class*="area-mobile"][class*="attention"]')) {
      for (const el of tab.querySelectorAll('svg, path, circle, rect, polygon, use')) {
        // Remove any fill attribute that references a gradient or blue colour
        const fillAttr = el.getAttribute('fill');
        if (fillAttr) {
          if (isBlueValue(fillAttr) || fillAttr.startsWith('url(')) {
            el.setAttribute('fill', colours.fill);
          }
        }
        // Also force the style fill
        if (el.style.fill && (isBlueValue(el.style.fill) || el.style.fill.startsWith('url('))) {
          el.style.fill = colours.fill;
        }
      }
    }
  }

  // ── Spectrum cycling ────────────────────────────────────────────────────────

  let spectrumRAF = null;

  function startSpectrum() {
    if (spectrumRAF) return;
    function tick(ts) {
      const hue = (ts / SPECTRUM_PERIOD_MS * 360) % 360;
      applyCSS(hue);
      const preview = document.getElementById('hs-preview');
      const hexval  = document.getElementById('hs-hexval');
      if (preview || hexval) {
        const c = getColours(hue);
        if (c) {
          if (preview) { preview.style.background = c.main; preview.style.boxShadow = `0 0 14px ${c.main}88`; }
          if (hexval) hexval.textContent = c.main.toUpperCase();
        }
      }
      spectrumRAF = requestAnimationFrame(tick);
    }
    spectrumRAF = requestAnimationFrame(tick);
  }

  function stopSpectrum() {
    if (spectrumRAF) { cancelAnimationFrame(spectrumRAF); spectrumRAF = null; }
  }

  // ── Inline style patcher ────────────────────────────────────────────────────

  let activeColourMap = {};

  function updateInlinePatcher(colours) {
    activeColourMap = {};
    if (!colours) return;
    for (const blue of BLUES) {
      const lc = blue.toLowerCase();
      activeColourMap[lc] = lc === '#d0ebff' ? colours.light
                          : lc === '#515dd3' ? colours.fill
                          : colours.main;
    }
  }

  function isInNavRow(el) {
    try { return !!el.closest('[class*="area-mobile"]'); } catch { return false; }
  }

  function isBlueValue(val) {
    if (!val) return false;
    const v = val.toLowerCase().trim();
    return v.includes('74c0fc') || v.includes('515dd3') || v.includes('d0ebff')
        || v.includes('sidebar_svg_gradient_regular_blue');
  }

  function patchInline(el) {
    if (el.nodeType !== 1 || !isInNavRow(el)) return;

    // Patch inline style properties
    if (el.style && Object.keys(activeColourMap).length) {
      for (const prop of ['color','backgroundColor','borderColor','fill']) {
        const val = el.style[prop]?.toLowerCase();
        if (val && activeColourMap[val]) el.style[prop] = activeColourMap[val];
      }
    }

    // Patch SVG fill/stroke attributes directly — catches attribute-level
    // fills that CSS cannot override
    const colours = getColours();
    if (!colours) return;
    const fillAttr = el.getAttribute('fill');
    if (fillAttr && isBlueValue(fillAttr)) {
      el.setAttribute('fill', colours.fill);
    }
    const strokeAttr = el.getAttribute('stroke');
    if (strokeAttr && isBlueValue(strokeAttr)) {
      el.setAttribute('stroke', colours.fill);
    }
  }

  function patchSubtree(root) {
    patchInline(root);
    for (const child of root.querySelectorAll?.('*') ?? []) patchInline(child);
  }

  // ── SVG gradient patcher ────────────────────────────────────────────────────
  // Torn's blue dot uses fill:url(#gradient-id) which CSS !important can't
  // override. We rewrite Torn's own stylesheet rules, replacing the url()
  // references with a plain hex colour, and also patch the gradient <stop>
  // elements directly as a belt-and-braces fallback.

  const GRADIENT_IDS = [
    'sidebar_svg_gradient_regular_blue_mobile',
    'sidebar_svg_gradient_regular_blue_mobile_active',
    'sidebar_svg_gradient_regular_blue_desktop',
  ];

  // Rewrite any CSSRule whose cssText contains a blue gradient url() reference
  function rewriteStylesheetGradients(colour) {
    for (const sheet of document.styleSheets) {
      let rules;
      try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
      catch { continue; }

      for (let i = 0; i < rules.length; i++) {
        const rule = rules[i];
        if (!rule.cssText) continue;
        const hasGradientRef = GRADIENT_IDS.some(id => rule.cssText.includes(id));
        if (!hasGradientRef) continue;

        // Replace the entire rule with one using a plain colour
        const newCSS = rule.cssText
          .replace(/fill\s*:\s*url\([^)]+\)/g, `fill: ${colour}`)
          .replace(/stroke\s*:\s*url\([^)]+\)/g, `stroke: ${colour}`);
        try {
          sheet.deleteRule(i);
          sheet.insertRule(newCSS, i);
        } catch (_) {}
      }
    }
  }

  // Also mutate gradient stop elements directly
  function patchSVGGradientStops(colour) {
    for (const id of GRADIENT_IDS) {
      // Try both document-level and inside any SVG elements
      const els = document.querySelectorAll(`#${id} stop, [id="${id}"] stop`);
      for (const stop of els) {
        stop.style.stopColor = colour;
        stop.setAttribute('stop-color', colour);
      }
    }
    // Also search all SVGs on the page
    for (const svg of document.querySelectorAll('svg')) {
      for (const id of GRADIENT_IDS) {
        const grad = svg.getElementById?.(id) || svg.querySelector(`#${id}`);
        if (!grad) continue;
        for (const stop of grad.querySelectorAll('stop')) {
          stop.style.stopColor = colour;
          stop.setAttribute('stop-color', colour);
        }
      }
    }
  }

  let gradientsRewritten = false;

  function patchSVGGradients() {
    const colours = getColours();
    if (!colours) return;
    const { main } = colours;
    // Rewrite stylesheet rules every time colour changes (spectrum mode)
    // but only do the expensive full rewrite once for static modes
    if (!gradientsRewritten || prefs.mode === 'spectrum') {
      rewriteStylesheetGradients(main);
      gradientsRewritten = true;
    }
    patchSVGGradientStops(main);
  }


  // Finds any element inside a nav tab with a blue background-color (the
  // notification dot), extracts its class names, and appends rules for them
  // to the live style tag so they get recoloured without needing a known selector.

  const BLUE_BG_HEX = new Set(['#74c0fc','#515dd3','#d0ebff','#0180ff','#007aff','#1c7ed6','#4dabf7']);
  let discoveredDotSelectors = new Set();

  function parseRgbToHex(rgb) {
    const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
    if (!m) return null;
    return '#' + [m[1],m[2],m[3]].map(v => parseInt(v).toString(16).padStart(2,'0')).join('');
  }

  function isBlueish(hex) {
    if (!hex) return false;
    const r = parseInt(hex.slice(1,3),16);
    const g = parseInt(hex.slice(3,5),16);
    const b = parseInt(hex.slice(5,7),16);
    const max = Math.max(r,g,b), min = Math.min(r,g,b);
    if (max === 0) return false;
    const l = (max + min) / 2;
    const s = max === min ? 0 : (l > 0.5 ? (max-min)/(2-max/255-min/255) : (max-min)/(max/255+min/255));
    const hue = max === r ? ((g-b)/(max-min) + (g<b?6:0)) * 60
              : max === g ? ((b-r)/(max-min) + 2) * 60
              :             ((r-g)/(max-min) + 4) * 60;
    return hue >= 180 && hue <= 270 && (b > r * 1.3) && l > 20 && l < 230;
  }

  function discoverDotClasses() {
    const colours = getColours();
    if (!colours) return;

    const navTabs = document.querySelectorAll('[class*="area-mobile"]');
    let changed = false;

    for (const tab of navTabs) {
      for (const el of tab.querySelectorAll('*')) {
        if (!el.className || typeof el.className !== 'string') continue;
        const cs = window.getComputedStyle(el);
        const bgHex = parseRgbToHex(cs.backgroundColor);
        if (!bgHex || !isBlueish(bgHex)) continue;

        // Build a selector from each individual class on this element
        const classes = el.className.trim().split(/\s+/).filter(Boolean);
        for (const cls of classes) {
          const sel = `[class*="area-mobile"] .${CSS.escape(cls)}`;
          if (!discoveredDotSelectors.has(sel)) {
            discoveredDotSelectors.add(sel);
            changed = true;
          }
        }
      }
    }

    if (changed) applyCSS();
  }

  function buildDiscoveredCSS(colours) {
    if (!colours || discoveredDotSelectors.size === 0) return '';
    const sels = [...discoveredDotSelectors].join(',\n      ');
    return `
      ${sels} {
        background-color: ${colours.main} !important;
        border-color: ${colours.main} !important;
      }
    `;
  }



  function buildDropdownHTML() {
    const current = MODES.find(m => m.value === prefs.mode) || MODES[0];
    const optionsHTML = MODES.map(m => `
      <div class="hs-dd-option" data-value="${m.value}" style="
        padding:9px 12px;font-size:13px;cursor:pointer;white-space:nowrap;
        color:${m.value === prefs.mode ? '#fff' : '#aaa'};
        background:${m.value === prefs.mode ? '#2a2a2a' : 'transparent'};
        transition:background 0.1s,color 0.1s;
      ">${m.label}</div>
    `).join('');

    return `
      <div id="hs-dd" style="position:relative;min-width:160px;user-select:none;">
        <div id="hs-dd-trigger" style="
          display:flex;align-items:center;justify-content:space-between;
          background:#1a1a1a;border:1px solid #3a3a3a;border-radius:6px;
          color:#fff;font-size:13px;padding:8px 10px 8px 12px;cursor:pointer;gap:8px;
        ">
          <span id="hs-dd-label">${current.label}</span>
          <span id="hs-dd-arrow" style="font-size:9px;color:#888;transition:transform 0.15s;display:inline-block;">▼</span>
        </div>
        <div id="hs-dd-list" style="
          display:none;position:absolute;top:calc(100% + 4px);right:0;min-width:100%;
          background:#1e1e1e;border:1px solid #3a3a3a;border-radius:6px;
          overflow:hidden;z-index:99999;box-shadow:0 4px 16px rgba(0,0,0,0.6);
        ">${optionsHTML}</div>
      </div>
    `;
  }

  function bindDropdown(container, onSelect) {
    const trigger = container.querySelector('#hs-dd-trigger');
    const list    = container.querySelector('#hs-dd-list');
    const label   = container.querySelector('#hs-dd-label');
    const arrow   = container.querySelector('#hs-dd-arrow');
    const options = container.querySelectorAll('.hs-dd-option');
    if (!trigger || !list) return;

    let open = false;
    const openList  = () => { list.style.display = 'block'; arrow.style.transform = 'rotate(180deg)'; open = true; };
    const closeList = () => { list.style.display = 'none';  arrow.style.transform = 'rotate(0deg)';   open = false; };

    trigger.addEventListener('click', e => { e.stopPropagation(); open ? closeList() : openList(); });

    options.forEach(opt => {
      opt.addEventListener('mouseenter', () => { opt.style.background = '#333'; opt.style.color = '#fff'; });
      opt.addEventListener('mouseleave', () => {
        const sel = opt.dataset.value === prefs.mode;
        opt.style.background = sel ? '#2a2a2a' : 'transparent';
        opt.style.color = sel ? '#fff' : '#aaa';
      });
      opt.addEventListener('click', e => {
        e.stopPropagation();
        const value = opt.dataset.value;
        options.forEach(o => {
          o.style.background = o.dataset.value === value ? '#2a2a2a' : 'transparent';
          o.style.color      = o.dataset.value === value ? '#fff' : '#aaa';
        });
        label.textContent = opt.textContent;
        closeList();
        onSelect(value);
      });
    });

    document.addEventListener('click', e => {
      if (open && !container.querySelector('#hs-dd').contains(e.target)) closeList();
    }, { capture: true });
  }

  // ── Settings HTML ───────────────────────────────────────────────────────────

  function buildSliderHTML(id, label, value, min, max, type) {
    let trackBg;
    if (type === 'hue') {
      trackBg = 'linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)';
    } else if (type === 'sat') {
      const { h, v } = prefs.hsv;
      trackBg = `linear-gradient(to right,${rgbToHex(hsvToRgb(h,0,v))},${rgbToHex(hsvToRgb(h,100,v))})`;
    } else {
      trackBg = 'linear-gradient(to right,#000,#fff)';
    }
    const unit = type === 'hue' ? '°' : '%';
    return `
      <div style="margin-bottom:10px;">
        <div style="display:flex;justify-content:space-between;font-size:11px;color:#888;margin-bottom:4px;">
          <span>${type === 'hue' ? 'Hue' : type === 'sat' ? 'Saturation' : 'Brightness'}</span>
          <span id="${id}-val">${value}${unit}</span>
        </div>
        <div style="position:relative;height:20px;display:flex;align-items:center;">
          <div id="${id}-track" style="position:absolute;left:0;right:0;height:6px;border-radius:3px;background:${trackBg};pointer-events:none;"></div>
          <input id="${id}" type="range" min="${min}" max="${max}" value="${value}" style="position:relative;width:100%;height:6px;-webkit-appearance:none;appearance:none;background:transparent;cursor:pointer;margin:0;">
        </div>
      </div>
    `;
  }

  function buildCheckbox(id, label, checked) {
    return `
      <label for="${id}" style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#ccc;user-select:none;">
        <span style="
          display:inline-flex;align-items:center;justify-content:center;
          width:16px;height:16px;flex-shrink:0;
          background:${checked ? '#2a2a2a' : '#111'};
          border:1px solid ${checked ? '#888' : '#3a3a3a'};
          border-radius:3px;font-size:11px;color:#fff;
          transition:background 0.1s,border-color 0.1s;
        " id="${id}-box">${checked ? '✓' : ''}</span>
        ${label}
        <input id="${id}" type="checkbox" ${checked ? 'checked' : ''} style="display:none;">
      </label>
    `;
  }

  function buildSettingsHTML() {
    const { mode, hsv } = prefs;
    const colours    = getColours();
    const previewHex = colours ? colours.main : '#74c0fc';
    const showPicker = mode === 'custom';

    return `
      <div id="${SETTINGS_ID}" style="margin:0;padding:0 0 14px;font-family:inherit;">
        <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
          <span style="font-size:14px;color:#ccc;font-weight:500;">Nav Icon Colour</span>
          ${buildDropdownHTML()}
        </div>

        <div style="display:flex;flex-direction:column;gap:6px;margin-bottom:${showPicker ? '10px' : '4px'};">
          ${buildCheckbox('hs-dot-colour', 'Include indicator dot', prefs.dotColour !== false)}
          ${buildCheckbox('hs-dot-hide',   'Hide indicator dot',    prefs.dotHide === true)}
        </div>
        <div id="hs-picker" style="
          display:${showPicker ? 'block' : 'none'};
          background:#141414;border:1px solid #2a2a2a;border-radius:10px;
          padding:14px 12px 12px;margin-top:4px;
        ">
          <div style="display:flex;align-items:center;gap:14px;margin-bottom:14px;">
            <div id="hs-preview" style="
              width:56px;height:56px;border-radius:30%;background:${previewHex};
              flex-shrink:0;box-shadow:0 0 14px ${previewHex}88;
              transition:background 0.05s,box-shadow 0.05s;
            "></div>
            <div>
              <div id="hs-hexval" style="font-family:'JetBrains Mono','Courier New',monospace;font-size:17px;color:#fff;letter-spacing:0.05em;">
                ${previewHex.toUpperCase()}
              </div>
              <div id="hs-hsvlabel" style="color:#666;font-size:11px;margin-top:2px;">
                H:${hsv.h}° &nbsp; S:${hsv.s}% &nbsp; V:${hsv.v}%
              </div>
            </div>
          </div>
          ${buildSliderHTML('hs-h', 'H', hsv.h, 0, 359, 'hue')}
          ${buildSliderHTML('hs-s', 'S', hsv.s, 0, 100, 'sat')}
          ${buildSliderHTML('hs-v', 'V', hsv.v, 0, 100, 'val')}
        </div>
      </div>
    `;
  }

  // ── Settings injection ──────────────────────────────────────────────────────

  function injectIntoSettings(panel) {
    if (panel.querySelector(`#${SETTINGS_ID}`)) return;

    let textNode = null;
    for (const el of panel.querySelectorAll('*')) {
      if (el.children.length === 0 && el.textContent.trim() === 'Private Sound') {
        textNode = el; break;
      }
    }
    if (!textNode) return;

    let row = textNode.parentElement;
    while (row && row !== panel) {
      const hasLabel  = row.textContent.includes('Private Sound');
      const hasSelect = !!row.querySelector('select, [class*="select"], [class*="dropdown"]');
      if (hasLabel && hasSelect) break;
      if (row.parentElement
          && row.parentElement.textContent.includes('Room Sound')
          && row.parentElement.textContent.includes('Private Sound')) break;
      row = row.parentElement;
    }
    if (!row || row === panel) {
      row = textNode.parentElement?.parentElement || textNode.parentElement;
    }

    const wrapper = document.createElement('div');
    wrapper.innerHTML = buildSettingsHTML();
    const ourBlock = wrapper.firstElementChild;

    const rowStyle = window.getComputedStyle(row);
    ourBlock.style.paddingLeft  = rowStyle.paddingLeft;
    ourBlock.style.paddingRight = rowStyle.paddingRight;

    row.parentNode.insertBefore(ourBlock, row.nextSibling);
    bindSettingsEvents(ourBlock);
  }

  function bindSettingsEvents(block) {
    if (!document.getElementById('hs-thumb-style')) {
      const s = document.createElement('style');
      s.id = 'hs-thumb-style';
      s.textContent = `
        #hs-h::-webkit-slider-thumb,#hs-s::-webkit-slider-thumb,#hs-v::-webkit-slider-thumb {
          -webkit-appearance:none;appearance:none;width:18px;height:18px;
          border-radius:50%;background:#fff;border:2px solid #333;
          box-shadow:0 1px 4px rgba(0,0,0,0.5);cursor:pointer;
        }
        #hs-h::-moz-range-thumb,#hs-s::-moz-range-thumb,#hs-v::-moz-range-thumb {
          width:18px;height:18px;border-radius:50%;background:#fff;
          border:2px solid #333;box-shadow:0 1px 4px rgba(0,0,0,0.5);cursor:pointer;
        }
      `;
      document.head.appendChild(s);
    }

    const Q = id => block.querySelector(`#${id}`);

    // Dot toggle checkboxes — independent of each other
    function bindCheckbox(id, onChange) {
      const input = block.querySelector(`#${id}`);
      const box   = block.querySelector(`#${id}-box`);
      if (!input || !box) return;
      const label = input.closest('label');
      if (label) label.addEventListener('click', e => {
        e.preventDefault();
        const next = !input.checked;
        input.checked        = next;
        box.textContent      = next ? '✓' : '';
        box.style.background    = next ? '#2a2a2a' : '#111';
        box.style.borderColor   = next ? '#888'    : '#3a3a3a';
        onChange(next);
      });
    }

    bindCheckbox('hs-dot-colour', (val) => {
      prefs.dotColour = val;
      savePrefs();
      gradientsRewritten = false;
      applyCSS();
    });

    bindCheckbox('hs-dot-hide', (val) => {
      prefs.dotHide = val;
      savePrefs();
      gradientsRewritten = false;
      applyCSS();
    });

    bindDropdown(block, (value) => {
      prefs.mode = value;
      savePrefs();

      const picker = Q('hs-picker');
      if (picker) picker.style.display = value === 'custom' ? 'block' : 'none';

      stopSpectrum();
      gradientsRewritten = false;
      applyCSS();
      if (value === 'spectrum') startSpectrum();

      refreshPreview();
    });

    function updateSatTrack() {
      const h = Q('hs-h') ? +Q('hs-h').value : prefs.hsv.h;
      const v = Q('hs-v') ? +Q('hs-v').value : prefs.hsv.v;
      const track = Q('hs-s-track');
      if (track) track.style.background = `linear-gradient(to right,${rgbToHex(hsvToRgb(h,0,v))},${rgbToHex(hsvToRgb(h,100,v))})`;
    }

    function refreshPreview() {
      const c   = getColours();
      const hex = c ? c.main : '#74c0fc';
      const preview = Q('hs-preview');
      const hexval  = Q('hs-hexval');
      const hsvlbl  = Q('hs-hsvlabel');
      if (preview) { preview.style.background = hex; preview.style.boxShadow = `0 0 14px ${hex}88`; }
      if (hexval)  hexval.textContent = hex.toUpperCase();
      if (hsvlbl) {
        const h = Q('hs-h') ? +Q('hs-h').value : prefs.hsv.h;
        const s = Q('hs-s') ? +Q('hs-s').value : prefs.hsv.s;
        const v = Q('hs-v') ? +Q('hs-v').value : prefs.hsv.v;
        hsvlbl.textContent = `H:${h}° \u00a0 S:${s}% \u00a0 V:${v}%`;
      }
    }

    function onSliderInput() {
      const h = Q('hs-h') ? +Q('hs-h').value : prefs.hsv.h;
      const s = Q('hs-s') ? +Q('hs-s').value : prefs.hsv.s;
      const v = Q('hs-v') ? +Q('hs-v').value : prefs.hsv.v;
      prefs.hsv = { h, s, v };

      const hVal = Q('hs-h-val'), sVal = Q('hs-s-val'), vVal = Q('hs-v-val');
      if (hVal) hVal.textContent = h + '°';
      if (sVal) sVal.textContent = s + '%';
      if (vVal) vVal.textContent = v + '%';

      updateSatTrack();
      refreshPreview();
      gradientsRewritten = false;
      applyCSS();
      savePrefs();
    }

    for (const id of ['hs-h', 'hs-s', 'hs-v']) {
      const el = Q(id);
      if (el) { el.addEventListener('input', onSliderInput); el.addEventListener('change', onSliderInput); }
    }

    updateSatTrack();
  }

  // ── Mutation observer ───────────────────────────────────────────────────────

  function startObserver() {
    let discoverTimer = null;
    new MutationObserver((mutations) => {
      for (const m of mutations) {
        if (m.type === 'childList') {
          for (const node of m.addedNodes) {
            if (node.nodeType !== 1) continue;
            patchSubtree(node);

            // Patch any SVG gradients that just appeared
            if (node.id && GRADIENT_IDS.includes(node.id)) {
              patchSVGGradients();
            }
            for (const id of GRADIENT_IDS) {
              if (node.querySelector?.(`#${id}`)) { patchSVGGradients(); break; }
            }

            // Re-run dot discovery if anything inside the nav changed
            if (node.matches?.('[class*="area-mobile"]') || node.closest?.('[class*="area-mobile"]')) {
              clearTimeout(discoverTimer);
              discoverTimer = setTimeout(discoverDotClasses, 300);
            }

            const all = [node, ...(node.querySelectorAll?.('*') ?? [])];
            for (const el of all) {
              if (el.children?.length === 0 && el.textContent?.trim() === 'Private Sound') {
                let panel = el.parentElement;
                for (let i = 0; i < 6; i++) { if (panel?.parentElement) panel = panel.parentElement; }
                if (panel) injectIntoSettings(panel);
                break;
              }
            }
          }
        }
        if (m.type === 'attributes' && m.target.nodeType === 1) {
          if (m.attributeName === 'style') patchInline(m.target);
          if ((m.attributeName === 'fill' || m.attributeName === 'stroke') && isInNavRow(m.target)) {
            patchInline(m.target);
          }
        }
      }
    }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'fill', 'stroke'] });
  }

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

  function init() {
    applyCSS();
    for (const el of document.querySelectorAll('[class*="area-mobile"]')) patchSubtree(el);
    if (prefs.mode === 'spectrum') startSpectrum();
    startObserver();
    // Delay slightly so Torn's nav has fully rendered before we scan
    setTimeout(() => { discoverDotClasses(); patchNavSVGs(); }, 1500);
  }

  document.body ? init() : document.addEventListener('DOMContentLoaded', init);

})();