☰

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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==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();
})();