AH/QQ/SB/SV Mobile readability

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

// ==UserScript==
// @name        AH/QQ/SB/SV Mobile readability
// @description Standardize font size, revert justified text, replace fonts with sans-serif/serif/monospace, dodge colored text from the background and lightly pastelize, detect centered text and add Uncenter toggle. Auto-hiding navbar sticky with style button (Light/Dark style selector + font size + padding widget).
// @version     1.28
// @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_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @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/');

const toPx = (base, factor) => (base * factor).toFixed(2) + 'px';
const applyStyle = (styleNode, templateFn, basePx) => styleNode && (styleNode.textContent = templateFn(basePx));

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

if (IS_THREAD) {
  styleBbTable = document.head.appendChild(document.createElement('style'));
  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/')) {
    styleSbStats = document.head.appendChild(document.createElement('style'));
    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;}`;

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

// ======================== Style + Font selector + Sticky Scroll Nav ======================== //

/* ───── 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. CSS ───── */
let css = `
.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: css}));

/* ───── HELPER FUNCTIONS (MOVED UP FOR CORRECT INITIALIZATION ORDER) ───── */

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

const colorCache = new Map();
const adjustTextColor = (textRGBStr) => {
    if (colorCache.has(textRGBStr)) {
        return colorCache.get(textRGBStr);
    }

    function toRGB(str) { // can return up to 3 values [] to [r,g,b]
        const nums = str.match(/\d+/g);
        return nums ? nums.map(Number).slice(0, 3) : [];
    }

    let fg = toRGB(textRGBStr);
    // The background color for comparison is hardcoded here (rgb(39, 39, 39)).
    // Consider making this dynamic based on the actual page background if needed.
    const bg = [39, 39, 39]; // Represents the dark background color value (from "rgb(39, 39, 39)")

    if (fg.length !== 3) { return textRGBStr }

    const pastelize = ([r, g, b]) => {
        const neutral = 128;
        const whiteBoost = Math.pow((r + g + b) / (3 * 255), 10);
        const factor = 0.8 + 0.1*whiteBoost;
        return [
            neutral + factor * (r - neutral),
            neutral + factor * (g - neutral),
            neutral + factor * (b - neutral)
        ].map(clamp);
    };

    fg = pastelize(fg);

    let positiveDiff = 0;
    positiveDiff += Math.max(0, fg[0] - bg[0]);
    positiveDiff += Math.max(0, fg[1] - bg[1]);
    positiveDiff += Math.max(0, fg[2] - bg[2]);

    const threshold = 180;
    if (positiveDiff < threshold) {
      fg[0] = Math.max(fg[0], bg[0]);
      fg[1] = Math.max(fg[1], bg[1]);
      fg[2] = Math.max(fg[2], bg[2]);

      const avg = (fg[0]+fg[1]+fg[2])/3;
      const boost = Math.min(1.0, (Math.abs(fg[0]-avg)+Math.abs(fg[1]-avg)+Math.abs(fg[2]-avg))/255/0.2); // grays = 0, colors >= 1

      let correction = Math.round((threshold - positiveDiff) / 3 + boost*40);
      fg = fg.map(c => Math.min(c + correction, 255));
    }

    const result = `rgb(${fg[0]}, ${fg[1]}, ${fg[2]})`;
    colorCache.set(textRGBStr, result);

    return result;
};

function applyFontSize(px) {
  px = px + 'px'

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

  document.querySelector('html').classList.add('ffss')

  const wrappers = document.getElementsByClassName('bbWrapper');
  for (let wrapper of wrappers) {
    wrapper.style.fontSize = px;
    wrapper.classList.add('ffss')

    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(px, FONT_SCALE_MONO);
        } else if (SERIF.test(font)) {
          child.classList.add('ffs')
          style.fontSize = scale(px, 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);
      }
    }
  }

  /* Update dependent styles */
  const basePx = parseFloat(px);
  applyStyle(styleBbTable, cssBbTable, basePx);
  applyStyle(styleSbStats, cssSbStats, basePx);
}

function applyPadding(px) {
  px = clamp(px, 0, 80) + 'px';
  const cells = document.querySelectorAll('.message-cell.message-cell--main');
  for (const cell of cells) {
    cell.style.paddingLeft = px;
    cell.style.paddingRight = px;
  }
}
/* ───── END HELPER FUNCTIONS ───── */

/* ───── Sticky bar injection ───── */

/* 1.  pick the nav and keep only our class */
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');

/* 2.  choose algorithm */
const useOld = location.origin .includes('alternatehistory');

/* 3.  state common to both algorithms */
let barH   = sticky.offsetHeight;
let offset = 0;
let lastY  = scrollY;

/* ──────────────────────────────────────────────────────────────
   A) OLD algorithm  (once-only measurement of page position)
────────────────────────────────────────────────────────────────*/
if (!DISABLE_STICKY_HIDE && useOld) {

  /* a.   measure the element’s true document position */
  function measure() {
    const prev = sticky.style.position;
    sticky.style.position = 'static';                       // neutralise sticky
    const originTop = sticky.getBoundingClientRect().top + scrollY;
    sticky.style.position = prev;                           // restore
    return { originTop, h: sticky.offsetHeight };
  }

  let { originTop } = measure();

  function onScrollOld() {
    const y = scrollY;

    /* still above the slot → keep bar fully visible */
    if (y < originTop) {
      offset = 0;
      sticky.style.transform = '';
      lastY = y;
      return;
    }

    /* bar is docked → pixel-for-pixel retract / reveal */
    const dy = y - lastY;        // + down, – up
    lastY    = y;
    offset   = clamp(offset + dy, 0, barH);
    sticky.style.transform = `translateY(${-offset}px)`;
  }

  addEventListener('scroll',  onScrollOld, { passive:true });
  addEventListener('resize', () => { ({ originTop, h:barH } = measure()); });

}
/* ──────────────────────────────────────────────────────────────
   B) NEW algorithm  (live getBoundingClientRect check)
────────────────────────────────────────────────────────────────*/
else if (!DISABLE_STICKY_HIDE) {

  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, barH);
    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 Tbtn = document.querySelector('.p-nav-opposite > div[data-xf-click="sv-font-size-chooser-form"]')
Tbtn?.remove();

const navOpposite = document.querySelector('#top .p-nav-opposite');
if (navOpposite) {
  navOpposite.querySelector('#js-XFUniqueId1')?.closest('div')?.remove();

  /* ─── NEW: use <a> instead of <div> ─── */
  const 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 ───── */
let popup;
popup = buildPopup(); // This call now happens *after* `applyFontSize` is defined
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 stored = clamp(Number(GM_getValue('fontSize', 15)), 10, 25);

  const num = Object.assign(document.createElement('input'), {
    type: 'number', step: '0.1', min: '10', max: '25', value: stored.toFixed(1)
  });
  fsRow.append(
    makeFSBtn('-1',  -1),  makeFSBtn('-0.1', -.1),
    num,
    makeFSBtn('+0.1',  .1),makeFSBtn('+1',    1)
  );
  p.appendChild(fsRow);

  applyFS(stored);
  num.addEventListener('change', () => applyFS(parseFloat(num.value)));

  /* Padding size */
  const padRow = Object.assign(document.createElement('div'), { className: 'gm-font-row' });
  const storedPad = clamp(Number(GM_getValue('paddingSize', 10)), 0, 80);

  const padNum = Object.assign(document.createElement('input'), {
    type: 'number', step: '0.1', min: '0', max: '80', value: storedPad.toFixed(1)
  });
  padRow.append(
    makePadBtn('-1',  -1),  makePadBtn('-0.1', -.1),
    padNum,
    makePadBtn('+0.1',  .1),makePadBtn('+1',    1)
  );
  p.appendChild(padRow);

  applyPad(storedPad);
  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);
    applyPadding(px);
  }

  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);
    applyFontSize(px);
  }
  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.contains(e.target) && !e.target.closest('.gm-navStyleBtn'))
    popup.style.display = 'none';
});

// ========================================================== //

if (!IS_THREAD) return;

// Centered text toggle
const OP = document.querySelector('.username.u-concealed')?.textContent || '!';
const messages = document.getElementsByClassName('message');
let foundCentered = false;
for (let message of messages) {
  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;
    });
  }
}