Hue Shift

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();