Replaces Torn's blue nav attention indicators with a configurable colour. Settings appear in the Chat settings panel.
// ==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}° S:${hsv.s}% 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);
})();