☰

Anime GF Format Fix

Fixes Anime GF's chat formatting, mainly for Plain RP and Text, which still show RP-style *asterisk* narration in bold italics. Shows narration as plain text, keeps real emphasis and extra spaces, fixes code blocks, colors curly-quote dialogue, adds bold and code colors, and has a Roleplay option.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         Anime GF Format Fix
// @namespace    animegf-plain-narration
// @version      1.2.2
// @description  Fixes Anime GF's chat formatting, mainly for Plain RP and Text, which still show RP-style *asterisk* narration in bold italics. Shows narration as plain text, keeps real emphasis and extra spaces, fixes code blocks, colors curly-quote dialogue, adds bold and code colors, and has a Roleplay option.
// @author       .moonsight.
// @copyright    2026, .moonsight.
// @license      MIT
// @match        https://www.anime.gf/*
// @match        https://anime.gf/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @run-at       document-start
// @noframes
// ==/UserScript==

/*
 * Anime GF Format Fix
 *
 * Fixes how Anime GF's chat formats messages. It is made mainly for the
 * Plain RP and Text formats in Response Settings: replies are saved with
 * plain narration, but the chat display wraps narration and actions back in
 * *asterisks* and shows them in bold italics, RP style, unlike the settings
 * preview. It also drops emphasis inside dialogue, squashes runs of spaces,
 * adds a stray space after italic words ("her ." for "*her*."), breaks
 * ```code blocks```, and only colors dialogue in straight quotes.
 *
 * With Plain RP or Text, narration and actions show as plain text, real
 * emphasis (a stressed word in dialogue or narration) stays italic, and
 * bold, dialogue and everything else keep the site's look unless you change
 * them in the menu. With Roleplay, turn on Italics as written: italics stay
 * where the reply has asterisks, and only the lines the site italicized on
 * its own, like status lines, show plain.
 *
 * It only changes how messages look. The text, the saved chat and what is
 * sent to the AI are never touched.
 *
 * Menu toggles (open your userscript manager's menu on the site):
 *   Format fix                ON/OFF   master switch: off turns everything
 *                                      below off too, without losing them
 *   Keep emphasis             ON/OFF   stressed words stay italic
 *   Italics as written        ON/OFF   off by default, for Roleplay: italics
 *     (for Roleplay)                   exactly where the reply has asterisks,
 *                                      so lines the site italicized on its
 *                                      own, like status lines, show plain
 *   Dialogue emphasis color   italics/dialogue
 *                                      color of stressed words inside dialogue
 *   Dialogue emphasis         ON/OFF   experimental, off by default: brings
 *     (experimental)                   back the emphasis the site drops from
 *                                      dialogue, *italic* in the italics
 *                                      color and **bold** in bold, also in
 *                                      dialogue with `code` in it
 *   Keep spacing              ON/OFF   shows runs of spaces and tabs as
 *                                      written, so status boxes and other
 *                                      aligned layouts line up
 *   Curly quote dialogue      ON/OFF   gives dialogue in curly quotes, double
 *                                      or single, the quotes color the site
 *                                      only gives to straight quotes
 *   Colors...                          pick colors for bold and for code
 *                                      (text in single or triple backticks),
 *                                      yours and the character's, with a
 *                                      picker laid out like the site's
 * Changes apply at once and are remembered.
 *
 * Not affiliated with Anime GF.
 */

// Matches the whole site because opening a chat from inside the site may not
// reload the page, and a script limited to /chat would then never load. The
// rules only affect chat messages, so other pages are unchanged.

(function () {
  'use strict';

  const STYLE_ID = 'agf-plain-narration';

  // The site renders Markdown, so *narration* and *emphasis* both arrive as
  // <em class="font-semibold italic text-chat-character-message-text-italics">.
  // The substring match also covers the user-message variant,
  // text-chat-user-message-text-italics. <strong> from **text** keeps its
  // weight; only its color can change, and only through Colors.
  const EM_SEL = 'em[class*="-message-text-italics"]';
  // The site wraps dialogue in AI replies in its own tag.
  const QUOTES_SEL = '[class*="-message-text-quotes"]';
  const BLOCK_SEL = 'p, li, blockquote, td, th, h1, h2, h3, h4, h5, h6';
  const MARK = 'data-agf-emphasis';

  // Above this, a span between two plain words reads as narration, not emphasis.
  const MAX_EMPHASIS_WORDS = 4;

  const settings = {
    enabled: GM_getValue('enabled', true),
    keepEmphasis: GM_getValue('keepEmphasis', true),
    dialogueEmphasisPlainColor: GM_getValue('dialogueEmphasisPlainColor', false),
    recoverEmphasis: GM_getValue('recoverEmphasis', false),
    keepSpacing: GM_getValue('keepSpacing', true),
    curlyDialogue: GM_getValue('curlyDialogue', true),
    italicsAsWritten: GM_getValue('italicsAsWritten', false),
    boldColorCharacter: GM_getValue('boldColorCharacter', ''),
    boldColorUser: GM_getValue('boldColorUser', ''),
    codeColorCharacter: GM_getValue('codeColorCharacter', ''),
    codeColorUser: GM_getValue('codeColorUser', '')
  };

  // The site lets you color base text, italics, quotes and OOC, but not bold.
  // An empty value means bold keeps whatever color the site gives it. The
  // order matches the site's Chat Appearance: user first, then character.
  const BOLD_COLORS = [
    { key: 'boldColorUser', who: 'user', label: 'Your bold' },
    { key: 'boldColorCharacter', who: 'character', label: 'Character bold' }
  ];
  // Code: text in single backticks, and code blocks in triple backticks. The
  // site's monospace look stays; only the text color changes. Unset, the
  // site's color stays.
  const CODE_COLORS = [
    { key: 'codeColorUser', who: 'user', label: 'Your code' },
    { key: 'codeColorCharacter', who: 'character', label: 'Character code' }
  ];
  const HEX_COLOR = /^#[0-9a-f]{6}$/i;

  // Stored values are checked before they reach the style sheet.
  function savedColor(key) {
    return HEX_COLOR.test(settings[key]) ? settings[key] : '';
  }

  // Tags that stay italic are marked; unmarked ones show as plain text.
  // Keep emphasis marks stressed words found by the text rules, and Italics
  // as written marks words the reply put in asterisks. Each works on its own.
  function wantMarks() {
    return settings.enabled && (settings.keepEmphasis || settings.italicsAsWritten);
  }

  // Experimental. The site removes *emphasis* inside dialogue before display,
  // but the original reply text still sits in the message component's props.
  // For a dialogue line that had emphasis, the site's own text is hidden (left
  // in place, never edited) and the script shows its own copy of the line with
  // the emphasized words in the site's italics tag. React never sees its text
  // change, and the copy is rebuilt before paint whenever the line updates.
  const COPY_CLASS = 'agf-dialogue-copy';
  const COPY_ATTR = 'data-agf-copy';
  // The site's italics tag for the side that wrote the message.
  function italicsClasses(who) {
    return 'font-semibold italic text-chat-' + (who || 'character') + '-message-text-italics';
  }

  function wantRecovery() {
    return wantMarks() && settings.recoverEmphasis;
  }

  // Two fixes to the white space of messages, both made by rewriting the
  // value of the site's text nodes in place, since CSS can do neither.
  //
  // Runs of spaces (Keep spacing). Browsers squash a run of spaces into one,
  // and show a tab as one space, so a status box or other layout lined up
  // with spaces falls apart. The site pads its own tags with line breaks, so
  // telling the browser to keep all white space (white-space: pre-wrap) would
  // show that padding too. Instead, each run of two or more spaces or tabs
  // between visible characters is rewritten with characters the browser does
  // not squash: spaces alternate with no-break spaces, which keeps the width
  // and still lets long lines wrap, and a tab becomes an em space, about four
  // spaces wide. Runs touching a line break are left alone, which is where
  // the site's padding is. Indentation at the start of a line never reaches
  // the page: Markdown drops it.
  //
  // Stray spaces (always, with Format fix on). The site writes each italics
  // and dialogue tag as a line break, the text, and another line break. A
  // line break shows as a space, so "*her*." shows as "her ." and "*un*real"
  // as "un real". Where a padding line break touches a visible character
  // outside its tag, it becomes a zero-width space, which shows nothing.
  // Where the reply had a real space, the padding sits next to it and is
  // left alone.
  //
  // Each character maps to exactly one, so the text keeps its length. React
  // only rewrites a text node when its own text changes, and then the fix is
  // made again before paint. The original is kept and put back when a
  // setting or Format fix is turned off.
  const MESSAGE_SEL = '[class*="-message-text"]';
  const MESSAGE_SCHEME_SEL = '[class*="text-chat-character-message-text"], [class*="text-chat-user-message-text"]';
  // Skipped: edit boxes, code, and anything that already keeps its spaces,
  // such as the hidden copy of the text the site sizes its edit box with.
  const SPACING_SKIP = 'textarea, input, pre, code, script, style, [class*="whitespace-pre"], ' +
    '[contenteditable]:not([contenteditable="false"])';
  // The character before the run is captured and put back rather than checked
  // with a lookbehind, which older Safari cannot parse at all.
  const SPACE_RUN = /(^|[^ \t\r\n])([ \t]{2,}|\t)(?![ \t\r\n])/g;
  const HAS_RUN = /[ \t]{2}|\t/;
  const ZERO_WIDTH = '\u200B';
  const textFixes = new WeakMap();
  let textFixApplied = false;

  function wantTextFix() {
    return settings.enabled;
  }

  function spacedRun(run) {
    return Array.from(run, (ch, i) => {
      if (ch === '\t') return '\u2003';
      return i % 2 ? ' ' : '\u00A0';
    }).join('');
  }

  // True when this text node holds the script's own rewrite.
  function isOwnSpacing(node) {
    const known = textFixes.get(node);
    return !!known && known.fixed === node.nodeValue;
  }

  // Tags the site pads with line breaks, plus the script's dialogue copy,
  // which is built from the padded text.
  function padded(el) {
    return el.matches(EM_SEL + ', ' + QUOTES_SEL + ', .' + COPY_CLASS);
  }

  // The nearest character outside a tag, before or after it, skipping line
  // breaks and zero-width spaces, which are padding. Null at a <br> or at
  // the edge of the paragraph, where nothing is glued on.
  function charOutside(el, forward) {
    const block = el.closest(BLOCK_SEL) || messageBox(el);
    if (!block) return null;
    const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
    if (forward) {
      let last = el;
      while (last.lastChild) last = last.lastChild;
      walker.currentNode = last;
    } else {
      walker.currentNode = el;
    }
    let node;
    while ((node = forward ? walker.nextNode() : walker.previousNode())) {
      if (node.nodeType === 1) {
        if (node.tagName === 'BR') return null;
        continue;
      }
      if (!forward && el.contains(node)) continue;
      const text = node.nodeValue;
      for (let k = 0; k < text.length; k++) {
        const ch = forward ? text[k] : text[text.length - 1 - k];
        if (ch !== '\n' && ch !== ZERO_WIDTH) return ch;
      }
    }
    return null;
  }

  function glued(ch) {
    return ch !== null && !/\s/.test(ch);
  }

  // The text a node should show, worked out from the site's own value.
  function wantedText(node, orig) {
    if (fenceHidden.has(node)) return '';
    const cut = lineCuts.get(node);
    let text = cut ? orig.slice(cut[0], cut[1]) : orig;
    if (settings.keepSpacing && HAS_RUN.test(text)) {
      text = text.replace(SPACE_RUN, (match, before, run) => before + spacedRun(run));
    }
    const parent = node.parentElement;
    if (padded(parent)) {
      // The padding can be its own one-character node or part of the text.
      // A node that is the only child holds both ends, so a lone line break
      // there is the opening one. In a dialogue tag holding the script's
      // copy, the site's text ends where the copy starts, and the copy looks
      // outside the dialogue tag, so the hidden line and the copy always get
      // the same fix.
      const kids = Array.from(parent.childNodes).filter(n => !(n.nodeType === 1 && n.classList.contains(COPY_CLASS)));
      const host = parent.classList.contains(COPY_CLASS) ? parent.parentElement : parent;
      const opening = node === kids[0];
      const closing = node === kids[kids.length - 1];
      if (opening && text[0] === '\n' && glued(charOutside(host, false))) {
        text = ZERO_WIDTH + text.slice(1);
      }
      const end = text.length - 1;
      if (closing && (end > 0 || !opening) && text[end] === '\n' && glued(charOutside(host, true))) {
        text = text.slice(0, end) + ZERO_WIDTH;
      }
    }
    return text;
  }

  function fixText(node) {
    const value = node.nodeValue;
    const known = textFixes.get(node);
    // Empty text is skipped, unless it is text the script emptied itself.
    if (!value && !known) return;
    if (!known && !fenceHidden.has(node) && !lineCuts.has(node) && !HAS_RUN.test(value) &&
      value[0] !== '\n' && value[value.length - 1] !== '\n') return;
    const parent = node.parentElement;
    if (!parent || !parent.closest(MESSAGE_SEL) || parent.closest(SPACING_SKIP)) return;
    const orig = known && known.fixed === value ? known.orig : value;
    const wanted = wantedText(node, orig);
    if (wanted === orig) {
      if (known) textFixes.delete(node);
      if (value !== orig) node.nodeValue = orig;
      return;
    }
    textFixes.set(node, { orig, fixed: wanted });
    textFixApplied = true;
    if (value !== wanted) node.nodeValue = wanted;
  }

  function fixTextWithin(root) {
    if (root.nodeType === 3) {
      fixText(root);
      return;
    }
    if (root.nodeType !== 1) return;
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
    let node;
    while ((node = walker.nextNode())) fixText(node);
  }

  // Puts back the original text wherever a rewrite is still in place.
  function restoreText() {
    if (!textFixApplied || !document.body) return;
    textFixApplied = false;
    const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
    let node;
    while ((node = walker.nextNode())) {
      const known = textFixes.get(node);
      if (!known) continue;
      textFixes.delete(node);
      if (known.fixed === node.nodeValue) node.nodeValue = known.orig;
    }
  }

  // Code blocks. The site's display puts its italics asterisks around a
  // ```-fenced block like around any other text, so the fence breaks: the
  // opening line shows as "*```", the code shows as plain text, and the
  // closing line, now "```*", opens a new code box with nothing in it. When
  // a reply ends with a fenced block, its content is read from the original
  // reply (see rawMessageText) and shown in that empty box, and the broken
  // lines above it are hidden: the site's tags by an attribute, its bare text
  // by emptying the text node through the text fix above, which puts the
  // text back when this stops. A block with more text after it is left as
  // the site shows it, since the broken box takes in the rest of the reply.
  const FENCE_COPY_CLASS = 'agf-code-copy';
  const HIDE_ATTR = 'data-agf-hide';
  const fenceHidden = new Set();
  const fenceState = new WeakMap();

  function wantFences() {
    return settings.enabled;
  }

  // The content of a fenced block that ends the reply, or null.
  function finalFence(raw) {
    const lines = raw.replace(/\s+$/, '').split('\n');
    if (lines.length < 2 || !/^[ \t]*```[ \t]*$/.test(lines[lines.length - 1])) return null;
    for (let i = lines.length - 2; i >= 0; i--) {
      if (/^[ \t]*```/.test(lines[i])) return lines.slice(i + 1, lines.length - 1).join('\n');
    }
    return null;
  }

  // What to show and hide in one message, or null when it has no broken
  // block at its end.
  function fencePlan(box) {
    const pre = Array.from(box.querySelectorAll('pre')).pop();
    if (!pre || pre.nextElementSibling) return null;
    const p = pre.previousElementSibling;
    if (!p || p.tagName !== 'P') return null;
    const code = pre.querySelector('code') || pre;
    const siteText = Array.from(code.childNodes)
      .filter(n => !(n.nodeType === 1 && n.classList.contains(FENCE_COPY_CLASS)))
      .map(n => n.textContent).join('');
    if (siteText.trim()) return null;
    let marker = null;
    const walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
    let n;
    while ((n = walker.nextNode())) {
      const shown = fenceHidden.has(n) && textFixes.has(n) ? textFixes.get(n).orig : n.nodeValue;
      if (shown.includes('```')) marker = n;
    }
    if (!marker) return null;
    let raw = null;
    try {
      raw = rawMessageText(pre);
    } catch (_) {
      // a prop getter on the site's side threw; leave the block as it is
    }
    const content = raw && finalFence(raw);
    if (content === null || content === undefined) return null;
    // Everything from the marker's line to the end of the paragraph, plus the
    // blank line before it, which would otherwise end the paragraph early.
    let top = marker;
    while (top.parentNode !== p) top = top.parentNode;
    const hide = [];
    for (let k = top; k; k = k.nextSibling) hide.push(k);
    for (let k = top.previousSibling; k; k = k.previousSibling) {
      const blank = k.nodeType === 1 ? k.tagName === 'BR' : !visible(k.nodeValue.replace(/\u00A0/g, ' '));
      if (!blank) break;
      hide.push(k);
    }
    return { code, content, hide: new Set(hide) };
  }

  function hideNode(node) {
    if (node.nodeType === 1) {
      if (!node.hasAttribute(HIDE_ATTR)) node.setAttribute(HIDE_ATTR, '');
    } else if (node.nodeType === 3 && !fenceHidden.has(node)) {
      fenceHidden.add(node);
      fixText(node);
    }
  }

  function showNode(node) {
    if (node.nodeType === 1) {
      if (node.hasAttribute(HIDE_ATTR)) node.removeAttribute(HIDE_ATTR);
    } else if (fenceHidden.delete(node)) {
      fixText(node);
    }
  }

  // Shows, updates or undoes the rebuilt block for one message. It writes
  // only what differs, so the records its own writes cause find nothing to do.
  function syncFence(box) {
    const plan = wantFences() ? fencePlan(box) : null;
    const before = fenceState.get(box);
    if (before) {
      for (const node of before.hide) if (!plan || !plan.hide.has(node)) showNode(node);
      if (before.copy && (!plan || before.copy.parentNode !== plan.code)) before.copy.remove();
    }
    if (!plan) {
      fenceState.delete(box);
      return;
    }
    plan.hide.forEach(hideNode);
    let copy = before && before.copy && before.copy.parentNode === plan.code ? before.copy : null;
    if (!copy) {
      copy = document.createElement('span');
      copy.className = FENCE_COPY_CLASS;
    }
    if (copy.textContent !== plan.content) copy.textContent = plan.content;
    if (copy.parentNode !== plan.code) plan.code.appendChild(copy);
    fenceState.set(box, { hide: plan.hide, copy });
  }

  function messageBoxes(root) {
    const sel = 'div[class*="text-chat-character-message-text"], div[class*="text-chat-user-message-text"]';
    const el = root.nodeType === 1 ? root : root.parentElement;
    if (!el) return [];
    const own = el.closest(sel);
    if (own) return [own];
    return Array.from(el.querySelectorAll(sel));
  }

  function syncFences() {
    if (!document.body) return;
    messageBoxes(document.body).forEach(syncFence);
    // Text emptied in messages that have since left the page is forgotten,
    // and anything left over from a switched-off fix is put back.
    for (const node of Array.from(fenceHidden)) {
      if (!node.isConnected) fenceHidden.delete(node);
      else if (!wantFences()) showNode(node);
    }
    if (!wantFences()) {
      document.querySelectorAll('[' + HIDE_ATTR + ']').forEach(el => el.removeAttribute(HIDE_ATTR));
      document.querySelectorAll('.' + FENCE_COPY_CLASS).forEach(el => el.remove());
    }
  }

  // Comparisons with the original reply read the rewritten characters as the
  // spaces, tabs and line breaks they stand for.
  function plainSpacing(text) {
    return text.replace(/\u00A0/g, ' ').replace(/\u2003/g, '\t').replace(/\u200B/g, '\n');
  }

  // Trimmed text, with zero-width spaces counted as the padding they replace.
  function visible(text) {
    return text.replace(/\u200B/g, '').trim();
  }

  // The site puts dialogue in its quotes tag, with the quotes color, only when
  // it uses straight quotes ("). Dialogue in curly quotes (\u201C \u201D, or
  // single \u2018 \u2019) is left as plain text in the base color. That text is
  // painted with the CSS Custom Highlight API, which colors text without
  // touching the page. It can change the color, not the weight, so this
  // dialogue is not semibold like the site's own.
  const CURLY_SIDES = ['character', 'user'];
  const canHighlight = typeof Highlight === 'function' &&
    typeof CSS !== 'undefined' && !!CSS.highlights;
  const curlyColors = { character: '', user: '' };
  const curlyBlocks = new Map();

  function wantCurly() {
    return settings.enabled && settings.curlyDialogue && canHighlight;
  }

  function curlyName(who) {
    return 'agf-curly-' + who;
  }

  function sideOf(el) {
    return CURLY_SIDES.find(who => el.closest('[class*="text-chat-' + who + '-message-text"]')) || null;
  }

  // Reads the quotes color the site gives each side, which follows the
  // person's Chat Appearance settings, from a hidden tag that exists for a
  // moment inside a message of that side. Returns true if a color changed.
  function readSiteColors() {
    let changed = false;
    const probes = new Set();
    const read = (host, className, store, who) => {
      const probe = document.createElement('span');
      probe.className = className;
      probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none';
      probes.add(probe);
      host.appendChild(probe);
      const color = getComputedStyle(probe).color;
      probe.remove();
      if (color && color !== store[who]) {
        store[who] = color;
        changed = true;
      }
    };
    for (const who of CURLY_SIDES) {
      const host = document.querySelector('[class*="text-chat-' + who + '-message-text"]:not(em):not(span)');
      if (!host) continue;
      read(host, 'text-chat-' + who + '-message-text-quotes', curlyColors, who);
    }
    // The probe's own records are dropped; anything else is handled as usual.
    if (bodyObserver && probes.size) {
      const isProbe = n => probes.has(n);
      const others = bodyObserver.takeRecords().filter(r => !(r.type === 'childList' &&
        Array.from(r.addedNodes).concat(Array.from(r.removedNodes)).every(isProbe)));
      if (others.length) onBodyMutations(others);
    }
    return changed;
  }

  function isLetter(ch) {
    return !!ch && /[\p{L}\p{N}]/u.test(ch);
  }

  // Ranges covering the curly-quoted dialogue in one block, quote marks
  // included, as the site's own dialogue tag includes them. A line break ends
  // any quote left open, so a missing closing mark colors one line at most.
  // Inside double quotes, single quotes are only quoted words. A closing
  // single quote is the same character as an apostrophe, so it only closes
  // when no letter follows it ("Mel\u2019s" does not close).
  function curlyRanges(block, who) {
    const ranges = [];
    const keepBold = !!savedColor(who === 'user' ? 'boldColorUser' : 'boldColorCharacter');
    // Stressed words keep the italics color when that is what they show in.
    // With the dialogue color picked, or emphasis off, they get the dialogue
    // color like the words around them.
    const keepEmphasisColor = wantMarks() && !settings.dialogueEmphasisPlainColor;
    const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
      acceptNode: n => n.nodeType === 1 && (n.matches(QUOTES_SEL) || n.classList.contains(COPY_CLASS))
        ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT
    });
    let open = null;
    let node;
    while ((node = walker.nextNode())) {
      if (node.nodeType === 1) {
        if (node.tagName === 'BR') open = null;
        continue;
      }
      const parent = node.parentElement;
      const paint = !((keepEmphasisColor && parent.closest(EM_SEL + '[' + MARK + ']')) ||
        (keepBold && parent.closest('strong')));
      const text = node.nodeValue;
      let start = open ? 0 : -1;
      for (let i = 0; i < text.length; i++) {
        const ch = text[i];
        let closesHere = false;
        if (!open) {
          if (ch === '\u201C') open = 'double';
          else if (ch === '\u2018' && !isLetter(text[i - 1])) open = 'single';
          if (open) start = i;
        } else if (open === 'double' && ch === '\u201D') {
          closesHere = true;
        } else if (open === 'single' && ch === '\u2019' && !isLetter(text[i + 1])) {
          closesHere = true;
        }
        if (closesHere) {
          if (paint) ranges.push([node, start, i + 1]);
          open = null;
          start = -1;
        }
      }
      if (open && paint && start < text.length) ranges.push([node, start, text.length]);
    }
    return ranges.map(([n, a, b]) => {
      const range = document.createRange();
      range.setStart(n, a);
      range.setEnd(n, b);
      return range;
    });
  }

  // Chrome does not always repaint when ranges are added to a highlight that
  // is already registered, so each update registers a fresh one per side.
  function commitCurly() {
    for (const who of CURLY_SIDES) {
      const ranges = [];
      curlyBlocks.forEach(entry => {
        if (entry.who === who) ranges.push(...entry.ranges);
      });
      if (ranges.length) CSS.highlights.set(curlyName(who), new Highlight(...ranges));
      else CSS.highlights.delete(curlyName(who));
    }
  }

  // Works out one block's ranges; commitCurly puts them on screen.
  function paintCurlyBlock(block) {
    curlyBlocks.delete(block);
    if (!block.isConnected) return;
    const text = block.textContent;
    if (text.indexOf('\u201C') === -1 && text.indexOf('\u2018') === -1) return;
    const who = sideOf(block);
    if (!who) return;
    const ranges = curlyRanges(block, who);
    if (!ranges.length) return;
    curlyBlocks.set(block, { who, ranges });
    if (!curlyColors[who]) scheduleRecovery();
  }

  function blocksWithin(root) {
    const el = root.nodeType === 1 ? root : root.parentElement;
    if (!el) return [];
    const own = el.closest(BLOCK_SEL);
    if (own) return [own];
    return Array.from(el.querySelectorAll(BLOCK_SEL)).filter(b => !b.parentElement.closest(BLOCK_SEL));
  }

  // Full pass: works out every block again, so blocks that left the page drop
  // out, and picks up a changed quotes color.
  function paintCurlyDialogue() {
    if (!canHighlight) return;
    if (!wantCurly()) {
      curlyBlocks.clear();
      commitCurly();
      return;
    }
    if (!document.body) return;
    curlyBlocks.clear();
    blocksWithin(document.body).forEach(paintCurlyBlock);
    commitCurly();
  }

  // The site color the curly-quote coloring follows.
  function refreshSiteColors() {
    if (!document.body || !wantCurly()) return;
    if (readSiteColors()) ensureStyle();
  }

  // Every declaration is !important inside a layer that is declared before
  // any of the site's CSS. For important declarations the first declared
  // layer wins over later layers and over unlayered rules, so the site cannot
  // outrank these with specificity, !important, or its own layers. Only an
  // inline style="... !important" on the tag itself could.
  function buildCss() {
    if (!settings.enabled) return '';
    const marks = wantMarks();
    const narration = marks ? EM_SEL + ':not([' + MARK + '])' : EM_SEL;
    const rules = [
      // A browser's dark mode for websites lightens dark text as it paints it,
      // so a dark italics, quotes or bold color shows up pink or near white,
      // while the page still reports the chosen color. The chat is always
      // dark, and saying so makes Chromium leave its text alone; it also keeps
      // the browser's own controls inside messages, like the edit box, dark.
      // Chromium honors this while the device is in dark theme. A browser
      // that forces dark mode with the device in light theme cannot be
      // stopped from CSS.
      '  ' + MESSAGE_SCHEME_SEL + ' {',
      '    color-scheme: dark !important;',
      '  }',
      // The broken lines of a code block the script rebuilt.
      '  [' + HIDE_ATTR + '] {',
      '    display: none !important;',
      '  }',
      '  ' + EM_SEL + ' {',
      '    font-style: normal !important;',
      '    font-weight: inherit !important;',
      '  }',
      '  ' + narration + ' {',
      '    color: inherit !important;',
      '  }'
    ];
    if (marks) {
      // No color here, so emphasis keeps the site's italics color, in dialogue
      // and in narration alike.
      rules.push(
        '  ' + EM_SEL + '[' + MARK + '] {',
        '    font-style: italic !important;',
        '  }'
      );
      if (settings.dialogueEmphasisPlainColor) {
        rules.push(
          '  ' + EM_SEL + '[' + MARK + '="dialogue"] {',
          '    color: inherit !important;',
          '  }'
        );
      }
    }
    if (wantRecovery()) {
      // The site's text in a copied line shrinks to nothing and cannot be
      // selected; the copy carries the size, so a selection copies one line.
      rules.push(
        '  [' + COPY_ATTR + '] {',
        '    font-size: 0 !important;',
        '    user-select: none !important;',
        '  }',
        '  .' + COPY_CLASS + ' {',
        '    font-size: var(--agf-copy-size) !important;',
        '    user-select: text !important;',
        '  }'
      );
    }
    // curlyColors come from getComputedStyle, so they are plain color values.
    if (wantCurly()) {
      for (const who of CURLY_SIDES) {
        if (!curlyColors[who]) continue;
        rules.push(
          '  ::highlight(' + curlyName(who) + ') {',
          '    color: ' + curlyColors[who] + ' !important;',
          '    -webkit-text-fill-color: ' + curlyColors[who] + ' !important;',
          '  }'
        );
      }
    }
    // Code in single and triple backticks, matched by who wrote it like bold.
    // The rebuilt code block sits inside the site's code tag and follows it.
    for (const { key, who } of CODE_COLORS) {
      const color = savedColor(key);
      if (!color) continue;
      rules.push(
        '  [class*="text-chat-' + who + '-message-text"] code {',
        '    color: ' + color + ' !important;',
        '    -webkit-text-fill-color: ' + color + ' !important;',
        '  }'
      );
    }
    // The text fill is set too: when a page sets -webkit-text-fill-color, that
    // is what gets painted, and "color" alone changes nothing on screen.
    // Each message's text classes carry "text-chat-character-message-text" or
    // "text-chat-user-message-text", so bold inside them, including bold the
    // dialogue copy brings back, is matched by who wrote it. Italics nested in
    // bold keep the italics color, since their own class sets it.
    for (const { key, who } of BOLD_COLORS) {
      const color = savedColor(key);
      if (!color) continue;
      rules.push(
        '  [class*="text-chat-' + who + '-message-text"] strong {',
        '    color: ' + color + ' !important;',
        '    -webkit-text-fill-color: ' + color + ' !important;',
        '  }'
      );
    }
    return '@layer ' + STYLE_ID + ' {\n' + rules.join('\n') + '\n}';
  }

  // Inserted as the first child so this layer is declared before the site's.
  function ensureStyle() {
    let style = document.getElementById(STYLE_ID);
    if (!style) {
      style = document.createElement('style');
      style.id = STYLE_ID;
      const parent = document.head || document.documentElement;
      parent.insertBefore(style, parent.firstChild);
    }
    const css = buildCss();
    if (style.textContent !== css) style.textContent = css;
  }

  // Straight quotes toggle; curly and angle quotes say which side they are on.
  // Single quotes are ignored because apostrophes use the same character.
  function scanQuotes(text, inside) {
    for (const ch of text) {
      if (ch === '"') inside = !inside;
      else if (ch === '\u201C' || ch === '\u00AB') inside = true;
      else if (ch === '\u201D' || ch === '\u00BB') inside = false;
    }
    return inside;
  }

  // Counts quotes only in plain text before the tag, so quotes written inside
  // narration (a sign being read, a height in inches) cannot flip the state.
  // The site often puts a whole reply in one paragraph with <br> between
  // lines, so a line break ends a quote left open: a missing closing mark
  // cannot turn the rest of the reply into dialogue.
  function insideDialogue(em, block) {
    const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
    let inside = false;
    let node;
    while ((node = walker.nextNode())) {
      if (!(em.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_PRECEDING)) break;
      if (node.nodeType === 1) {
        if (node.tagName === 'BR') inside = false;
        continue;
      }
      const parent = node.parentElement;
      if (parent && parent.closest('.' + COPY_CLASS)) continue;
      const owner = parent && parent.closest(EM_SEL);
      if (owner && block.contains(owner)) continue;
      inside = scanQuotes(node.nodeValue, inside);
    }
    return inside;
  }

  // Emphasis outside quotes, as in: He was *so* tired. Needs plain text on both
  // sides, a word right before, and a lowercase word right after. The site pads
  // tags with newlines, which display as spaces, so any whitespace counts as a
  // space here. A real line break is a <br>, which is not text and fails the test.
  function midSentence(em) {
    const prev = em.previousSibling;
    const next = em.nextSibling;
    if (!prev || prev.nodeType !== 3 || !next || next.nodeType !== 3) return false;
    const words = em.textContent.trim().split(/\s+/).filter(Boolean).length;
    if (words === 0 || words > MAX_EMPHASIS_WORDS) return false;
    return /[\p{L}\p{N}]\s*$/u.test(prev.nodeValue) && /^\s*\p{Ll}/u.test(next.nodeValue);
  }

  function hasOwnText(el) {
    return Array.from(el.childNodes).some(n => n.nodeType === 3 && visible(n.nodeValue));
  }

  // Returns "dialogue" for emphasis inside dialogue, "text" for emphasis
  // anywhere else, and null for narration. A tag inside the site's dialogue
  // tag is emphasis by structure. A tag inside a narration tag is emphasis
  // only when that narration has words of its own around it: when the AI
  // already wrapped narration in asterisks and a quote splits it, the site
  // adds its own on top, and the result is a narration tag holding nothing
  // but the narration pieces, which are not emphasis. The text rules are the
  // fallback for the rest.
  function emphasisKind(em) {
    const block = em.closest(BLOCK_SEL) || em.parentElement;
    if (!block) return null;
    if (!em.closest(QUOTES_SEL) && wantWritten()) {
      const written = writtenItalic(em);
      if (written === false) return null;
      if (written === true) return insideDialogue(em, block) ? 'dialogue' : 'text';
    }
    // From here on the text rules guess, which is what Keep emphasis is for.
    // A tag inside the site's dialogue tag is not a guess, so it always counts.
    if (em.closest(QUOTES_SEL)) return 'dialogue';
    if (!settings.keepEmphasis) return null;
    if (insideDialogue(em, block)) return 'dialogue';
    const parent = em.parentElement;
    const outer = parent && parent.closest(EM_SEL);
    if ((outer && hasOwnText(outer)) || midSentence(em)) return 'text';
    return null;
  }

  // Italics as written. The site's display adds asterisks around every piece
  // of a line outside quotes that does not already have them, so in every
  // format text the reply never put in asterisks shows up italic: narration
  // in Plain RP and Text, and status lines like "**HP:** 10" in Roleplay.
  // The reply as written is in the message component's props (see
  // rawMessageText). It is read to find which words really had asterisks,
  // and only those stay italic. When the original cannot be read, or a piece
  // of text cannot be found in it, the text rules above decide instead.
  // Off by default: in Plain RP and Text the AI sometimes wraps narration in
  // asterisks anyway, and there the narration should still show plain.
  function wantWritten() {
    return settings.enabled && settings.italicsAsWritten;
  }

  const writtenCache = new Map();

  function squashSpace(text) {
    return plainSpacing(text).replace(/\s+/g, ' ');
  }

  // The reply with its asterisk markers removed, white space squashed as the
  // page shows it, and for each character whether it sat inside italics.
  // Asterisk runs pair up as Markdown pairs them, closely enough for this:
  // a run can open when a non-space follows it and close when a non-space
  // comes before it, and each closer takes the nearest open run. One
  // asterisk on each side is italic, two bold, three both. Runs that never
  // pair stay in the text, as the page shows them.
  function writtenItalics(raw) {
    let entry = writtenCache.get(raw);
    if (entry) return entry;
    const marker = new Uint8Array(raw.length);
    const depth = new Int32Array(raw.length + 1);
    const open = [];
    for (const m of raw.matchAll(/\*+/g)) {
      const run = { start: m.index, count: m[0].length, left: m[0].length };
      const before = raw[m.index - 1];
      const after = raw[m.index + m[0].length];
      const canOpen = !!after && !/\s/.test(after);
      const canClose = !!before && !/\s/.test(before);
      let closedFrom = 0;
      while (canClose && run.left > 0 && open.length) {
        const opener = open[open.length - 1];
        const use = run.left >= 2 && opener.left >= 2 ? 2 : 1;
        // The opener gives up its innermost asterisks, the closer its first.
        for (let k = 0; k < use; k++) {
          marker[opener.start + opener.left - 1 - k] = 1;
          marker[run.start + closedFrom + k] = 1;
        }
        if (use === 1) {
          depth[opener.start + opener.count] += 1;
          depth[run.start] -= 1;
        }
        opener.left -= use;
        run.left -= use;
        closedFrom += use;
        if (opener.left === 0) open.pop();
      }
      if (run.left > 0 && canOpen) {
        run.start += closedFrom;
        run.count -= closedFrom;
        open.push(run);
      }
    }
    let plain = '';
    const italic = [];
    let level = 0;
    let lastSpace = false;
    for (let i = 0; i < raw.length; i++) {
      level += depth[i];
      if (marker[i]) continue;
      const ch = raw[i];
      if (/\s/.test(ch)) {
        if (lastSpace) continue;
        plain += ' ';
        italic.push(level > 0);
        lastSpace = true;
      } else {
        plain += ch;
        italic.push(level > 0);
        lastSpace = false;
      }
    }
    entry = { plain, italic };
    if (writtenCache.size >= MAX_CACHED_TEXTS) writtenCache.clear();
    writtenCache.set(raw, entry);
    return entry;
  }

  // The message box an element sits in, for placing text within the reply.
  function messageBox(el) {
    return el.closest('div[class*="text-chat-character-message-text"], div[class*="text-chat-user-message-text"]');
  }

  // How far into its message each text node starts, in squashed characters.
  // Worked out once per message for each batch of updates: the page does not
  // change while a batch is handled, and the table is dropped right after.
  let offsetTables = null;

  function offsetsFor(box) {
    if (!offsetTables) {
      offsetTables = new Map();
      queueMicrotask(() => {
        offsetTables = null;
      });
    }
    let table = offsetTables.get(box);
    if (table) return table;
    table = new Map();
    const walker = document.createTreeWalker(box, NodeFilter.SHOW_TEXT);
    let length = 0;
    let n;
    while ((n = walker.nextNode())) {
      table.set(n, length);
      length += squashSpace(n.nodeValue).length;
    }
    offsetTables.set(box, table);
    return table;
  }

  // Where a piece of page text sits in the plain reply. When it appears more
  // than once, the copy nearest to how far into the message the piece is
  // on the page wins.
  function findInWritten(written, needle, node) {
    const hits = [];
    let at = written.plain.indexOf(needle);
    while (at !== -1 && hits.length < 64) {
      hits.push(at);
      at = written.plain.indexOf(needle, at + 1);
    }
    if (hits.length <= 1) return hits.length ? hits[0] : -1;
    const box = messageBox(node.parentElement);
    const expected = box ? offsetsFor(box).get(node) : undefined;
    if (expected === undefined) return hits[0];
    return hits.reduce((best, hit) => Math.abs(hit - expected) < Math.abs(best - expected) ? hit : best);
  }

  // True when most of the tag's own words had asterisks in the reply, false
  // when they did not, and null when that cannot be told.
  function writtenItalic(em) {
    let raw = null;
    try {
      raw = rawMessageText(em);
    } catch (_) {
      // a prop getter on the site's side threw; let the text rules decide
    }
    if (!raw) return null;
    const written = writtenItalics(raw);
    // The tag's own text, not the text of tags inside it, which are judged
    // on their own. A tag with no words of its own that wraps other italics
    // tags or dialogue is only a wrapper: italic, it would slant the dialogue
    // inside it too, so it stays plain and the tags inside decide. Any other
    // tag with no words of its own, such as one holding only bold, is judged
    // by all of its text.
    let nodes = Array.from(em.childNodes).filter(n => n.nodeType === 3 && visible(n.nodeValue));
    if (!nodes.length && em.querySelector(EM_SEL + ', ' + QUOTES_SEL)) return false;
    if (!nodes.length) {
      const walker = document.createTreeWalker(em, NodeFilter.SHOW_TEXT);
      let n;
      while ((n = walker.nextNode())) if (visible(n.nodeValue)) nodes.push(n);
    }
    let found = 0;
    let italicChars = 0;
    let chars = 0;
    for (const node of nodes) {
      const needle = squashSpace(node.nodeValue).trim();
      if (!needle) continue;
      const at = findInWritten(written, needle, node);
      if (at === -1) continue;
      found++;
      for (let i = at; i < at + needle.length; i++) {
        if (written.plain[i] === ' ') continue;
        chars++;
        if (written.italic[i]) italicChars++;
      }
    }
    if (!found || !chars) return null;
    return italicChars * 2 >= chars;
  }

  // Sets or removes one data attribute. React ignores attributes it did not
  // set, and the text and structure of the message are never changed.
  function classify(em) {
    const kind = emphasisKind(em);
    if (kind === em.getAttribute(MARK)) return;
    if (kind) em.setAttribute(MARK, kind);
    else em.removeAttribute(MARK);
  }

  function classifyWithin(root) {
    if (root.matches(EM_SEL)) classify(root);
    root.querySelectorAll(EM_SEL).forEach(classify);
  }

  // The message component's props hold the reply as written, with the
  // asterisks inside dialogue that the display drops. In Firefox, a userscript
  // manager can run the script behind an Xray wrapper that hides properties
  // the page's own code set on elements; wrappedJSObject reaches past it.
  // Chromium has no such wrapper, and the element is read directly.
  function rawMessageText(el) {
    const host = el.wrappedJSObject || el;
    const key = Object.keys(host).find(k => k.startsWith('__reactFiber$'));
    let fiber = key ? host[key] : null;
    for (let depth = 0; fiber && depth < 30; depth++, fiber = fiber.return) {
      const props = fiber.memoizedProps;
      if (!props || typeof props !== 'object') continue;
      if (props.message && typeof props.message.text === 'string') return props.message.text;
      if (typeof props.displayText === 'string') return props.displayText;
    }
    return null;
  }

  // For each quote in the raw text that had *emphasis* or `code`, the quote
  // as displayed (asterisks and backticks removed) and where the emphasized
  // words and the code sit inside it.
  const quoteCache = new Map();
  const MAX_CACHED_TEXTS = 200;

  function emphasizedQuotes(raw) {
    let list = quoteCache.get(raw);
    if (list) return list;
    list = [];
    for (const m of raw.matchAll(/"[^"]{1,2000}"|\u201C[^\u201D]{1,2000}\u201D/g)) {
      const quote = m[0];
      if (!quote.includes('*') && !quote.includes('`')) continue;
      const ranges = [];
      let plain = '';
      let last = 0;
      // Code in paired backticks comes first, as in Markdown, and is marked
      // 'code'. Emphasis runs must match: one asterisk is italic, two bold,
      // three both. Unpaired asterisks are dropped, as the site does.
      for (const e of quote.matchAll(/`([^`\n]{1,500})`|(\*{1,3})([^*`]{1,500}?)\2/g)) {
        plain += quote.slice(last, e.index).replace(/\*/g, '');
        const start = plain.length;
        plain += e[1] !== undefined ? e[1] : e[3];
        ranges.push([start, plain.length, e[1] !== undefined ? 'code' : e[2].length]);
        last = e.index + e[0].length;
      }
      plain += quote.slice(last).replace(/\*/g, '');
      if (ranges.length) list.push({ plain, ranges });
    }
    if (quoteCache.size >= MAX_CACHED_TEXTS) quoteCache.clear();
    quoteCache.set(raw, list);
    return list;
  }

  // The site's own text inside a dialogue tag, leaving out the script's copy.
  function siteText(span) {
    let text = '';
    for (const node of span.childNodes) {
      if (node.nodeType === 3) text += node.nodeValue;
      else if (node.nodeType === 1 && !node.classList.contains(COPY_CLASS)) text += node.textContent;
    }
    return text;
  }

  // Where the emphasized words sit in the line as displayed, or null when
  // the line had no emphasis or the original text cannot be read.
  function copyPlan(span) {
    let raw = null;
    try {
      raw = rawMessageText(span);
    } catch (_) {
      // a prop getter on the site's side threw; leave this line alone
    }
    if (!raw) return null;
    const quotes = emphasizedQuotes(raw);
    if (!quotes.length) return null;
    const text = siteText(span);
    const plainText = plainSpacing(text);
    const ranges = [];
    for (const quote of quotes) {
      const at = plainText.indexOf(plainSpacing(quote.plain));
      if (at === -1) continue;
      for (const [start, end, stars] of quote.ranges) ranges.push([at + start, at + end, stars]);
    }
    if (!ranges.length) return null;
    ranges.sort((a, b) => a[0] - b[0]);
    return { text, ranges, who: sideOf(span) };
  }

  // Italic words use the site's italics tag, so its color and the toggles
  // apply. Bold uses a plain <strong>, which is one step heavier than the
  // dialogue around it and keeps the dialogue color. Code uses the site's
  // own code look.
  const CODE_CLASSES = 'bg-container-secondary inline-block w-fit rounded px-2 py-1.5 font-medium';

  function emphasisNode(text, stars, who) {
    let node = document.createTextNode(text);
    if (stars === 'code') {
      const code = document.createElement('code');
      code.className = CODE_CLASSES;
      code.appendChild(node);
      return code;
    }
    if (stars !== 2) {
      const em = document.createElement('em');
      em.className = italicsClasses(who);
      em.setAttribute(MARK, 'dialogue');
      em.appendChild(node);
      node = em;
    }
    if (stars >= 2) {
      const strong = document.createElement('strong');
      strong.appendChild(node);
      node = strong;
    }
    return node;
  }

  function fillCopy(copy, plan) {
    copy.replaceChildren(...copyParts(plan));
  }

  function copyParts(plan) {
    const parts = [];
    let last = 0;
    for (const [start, end, stars] of plan.ranges) {
      if (start < last) continue;
      if (start > last) parts.push(document.createTextNode(plan.text.slice(last, start)));
      parts.push(emphasisNode(plan.text.slice(start, end), stars, plan.who));
      last = end;
    }
    parts.push(document.createTextNode(plan.text.slice(last)));
    return parts;
  }

  // Shows, updates or removes the copy for one dialogue tag. It writes only
  // when the wanted copy differs from what is there, so the mutation records
  // its own writes cause find nothing to do, and it cannot loop.
  function syncCopy(span) {
    const copy = Array.from(span.children).find(c => c.classList.contains(COPY_CLASS)) || null;
    const plan = wantRecovery() ? copyPlan(span) : null;
    if (!plan) {
      if (copy) copy.remove();
      if (span.hasAttribute(COPY_ATTR)) span.removeAttribute(COPY_ATTR);
      return;
    }
    const signature = JSON.stringify([plan.text, plan.ranges]);
    if (copy && copy.dataset.agfSignature === signature && span.hasAttribute(COPY_ATTR)) return;
    // Read only when building, since a style read can force layout. The copy
    // takes the size the line had, measured on its parent, which is not hidden.
    const size = getComputedStyle(span.parentElement || span).fontSize;
    const target = copy || document.createElement('span');
    if (!copy) {
      target.className = COPY_CLASS;
      // Screen readers keep reading the site's own text, so nothing is read twice.
      target.setAttribute('aria-hidden', 'true');
    }
    target.dataset.agfSignature = signature;
    target.style.setProperty('--agf-copy-size', size);
    fillCopy(target, plan);
    if (!copy) span.appendChild(target);
    if (!span.hasAttribute(COPY_ATTR)) span.setAttribute(COPY_ATTR, '');
  }

  // The script's own rebuilt lines hold a dialogue tag too; that one is not
  // the site's and is left alone.
  function innermostQuoteSpans(root) {
    const list = root.matches(QUOTES_SEL) ? [root] : [];
    list.push(...root.querySelectorAll(QUOTES_SEL));
    return list.filter(s => !s.querySelector(QUOTES_SEL) && !s.closest('.' + LINE_CLASS));
  }

  // Dialogue the site never put in its dialogue tag. The site finds dialogue
  // one piece of text at a time, so a quote with `code` in it, which splits
  // the quote around a code tag, gets no tag: no dialogue color, and no line
  // for the copy above to work from. Its asterisks are gone too, removed
  // before display like in any straight-quoted dialogue. For such a quote
  // the script shows the whole line rebuilt from the original reply, in the
  // site's dialogue tag with emphasis and code back in place, and hides the
  // site's pieces of it: its tags by an attribute, its text by trimming the
  // text nodes through the text fix, which puts the text back when this
  // stops. Only straight quotes, which the site colors; curly quotes have
  // their own coloring above.
  const LINE_CLASS = 'agf-line-copy';
  const lineCuts = new Map();
  const lineState = new Map();

  // The block's text as the site wrote it, piece by piece, leaving out the
  // script's own copies.
  function blockPieces(block) {
    const pieces = [];
    let text = '';
    const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
    let n;
    while ((n = walker.nextNode())) {
      if (n.parentElement.closest('.' + COPY_CLASS + ', .' + LINE_CLASS)) continue;
      const known = textFixes.get(n);
      const value = known && known.fixed === n.nodeValue ? known.orig : n.nodeValue;
      pieces.push({ node: n, start: text.length, value });
      text += value;
    }
    return { pieces, text };
  }

  function topChild(node, block) {
    let top = node;
    while (top.parentNode !== block) top = top.parentNode;
    return top;
  }

  // What to show, trim and hide in one block, or an empty plan.
  function linePlan(block) {
    const plan = { copies: [], cuts: new Map(), hidden: new Set() };
    let raw = null;
    try {
      raw = rawMessageText(block);
    } catch (_) {
      // a prop getter on the site's side threw; leave the block as it is
    }
    if (!raw) return plan;
    const quotes = emphasizedQuotes(raw).filter(q => q.plain[0] === '"');
    if (!quotes.length) return plan;
    const who = sideOf(block);
    if (!who) return plan;
    const { pieces, text } = blockPieces(block);
    const flat = plainSpacing(text);
    let cursor = 0;
    for (const quote of quotes) {
      const at = flat.indexOf(plainSpacing(quote.plain), cursor);
      if (at === -1) continue;
      const end = at + quote.plain.length;
      cursor = end;
      const covered = pieces.filter(pc => pc.start < end && pc.start + pc.value.length > at && pc.value.length);
      if (covered.length < 2) continue;
      // Inside the site's dialogue tag, the copy above handles it.
      if (covered.some(pc => pc.node.parentElement.closest(QUOTES_SEL))) continue;
      const tops = [];
      for (const pc of covered) {
        const top = topChild(pc.node, block);
        if (!tops.includes(top)) tops.push(top);
      }
      // A tag the quote only partly covers cannot be hidden in part.
      const partial = tops.some(top => {
        if (top.nodeType === 3) return false;
        const inside = pieces.filter(pc => top.contains(pc.node));
        return inside.some(pc => pc.start < at || pc.start + pc.value.length > end);
      });
      if (partial) continue;
      let before = null;
      for (const top of tops) {
        if (top.nodeType === 1) {
          plan.hidden.add(top);
          continue;
        }
        const pc = pieces.find(x => x.node === top);
        const from = Math.max(0, at - pc.start);
        const to = Math.min(pc.value.length, end - pc.start);
        // The part outside the quote stays: text before it, or after it.
        if (from > 0) {
          plan.cuts.set(top, [0, from]);
          before = top;
        } else if (to < pc.value.length) {
          plan.cuts.set(top, [to, pc.value.length]);
        } else {
          plan.cuts.set(top, [0, 0]);
        }
      }
      plan.copies.push({
        signature: JSON.stringify([who, quote.plain, quote.ranges]),
        after: before,
        first: tops[0],
        build: () => {
          const tag = document.createElement('span');
          tag.className = 'font-semibold text-chat-' + who + '-message-text-quotes';
          tag.append(...copyParts({ text: quote.plain, ranges: quote.ranges.slice().sort((a, b) => a[0] - b[0]), who }));
          return tag;
        }
      });
    }
    return plan;
  }

  function setCut(node, cut) {
    const old = lineCuts.get(node);
    if (cut && old && old[0] === cut[0] && old[1] === cut[1]) return;
    if (cut) lineCuts.set(node, cut);
    else if (!lineCuts.delete(node)) return;
    fixText(node);
  }

  // Shows, updates or undoes the rebuilt lines of one block, writing only
  // what differs, so the records its own writes cause find nothing to do.
  function syncLines(block) {
    const plan = wantRecovery() && block.isConnected ? linePlan(block) : { copies: [], cuts: new Map(), hidden: new Set() };
    const before = lineState.get(block);
    if (before) {
      for (const node of before.cuts) if (!plan.cuts.has(node)) setCut(node, null);
      for (const el of before.hidden) if (!plan.hidden.has(el) && el.hasAttribute(HIDE_ATTR)) el.removeAttribute(HIDE_ATTR);
    }
    plan.cuts.forEach((cut, node) => setCut(node, cut));
    plan.hidden.forEach(el => {
      if (!el.hasAttribute(HIDE_ATTR)) el.setAttribute(HIDE_ATTR, '');
    });
    const existing = Array.from(block.children).filter(c => c.classList.contains(LINE_CLASS));
    const kept = new Set();
    for (const want of plan.copies) {
      let copy = existing.find(c => !kept.has(c) && c.dataset.agfSignature === want.signature);
      if (!copy) {
        copy = document.createElement('span');
        copy.className = LINE_CLASS;
        copy.dataset.agfSignature = want.signature;
        copy.append(want.build());
      }
      kept.add(copy);
      const placed = want.after ? want.after.nextSibling === copy : copy.nextSibling === want.first;
      if (!placed) block.insertBefore(copy, want.after ? want.after.nextSibling : want.first);
    }
    existing.forEach(c => {
      if (!kept.has(c)) c.remove();
    });
    if (plan.copies.length || plan.cuts.size || plan.hidden.size) {
      lineState.set(block, { cuts: new Set(plan.cuts.keys()), hidden: plan.hidden });
    } else {
      lineState.delete(block);
    }
  }

  function syncAllLines() {
    if (!document.body) return;
    if (wantRecovery()) blocksWithin(document.body).forEach(syncLines);
    for (const block of Array.from(lineState.keys())) {
      if (!block.isConnected) {
        lineState.get(block).cuts.forEach(node => lineCuts.delete(node));
        lineState.delete(block);
      } else if (!wantRecovery()) {
        syncLines(block);
      }
    }
  }

  function recoverDialogueEmphasis() {
    syncAllLines();
    if (wantRecovery()) {
      innermostQuoteSpans(document.body).forEach(syncCopy);
      return;
    }
    document.querySelectorAll('.' + COPY_CLASS).forEach(el => el.remove());
    document.querySelectorAll('[' + COPY_ATTR + ']').forEach(el => el.removeAttribute(COPY_ATTR));
  }

  // Coalesces a burst of updates, such as a streaming reply, into one pass.
  const RECOVERY_DELAY_MS = 150;
  let recoveryTimer = 0;

  function scheduleRecovery() {
    if (recoveryTimer) return;
    recoveryTimer = setTimeout(() => {
      recoveryTimer = 0;
      syncFences();
      recoverDialogueEmphasis();
      paintCurlyDialogue();
      refreshSiteColors();
    }, RECOVERY_DELAY_MS);
  }

  // Runs before the browser paints, so a new tag never shows in the wrong
  // style first. Attribute changes are not observed, so marking cannot loop.
  // The record a text fix's own write causes is skipped, and a fix worked out
  // again from the site's value comes out the same, so that cannot loop
  // either.
  function onBodyMutations(records) {
    records = records.filter(r => !(r.type === 'characterData' && isOwnSpacing(r.target)));
    if (!records.length) return;
    // Text first, so marking and coloring see the fixed text. Whole
    // paragraphs are redone, because a stray space depends on the text next
    // to a tag, which can arrive after the tag while a reply streams in.
    const boxes = new Set();
    for (const r of records) {
      messageBoxes(r.target).forEach(b => boxes.add(b));
      for (const n of Array.from(r.addedNodes)) messageBoxes(n).forEach(b => boxes.add(b));
    }
    boxes.forEach(syncFence);
    const dirtyBlocks = new Set();
    for (const r of records) {
      blocksWithin(r.target).forEach(b => dirtyBlocks.add(b));
      for (const n of Array.from(r.addedNodes)) blocksWithin(n).forEach(b => dirtyBlocks.add(b));
    }
    if (wantRecovery() || lineState.size) dirtyBlocks.forEach(syncLines);
    if (wantTextFix()) dirtyBlocks.forEach(fixTextWithin);
    const curly = wantCurly();
    const roots = new Set();
    const curlyDirty = new Set();
    for (const r of records) {
      if (curly) {
        blocksWithin(r.target).forEach(b => curlyDirty.add(b));
        for (const n of Array.from(r.addedNodes)) blocksWithin(n).forEach(b => curlyDirty.add(b));
        if (r.removedNodes.length) scheduleRecovery();
      }
      if (!wantMarks()) continue;
      const el = r.target.nodeType === 1 ? r.target : r.target.parentElement;
      if (!el) continue;
      roots.add(el.closest(BLOCK_SEL) || el);
      for (const n of Array.from(r.addedNodes)) {
        if (n.nodeType === 1) roots.add(n);
      }
    }
    roots.forEach(root => {
      if (root.isConnected) classifyWithin(root);
    });
    // After marking, so emphasis marked just now keeps its own color.
    if (curlyDirty.size) {
      curlyDirty.forEach(paintCurlyBlock);
      commitCurly();
    }
    // A message from a side whose color is still unread gets it read.
    if (wantCurly()) {
      for (const box of boxes) {
        const who = sideOf(box);
        if (who && !curlyColors[who]) {
          scheduleRecovery();
          break;
        }
      }
    }
    if (!wantRecovery()) return;
    // Lines the site just changed are re-synced now, before paint, so a
    // hidden line never shows without its copy. A full pass follows shortly
    // to catch lines whose original text changed with no visible change.
    const spans = new Set();
    for (const r of records) {
      const el = r.target.nodeType === 1 ? r.target : r.target.parentElement;
      const span = el && el.closest(QUOTES_SEL);
      if (span) spans.add(span);
      for (const n of Array.from(r.addedNodes)) {
        if (n.nodeType === 1) innermostQuoteSpans(n).forEach(s => spans.add(s));
      }
    }
    spans.forEach(span => {
      if (span.isConnected && !span.querySelector(QUOTES_SEL)) syncCopy(span);
    });
    scheduleRecovery();
  }

  let bodyObserver = null;
  let observedBody = null;

  function wantBodyWatch() {
    return wantMarks() || wantTextFix() || wantCurly();
  }

  // The body watcher runs while Format fix is on, for the text fixes, marking
  // and coloring. With Format fix off it stops, and the script does nothing.
  function syncBodyObserver() {
    const body = document.body;
    if (bodyObserver && (!wantBodyWatch() || observedBody !== body)) {
      bodyObserver.disconnect();
      bodyObserver = null;
      observedBody = null;
    }
    if (!bodyObserver && wantBodyWatch() && body) {
      bodyObserver = new MutationObserver(onBodyMutations);
      bodyObserver.observe(body, { childList: true, subtree: true, characterData: true });
      observedBody = body;
    }
    if (!body) return;
    syncFences();
    if (wantTextFix()) fixTextWithin(body);
    else restoreText();
    if (wantMarks()) classifyWithin(body);
    recoverDialogueEmphasis();
    paintCurlyDialogue();
    refreshSiteColors();
  }

  // Watches only where the style tag can live: <html>'s direct children, and
  // <head> when it exists. React can replace <head> during hydration or
  // navigation, so a new <head> gets observed in place of the old one.
  let observedHead = null;
  const headObserver = new MutationObserver(ensureStyle);

  function watchHead() {
    const head = document.head;
    if (!head || head === observedHead) return;
    headObserver.disconnect();
    headObserver.observe(head, { childList: true });
    observedHead = head;
  }

  function start() {
    new MutationObserver(() => {
      watchHead();
      ensureStyle();
      syncBodyObserver();
    }).observe(document.documentElement, { childList: true });

    watchHead();
    ensureStyle();
    syncBodyObserver();
  }

  // At document-start the browser may not have created <html> yet, and
  // observing a missing element throws and stops the whole script.
  if (document.documentElement) {
    start();
  } else {
    const docObserver = new MutationObserver(() => {
      if (!document.documentElement) return;
      docObserver.disconnect();
      start();
    });
    docObserver.observe(document, { childList: true });
  }

  const menuIds = [];

  function buildMenu() {
    while (menuIds.length) GM_unregisterMenuCommand(menuIds.pop());
    // Format fix is the master switch. While it is off the other settings keep
    // their values but do nothing, and the menu says so.
    const paused = settings.enabled ? '' : ' (paused)';
    menuIds.push(GM_registerMenuCommand(
      'Format fix: ' + (settings.enabled ? 'ON' : 'OFF, everything off'),
      () => toggle('enabled')
    ));
    menuIds.push(GM_registerMenuCommand(
      'Keep emphasis: ' + (settings.keepEmphasis ? 'ON' : 'OFF') + paused,
      () => toggle('keepEmphasis')
    ));
    menuIds.push(GM_registerMenuCommand(
      'Italics as written (for Roleplay): ' + (settings.italicsAsWritten ? 'ON' : 'OFF') + paused,
      () => toggle('italicsAsWritten')
    ));
    menuIds.push(GM_registerMenuCommand(
      'Dialogue emphasis color: ' + (settings.dialogueEmphasisPlainColor ? 'dialogue' : 'italics') + paused,
      () => toggle('dialogueEmphasisPlainColor')
    ));
    menuIds.push(GM_registerMenuCommand(
      'Dialogue emphasis (experimental): ' + (settings.recoverEmphasis ? 'ON' : 'OFF') + paused,
      () => toggle('recoverEmphasis')
    ));
    menuIds.push(GM_registerMenuCommand(
      'Keep spacing: ' + (settings.keepSpacing ? 'ON' : 'OFF') + paused,
      () => toggle('keepSpacing')
    ));
    menuIds.push(GM_registerMenuCommand(
      'Curly quote dialogue: ' + (canHighlight ? (settings.curlyDialogue ? 'ON' : 'OFF') : 'not supported here') + paused,
      () => toggle('curlyDialogue')
    ));
    menuIds.push(GM_registerMenuCommand('Colors...' + paused, openColorPanel));
  }

  // syncBodyObserver also repaints curly-quoted dialogue, since Keep emphasis
  // and the dialogue emphasis color change which words it paints.
  function toggle(key) {
    settings[key] = !settings[key];
    GM_setValue(key, settings[key]);
    ensureStyle();
    syncBodyObserver();
    buildMenu();
  }

  // While a picker is being dragged the color applies live but is saved only
  // when the drag ends, so storage is not written on every pointer move.
  function setColor(key, value, save = true) {
    const had = !!settings[key];
    settings[key] = HEX_COLOR.test(value) ? value : '';
    if (save) GM_setValue(key, settings[key]);
    ensureStyle();
    // Bold in curly-quoted dialogue takes the bold color when one is set, and
    // the dialogue color when not.
    if (BOLD_COLORS.some(c => c.key === key) && had !== !!settings[key]) paintCurlyDialogue();
  }

  // A small sheet at the bottom of the screen, styled to sit with the site's
  // dark settings panels. It is the script's own element, outside the chat.
  const PANEL_ID = 'agf-colors';
  const PANEL_COLORS = { sheet: '#1a1b26', line: '#2c2d3c', text: '#ececf4', muted: '#9a9bb0', button: '#272838' };

  function make(tag, css, text) {
    const el = document.createElement(tag);
    if (css) el.style.cssText = css;
    if (text) el.textContent = text;
    return el;
  }

  // Chrome on Android flashes a tinted box on a tapped button. A swatch's flash
  // would show through the picker that opens over it, so buttons here turn it off.
  function panelButton(text) {
    return make('button',
      'min-height:44px;padding:0 16px;-webkit-tap-highlight-color:transparent;border:1px solid ' + PANEL_COLORS.line + ';border-radius:12px;' +
      'background:' + PANEL_COLORS.button + ';color:' + PANEL_COLORS.text + ';font:inherit;font-size:15px;', text);
  }

  // The color picker copies the layout of Anime GF's own: a saturation and
  // brightness area, a hue bar, Hex and RGB fields, and the same presets.
  const PICKER_ID = 'agf-color-picker';
  const PRESETS = ['#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321', '#417505', '#BD10E0', '#9013FE',
    '#4A90E2', '#50E3C2', '#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF'];

  function hexToRgb(hex) {
    const n = parseInt(hex.slice(1), 16);
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }

  function rgbToHex(rgb) {
    return '#' + rgb.map(c => c.toString(16).padStart(2, '0')).join('').toUpperCase();
  }

  function rgbToHsv(rgb) {
    const [r, g, b] = rgb.map(c => c / 255);
    const max = Math.max(r, g, b);
    const d = max - Math.min(r, g, b);
    let h = 0;
    if (d) {
      if (max === r) h = ((g - b) / d) % 6;
      else if (max === g) h = (b - r) / d + 2;
      else h = (r - g) / d + 4;
      h = (h * 60 + 360) % 360;
    }
    return { h, s: max ? d / max : 0, v: max };
  }

  function hsvToRgb({ h, s, v }) {
    const channel = n => {
      const k = (n + h / 60) % 6;
      return v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
    };
    return [channel(5), channel(3), channel(1)].map(x => Math.round(x * 255));
  }

  function clamp01(x) {
    return Math.min(1, Math.max(0, x));
  }

  // onPick(hex, save) runs on every change; save is false mid-drag.
  function openPicker(startHex, onPick) {
    // Kept as hue, saturation and brightness so the hue survives dragging to
    // black or gray, where the color alone no longer says what it was.
    let hsv = rgbToHsv(hexToRgb(startHex));
    const layer = make('div', 'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;' +
      'justify-content:center;font-family:system-ui,sans-serif;');
    layer.id = PICKER_ID;
    // On a short screen, such as a phone held sideways, the card scrolls. The
    // dvh value follows the visible height on phones and is skipped where unsupported.
    const card = make('div', 'width:min(320px,calc(100vw - 32px));box-sizing:border-box;padding:12px 12px 14px;' +
      'max-height:calc(100vh - 24px);max-height:calc(100dvh - 24px);overflow-y:auto;' +
      'background:#fff;color:#333;border-radius:6px;box-shadow:0 0 0 1px rgba(0,0,0,0.15),0 8px 24px rgba(0,0,0,0.35);');
    card.setAttribute('role', 'dialog');
    card.setAttribute('aria-label', 'Pick a color');

    const area = make('div', 'position:relative;width:100%;aspect-ratio:4/3;border-radius:2px;' +
      'touch-action:none;cursor:crosshair;');
    const dot = make('div', 'position:absolute;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;' +
      'box-shadow:0 0 0 2px #fff,0 0 1px 3px rgba(0,0,0,0.4);pointer-events:none;');
    area.append(dot);
    const hue = make('div', 'position:relative;height:14px;margin-top:12px;border-radius:2px;touch-action:none;' +
      'cursor:pointer;background:linear-gradient(to right,#f00 0%,#ff0 17%,#0f0 33%,#0ff 50%,#00f 67%,#f0f 83%,#f00 100%);');
    const knob = make('div', 'position:absolute;top:-2px;width:6px;height:18px;margin-left:-3px;background:#fff;' +
      'border-radius:1px;box-shadow:0 0 2px rgba(0,0,0,0.6);pointer-events:none;');
    hue.append(knob);

    const fields = make('div', 'display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:8px;margin-top:12px;');
    const field = label => {
      const wrap = make('label', 'display:flex;flex-direction:column;align-items:center;gap:4px;' +
        'font-size:12px;color:#666;');
      const input = make('input', 'width:100%;box-sizing:border-box;padding:6px;border:0;border-radius:0;' +
        'box-shadow:inset 0 0 0 1px #ccc;background:#fff;color:#333;font:14px system-ui,sans-serif;');
      input.inputMode = label === 'Hex' ? 'text' : 'numeric';
      input.autocomplete = 'off';
      input.spellcheck = false;
      wrap.append(input, document.createTextNode(label));
      fields.append(wrap);
      return input;
    };
    const hexField = field('Hex');
    const rgbFields = [field('R'), field('G'), field('B')];

    const presets = make('div', 'display:grid;grid-template-columns:repeat(8,1fr);gap:8px;' +
      'margin:12px -12px 0;padding:12px 12px 0;border-top:1px solid #eee;');
    card.append(area, hue, fields, presets);
    layer.append(card);

    // except: the field being typed in, which is not rewritten under the cursor.
    // quiet: redraw only, for opening the picker and leaving a field, so neither
    // applies a color the person did not pick.
    const render = (save, except, quiet) => {
      const rgb = hsvToRgb(hsv);
      const hex = rgbToHex(rgb);
      area.style.background = 'linear-gradient(to top,#000,transparent),' +
        'linear-gradient(to right,#fff,transparent),hsl(' + hsv.h + ',100%,50%)';
      dot.style.left = hsv.s * 100 + '%';
      dot.style.top = (1 - hsv.v) * 100 + '%';
      dot.style.background = hex;
      knob.style.left = (hsv.h / 360) * 100 + '%';
      if (except !== hexField) hexField.value = hex.slice(1);
      rgbFields.forEach((input, i) => {
        if (except !== input) input.value = rgb[i];
      });
      if (!quiet) onPick(hex, save);
    };

    PRESETS.forEach(hex => {
      const swatch = make('button', 'aspect-ratio:1;padding:0;border:0;border-radius:3px;cursor:pointer;' +
        '-webkit-tap-highlight-color:transparent;' +
        'background:' + hex + ';box-shadow:inset 0 0 0 1px rgba(0,0,0,0.15);');
      swatch.setAttribute('aria-label', hex);
      swatch.addEventListener('click', () => {
        hsv = rgbToHsv(hexToRgb(hex));
        render(true);
      });
      presets.append(swatch);
    });

    // Pointer capture keeps a drag tracking after the finger leaves the area.
    const drag = (el, move) => {
      el.addEventListener('pointerdown', e => {
        e.preventDefault();
        el.setPointerCapture(e.pointerId);
        move(e);
        render(false);
        const onMove = ev => {
          move(ev);
          render(false);
        };
        const onUp = () => {
          el.removeEventListener('pointermove', onMove);
          el.removeEventListener('pointerup', onUp);
          el.removeEventListener('pointercancel', onUp);
          render(true);
        };
        el.addEventListener('pointermove', onMove);
        el.addEventListener('pointerup', onUp);
        el.addEventListener('pointercancel', onUp);
      });
    };
    drag(area, e => {
      const r = area.getBoundingClientRect();
      hsv = { h: hsv.h, s: clamp01((e.clientX - r.left) / r.width), v: 1 - clamp01((e.clientY - r.top) / r.height) };
    });
    drag(hue, e => {
      const r = hue.getBoundingClientRect();
      hsv = { h: clamp01((e.clientX - r.left) / r.width) * 360, s: hsv.s, v: hsv.v };
    });

    hexField.addEventListener('input', () => {
      const value = hexField.value.trim().replace(/^#/, '');
      if (!/^[0-9a-f]{6}$/i.test(value)) return;
      hsv = rgbToHsv(hexToRgb('#' + value));
      render(true, hexField);
    });
    rgbFields.forEach(input => input.addEventListener('input', () => {
      if (rgbFields.some(f => !/^\d{1,3}$/.test(f.value))) return;
      const rgb = rgbFields.map(f => Number(f.value));
      if (rgb.some(c => c > 255)) return;
      hsv = rgbToHsv(rgb);
      render(true, input);
    }));
    // A half-typed field snaps back to the current color when left.
    [hexField, ...rgbFields].forEach(input => input.addEventListener('blur', () => render(false, null, true)));

    const close = () => {
      layer.remove();
      document.removeEventListener('keydown', onKey);
    };
    const onKey = e => {
      if (e.key === 'Escape') close();
    };
    // Closes on the click, so a tap's click is used up here and does not reach
    // the panel underneath and close it too. The press must also start
    // outside the card, so a drag that ends outside it does not close it.
    let pressedOutside = false;
    layer.addEventListener('pointerdown', e => {
      pressedOutside = e.target === layer;
    });
    layer.addEventListener('click', e => {
      if (e.target === layer && pressedOutside) close();
    });
    document.addEventListener('keydown', onKey);
    document.body.append(layer);
    render(false, null, true);
  }

  // unset: what an unset color shows as, since something else decides it.
  function colorRow({ key, label }, unset) {
    const row = make('div', 'display:flex;align-items:center;gap:12px;min-height:56px;');
    const name = make('div', 'flex:1;');
    name.append(make('div', 'font-size:16px;', label));
    const status = make('div', 'font-size:13px;color:' + PANEL_COLORS.muted + ';');
    name.append(status);
    const swatch = make('button', 'width:52px;height:44px;padding:0;border-radius:12px;cursor:pointer;' +
      '-webkit-tap-highlight-color:transparent;' +
      'border:1px solid ' + PANEL_COLORS.line + ';');
    swatch.setAttribute('aria-label', 'Pick ' + label.toLowerCase() + ' color');
    const reset = panelButton('Default');
    const show = () => {
      const color = savedColor(key);
      // An unset color shows as an empty dashed swatch, since the site decides it.
      swatch.style.background = color || 'transparent';
      swatch.style.borderStyle = color ? 'solid' : 'dashed';
      swatch.style.borderColor = color ? PANEL_COLORS.line : PANEL_COLORS.muted;
      status.textContent = color ? color.toUpperCase() : unset;
      reset.disabled = !color;
      reset.style.opacity = color ? '1' : '0.45';
    };
    swatch.addEventListener('click', () => {
      // White is the starting point for an unset color, near the site's light text.
      openPicker(savedColor(key) || '#FFFFFF', (hex, save) => {
        setColor(key, hex, save);
        show();
      });
    });
    reset.addEventListener('click', () => {
      setColor(key, '');
      show();
    });
    show();
    row.append(name, swatch, reset);
    return row;
  }

  function openColorPanel() {
    if (!document.body || document.getElementById(PANEL_ID)) return;
    const backdrop = make('div', 'position:fixed;inset:0;z-index:2147483646;background:rgba(0,0,0,0.5);' +
      'display:flex;align-items:flex-end;justify-content:center;font-family:system-ui,sans-serif;');
    backdrop.id = PANEL_ID;
    const sheet = make('div', 'width:min(480px,100%);box-sizing:border-box;padding:20px 20px 24px;' +
      'max-height:calc(100vh - 24px);max-height:calc(100dvh - 24px);overflow-y:auto;' +
      'border-radius:20px 20px 0 0;background:' + PANEL_COLORS.sheet + ';color:' + PANEL_COLORS.text + ';');
    sheet.setAttribute('role', 'dialog');
    sheet.setAttribute('aria-label', 'Colors');
    // Focus goes to the sheet, not a button, so opening the panel shows no
    // focus ring. A keyboard user tabs from here into the buttons, and the
    // browser rings each one as usual.
    sheet.tabIndex = -1;
    sheet.style.outline = 'none';
    sheet.append(
      make('div', 'font-size:18px;font-weight:600;margin-bottom:4px;', 'Colors'),
      make('div', 'font-size:14px;color:' + PANEL_COLORS.muted + ';margin-bottom:12px;',
        'Anime GF has no color settings for bold text or text in `backticks`, so pick them here.')
    );
    if (!settings.enabled) {
      sheet.append(make('div', 'font-size:14px;margin-bottom:12px;padding:10px 12px;border-radius:12px;' +
        'background:' + PANEL_COLORS.button + ';',
        'Format fix is off, so these colors are saved but not shown. Turn Format fix on to see them.'));
    }
    const heading = text => make('div', 'font-size:13px;font-weight:600;letter-spacing:0.04em;' +
      'text-transform:uppercase;color:' + PANEL_COLORS.muted + ';margin-top:12px;', text);
    sheet.append(heading('Bold'));
    BOLD_COLORS.forEach(entry => sheet.append(colorRow(entry, "Site's color")));
    sheet.append(heading('Code (text in backticks)'));
    CODE_COLORS.forEach(entry => sheet.append(colorRow(entry, "Site's color")));
    const done = panelButton('Done');
    done.style.width = '100%';
    done.style.marginTop = '16px';
    sheet.append(done);
    backdrop.append(sheet);

    const close = () => {
      backdrop.remove();
      document.removeEventListener('keydown', onKey);
    };
    // With the color picker open, Escape closes only the picker.
    const onKey = e => {
      if (e.key === 'Escape' && !document.getElementById(PICKER_ID)) close();
    };
    done.addEventListener('click', close);
    backdrop.addEventListener('click', e => {
      if (e.target === backdrop) close();
    });
    document.addEventListener('keydown', onKey);
    document.body.append(backdrop);
    sheet.focus();
  }

  buildMenu();
})();