AH/QQ/SB/SV Mobile readability

Auto-hiding navbar sticky with style button (Light/Dark style selector + font size + padding widget). Standardize font size, revert justified text, replace fonts with sans-serif/serif/monospace, dodge colored text from the background, detect centered text and add Uncenter toggle.

// ==UserScript==
// @name        AH/QQ/SB/SV Mobile readability
// @description Auto-hiding navbar sticky with style button (Light/Dark style selector + font size + padding widget). Standardize font size, revert justified text, replace fonts with sans-serif/serif/monospace, dodge colored text from the background, detect centered text and add Uncenter toggle.
// @version     1.33
// @author      C89sd
// @namespace   https://greasyfork.org/users/1376767
// @match       https://*.spacebattles.com/*
// @match       https://*.sufficientvelocity.com/*
// @match       https://*.questionablequesting.com/*
// @match       https://*.alternatehistory.com/*
// @grant       GM_getValue
// @grant       GM_setValue
// @run-at      document-body
// @noframes
// ==/UserScript==
'use strict';

const DISABLE_STICKY_HIDE = false;
const FONT_SCALE_MONO  = 0.8917;
const FONT_SCALE_SERIF = 0.9108;


const IS_THREAD = document.URL.includes('/threads/');

// ======================== Global State and Core Functions (Run at document-start) ======================== //

let currentFontSize, currentPaddingSize;
let styleBbTable, styleSbStats; /* script el */
let cssBbTable,   cssSbStats;   /* templates */

const toPx = (base, factor) => (base * factor).toFixed(2) + 'px';

/* ───── 1. Site-specific style IDs + path ───── */
const site = location.hostname.split('.').slice(-2, -1)[0];
const [LIGHT_ID, DARK_ID] = {
  spacebattles        : [ 6,  2],
  sufficientvelocity  : [19, 20],
  questionablequesting: [ 1, 31],
  alternatehistory    : [13, 15]
}[site];
const STYLE_PATH = site === 'alternatehistory' ? '/forum/misc/style' : '/misc/style';

/* ───── 2. Inject Static CSS ASAP ───── */
let staticCss = `
.ffss { font-family: Roboto, Segoe UI, Ubuntu, sans-serif !important; }
.ffm  { font-family: monospace !important; }
.ffs  { font-family: serif !important; }

  .p-nav { border-top: none !important; }
  .gm-navStyleBtn{
    cursor:pointer;
    -webkit-user-select:none;  /* stop long-press text selection */
    user-select:none;
  }
  /* popup */
  #gmStylePopup{
    position:fixed;top:64px;right:20px;background:#fff;color:#000;
    border:1px solid #666;border-radius:8px;padding:16px;z-index:9999;
    box-shadow:0 4px 14px rgba(0,0,0,.25);display:none;
    flex-direction:column;gap:16px;min-width:280px;font-size:14px}
  @media (max-width:600px){#gmStylePopup{transform:scale(1.1);transform-origin:top right}}

  /* Light / Dark tiles */
  .gm-tile-row{display:flex;width:100%;gap:8px}
  .gm-style-tile{
    flex:1 1 50%;height:60px;line-height:60px;font-size:1.05em;
    border-radius:6px;text-align:center;cursor:pointer;user-select:none;
    border:1px solid #666;text-decoration:none}
  .gm-light{background:#eaeaea;color:#000}.gm-light:hover{background:#dbdbdb}
  .gm-dark {background:#222;color:#fff}.gm-dark:hover {background:#2d2d2d}

  /* Font-size block */
  .gm-font-row{display:flex;align-items:center;gap:6px;justify-content:center}
  .gm-font-row button{
    width:41.1px;height:50px;font-size:15px;cursor:pointer;
    border:1px solid #666;border-radius:6px;background:#f5f5f5;color:#000}
  .gm-font-row button:hover{background:#e4e4e4}
  .gm-font-row input{
    width:70px;height:50px;text-align:center;font-size:16px;
    border:1px solid #666;border-radius:6px}

  .gmStickyNav{
    position:sticky;
    top:0;                   /* docks here once we reach it          */
    z-index:1010;
    will-change:transform;   /* we only translate it, no repaint     */
  }
`;
document.head.appendChild(Object.assign(document.createElement('style'), {textContent: staticCss}));
document.querySelector('html').classList.add('ffss');

/* ───── 3. Dynamic Style Templates ───── */
if (IS_THREAD) {
  cssBbTable = px => `
    .bbTable{
      font-size:${toPx(px, 14.0 / 15)};
      overflow:scroll;
      table-layout:auto;
    }
    .bbTable td{
      vertical-align:top;
      white-space:normal;
      word-wrap:normal;
    }`;

  /* 2) SB weekly-stats */
  if (document.URL.includes('.1140820/')) {
    cssSbStats = px => `
      /* Indicator */
      .bbTable td >  b:first-of-type:has(code.bbCodeInline){display:inline-block;padding:2px 0;}
      .bbTable td >  b:first-of-type code.bbCodeInline     {font-size:${toPx(px, 11 / 15)};}

      /* Name / Metric */
      .bbTable span:has(.username)                         {font-size:${toPx(px, 11.5 / 15)};}
      .bbTable span:has(.bbc-abbr)                         {font-size:${toPx(px, 12.5 / 15)};}

      /* Bottom Tags */
      .bbTable td > span:last-child:has(>code.bbCodeInline){display:inline-flex;flex-wrap:wrap;gap:1px;padding-top:6px;}
      .bbTable td > span:last-child > code.bbCodeInline    {font-size:${toPx(px,  9  / 15)};padding:1px 3px;}`;
  }
}

/* ───── 4. Core Helper Functions ───── */

function clamp(v, min = 0, max = 1) { return v < min ? min : v > max ? max : v; }

const srgbToLinear = c => { c /= 255; return c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); };
function linearToSrgb(c){ const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1/2.4) - 0.055; return Math.round(clamp(v) * 255); }

// sRGB (array of 3 ints 0-255) -------------→ OKLab {L,a,b}
function srgbToOklab([R8, G8, B8]){
  // linear RGB
  const r = srgbToLinear(R8), g = srgbToLinear(G8), b = srgbToLinear(B8);
  // RGB → LMS (Bjørn Ottosson, 2020)
  const l = 0.4122214708*r + 0.5363325363*g + 0.0514459929*b;
  const m = 0.2119034982*r + 0.6806995451*g + 0.1073969566*b;
  const s = 0.0883024619*r + 0.2817188376*g + 0.6299787005*b;
  // LMS ^ (1/3)
  const l_ = Math.cbrt(l), m_ = Math.cbrt(m), s_ = Math.cbrt(s);
  // LMS → OKLab
  return {
    L :  0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_,
    a :  1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_,
    b :  0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_
  };
}

// OKLab {L,a,b} ----------------------------→ sRGB array [R,G,B]
function oklabToSrgb({L,a,b}){
  // OKLab → LMS^
  const l_ = Math.pow(L + 0.3963377774*a + 0.2158037573*b, 3);
  const m_ = Math.pow(L - 0.1055613458*a - 0.0638541728*b, 3);
  const s_ = Math.pow(L - 0.0894841775*a - 1.2914855480*b, 3);

  // LMS → linear RGB
  const r = + 4.0767416621*l_ - 3.3077115913*m_ + 0.2309699292*s_;
  const g = - 1.2684380046*l_ + 2.6097574011*m_ - 0.3413193965*s_;
  const b_ = - 0.0041960863*l_ - 0.7034186147*m_ + 1.7076147010*s_;

  return [linearToSrgb(r), linearToSrgb(g), linearToSrgb(b_)];
}

const minContrastDistance = 0.28;
const colorCache = new Map();

const defaultTextColors = {
    dark: 'rgb(220, 220, 220)',
    light: 'rgb(29, 29, 29)',
};

// adjust the lightness of a source color to ensure it has minimum contrast distance against a reference color
function pushAway(sourceLab, referenceLab) {
    const dL = sourceLab.L - referenceLab.L;
    const da = sourceLab.a - referenceLab.a;
    const db = sourceLab.b - referenceLab.b;
    const currentContrast = Math.sqrt(dL * dL + da * da + db * db);

    if (currentContrast >= minContrastDistance) {
        return sourceLab; // Contrast is sufficient.
    }

    const chromaContrastSq = da * da + db * db;
    const minContrastSq = minContrastDistance * minContrastDistance;
    // Math.max prevents sqrt of a negative number if color contrast alone is high
    const required_dL_magnitude = Math.sqrt(Math.max(0, minContrastSq - chromaContrastSq));

    // Determine direction based on the reference color's lightness
    const direction = referenceLab.L < 0.5 ? 1 : -1; // Push lighter from dark, darker from light

    const newL = referenceLab.L + (direction * required_dL_magnitude);
    sourceLab.L = clamp(newL, 0, 1);

    return sourceLab;
}


function adjustTextColor(textRGBStr, textBgRGBStr) {
    const cacheKey = textRGBStr + '|' + textBgRGBStr;
    if (colorCache.has(cacheKey)) return colorCache.get(cacheKey);

    const toRGB = str => (str.match(/\d+/g) || []).map(Number).slice(0, 3);

    const fg = toRGB(textRGBStr);
    const bg = toRGB(textBgRGBStr);
    if (fg.length !== 3 || bg.length !== 3) return textRGBStr;

    let fgLab = srgbToOklab(fg);
    const bgLab = srgbToOklab(bg);

    // Determine Light/Dark mode and matching text color
    const darkMode = bgLab.L < 0.5;
    const defaultTextColorStr = darkMode ? defaultTextColors.dark : defaultTextColors.light;
    const defaultTextLab = srgbToOklab(toRGB(defaultTextColorStr));

    // Step 1: push away from the default text color
    fgLab = pushAway(fgLab, defaultTextLab);

    // Step 2: push away from the background
    fgLab = pushAway(fgLab, bgLab);

    // Convert to RGB
    const rgb = oklabToSrgb(fgLab);
    const result = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
    colorCache.set(cacheKey, result);

    return result;
}


function applyFontSizeToElement(wrapper, pxStr) {
  const SANS_SERIF = /arial|tahoma|trebuchet ms|verdana/;
  const MONOSPACE = /courier new/;
  const SERIF = /times new roman|georgia|book antiqua/;

  const scale = (val, factor) => (parseFloat(val) * factor).toFixed(2) + "px";

  wrapper.style.fontSize = pxStr;
  wrapper.classList.add('ffss');

  // .message for posts; .block-container for summary
  let bgColor = window.getComputedStyle( wrapper.closest('.message,.block-container')).backgroundColor;

  const children = wrapper.querySelectorAll('*');
  for (let child of children) {
    const style = child.style;

    if (style.fontSize) {
      style.fontSize = ''; // Reset explicit font sizes for consistency
    }

    if (style.fontFamily) {
      const font = style.fontFamily;
      if (SANS_SERIF.test(font)) {
        child.classList.add('ffss');
      } else if (MONOSPACE.test(font)) {
        child.classList.add('ffm');
        style.fontSize = scale(pxStr, FONT_SCALE_MONO);
      } else if (SERIF.test(font)) {
        child.classList.add('ffs');
        style.fontSize = scale(pxStr, FONT_SCALE_SERIF);
      }
    }

    if (style.textAlign) {
      // Keep 'center' and 'right', clear others for readability
      if (style.textAlign !== 'center' && style.textAlign !== 'right') {
        style.textAlign = '';
      }
    }

    if (style.color && style.color.startsWith('rgb')) {
      style.color = adjustTextColor(style.color, bgColor);
    }
  }
}

function applyPaddingToElement(cell, pxStr) {
  cell.style.paddingLeft = pxStr;
  cell.style.paddingRight = pxStr;
}

const mo_map = new Map();
function applyOnElement(selector, cb) {
  document.querySelectorAll(selector).forEach(cb);
  if (document.readyState !== 'loading') {
    const mo = mo_map.get(selector);
    if (mo) { mo.disconnect(); mo_map.delete(selector); }
    return;
  }
  if (mo_map.has(selector)) return; // Observer already set up
  const mo = new MutationObserver(m => m.forEach(u => u.addedNodes.forEach(n => {
    if (n.nodeType === 1) {
      if (n.matches(selector)) cb(n);
      n.querySelectorAll(selector).forEach(cb);
    }
  })));
  mo.observe(document.documentElement, { childList: true, subtree: true });
  mo_map.set(selector, mo);
  document.addEventListener('DOMContentLoaded', () => { mo.disconnect(); mo_map.delete(selector); }, { once: true });
}

/* ───── 5. Unified Style Controller ───── */

function updateDynamicStyles() {
    if (!IS_THREAD) return;
    if (cssBbTable) {
        styleBbTable = styleBbTable || document.head.appendChild(Object.assign(document.createElement('style'), { id: 'gmStyleBbTable' }));
        styleBbTable.textContent = cssBbTable(currentFontSize);
    }
    if (cssSbStats) {
        styleSbStats = styleSbStats || document.head.appendChild(Object.assign(document.createElement('style'), { id: 'gmStyleSbStats' }));
        styleSbStats.textContent = cssSbStats(currentFontSize);
    }
}

let lastUpdate = 0;
function updateAndApplyStyles(force = false) {
    const now = Date.now();
    if (!force && now - lastUpdate < 500) return;
    lastUpdate = now;

    const newFontSize = clamp(Number(GM_getValue('fontSize', 15.7)), 10, 25);
    const newPaddingSize = clamp(Number(GM_getValue('paddingSize', 10)), 0, 80);

    if (newFontSize !== currentFontSize) {
        currentFontSize = newFontSize;
        const pxStr = currentFontSize + 'px';
        applyOnElement('.bbWrapper', el => applyFontSizeToElement(el, pxStr));
        updateDynamicStyles();
    }

    if (newPaddingSize !== currentPaddingSize) {
        currentPaddingSize = newPaddingSize;
        const pxStr = currentPaddingSize + 'px';
        applyOnElement('.message-cell.message-cell--main', el => applyPaddingToElement(el, pxStr));
    }
}

/* ───── 6. Live Update Listeners ───── */

// Initial application
updateAndApplyStyles(true);


// ======================== UI and DOM-Dependent Code (Run at DOMContentLoaded) ======================== //
let CORRUPTED = false;
document.addEventListener('DOMContentLoaded', () => {
  let popup;
  /* ───── Sticky bar injection ───── */
  if (!DISABLE_STICKY_HIDE) {
    const sticky = document.querySelector('.p-navSticky.p-navSticky--primary') || document.querySelector('.p-nav');
    sticky.classList.add('gmStickyNav');
    sticky.classList.remove('p-navSticky', 'p-navSticky--primary', 'is-sticky');
    sticky.removeAttribute('data-xf-init');

    /* 3.  state common to both algorithms */
    let barH = sticky.offsetHeight;
    let offset = 0;
    let lastY = scrollY;
    let stuck = false; // “is the bar currently touching the viewport top?”

    function onScrollNew() {
      const y = scrollY;
      const top = sticky.getBoundingClientRect().top;  // distance to viewport top

      /* bar still in normal flow */
      if (top > 0) {
        if (stuck) {
          stuck = false;
          offset = 0;
          sticky.style.transform = '';
        }
        lastY = y;
        return;
      }

      /* bar just became sticky */
      if (!stuck) {
        stuck = true;
        lastY = y; // reset baseline: no initial jump
        return;
      }

      /* regular retract / reveal */
      const dy = y - lastY;  // + down, – up
      lastY = y;
      offset = clamp(offset + dy, 0.1, barH); // 0.1 prevent fully open top artifact
      sticky.style.transform = `translateY(${-offset}px)`;
    }
    addEventListener('scroll', onScrollNew, { passive: true });
    addEventListener('resize', () => { barH = sticky.offsetHeight; });
  }

  /* 4.  Remove old XF button, add our own to the right of Jump */
  let btn;
  document.querySelector('.p-nav-opposite > div[data-xf-click="sv-font-size-chooser-form"]')?.remove();
  const navOpposite = document.querySelector('#top .p-nav-opposite');
  if (navOpposite) {
    navOpposite.querySelector('#js-XFUniqueId1')?.closest('div')?.remove();

    btn = document.createElement('div');
    btn.className = 'p-navgroup-link gm-navStyleBtn';
    btn.setAttribute('role', 'button');     // accessibility
    btn.textContent = 'Style';

    const firstLink = navOpposite.querySelector('.p-navgroup-link');
    if (firstLink && firstLink.textContent.trim() === 'Jump')
        firstLink.after(btn);
    else
      navOpposite.insertBefore(btn, navOpposite.firstChild);

    btn.onclick = (event) => {
      event.preventDefault();
      event.stopPropagation();
      togglePopup();
    };
  }

  /* ───── 5. Build popup once ───── */
  popup = buildPopup();
  document.body.appendChild(popup);

  /* ───── 6. Popup builder ───── */
  function buildPopup() {
    const p = Object.assign(document.createElement('div'), { id: 'gmStylePopup' });

    /* Light / Dark */
    const tiles = Object.assign(document.createElement('div'), { className: 'gm-tile-row' });
    tiles.append(makeTile('Light', LIGHT_ID, 'gm-light'),
                 makeTile('Dark',  DARK_ID,  'gm-dark'));
    p.appendChild(tiles);

    /* Font size */
    const fsRow = Object.assign(document.createElement('div'), { className: 'gm-font-row' });
    const num = Object.assign(document.createElement('input'), {
      type: 'number', step: '0.1', min: '10', max: '25', value: currentFontSize.toFixed(1)
    });
    fsRow.append(
      makeFSBtn('-1',  -1),  makeFSBtn('-0.1', -.1),
      num,
      makeFSBtn('+0.1',  .1),makeFSBtn('+1',    1)
    );
    p.appendChild(fsRow);
    num.addEventListener('change', () => applyFS(parseFloat(num.value)));

    /* Padding size */
    const padRow = Object.assign(document.createElement('div'), { className: 'gm-font-row' });
    const padNum = Object.assign(document.createElement('input'), {
      type: 'number', step: '0.1', min: '0', max: '80', value: currentPaddingSize.toFixed(1)
    });
    padRow.append(
      makePadBtn('-1',  -1),  makePadBtn('-0.1', -.1),
      padNum,
      makePadBtn('+0.1',  .1),makePadBtn('+1',    1)
    );
    p.appendChild(padRow);
    padNum.addEventListener('change', () => applyPad(parseFloat(padNum.value)));

    function makePadBtn(txt, delta) {
      const b = document.createElement('button');
      b.textContent = txt;
      b.addEventListener('click', () => applyPad(parseFloat(padNum.value) + delta));
      return b;
    }
    function applyPad(px) {
      px = clamp(Math.round(px * 10) / 10, 0, 80);
      padNum.value = px.toFixed(1);
      GM_setValue('paddingSize', px);
      updateAndApplyStyles(true); // Force immediate update on click
    }
    function makeFSBtn(txt, delta) {
      const b = document.createElement('button');
      b.textContent = txt;
      b.addEventListener('click', () => applyFS(parseFloat(num.value) + delta));
      return b;
    }
    function applyFS(px) {
      px = clamp(Math.round(px * 10) / 10, 10, 25);
      num.value = px.toFixed(1);
      GM_setValue('fontSize', px);
      updateAndApplyStyles(true); // Force immediate update on click
    }
    return p;
  }

  /* ───── 7. Style tiles ───── */
  function makeTile(label, id, cls) {
    const token = document.querySelector('input[name="_xfToken"]')?.value || '';
    const url   = `${STYLE_PATH}?style_id=${id}&_xfRedirect=/&t=${encodeURIComponent(token)}`;
    const a = Object.assign(document.createElement('a'), {
      className: `gm-style-tile ${cls}`,
      textContent: label,
      href: url
    });
    a.addEventListener('click', ev => {
      ev.preventDefault();
      fetch(url, { credentials: 'same-origin', mode: 'same-origin' })
        .then(r => { if (!r.ok) throw 0; return r.text(); })
        .then(() => location.reload())
        .catch(() => (location.href = url));
    });
    return a;
  }

  /* ───── 8. Popup show / hide ───── */
  function togglePopup() {
    popup.style.display = popup.style.display === 'flex' ? 'none' : 'flex';
  }
  document.addEventListener('click', e => {
    if (popup && !popup.contains(e.target) && !e.target.closest('.gm-navStyleBtn'))
      popup.style.display = 'none';
  });

  // refresh style on alt-tab
  window.addEventListener('pageshow', (e) => { if (!e.persisted) return;
    CORRUPTED = false; // you just cant updateAndApplyStyles() until tab change
  });
  document.addEventListener('focus', () => { if (!CORRUPTED) setTimeout(() => updateAndApplyStyles(), 100); });
  document.addEventListener("visibilitychange", () => { if (!document.hidden && !CORRUPTED) updateAndApplyStyles(); });

  // ========================================================== //
  if (!IS_THREAD) return;

  /* Post-processing for specific threads */
  if (document.URL.includes('.1140820/')) {
    const width = { 2: '50%', 3: '33.33%' };
    document.querySelectorAll('.bbTable table').forEach(t => {
      const cells = t.rows[0]?.cells;
      const n     = cells?.length;
      if (width[n]) {
        t.style.cssText = 'table-layout:fixed;width:100%';
        [...cells].forEach(c => (c.style.width = width[n]));
      }
    });
  }

  // Centered text toggle
  const OP = document.querySelector('.username.u-concealed')?.textContent || '!';
  let foundCentered = false;
  for (let message of document.getElementsByClassName('message')) {
    if (message.getAttribute('data-author') === OP || message.classList.contains('hasThreadmark')) {
      const centeredDiv = message.querySelector('.bbWrapper:first-of-type > div[style*="text-align: center"], .bbWrapper > div.uncentered');
      if (centeredDiv) {
        foundCentered = true;
        break;
      }
    }
  }
  if (foundCentered) {
    let buttonGrp = document.querySelector('.block-outer-threadmarks .buttonGroup');
    let centered = true;
    if (buttonGrp) {
      buttonGrp.insertAdjacentHTML('afterbegin', '<div class="buttonGroup buttonGroup-buttonWrapper"><span class="button--link button" style="cursor:pointer" title="Toggle center text">Uncenter</span></div>');
      buttonGrp.firstElementChild.firstElementChild.addEventListener('click', function() {
        document.querySelectorAll(centered ? '.bbWrapper > div[style*="text-align: center"]' : '.bbWrapper > div.uncentered').forEach(el => {
          if (centered) {
            el.classList.add('uncentered');
            el.style.textAlign = '';
          } else {
            el.style.textAlign = 'center';
          }
        });
        centered = !centered;
      });
    }
  }
});