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, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();

})();