Headspace

Customize character names and pronouns on fiction sites

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         Headspace
// @namespace    headspace
// @version      1.12
// @description  Customize character names and pronouns on fiction sites
// @match        https://www.literotica.com/s/*
// @match        https://archiveofourown.org/works/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

/* jshint esversion: 11 */

(function () {
  'use strict';

  // =====================================================================
  //  SITE DETECTION
  // =====================================================================
  const SITE = location.hostname.includes('literotica.com') ? 'lit'
             : location.hostname.includes('archiveofourown.org') ? 'ao3'
             : null;
  if (!SITE) return;

  // =====================================================================
  //  CONSTANTS
  // =====================================================================
  const STORAGE_KEY = 'headspace_global';
  const STORAGE_KEY_FICS = 'headspace_fics';
  const COMPROMISE_URL = 'https://unpkg.com/compromise';

  // =====================================================================
  //  PRONOUN DATA
  // =====================================================================
  // Five cases: subject, object, possessive-determiner, possessive-pronoun, reflexive
  const PRONOUN_FULL = {
    he_him:    { subj: 'he',   obj: 'him',  det: 'his',   poss: 'his',    refl: 'himself' },
    she_her:   { subj: 'she',  obj: 'her',  det: 'her',   poss: 'hers',   refl: 'herself' },
    they_them: { subj: 'they', obj: 'them', det: 'their', poss: 'theirs', refl: 'themselves' },
  };

  // Top 10 most common neopronoun sets (subj/obj/det/poss/refl)
  const NEOPRONOUN_SETS = [
    { subj: 'ze',  obj: 'hir',  det: 'hir',  poss: 'hirs',  refl: 'hirself' },
    { subj: 'ze',  obj: 'zir',  det: 'zir',  poss: 'zirs',  refl: 'zirself' },
    { subj: 'xe',  obj: 'xem',  det: 'xyr',  poss: 'xyrs',  refl: 'xemself' },
    { subj: 'ey',  obj: 'em',   det: 'eir',  poss: 'eirs',  refl: 'emself' },
    { subj: 'fae', obj: 'faer', det: 'faer', poss: 'faers', refl: 'faerself' },
    { subj: 've',  obj: 'ver',  det: 'vis',  poss: 'vis',   refl: 'verself' },
    { subj: 'ae',  obj: 'aer',  det: 'aer',  poss: 'aers',  refl: 'aerself' },
    { subj: 'per', obj: 'per',  det: 'per',  poss: 'pers',  refl: 'perself' },
    { subj: 'e',   obj: 'em',   det: 'eir',  poss: 'eirs',  refl: 'emself' },
    { subj: 'co',  obj: 'co',   det: 'cos',  poss: 'cos',   refl: 'coself' },
  ];

  // Subject-verb agreement for they/them
  const VERB_AGREEMENT = {
    'was': 'were', 'is': 'are', 'has': 'have', 'does': 'do',
    "wasn't": "weren't", "isn't": "aren't", "hasn't": "haven't", "doesn't": "don't",
  };

  // Contraction suffixes to strip when matching pronouns
  const CONTRACTION_TAILS = ["'s", "\u2019s", "'d", "\u2019d", "'ll", "\u2019ll", "'ve", "\u2019ve"];

  // Perception & causative verbs: these take object + bare infinitive
  const _PERCEPTION_CAUSATIVE = new Set([
    'made', 'let', 'had', 'helped', 'watched', 'saw', 'heard', 'felt',
    'kept', 'found', 'noticed', 'observed', 'seen', 'watch', 'make',
    'help', 'find', 'keep', 'notice', 'observe', 'hear', 'see', 'feel',
  ]);

  // =====================================================================
  //  DEFAULT SETTINGS
  // =====================================================================
  function defaultSettings() {
    return {
      enabled: true,
      changePronouns: true,
      changeFrom: 'all_binary',
      customFrom: ['', '', ''],
      changeTo: 'she_her',
      customTo: ['', '', ''],
      changeNames: false,
      nameSwaps: [],
    };
  }

  // =====================================================================
  //  STORAGE HELPERS
  // =====================================================================
  function loadGlobal() {
    try { return Object.assign(defaultSettings(), JSON.parse(localStorage.getItem(STORAGE_KEY))); }
    catch { return defaultSettings(); }
  }
  function saveGlobal(s) { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); }

  function loadAllFics() {
    try { return JSON.parse(localStorage.getItem(STORAGE_KEY_FICS)) || {}; }
    catch { return {}; }
  }
  function saveAllFics(f) { localStorage.setItem(STORAGE_KEY_FICS, JSON.stringify(f)); }

  function ficKey() {
    if (SITE === 'lit') {
      const m = location.pathname.match(/^\/s\/([^/?#]+)/);
      return m ? m[1] : null;
    }
    if (SITE === 'ao3') {
      const m = location.pathname.match(/^\/works\/(\d+)/);
      return m ? 'ao3_' + m[1] : null;
    }
    return null;
  }

  function hasFicSettings() {
    const key = ficKey();
    return key ? (key in loadAllFics()) : false;
  }

  function loadFic() {
    const key = ficKey();
    if (!key) return null;
    const all = loadAllFics();
    return all[key] ? Object.assign(defaultSettings(), all[key]) : null;
  }

  function saveFic(s) {
    const key = ficKey();
    if (!key) return;
    const all = loadAllFics();
    all[key] = s;
    saveAllFics(all);
  }

  function clearFic() {
    const key = ficKey();
    if (!key) return;
    const all = loadAllFics();
    delete all[key];
    saveAllFics(all);
  }

  function getActive() {
    return loadFic() || loadGlobal();
  }

  function loadForScope(scope) {
    if (scope === 'fic') {
      const fic = loadFic();
      if (fic) return fic;
      return Object.assign({}, loadGlobal());
    }
    return loadGlobal();
  }

  function saveForScope(scope, s) {
    if (scope === 'fic') saveFic(s);
    else saveGlobal(s);
  }

  // =====================================================================
  //  CASE MATCHING
  // =====================================================================
  function matchCase(original, replacement) {
    if (!replacement) return replacement;
    if (original === original.toUpperCase() && original.length > 1) return replacement.toUpperCase();
    if (original[0] === original[0].toUpperCase()) return replacement[0].toUpperCase() + replacement.slice(1);
    return replacement.toLowerCase();
  }

  // =====================================================================
  //  PRONOUN SET BUILDERS
  // =====================================================================
  function getSourceSets(settings) {
    switch (settings.changeFrom) {
      case 'all_binary':
        return [PRONOUN_FULL.he_him, PRONOUN_FULL.she_her];
      case 'all':
        return [PRONOUN_FULL.he_him, PRONOUN_FULL.she_her, PRONOUN_FULL.they_them, ...NEOPRONOUN_SETS];
      case 'he_him':
        return [PRONOUN_FULL.he_him];
      case 'she_her':
        return [PRONOUN_FULL.she_her];
      case 'custom': {
        const c = settings.customFrom.map(s => (s || '').trim());
        if (c.some(Boolean)) return [{ subj: c[0], obj: c[1], det: c[2], poss: c[2], refl: '' }];
        return [];
      }
      default: return [];
    }
  }

  function getToSet(settings) {
    if (settings.changeTo === 'she_her') return PRONOUN_FULL.she_her;
    if (settings.changeTo === 'he_him') return PRONOUN_FULL.he_him;
    if (settings.changeTo === 'they_them') return PRONOUN_FULL.they_them;
    if (settings.changeTo === 'custom') {
      const c = settings.customTo.map(s => (s || '').trim());
      return { subj: c[0], obj: c[1], det: c[2], poss: c[2], refl: '' };
    }
    return null;
  }

  // =====================================================================
  //  CONTRACTION HANDLING
  // =====================================================================
  function stripContraction(text) {
    const lower = text.toLowerCase();
    for (const tail of CONTRACTION_TAILS) {
      if (lower.endsWith(tail)) {
        return { stem: text.slice(0, -tail.length), tail: text.slice(-tail.length) };
      }
    }
    return { stem: text, tail: '' };
  }

  // =====================================================================
  //  NLP REPLACEMENT ENGINE
  // =====================================================================
  /**
   * Disambiguation for ambiguous pronouns when mapping to they/them:
   *
   *   "her" → obj (them) OR det (their):
   *     "I saw her" → "I saw them"  |  "her dress" → "their dress"
   *
   *   "his" → det (their) OR poss (theirs):
   *     "his book" → "their book"  |  "the book was his" → "the book was theirs"
   *
   * compromise tags all of these as {Possessive, Pronoun} regardless of role,
   * so we use contextual rules:
   *
   *   1. CAUSATIVE RULE: perception/causative verb before pronoun → object
   *      "made her feel", "watched her leave", "let her go"
   *   2. GERUND RULE: [verb]ing + pronoun defaults to possessive-det, unless
   *      next word is adv/adj or the gerund is a perception verb
   *      "burying her head" → det  |  "watching her leave" → obj
   *   3. DEEP SCAN: scan past adj/adv chains to find a following noun → det
   *      "her soft, rather pale legs" → det
   *   4. ADJ+NOUN: 2-word lookahead for adjective then noun → det
   *      "her close friend" → det  |  "pulled her close" → obj
   *   5. VERB BEFORE + NO NOUN AFTER → object
   *   6. DEFAULT → object (most common in fiction prose)
   */
  function nlpReplace(text, settings, namePairs) {
    if (typeof nlp === 'undefined') return fallbackReplace(text, settings, namePairs);

    const toSet = settings.changePronouns ? getToSet(settings) : null;
    const doPronouns = settings.changePronouns && toSet;
    const doNames = settings.changeNames && namePairs.length > 0;
    if (!doPronouns && !doNames) return text;

    // Build pronoun lookup
    const pronounMap = {};
    if (doPronouns) {
      const fromSets = getSourceSets(settings);
      for (const set of fromSets) {
        const entries = [
          { word: set.subj, role: 'subj' },
          { word: set.obj,  role: 'obj' },
          { word: set.det,  role: 'det' },
          { word: set.poss, role: 'poss' },
          { word: set.refl, role: 'refl' },
        ];
        for (const { word, role } of entries) {
          if (!word) continue;
          const key = word.toLowerCase();
          if (key === (toSet[role] || '').toLowerCase()) continue;
          if (!pronounMap[key]) pronounMap[key] = { roles: new Set() };
          pronounMap[key].roles.add(role);
        }
      }
    }

    const nameMap = {};
    if (doNames) {
      for (const { from, to } of namePairs) nameMap[from.toLowerCase()] = to;
    }

    const needsVerbFix = doPronouns && settings.changeTo === 'they_them';

    const doc = nlp(text);
    const json = doc.json();

    let result = '';
    for (const sentence of json) {
      const terms = sentence.terms;
      for (let ti = 0; ti < terms.length; ti++) {
        const term = terms[ti];
        const tags = new Set(term.tags || []);
        let replaced = false;

        if (doPronouns && tags.has('Pronoun')) {
          const { stem, tail } = stripContraction(term.text);
          const stemLower = stem.toLowerCase();

          if (pronounMap[stemLower]) {
            const entry = pronounMap[stemLower];
            const roles = entry.roles;
            let role;

            if (roles.size === 1) {
              role = roles.values().next().value;
            } else {
              if (stemLower.endsWith('self') || stemLower.endsWith('selves')) {
                role = 'refl';
              } else {
                const prevTerm = ti > 0 ? terms[ti - 1] : null;
                const nextTerm = ti < terms.length - 1 ? terms[ti + 1] : null;
                const prevTags = prevTerm ? new Set(prevTerm.tags || []) : new Set();
                const nextTags = nextTerm ? new Set(nextTerm.tags || []) : new Set();

                const prevIsVerb = prevTags.has('Verb') || prevTags.has('Gerund') ||
                                   prevTags.has('PastTense') || prevTags.has('PresentTense') ||
                                   prevTags.has('Preposition') || prevTags.has('Conjunction') ||
                                   prevTags.has('Copula');
                const prevIsGerund = prevTags.has('Gerund');
                const prevText = (prevTerm?.text || '').toLowerCase();
                const nextIsNoun = nextTags.has('Noun');
                const nextIsAdj  = nextTags.has('Adjective');
                const nextIsAdv  = nextTags.has('Adverb');
                const nextIsDet  = nextTags.has('Determiner');
                const nextIsGerund = nextTags.has('Gerund');

                // Deep lookahead: scan past adj/adv chains to find a noun
                const deepNoun = (function() {
                  for (let j = ti + 1; j < Math.min(terms.length, ti + 6); j++) {
                    const jt = new Set(terms[j].tags || []);
                    if (jt.has('Noun')) return true;
                    if (!jt.has('Adjective') && !jt.has('Adverb') && !jt.has('Determiner')) return false;
                  }
                  return false;
                })();

                // 2-word lookahead for adj+noun
                const next2Term = ti < terms.length - 2 ? terms[ti + 2] : null;
                const next2Tags = next2Term ? new Set(next2Term.tags || []) : new Set();
                const adjThenNoun = nextIsAdj && next2Tags.has('Noun');

                const followedByNoun = nextIsNoun || nextIsDet || nextIsGerund || adjThenNoun || deepNoun;

                const PERCEPTION_CAUSATIVE = _PERCEPTION_CAUSATIVE;
                const prevIsCausative = PERCEPTION_CAUSATIVE.has(prevText);

                // CAUSATIVE RULE
                if (roles.has('obj') && prevIsCausative) {
                  role = 'obj';
                }
                // GERUND RULE
                else if (roles.has('det') && prevIsGerund) {
                  if (nextIsAdv || nextIsAdj) {
                    role = 'obj';
                  } else if (PERCEPTION_CAUSATIVE.has(prevText.replace(/ing$/, 'e')) ||
                             PERCEPTION_CAUSATIVE.has(prevText.replace(/ing$/, '')) ||
                             PERCEPTION_CAUSATIVE.has(prevText.replace(/ting$/, 't')) ||
                             PERCEPTION_CAUSATIVE.has(prevText.replace(/ping$/, 'p')) ||
                             PERCEPTION_CAUSATIVE.has(prevText.replace(/([a-z])\1ing$/, '$1'))) {
                    role = 'obj';
                  } else {
                    role = 'det';
                  }
                }
                // RULE 1: followed by noun → possessive det
                else if (roles.has('det') && followedByNoun) {
                  role = 'det';
                }
                // RULE 2: prev is verb-like, NOT followed by noun → object
                else if (roles.has('obj') && (prevIsVerb || !followedByNoun)) {
                  role = 'obj';
                }
                // RULE 3: standalone possessive
                else if (roles.has('poss') && !followedByNoun) {
                  role = 'poss';
                }
                // RULE 4: default to object
                else if (roles.has('obj')) {
                  role = 'obj';
                }
                else if (roles.has('subj')) {
                  role = 'subj';
                }
                else {
                  role = roles.values().next().value;
                }
              }
            }

            const replacement = toSet[role];
            if (replacement && stemLower !== replacement.toLowerCase()) {
              const casedStem = matchCase(stem, replacement);
              result += term.pre + casedStem + tail + term.post;
              replaced = true;

              // Subject-verb agreement for they/them
              if (needsVerbFix && role === 'subj' && !tail && ti < terms.length - 1) {
                const next = terms[ti + 1];
                const nextLower = next.text.toLowerCase();
                if (VERB_AGREEMENT[nextLower]) {
                  ti++;
                  result += next.pre + matchCase(next.text, VERB_AGREEMENT[nextLower]) + next.post;
                }
              }
            }
          }
        }

        if (!replaced && doNames) {
          const { stem: nameStem, tail: nameTail } = stripContraction(term.text);
          const nameLower = nameStem.toLowerCase();
          if (nameMap[nameLower]) {
            result += term.pre + matchCase(nameStem, nameMap[nameLower]) + nameTail + term.post;
            replaced = true;
          }
        }

        if (!replaced) {
          result += term.pre + term.text + term.post;
        }
      }
    }

    return result;
  }

  function fallbackReplace(text, settings, namePairs) {
    const toSet = settings.changePronouns ? getToSet(settings) : null;
    const pairs = [];

    if (settings.changePronouns && toSet) {
      const fromSets = getSourceSets(settings);
      for (const set of fromSets) {
        for (const role of ['subj', 'obj', 'det', 'poss', 'refl']) {
          if (set[role] && toSet[role] && set[role].toLowerCase() !== toSet[role].toLowerCase()) {
            pairs.push({ from: set[role], to: toSet[role] });
          }
        }
      }
    }
    if (settings.changeNames) {
      for (const n of namePairs) pairs.push(n);
    }
    if (!pairs.length) return text;

    const seen = new Set();
    const deduped = [];
    for (const p of pairs) { const k = p.from.toLowerCase(); if (!seen.has(k)) { seen.add(k); deduped.push(p); } }

    const sorted = deduped.sort((a, b) => b.from.length - a.from.length);
    const pattern = sorted.map(p => '\\b' + p.from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b').join('|');
    const regex = new RegExp(pattern, 'gi');
    const lookup = {};
    for (const p of sorted) lookup[p.from.toLowerCase()] = p.to;

    return text.replace(regex, m => { const r = lookup[m.toLowerCase()]; return r ? matchCase(m, r) : m; });
  }

  // =====================================================================
  //  DOM APPLICATION (shared)
  // =====================================================================
  function $$(sel) {
    try { return document.querySelectorAll(sel); } catch { return []; }
  }

  // Last-resort fallback for locating the story body when no known container
  // selector matches (e.g. the site renamed its containers). Groups <p> by
  // parent and picks the parent whose paragraphs hold the most text — on a
  // fiction page that's the story itself. Chrome (nav/comments/cards/etc.) is
  // filtered out so a long comment thread can't win.
  const PROSE_EXCLUDE = /comment|footer|header|\bnav\b|sidebar|aside|related|menu|promo|advert|\bshare\b|widget|\bcard\b|stats/i;
  function findProseParagraphs() {
    const groups = new Map();
    for (const p of document.querySelectorAll('p')) {
      const text = (p.textContent || '').trim();
      if (text.length < 40) continue;                       // skip labels/blurbs
      if (p.closest('nav, header, footer, aside, form')) continue;
      const parent = p.parentElement;
      if (!parent) continue;
      let chrome = false;
      for (let el = parent; el && el !== document.body; el = el.parentElement) {
        const sig = (el.id || '') + ' ' + (typeof el.className === 'string' ? el.className : '');
        if (PROSE_EXCLUDE.test(sig)) { chrome = true; break; }
      }
      if (chrome) continue;
      const g = groups.get(parent) || { len: 0, ps: [] };
      g.len += text.length;
      g.ps.push(p);
      groups.set(parent, g);
    }
    let best = null;
    for (const g of groups.values()) if (!best || g.len > best.len) best = g;
    return best ? best.ps : [];
  }

  function getStoryTargets() {
    // Try selectors from most to least specific, falling through on no match.
    // Prefer semantic anchors and class substrings so the script keeps working
    // when the site renames or restyles its containers.
    const selectorSets = SITE === 'lit'
      ? [
          '[itemprop="articleBody"] p',         // semantic microdata
          'div[class*="article__content"] p',   // matches on class substring
          'div[class*="aa_ht"] p',              // older layout
          '.panel.article p',
          'div[class*="article"] p',
          'div[class*="reading"] p, div[class*="content"] p',
        ]
      : SITE === 'ao3'
      ? [
          '#workskin .userstuff p',
          '.userstuff p',
          'div[class*="userstuff"] p',
        ]
      : [];

    for (const sel of selectorSets) {
      const t = $$(sel);
      if (t.length) return t;
    }
    // Nothing matched a known container — detect the story body by content.
    return findProseParagraphs();
  }

  function applyReplacements() {
    const settings = getActive();
    if (!settings.enabled || (!settings.changePronouns && !settings.changeNames)) { restoreOriginals(); return; }

    const namePairs = (settings.nameSwaps || [])
      .filter(n => n.from && n.from.trim() && n.to && n.to.trim())
      .map(n => ({ from: n.from.trim(), to: n.to.trim() }))
      .filter(n => n.from.toLowerCase() !== n.to.toLowerCase());

    const targets = getStoryTargets();
    targets.forEach(el => {
      if (!el.dataset.hsOriginal) el.dataset.hsOriginal = el.innerHTML;
      el.innerHTML = replaceInHTML(el.dataset.hsOriginal, settings, namePairs);
    });
  }

  function restoreOriginals() {
    document.querySelectorAll('[data-hs-original]').forEach(el => { el.innerHTML = el.dataset.hsOriginal; });
  }

  function replaceInHTML(html, settings, namePairs) {
    return html.replace(/(<[^>]*>)|([^<]+)/g, (m, tag, text) => tag ? tag : nlpReplace(text, settings, namePairs));
  }

  // =====================================================================
  //  LOAD COMPROMISE
  // =====================================================================
  function loadCompromise() {
    return new Promise((resolve) => {
      if (typeof nlp !== 'undefined') { resolve(); return; }
      const script = document.createElement('script');
      script.src = COMPROMISE_URL;
      script.onload = () => { console.log('[headspace] compromise loaded'); resolve(); };
      script.onerror = () => { console.warn('[headspace] compromise failed, using regex fallback'); resolve(); };
      document.head.appendChild(script);
    });
  }

  // Exposed by AO3 adapter for Cancel button
  let closeAO3Dialog = null;

  // =====================================================================
  //  SHARED SETTINGS HTML BUILDER
  // =====================================================================
  let currentScope = hasFicSettings() ? 'fic' : 'global';

  // Working copy: edited in-memory, only persisted on Apply/Save.
  // Initialized from storage; reset actions replace it.
  let workingSettings = loadForScope(currentScope);

  // Dirty = working copy differs from what's currently applied to the page.
  // Starts true so the first Apply works on page load.
  let isDirty = true;

  function markClean() { isDirty = false; }

  function markDirtyAndUpdate(panel) {
    isDirty = true;
    const applyBtn = panel.querySelector('#hs-apply');
    if (applyBtn) applyBtn.disabled = false;
  }

  function esc(s) { return (s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }

  function buildSettingsHTML(opts) {
    const s = workingSettings;
    const isAO3 = opts?.ao3;

    // Use site-appropriate button classes
    const btnPrimary = isAO3 ? 'class="button"' : 'class="button__small button--brand"';
    const btnSecondary = isAO3 ? 'class="button"' : 'class="button__small button__small--secondary"';

    const fromOpts = [
      ['all_binary', 'All binary pronouns'],
      ['all', 'All pronouns'],
      ['he_him', 'He/him/his'],
      ['she_her', 'She/her/hers'],
      ['custom', 'Custom'],
    ];
    const toOpts = [
      ['she_her', 'She/her/hers'],
      ['he_him', 'He/him/his'],
      ['they_them', 'They/them/theirs'],
      ['custom', 'Custom'],
    ];

    function sel(name, options, selected) {
      let h = `<select data-hs-sel="${name}">`;
      for (const [val, label] of options) h += `<option value="${val}" ${selected === val ? 'selected' : ''}>${label}</option>`;
      return h + '</select>';
    }

    let h = '';

    if (!isAO3) {
      h += '<div class="hs-heading">Headspace</div>';
    }

    // --- Scope toggle ---
    const activeClass = isAO3 ? 'hs-scope-active' : 'hs-scope-active';
    const globalDisabled = (currentScope === 'fic' && hasFicSettings()) ? 'disabled' : '';
    h += `<div class="${isAO3 ? 'hs-scope-group' : 'hs-scope-group'}">
      <button data-hs-scope="global" class="${currentScope === 'global' ? activeClass : ''}" ${globalDisabled}>All fics</button>
      <button data-hs-scope="fic" class="${currentScope === 'fic' ? activeClass : ''}">This fic</button>
    </div>`;

    // --- Enabled checkbox ---
    const enabledClass = isAO3 ? 'checkbox-label' : 'hs-section-toggle';
    h += `<label class="${enabledClass}"><input type="checkbox" id="hs-enabled" ${s.enabled ? 'checked' : ''}> Enabled</label>`;

    // --- Settings (hidden when disabled) ---
    h += `<div id="hs-settings-body" style="display:${s.enabled ? 'block' : 'none'}">`;

    // --- Pronouns ---
    const cbClass = isAO3 ? 'checkbox-label' : 'hs-section-toggle';
    h += `<label class="${cbClass}"><input type="checkbox" data-hs-section="pronouns" ${s.changePronouns ? 'checked' : ''}> Change pronouns</label>`;
    h += `<div class="${isAO3 ? 'subsettings' : 'hs-section-body'}" data-hs-body="pronouns" style="display:${s.changePronouns ? 'block' : 'none'}">`;
    h += `<label class="${isAO3 ? 'setting-label' : 'hs-title'}">From</label>`;
    h += sel('from', fromOpts, s.changeFrom);
    h += `<div class="${isAO3 ? 'hs-custom-row' : 'hs-custom-row'}" data-hs-toggle="from" style="display:${s.changeFrom === 'custom' ? 'flex' : 'none'}">
      <input type="text" placeholder="subj" value="${esc(s.customFrom[0])}" data-hs-cf="0">
      <input type="text" placeholder="obj"  value="${esc(s.customFrom[1])}" data-hs-cf="1">
      <input type="text" placeholder="poss" value="${esc(s.customFrom[2])}" data-hs-cf="2">
    </div>`;
    h += `<label class="${isAO3 ? 'setting-label' : 'hs-title'}">To</label>`;
    h += sel('to', toOpts, s.changeTo);
    h += `<div class="${isAO3 ? 'hs-custom-row' : 'hs-custom-row'}" data-hs-toggle="to" style="display:${s.changeTo === 'custom' ? 'flex' : 'none'}">
      <input type="text" placeholder="subj" value="${esc(s.customTo[0])}" data-hs-ct="0">
      <input type="text" placeholder="obj"  value="${esc(s.customTo[1])}" data-hs-ct="1">
      <input type="text" placeholder="poss" value="${esc(s.customTo[2])}" data-hs-ct="2">
    </div>`;
    h += '</div>';

    // --- Names ---
    h += `<label class="${cbClass}" style="margin-top:15px"><input type="checkbox" data-hs-section="names" ${s.changeNames ? 'checked' : ''}> Change names</label>`;
    h += `<div class="${isAO3 ? 'subsettings' : 'hs-section-body'}" data-hs-body="names" style="display:${s.changeNames ? 'block' : 'none'}">`;
    h += '<div id="hs-name-swaps">';
    const swaps = s.nameSwaps || [];
    for (let i = 0; i < swaps.length; i++) {
      h += `<div class="${isAO3 ? 'hs-name-row' : 'hs-name-row'}" data-hs-swap="${i}">
        <input type="text" placeholder="Old" value="${esc(swaps[i].from)}" data-field="from">
        <span>\u2192</span>
        <input type="text" placeholder="New" value="${esc(swaps[i].to)}" data-field="to">
        <button class="${isAO3 ? 'button hs-btn-remove' : 'hs-btn-inline hs-btn-remove'}" data-hs-remove="${i}">\u2715</button>
      </div>`;
    }
    h += '</div>';
    h += `<button class="${isAO3 ? 'button' : 'button__small--secondary hs-btn-inline hs-btn-add'}" id="hs-add-name" style="margin-top:${swaps.length ? '10' : '0'}px">+ Add name</button>`;
    h += '</div>';

    // Close the settings body wrapper
    h += '</div>';

    // --- Buttons ---
    if (isAO3) {
      h += `<div class="button-group" style="margin-top:15px">`;
      h += `<input type="submit" class="button" id="hs-apply" value="Save" ${isDirty ? '' : 'disabled'}>`;
      h += '</div>';
      h += '<div class="reset-link">';
      if (currentScope === 'global') {
        h += '<a href="#" id="hs-reset-defaults">Reset to default settings</a>';
      }
      if (currentScope === 'fic') {
        h += '<a href="#" id="hs-reset-fic">Clear customizations</a>';
      }
      h += '</div>';
    } else {
      h += `<div class="hs-btn-group" style="margin-top:15px">`;
      h += `<input type="submit" ${btnPrimary} id="hs-apply" value="Apply" ${isDirty ? '' : 'disabled'}>`;
      if (currentScope === 'global') {
        h += `<input type="submit" ${btnSecondary} id="hs-reset-defaults" value="Reset to defaults">`;
      }
      if (currentScope === 'fic') {
        h += `<input type="submit" ${btnSecondary} id="hs-reset-fic" value="Clear customizations">`;
      }
      h += '</div>';
    }

    h += `<div class="${isAO3 ? 'donate-link' : 'hs-status'}">Love it? <a href="https://buymeacoffee.com/spindrift" target="_blank" rel="noopener">Donate!</a></div>`;

    return h;
  }

  // =====================================================================
  //  SHARED EVENT BINDING
  // =====================================================================
  function bindPanelEvents(panel, renderFn) {
    const dirtyInput = () => markDirtyAndUpdate(panel);

    // Scope toggle (All fics / This fic)
    panel.querySelectorAll('[data-hs-scope]').forEach(btn => {
      btn.addEventListener('click', () => {
        if (btn.disabled) return;
        const newScope = btn.dataset.hsScope;
        if (newScope === currentScope) return;
        currentScope = newScope;
        // Load the appropriate settings for the new scope into working copy
        workingSettings = loadForScope(currentScope);
        // Don't mark dirty — we haven't changed anything, just switched view
        renderFn();
      });
    });

    // Enabled checkbox
    panel.querySelector('#hs-enabled')?.addEventListener('change', (e) => {
      workingSettings.enabled = e.target.checked;
      const body = panel.querySelector('#hs-settings-body');
      if (body) body.style.display = workingSettings.enabled ? 'block' : 'none';
      if (!workingSettings.enabled) restoreOriginals();
      dirtyInput();
    });

    // Section toggles
    panel.querySelectorAll('[data-hs-section]').forEach(cb => {
      cb.addEventListener('change', () => {
        const section = cb.dataset.hsSection;
        const body = panel.querySelector(`[data-hs-body="${section}"]`);
        if (body) body.style.display = cb.checked ? 'block' : 'none';
        if (section === 'pronouns') workingSettings.changePronouns = cb.checked;
        if (section === 'names') workingSettings.changeNames = cb.checked;
        dirtyInput();
      });
    });

    // Dropdowns
    panel.querySelectorAll('[data-hs-sel]').forEach(sel => {
      sel.addEventListener('change', () => {
        const name = sel.dataset.hsSel;
        const val = sel.value;
        const row = panel.querySelector(`[data-hs-toggle="${name}"]`);
        if (row) row.style.display = val === 'custom' ? 'flex' : 'none';
        if (name === 'from') workingSettings.changeFrom = val;
        if (name === 'to') workingSettings.changeTo = val;
        dirtyInput();
      });
    });

    // Custom inputs
    panel.querySelectorAll('[data-hs-cf]').forEach(inp => {
      inp.addEventListener('input', () => { workingSettings.customFrom[+inp.dataset.hsCf] = inp.value; dirtyInput(); });
    });
    panel.querySelectorAll('[data-hs-ct]').forEach(inp => {
      inp.addEventListener('input', () => { workingSettings.customTo[+inp.dataset.hsCt] = inp.value; dirtyInput(); });
    });

    // Name swaps
    const sc = panel.querySelector('#hs-name-swaps');
    if (sc) {
      sc.addEventListener('input', (e) => {
        const row = e.target.closest('[data-hs-swap]');
        if (!row || e.target.tagName !== 'INPUT') return;
        const idx = +row.dataset.hsSwap;
        if (workingSettings.nameSwaps[idx]) { workingSettings.nameSwaps[idx][e.target.dataset.field] = e.target.value; dirtyInput(); }
      });
      sc.addEventListener('click', (e) => {
        const btn = e.target.closest('[data-hs-remove]');
        if (!btn) return;
        workingSettings.nameSwaps.splice(+btn.dataset.hsRemove, 1);
        isDirty = true;
        renderFn();
      });
    }

    panel.querySelector('#hs-add-name')?.addEventListener('click', () => {
      workingSettings.nameSwaps.push({ from: '', to: '' });
      isDirty = true;
      renderFn();
    });

    // Apply / Save — persist working copy to storage and apply to page
    panel.querySelector('#hs-apply')?.addEventListener('click', (e) => {
      e.preventDefault();
      saveForScope(currentScope, workingSettings);
      applyReplacements();
      markClean();
      if (SITE === 'ao3' && closeAO3Dialog) {
        closeAO3Dialog();
      } else {
        renderFn();
      }
    });

    // Close button (AO3 dialog)
    panel.querySelector('#hs-close')?.addEventListener('click', (e) => {
      e.preventDefault();
      // Discard unsaved changes by reloading from storage
      workingSettings = loadForScope(currentScope);
      isDirty = false;
      if (closeAO3Dialog) closeAO3Dialog();
    });

    // Reset to defaults — reset storage, reload working copy
    panel.querySelector('#hs-reset-defaults')?.addEventListener('click', (e) => {
      e.preventDefault();
      saveGlobal(defaultSettings());
      workingSettings = defaultSettings();
      restoreOriginals();
      isDirty = true;
      renderFn();
    });

    // Clear fic customizations — delete fic storage, switch to global
    panel.querySelector('#hs-reset-fic')?.addEventListener('click', (e) => {
      e.preventDefault();
      clearFic();
      currentScope = 'global';
      workingSettings = loadGlobal();
      restoreOriginals();
      isDirty = true;
      renderFn();
    });
  }

  // =====================================================================
  //  LITEROTICA ADAPTER
  // =====================================================================
  function initLiterotica() {
    const TAB_ID = 'tab__headspace';
    const TAB_DATA = 'tabpanel-headspace';
    let settingsPane = null;

    function injectStyles() {
      const style = document.createElement('style');
      style.textContent = `
        /* Reveal our settings pane when our tab is selected. Matches on
           attributes and class substrings so it tolerates the site renaming
           its classes. The site default-hides inactive panes; our pane
           inherits that and this rule overrides it for our tab. */
        [class*="widget__tab"]:has(input[id="${TAB_ID}"]:checked)
          [data-tab="${TAB_DATA}"] {
          display: block !important;
          background-color: rgb(255, 255, 255);
          padding: 10px 5px;
          width: 200px;
        }
        .dark_theme [class*="widget__tab"]:has(input[id="${TAB_ID}"]:checked)
          [data-tab="${TAB_DATA}"] {
          background-color: rgb(16, 16, 16);
        }

        @media screen and (max-width: 479px) {
          .hs-panel {
            padding-left: 18px;
            padding-right: 18px;
            padding-top: 15px;
            padding-bottom: 15px;
          }
        }

        .hs-icon-symbol { font-style: normal; font-size: 18px; font-weight: bold; line-height: 1; vertical-align: middle; position: relative; top: -5px; }
        .hs-panel { font-family: inherit; font-size: inherit; color: inherit; }
        .hs-heading { margin: 0 0 8px; font-size: 13px; font-weight: 600; letter-spacing: .03em; opacity: .7; }
        .hs-scope-group { display: flex; margin-bottom: 12px; border: 1px solid rgba(128,128,128,.3); border-radius: 4px; overflow: hidden; }
        .hs-scope-group button { flex: 1; padding: 5px 0; font-family: inherit; border: none; background: transparent; color: inherit; cursor: pointer; transition: background .15s; font-weight: 400; }
        .hs-scope-group button.hs-scope-active { background: rgba(128,128,128,.2); }
        .hs-scope-group button:not(:last-child) { border-right: 1px solid rgba(128,128,128,.3); border-radius: 4px 0px 0px 4px !important; }
        .hs-scope-group button:last-child { border-radius: 0px 4px 4px 0px !important; }
        .hs-scope-group button:disabled { opacity: .4; cursor: not-allowed; }
        .hs-section-toggle { display: flex; align-items: center; gap: 5px; cursor: pointer; margin: 20px 0px; }
        .hs-section-toggle input { accent-color: rgb(74, 137, 243); margin: 0; }
        .hs-section-body { margin-left: 0; margin-bottom: 6px; }
        .hs-panel .hs-title { display: block; margin: 6px 0 2px; color: inherit; }
        .hs-panel select { width: 100%; padding: 5px 10px; font-size: 14px; border: 1px solid rgba(128,128,128,.3); border-radius: 3px; background: transparent; color: inherit; font-family: inherit; margin-bottom: 8px; cursor: pointer; -webkit-appearance: auto; appearance: auto; }
        .hs-custom-row { display: flex; gap: 3px; margin: 0 0 8px; }
        .hs-custom-row input[type="text"], .hs-name-row input[type="text"] { flex: 1; padding: 4px 6px; font-size: 12px; border: 1px solid rgba(128,128,128,.3); border-radius: 3px; background: transparent; color: inherit; font-family: inherit; min-width: 0; }
        .hs-custom-row input::placeholder, .hs-name-row input::placeholder { opacity: .8; font-style: italic; }
        .hs-divider { border: none; border-top: 1px solid rgba(128,128,128,.15); margin: 10px 0; }
        .hs-name-row { display: flex; gap: 3px; align-items: center; margin: 3px 0; }
        .hs-name-row span { opacity: .5; flex-shrink: 0; }
        .hs-btn-inline { display: inline-flex; align-items: center; justify-content: center; border: 1px solid rgba(128,128,128,.3); background: transparent; color: inherit; border-radius: 3px; cursor: pointer; padding: 1px 6px; font-family: inherit; }
        .hs-btn-inline:hover { opacity: .7; }
        .hs-btn-remove { color: #e53935; border-color: #e53935; padding: 1px 4px; font-size: 10px; margin-left: 5px; }
        .hs-btn-add { margin-top: 0px; }
        .hs-section-body:has(.hs-name-row) .hs-btn-add { margin-top: 10px; }
        .hs-status { font-size: 14px; text-align: center; margin-top: 15px; }
        .hs-panel.hs-disabled .hs-section-toggle, .hs-panel.hs-disabled .hs-section-body, .hs-panel.hs-disabled select { opacity: .35; pointer-events: none; }
        .hs-panel .hs-btn-group { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
        .hs-panel .button--brand[disabled] { opacity: .4; cursor: not-allowed; pointer-events: none; }
      `;
      document.head.appendChild(style);
    }

    function renderPanel() {
      if (!settingsPane) return;
      settingsPane.innerHTML = '<div class="hs-panel">' + buildSettingsHTML() + '</div>';
      bindPanelEvents(settingsPane, renderPanel);
    }

    function injectTab() {
      // Idempotent and safe to call repeatedly: the site may render the tab
      // widget after we first run, or replace it when the SPA swaps pages. Bail
      // if our tab is already in place; otherwise clear any stale remnant first.
      const existing = document.getElementById(TAB_ID);
      if (existing && settingsPane && settingsPane.isConnected) return true;
      if (existing) (existing.closest('li') || existing).remove();
      if (settingsPane && settingsPane.parentNode) settingsPane.remove();
      settingsPane = null;

      const tabNav = document.querySelector('ul[class*="tab__list__nav"]')
                  || document.querySelector('ul[class*="tab__list"]');
      if (!tabNav) return false;

      // Copy the class names off an existing tab rather than hardcoding them,
      // so our tab stays visually consistent with the site's own tabs.
      const cls = (el) => (el && typeof el.className === 'string') ? el.className : '';
      const sampleRadio = tabNav.querySelector('li input[type="radio"]');
      const sampleLabel = tabNav.querySelector('li label');
      const sampleItem  = tabNav.querySelector('li label > span');
      const sampleLink  = tabNav.querySelector('li label > span > span');

      const li = document.createElement('li');
      li.innerHTML = `
        <input type="radio" id="${TAB_ID}" name="panel-tabs" class="${cls(sampleRadio)}">
        <label for="${TAB_ID}" class="${cls(sampleLabel)}">
          <span class="${cls(sampleItem)}">
            <span class="${cls(sampleLink)}" title="Headspace">
              <i class="hs-icon-symbol">\u26A7</i>
            </span>
          </span>
        </label>`;
      tabNav.appendChild(li);

      // The pane container lives in the same tab widget as the nav.
      const widget = tabNav.closest('[class*="widget__tab"]');
      const tabContent = (widget && widget.querySelector('[class*="tab__content"]'))
                      || document.querySelector('div[class*="tab__content"]');
      if (!tabContent) return false;

      // Reuse the existing pane's classes (minus any "active" modifier) so our
      // pane inherits the site's default-hidden styling; our CSS reveals it when
      // our tab is selected.
      const samplePane = tabContent.querySelector('[data-tab][role="tabpanel"]')
                      || document.querySelector('[class*="tab__pane"]');
      const paneCls = cls(samplePane).split(/\s+/).filter(c => c && !/active/i.test(c)).join(' ');

      settingsPane = document.createElement('div');
      settingsPane.dataset.tab = TAB_DATA;
      settingsPane.setAttribute('role', 'tabpanel');
      if (paneCls) settingsPane.className = paneCls;
      tabContent.appendChild(settingsPane);

      renderPanel();
      return true;
    }

    injectStyles();
    injectTab();
    return { renderPanel, ensureInjected: injectTab };
  }

  // =====================================================================
  //  AO3 ADAPTER
  // =====================================================================
  function initAO3() {
    let dialog = null;
    let overlay = null;

    function injectStyles() {
      const style = document.createElement('style');
      style.textContent = `
        /* AO3 Headspace Dialog — reuses ao3-menu-dialog patterns */
        .hs-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9999; }
        .hs-dialog {
          position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
          background: rgb(255,255,255); padding: 20px;
          border: 10px solid rgb(238,238,238); border-radius: 0;
          box-shadow: rgba(0,0,0,0.2) 0 0 8px 0;
          z-index: 10000; width: 90%; max-width: 500px; max-height: 80vh;
          overflow-y: auto; font-family: inherit; font-size: inherit;
          color: rgb(42,42,42); box-sizing: border-box;
        }
        @media (max-width: 768px) {
          .hs-dialog { width: 96% !important; max-width: 96% !important; max-height: calc(100vh - 120px) !important; padding: 15px !important; }
        }
        .hs-dialog h3 { text-align: center; margin-top: 0; color: inherit; font-family: inherit; }
        .hs-settings {
          background: rgb(221,221,221); border: 2px solid rgb(243,239,236);
          padding: 15px; margin-bottom: 15px;
          box-shadow: rgb(153,153,153) 1px 0 5px 0 inset;
        }
        .hs-settings .setting-label { display: block; margin-bottom: 6px; font-weight: bold; color: inherit; opacity: 0.9; }
        .hs-settings .checkbox-label { display: block; font-weight: normal; color: inherit; margin-bottom: 8px; cursor: pointer; }
        .hs-settings .subsettings { padding-left: 20px; margin-top: 10px; }
        .hs-settings input[type="text"], .hs-settings select {
          width: 100%; box-sizing: border-box; padding: 4px 8px;
          background: rgb(255,255,255); border: 1px solid rgb(187,187,187);
          border-radius: 0; color: rgb(0,0,0); margin-bottom: 8px;
        }
        .hs-settings select { cursor: pointer; }
        .hs-settings input::placeholder { opacity: 0.6 !important; }
        .hs-settings .button-group { display: flex; justify-content: space-between; gap: 10px; margin-top: 15px; }
        .hs-settings .button-group input[type="submit"] { flex: 1; padding: 8px; }
        .hs-settings .reset-link { text-align: center; margin-top: 10px; font-size: 0.9em; color: inherit; opacity: 0.8; }
        .hs-settings .donate-link { text-align: center; margin-top: 15px; font-size: 0.8em; color: inherit; opacity: 0.8; }
        .hs-scope-group { display: flex; margin-bottom: 12px; border: 1px solid rgb(187,187,187); border-radius: 4px; overflow: hidden; }
        .hs-scope-group button { flex: 1; padding: 6px 0; font-family: inherit; border: none; background: transparent; color: inherit; cursor: pointer; font-weight: normal; }
        .hs-scope-group button.hs-scope-active { background: rgba(0,0,0,0.1); font-weight: bold; }
        .hs-scope-group button:not(:last-child) { border-right: 1px solid rgb(187,187,187); border-radius: 4px 0px 0px 4px; }
        .hs-scope-group button:last-child { border-radius: 0px 4px 4px 0px; }
        .hs-scope-group button:disabled { opacity: .4; cursor: not-allowed; }
        .hs-custom-row { display: flex; gap: 4px; margin: 0 0 8px; }
        .hs-custom-row input[type="text"] { flex: 1; min-width: 0; }
        .hs-name-row { display: flex; gap: 4px; align-items: center; margin: 4px 0; }
        .hs-name-row input[type="text"] { flex: 1; min-width: 0; }
        .hs-name-row span { opacity: .5; flex-shrink: 0; }
        .hs-btn-remove { padding: 2px 6px !important; font-size: 11px; }
        .hs-settings.hs-disabled .checkbox-label, .hs-settings.hs-disabled .subsettings, .hs-settings.hs-disabled select { opacity: .35; pointer-events: none; }
        .hs-settings .button[disabled] { opacity: .4; cursor: not-allowed; }
      `;
      document.head.appendChild(style);
    }

    function openDialog() {
      if (dialog) { dialog.style.display = 'block'; overlay.style.display = 'block'; renderDialog(); return; }

      overlay = document.createElement('div');
      overlay.className = 'hs-overlay';
      overlay.addEventListener('click', closeDialog);
      document.body.appendChild(overlay);

      dialog = document.createElement('div');
      dialog.className = 'hs-dialog';
      document.body.appendChild(dialog);

      renderDialog();
    }

    function closeDialog() {
      // Discard any unsaved changes
      workingSettings = loadForScope(currentScope);
      isDirty = false;
      if (dialog) dialog.style.display = 'none';
      if (overlay) overlay.style.display = 'none';
    }
    closeAO3Dialog = closeDialog;

    function renderDialog() {
      if (!dialog) return;
      let h = '<div style="position:relative"><h3>\u26A7 Headspace</h3><button id="hs-close" style="position:absolute;top:0;right:0;background:none;border:none;font-size:20px;cursor:pointer;color:inherit;opacity:.5;padding:0 4px" title="Close">\u2715</button></div>';
      h += '<div class="hs-settings">';
      h += buildSettingsHTML({ ao3: true });
      h += '</div>';
      dialog.innerHTML = h;
      bindPanelEvents(dialog, renderDialog);
    }

    function injectMenu() {
      // Find or create the #scriptconfig dropdown
      let scriptConfig = document.querySelector('#scriptconfig');
      if (!scriptConfig) {
        const nav = document.querySelector('nav[aria-label="Site"] ul.primary');
        if (!nav) { console.warn('[headspace] AO3 nav not found'); return; }

        scriptConfig = document.createElement('li');
        scriptConfig.className = 'dropdown';
        scriptConfig.id = 'scriptconfig';
        scriptConfig.setAttribute('aria-haspopup', 'true');
        scriptConfig.innerHTML = `
          <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a>
          <ul class="menu dropdown-menu"></ul>`;
        // Insert before the search li
        const searchLi = nav.querySelector('li.search');
        if (searchLi) nav.insertBefore(scriptConfig, searchLi);
        else nav.appendChild(scriptConfig);
      }

      const menu = scriptConfig.querySelector('ul.menu, ul.dropdown-menu');
      if (!menu) return;

      // Add our entry if not already present
      if (!document.querySelector('#opencfg_headspace')) {
        const li = document.createElement('li');
        li.innerHTML = '<a href="javascript:void(0);" id="opencfg_headspace">Headspace</a>';
        menu.appendChild(li);
        li.querySelector('a').addEventListener('click', (e) => {
          e.preventDefault();
          openDialog();
        });
      }
    }

    injectStyles();
    injectMenu();
    return { renderPanel: renderDialog, ensureInjected: injectMenu };
  }

  // =====================================================================
  //  INIT
  // =====================================================================
  async function init() {
    const adapter = SITE === 'lit' ? initLiterotica() : initAO3();

    await loadCompromise();
    adapter.renderPanel();

    const settings = getActive();
    if (settings.enabled && (settings.changePronouns || settings.changeNames)) {
      setTimeout(() => { applyReplacements(); markClean(); }, 100);
    }

    // Literotica loads pages dynamically — observe for new content.
    // AO3 content is static, so no observer needed (and it causes selection issues).
    if (SITE === 'lit') {
      let debounce;
      let isReplacing = false;
      let lastURL = location.href;

      const safeApply = () => {
        isReplacing = true;
        applyReplacements();
        setTimeout(() => { isReplacing = false; }, 50);
      };

      // Watch for DOM changes (new story paragraphs loaded)
      const observer = new MutationObserver(() => {
        // Re-assert our tab. It may not have existed when we first ran (the tab
        // widget renders after page load), or the SPA may have replaced it on a
        // page swap. injectTab is idempotent, so this is a cheap no-op once in.
        adapter.ensureInjected();

        if (isReplacing) return;

        // Detect SPA page navigation via URL change
        if (location.href !== lastURL) {
          lastURL = location.href;
          // New page: clear original-text cache so fresh content gets processed
          document.querySelectorAll('[data-hs-original]').forEach(el => {
            delete el.dataset.hsOriginal;
          });
        }

        clearTimeout(debounce);
        debounce = setTimeout(() => {
          const s = getActive();
          if (s.enabled && (s.changePronouns || s.changeNames)) safeApply();
        }, 300);
      });

      // Observe document.body to catch all content swaps across the SPA
      observer.observe(document.body, { childList: true, subtree: true });

      // The observer only fires on future mutations; if the tab widget is
      // already present (or settles with no further changes), inject now.
      adapter.ensureInjected();
    }
  }

  if (document.readyState === 'complete' || document.readyState === 'interactive') {
    setTimeout(init, 200);
  } else {
    window.addEventListener('DOMContentLoaded', () => setTimeout(init, 200));
  }
})();