Greasy Fork is available in English.
Opinionated per-site web font overrides — independent settings per domain, always-on dot UI, enable per site via the panel. AI-assisted.
// ==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();
})();