R89.FontStack

Opinionated per-site web font overrides — independent settings per domain, always-on dot UI, enable per site via the panel. AI-assisted.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

You will need to install an extension such as Tampermonkey to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         R89.FontStack
// @namespace    github.com/rareyman/tampermonkey-scripts
// @version      1.8.0
// @description  Opinionated per-site web font overrides — independent settings per domain, always-on dot UI, enable per site via the panel. AI-assisted.
// @author       R89
// @homepageURL  https://github.com/rareyman/tampermonkey-scripts
// @supportURL   https://github.com/rareyman/tampermonkey-scripts/issues
// @license      MIT
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      fonts.googleapis.com
// @connect      fonts.gstatic.com
// @connect      cdn.jsdelivr.net
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const VERSION = '1.8.0';
  const domain = location.hostname;

  // ── Font options ────────────────────────────────────────────────────────────
  const UI_FONTS = [
    'Noto Sans',
    'Inter',
    'Libre Franklin',
    'Open Sans',
    'Lato',
    'Work Sans',
    'Source Sans 3',
    'DM Sans',
  ];

  const MONO_FONTS = [
    'Noto Sans Mono',
    'Monaspace Neon',
    'Monaspace Argon',
    'Monaspace Xenon',
    'Monaspace Radon',
    'Monaspace Krypton',
  ];

  // ── Persisted settings (per domain) ─────────────────────────────────────────
  function getSetting(key, fallback) {
    return GM_getValue(domain + '_' + key, fallback);
  }
  function setSetting(key, value) {
    GM_setValue(domain + '_' + key, value);
  }

  let enabled  = getSetting('enabled',  false);
  let uiFont   = getSetting('uiFont',   'Noto Sans');
  let monoFont = getSetting('monoFont', 'Noto Sans Mono');

  // Dot UI state
  let dot, dotPanel;

  // ── init: load fonts + build UI ─────────────────────────────────────────────
  function init() {

  // ── Inject Google Fonts ──────────────────────────────────────────────────────
  const GFONTS_URL =
    'https://fonts.googleapis.com/css2?' +
    'family=Noto+Sans:ital,wght@0,100..900;1,100..900' +
    '&family=Inter:[email protected]' +
    '&family=Libre+Franklin:ital,wght@0,100..900;1,100..900' +
    '&family=Open+Sans:ital,wght@0,300..800;1,300..800' +
    '&family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900' +
    '&family=Work+Sans:ital,wght@0,100..900;1,100..900' +
    '&family=Source+Sans+3:ital,wght@0,200..900;1,200..900' +
    '&family=DM+Sans:ital,wght@0,100..900;1,100..900' +
    '&family=Noto+Sans+Mono:[email protected]' +
    '&display=swap';

  // Parse Google Fonts CSS and register each @font-face directly via the
  // FontFace API using the raw ArrayBuffer. Passing binary data to FontFace()
  // never triggers a browser URL fetch, so font-src CSP is never evaluated.
  function loadFontsViaAPI(css) {
    // Parse every @font-face block
    const faceBlocks = [...css.matchAll(/@font-face\s*\{([^}]+)\}/g)];
    const fontDefs = [];
    for (const [, block] of faceBlocks) {
      const family      = (block.match(/font-family:\s*['"](.*?)['"]/)  || [])[1];
      const style       = (block.match(/font-style:\s*([^;\s]+)/)        || [])[1] || 'normal';
      const weight      = (block.match(/font-weight:\s*([^;\n]+)/)      || [])[1]?.trim() || '400';
      const unicodeRange= (block.match(/unicode-range:\s*([^;\n]+)/)    || [])[1]?.trim();
      const urlMatch    = block.match(/url\(['"]?(https:\/\/fonts\.gstatic\.com[^'"\)]+)['"]?\)/);
      const url         = urlMatch?.[1];
      if (family && url) fontDefs.push({ family, style, weight, unicodeRange, url });
    }

    console.log('[SiteFonts] Parsed', fontDefs.length, '@font-face definitions from Google Fonts CSS');
    if (fontDefs.length === 0) return;

    const urlMap  = new Map();
    const unique  = [...new Set(fontDefs.map(f => f.url))];
    let remaining = unique.length;

    function commitFonts() {
      const loadPromises = [];
      for (const def of fontDefs) {
        const buf = urlMap.get(def.url);
        if (!buf) continue;
        const descriptors = { style: def.style, weight: def.weight };
        if (def.unicodeRange) descriptors.unicodeRange = def.unicodeRange;
        try {
          // Passing ArrayBuffer bypasses font-src CSP entirely.
          // Must call .load() to parse the binary and move face to "loaded" state.
          const face = new FontFace(def.family, buf, descriptors);
          loadPromises.push(
            face.load()
              .then(loaded => { document.fonts.add(loaded); })
              .catch(e => console.error('[SiteFonts] FontFace load error:', def.family, def.weight, def.style, e))
          );
        } catch (e) {
          console.error('[SiteFonts] FontFace construct error:', def.family, e);
        }
      }
      Promise.all(loadPromises).then(() => {
        console.log('[SiteFonts] All', loadPromises.length, 'font faces loaded and registered.');
        // Re-apply in case fonts were enabled before async loading completed
        applyFonts();
      });
    }

    for (const url of unique) {
      GM_xmlhttpRequest({
        method:       'GET',
        url,
        responseType: 'arraybuffer',
        onload(res) {
          urlMap.set(url, res.response);
          if (--remaining === 0) commitFonts();
        },
        onerror(err) {
          console.error('[SiteFonts] Failed to fetch font binary:', url, err);
          if (--remaining === 0) commitFonts();
        },
      });
    }
  }

  GM_xmlhttpRequest({
    method:  'GET',
    url:     GFONTS_URL,
    headers: { 'User-Agent': navigator.userAgent },
    onload(res) {
      console.log('[SiteFonts] Google Fonts CSS fetched, status:', res.status);
      loadFontsViaAPI(res.responseText);
    },
    onerror(err) {
      console.error('[SiteFonts] Failed to fetch Google Fonts CSS:', err);
    },
  });

  // ── Inject Monaspace @font-face ──────────────────────────────────────────────
  const CDN = 'https://cdn.jsdelivr.net/gh/githubnext/[email protected]/fonts/Web%20Fonts/Static%20Web%20Fonts';
  const monaspaceFamilies = [
    { name: 'Monaspace Neon',    dir: 'Monaspace%20Neon',    file: 'MonaspaceNeon'    },
    { name: 'Monaspace Argon',   dir: 'Monaspace%20Argon',   file: 'MonaspaceArgon'   },
    { name: 'Monaspace Xenon',   dir: 'Monaspace%20Xenon',   file: 'MonaspaceXenon'   },
    { name: 'Monaspace Radon',   dir: 'Monaspace%20Radon',   file: 'MonaspaceRadon'   },
    { name: 'Monaspace Krypton', dir: 'Monaspace%20Krypton', file: 'MonaspaceKrypton' },
  ];
  const monaspaceWeights = [
    { weight: 400, style: 'normal', suffix: 'Regular'    },
    { weight: 400, style: 'italic', suffix: 'Italic'     },
    { weight: 700, style: 'normal', suffix: 'Bold'       },
    { weight: 700, style: 'italic', suffix: 'BoldItalic' },
  ];

  for (const fam of monaspaceFamilies) {
    for (const w of monaspaceWeights) {
      const url = `${CDN}/${fam.dir}/${fam.file}-${w.suffix}.woff2`;
      GM_xmlhttpRequest({
        method:       'GET',
        url,
        responseType: 'arraybuffer',
        onload(res) {
          if (res.status !== 200) return;
          try {
            const face = new FontFace(fam.name, res.response, { weight: String(w.weight), style: w.style });
            face.load()
              .then(loaded => { document.fonts.add(loaded); })
              .catch(e => console.error('[SiteFonts] Monaspace load error:', fam.name, w.suffix, e));
          } catch (e) {
            console.error('[SiteFonts] Monaspace construct error:', fam.name, e);
          }
        },
        onerror(err) {
          console.error('[SiteFonts] Failed to fetch Monaspace font:', url, err);
        },
      });
    }
  }

  // ── Live CSS override ────────────────────────────────────────────────────────
  const overrideStyle = document.createElement('style');
  overrideStyle.id = 'site-fonts-override';
  document.head.appendChild(overrideStyle);

  function applyFonts() {
    if (!enabled) {
      overrideStyle.textContent = '';
      return;
    }
    overrideStyle.textContent = `
      h1,h2,h3,h4,h5,h6,
      p,li,ul,ol,button,input,textarea,span,div,label,strong,em,a {
        font-family: '${uiFont}', system-ui, sans-serif !important;
      }
      code,pre,kbd,samp,code *,pre *,
      code span,code div,pre span,pre div {
        font-family: '${monoFont}', monospace !important;
      }
    `;
  }

  applyFonts();

  // ── Dot UI ───────────────────────────────────────────────────────────────────
  const PANEL_Z = 2147483647;

  function getFontDotColor() {
    if (!enabled) return 'rgba(69,71,90,0.55)';
    // Read Cat theme from localStorage (shared across all TM scripts on same page)
    try {
      const raw = localStorage.getItem('__cat_theme');
      const catTheme = raw ? JSON.parse(raw) : null;
      if (catTheme && typeof catTheme.accent === 'string') return catTheme.accent;
    } catch (_) {}
    const catAccent = localStorage.getItem('__cat_accentHex');
    return (catAccent && typeof catAccent === 'string') ? catAccent : '#2563eb';
  }

  function refreshDot() {
    if (!dot) return;
    const color = getFontDotColor();
    dot.style.background = color;
    dot.style.opacity    = enabled ? '0.65' : '0.35';
    dot.style.boxShadow  = 'none';
    dot.title = enabled
      ? `Site Fonts — ON on ${domain} (click to configure)`
      : `Site Fonts — OFF on ${domain} (click to enable)`;
  }

  function showPanel() {
    const existing = document.getElementById('sf-panel');
    if (existing) { existing.remove(); return; }

    // Resolve theme: use Cat palette snapshot from localStorage if available, otherwise Mocha defaults
    let T;
    try {
      const raw = localStorage.getItem('__cat_theme');
      T = raw ? JSON.parse(raw) : null;
    } catch (_) { T = null; }
    T = T || {
      base:     '#1e1e2e', surface0: '#313244', surface1: '#45475a',
      crust:    '#11111b', text:     '#cdd6f4', subtext0: '#6c7086',
      overlay1: '#7f849c', green:    '#a6e3a1', red:      '#f38ba8',
      accent:   '#89b4fa',
    };
    const accentColor = getFontDotColor(); // ON = cat accent or blue; OFF = dim

    const p = document.createElement('div');
    p.id = 'sf-panel';
    Object.assign(p.style, {
      position: 'fixed', bottom: '38px', right: '16px', zIndex: PANEL_Z,
      background: T.base, color: T.text,
      border: `1px solid ${T.surface0}`, borderRadius: '14px',
      padding: '20px 22px 18px', fontFamily: 'system-ui, sans-serif',
      fontSize: '13px', width: '280px',
      boxShadow: '0 8px 32px rgba(0,0,0,0.55)',
      animation: 'sf-fadein 0.15s ease',
      lineHeight: '1.6',
    });

    // Always re-inject panel styles so theme changes take effect immediately
    const existingStyle = document.getElementById('sf-panel-style');
    if (existingStyle) existingStyle.remove();
    const s = document.createElement('style');
    s.id = 'sf-panel-style';
    s.textContent = `
      @keyframes sf-fadein { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
      #sf-panel label { display:block; font-size:11px; font-weight:500; color:${T.subtext0}; text-transform:uppercase; letter-spacing:0.07em; margin:12px 0 5px; }
      #sf-panel select { width:100%; padding:7px 10px; border-radius:8px; background:${T.surface0}; color:${T.text}; border:1px solid ${T.surface1}; font-size:13px; cursor:pointer; appearance:none; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a6adc8' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 10px center; padding-right:30px; box-sizing:border-box; }
      #sf-panel select:focus { outline:2px solid ${T.accent}; outline-offset:1px; }
      #sf-panel .sf-actions { display:flex; gap:7px; margin-top:14px; }
      #sf-panel .sf-actions button { flex:1; padding:7px 0; border-radius:7px; border:none; font-size:12px; font-weight:500; cursor:pointer; transition:filter 0.15s; }
      #sf-panel .sf-actions button:hover { filter:brightness(1.12); }
      #sf-btn-enable  { background:${T.green}; color:${T.base}; }
      #sf-btn-apply   { background:${T.accent}; color:${T.base}; }
      #sf-btn-disable { background:${T.surface0}; color:${T.red}; border:1px solid ${T.red} !important; }
      #sf-panel hr { border:none; border-top:1px solid ${T.surface1}; margin:14px 0 2px; }
    `;
    document.head.appendChild(s);

    // Header
    const hdr = document.createElement('div');
    Object.assign(hdr.style, { display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:'4px' });
    const hdrText = document.createElement('span');
    hdrText.style.cssText = `font-weight:600; font-size:13px; color:${T.accent};`;
    hdrText.textContent = `⚙ Site Fonts`;
    const closeBtn = document.createElement('button');
    Object.assign(closeBtn.style, { background:'none', border:'none', color:T.overlay1, cursor:'pointer', fontSize:'14px', lineHeight:'1', padding:'0' });
    closeBtn.textContent = '✕';
    closeBtn.title = 'Close';
    closeBtn.addEventListener('click', e => { e.stopPropagation(); p.remove(); s.remove(); });
    hdr.appendChild(hdrText);
    hdr.appendChild(closeBtn);
    p.appendChild(hdr);

    // Domain + status badge
    const meta = document.createElement('div');
    meta.style.cssText = `font-size:10px; color:${T.subtext0}; margin-bottom:12px;`;
    meta.textContent = `${domain}  ·  v${VERSION}`;
    p.appendChild(meta);

    const badge = document.createElement('span');
    badge.style.cssText = `display:inline-block; font-size:10px; font-weight:600; letter-spacing:0.08em; padding:2px 7px; border-radius:99px; margin-bottom:12px;`;
    if (enabled) {
      badge.textContent = '● ON';
      badge.style.cssText += `background:${accentColor}22; color:${accentColor}; border:1px solid ${accentColor}55;`;
    } else {
      badge.textContent = '○ OFF';
      badge.style.cssText += `background:${T.surface0}88; color:${T.subtext0}; border:1px solid ${T.surface1};`;
    }
    p.appendChild(badge);

    // Font selects
    function makeRow(labelText, sel) {
      const wrap = document.createElement('div');
      const lbl = document.createElement('label');
      lbl.textContent = labelText;
      wrap.appendChild(lbl);
      wrap.appendChild(sel);
      return wrap;
    }
    function makeSelect(options, value) {
      const sel = document.createElement('select');
      for (const opt of options) {
        const o = document.createElement('option');
        o.value = opt; o.textContent = opt;
        if (opt === value) o.selected = true;
        sel.appendChild(o);
      }
      return sel;
    }
    const uiSel   = makeSelect(UI_FONTS,   uiFont);
    const monoSel = makeSelect(MONO_FONTS, monoFont);
    p.appendChild(makeRow('UI Font',   uiSel));
    p.appendChild(makeRow('Mono Font', monoSel));

    // Live-preview on select change (when already enabled)
    uiSel.addEventListener('change',   () => { if (enabled) { uiFont   = uiSel.value;   applyFonts(); } });
    monoSel.addEventListener('change', () => { if (enabled) { monoFont = monoSel.value; applyFonts(); } });

    // Actions
    p.appendChild(Object.assign(document.createElement('hr')));
    const actions = document.createElement('div');
    actions.className = 'sf-actions';

    if (!enabled) {
      const enableBtn = document.createElement('button');
      enableBtn.id = 'sf-btn-enable';
      enableBtn.textContent = '✓ Enable';
      enableBtn.addEventListener('click', () => {
        uiFont   = uiSel.value;   setSetting('uiFont',   uiFont);
        monoFont = monoSel.value; setSetting('monoFont', monoFont);
        enabled  = true;          setSetting('enabled',  true);
        applyFonts();
        refreshDot();
        p.remove();
      });
      actions.appendChild(enableBtn);
    } else {
      const applyBtn = document.createElement('button');
      applyBtn.id = 'sf-btn-apply';
      applyBtn.textContent = 'Apply';
      applyBtn.addEventListener('click', () => {
        uiFont   = uiSel.value;   setSetting('uiFont',   uiFont);
        monoFont = monoSel.value; setSetting('monoFont', monoFont);
        applyFonts();
        refreshDot();
        p.remove();
      });
      const disableBtn = document.createElement('button');
      disableBtn.id = 'sf-btn-disable';
      disableBtn.textContent = 'Disable';
      disableBtn.addEventListener('click', () => {
        enabled = false; setSetting('enabled', false);
        applyFonts();
        refreshDot();
        p.remove();
      });
      actions.appendChild(applyBtn);
      actions.appendChild(disableBtn);
    }
    p.appendChild(actions);
    document.body.appendChild(p);

    // Close on outside click
    setTimeout(() => {
      document.addEventListener('click', function outside(e) {
        if (!p.contains(e.target) && e.target !== dot) {
          p.remove(); s.remove();
          document.removeEventListener('click', outside);
        }
      });
    }, 50);
  }

  // Persistent dot button
  dot = document.createElement('button');
  dot.id = 'sf-dot';
  Object.assign(dot.style, {
    position: 'fixed', bottom: '16px', right: '16px',
    zIndex: PANEL_Z, width: '14px', height: '14px',
    borderRadius: '50%', border: 'none', padding: '0',
    cursor: 'pointer',
    transition: 'transform 0.15s, box-shadow 0.15s, opacity 0.15s',
  });
  refreshDot();
  dot.addEventListener('mouseenter', () => { dot.style.transform = 'scale(1.6)'; });
  dot.addEventListener('mouseleave', () => { dot.style.transform = 'scale(1)'; });
  dot.addEventListener('click',      e  => { e.stopPropagation(); showPanel(); });
  document.body.appendChild(dot);

  // Sync font dot when Cat theme changes its accent/enables/disables
  document.addEventListener('cat-theme-updated', refreshDot);

  } // end init()

  // ── Tampermonkey menu command ────────────────────────────────────────────────
  GM_registerMenuCommand('⚙ Site Fonts', () => showPanel && showPanel());

  // Always init on every page load
  init();

})();