Headspace

Customize character names and pronouns on fiction sites

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Headspace
// @namespace    headspace
// @version      1.0
// @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 getStoryTargets() {
    if (SITE === 'lit') {
      let t = document.querySelectorAll('div[class*="aa_ht"] p');
      if (!t.length) t = document.querySelectorAll('.panel.article p');
      if (!t.length) t = document.querySelectorAll('div[class*="reading"] p, div[class*="content"] p');
      return t;
    }
    if (SITE === 'ao3') {
      let t = document.querySelectorAll('#workskin .userstuff p');
      if (!t.length) t = document.querySelectorAll('.userstuff p');
      return t;
    }
    return [];
  }

  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 = `
        /* :has() tab integration */
        ._widget__tab_1n1dq_51:has(._tab__list_1n1dq_51 input[id="${TAB_ID}"]:checked)
          ._tab__pane_1n1dq_875[data-tab="${TAB_DATA}"] {
          display: block !important;
          background-color: rgb(255, 255, 255);
          padding: 10px 5px;
          width: 200px;
        }
        .dark_theme ._widget__tab_1n1dq_51:has(._tab__list_1n1dq_51 input[id="${TAB_ID}"]:checked)
          ._tab__pane_1n1dq_875[data-tab="${TAB_DATA}"] {
          background-color: rgb(16, 16, 16);
        }
        ._widget__tab_1n1dq_51:has(._tab__list_1n1dq_51 input[id="mobile_${TAB_ID}"]:checked)
          ._tab__pane_1n1dq_875[data-tab="${TAB_DATA}"] {
          display: block !important;
          background-color: rgb(255, 255, 255);
          padding: 10px 5px;
          width: 200px;
        }
        .dark_theme ._widget__tab_1n1dq_51:has(._tab__list_1n1dq_51 input[id="mobile_${TAB_ID}"]:checked)
          ._tab__pane_1n1dq_875[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() {
      const tabNav = document.querySelector('ul._tab__list_1n1dq_51._tab__list__nav_1n1dq_1069');
      if (!tabNav) { console.warn('[headspace] Tab nav not found'); return; }

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

      const tabContent = document.querySelector('div._tab__content_1n1dq_875');
      if (!tabContent) { console.warn('[headspace] Tab content not found'); return; }

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

      renderPanel();
    }

    injectStyles();
    injectTab();
    return { renderPanel };
  }

  // =====================================================================
  //  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 };
  }

  // =====================================================================
  //  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(() => {
        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 });
    }
  }

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