// ==UserScript== // @name AO3: Site Wizard // @version 3.5 // @description Make AO3 easier to read: customize fonts and sizes across the entire site, adjust work reader margins, fix spacing issues, and configure text alignment preferences. // @author Blackbatcat // @match *://archiveofourown.org/* // @license MIT // @require https://update.greasyfork.icu/scripts/552743/1690921/AO3%3A%20Menu%20Helpers%20Library.js?v=2.1.2 // @grant none // @run-at document-start // @namespace https://greasyfork.org/users/1498004 // @downloadURL none // ==/UserScript== (function () { "use strict"; // --- CONSTANTS --- const FORMATTER_CONFIG_KEY = "ao3_wizard_config"; const DEFAULT_FORMATTER_CONFIG = { paragraphWidthPercent: 70, paragraphFontSizePercent: 100, paragraphTextAlign: "left", paragraphFontFamily: "", fixParagraphSpacing: true, paragraphGap: 1.286, siteFontFamily: "", siteFontWeight: "", siteFontSizePercent: 100, headerFontFamily: "", headerFontWeight: "", codeFontFamily: "", codeFontStyle: "normal", codeFontSize: "", expandCodeFontUsage: false, }; const WORKS_PAGE_REGEX = /^https?:\/\/archiveofourown\.org\/(?:.*\/)?(works|chapters)(\/|$)/; // --- STATE --- let FORMATTER_CONFIG = { ...DEFAULT_FORMATTER_CONFIG }; let cachedElements = { paraStyle: null, siteStyle: null, }; // --- UTILITIES --- function getOrCreateStyle(id) { if (!document.head) return null; let style = document.getElementById(id); if (!style) { style = document.createElement("style"); style.id = id; document.head.appendChild(style); } return style; } function loadFormatterConfig() { try { const saved = localStorage.getItem(FORMATTER_CONFIG_KEY); if (saved) { FORMATTER_CONFIG = { ...DEFAULT_FORMATTER_CONFIG, ...JSON.parse(saved), }; } } catch (e) { console.error("Error loading config:", e); } } function saveFormatterConfig() { try { localStorage.setItem( FORMATTER_CONFIG_KEY, JSON.stringify(FORMATTER_CONFIG) ); } catch (e) { console.error("Error saving config:", e); } } // --- APPLY STYLES --- function applyParagraphWidth() { if (!cachedElements.paraStyle) { cachedElements.paraStyle = getOrCreateStyle( "ao3-formatter-paragraph-style" ); if (!cachedElements.paraStyle) return; } if (WORKS_PAGE_REGEX.test(window.location.href)) { const { paragraphWidthPercent, paragraphFontSizePercent, paragraphTextAlign, paragraphGap, } = FORMATTER_CONFIG; cachedElements.paraStyle.textContent = ` #workskin p { text-align: ${paragraphTextAlign} !important; } ${ paragraphTextAlign === "justify" || paragraphTextAlign === "left" ? `#workskin dd { text-align: ${paragraphTextAlign} !important; }` : "" } ${ paragraphTextAlign === "justify" || paragraphTextAlign === "left" ? `#workskin blockquote { text-align: ${paragraphTextAlign} !important; }` : "" } #workskin { max-width: ${paragraphWidthPercent}vw !important; font-size: ${paragraphFontSizePercent}% !important; } #workskin p { margin-bottom: ${paragraphGap}em !important; } #workskin p[align] { text-align: ${paragraphTextAlign} !important; } ${ paragraphTextAlign === "right" ? ` #workskin ul, #workskin ol { direction: rtl !important; text-align: right !important; } #workskin li { text-align: right !important; } #workskin dl { direction: rtl !important; } #workskin dt, #workskin dd { text-align: right !important; } #workskin blockquote { text-align: right !important; } #workskin summary { text-align: right !important; } #workskin h1, #workskin h2, #workskin h3, #workskin h4, #workskin h5, #workskin h6 { text-align: right !important; } ` : "" } `; const workskin = document.getElementById("workskin"); if (workskin) { if (paragraphTextAlign === "right") { workskin.setAttribute("dir", "rtl"); } else { workskin.removeAttribute("dir"); } } } else { cachedElements.paraStyle.textContent = ""; } applySiteWideStyles(); } function applySiteWideStyles() { if (!cachedElements.siteStyle) { cachedElements.siteStyle = getOrCreateStyle("ao3-sitewide-style"); if (!cachedElements.siteStyle) return; } const { siteFontSizePercent, siteFontFamily, siteFontWeight, headerFontFamily, headerFontWeight, paragraphFontFamily, codeFontFamily, codeFontStyle, codeFontSize, expandCodeFontUsage, } = FORMATTER_CONFIG; const rules = []; rules.push(`html { font-size: ${siteFontSizePercent}% !important; }`); if (siteFontFamily) { if (expandCodeFontUsage) { rules.push( `body, body *:not(textarea):not(textarea *):not(code):not(pre):not(tt):not(kbd):not(samp):not(var), input:not([type="file"]), select, button:not(.comment-format button):not(ul.comment-format button) { font-family: ${siteFontFamily} !important; }` ); } else { rules.push( `body, body *:not(code):not(pre):not(tt):not(kbd):not(samp):not(var), input:not([type="file"]), textarea:not(#skin_css):not(#floaty-textarea), select, button:not(.comment-format button):not(ul.comment-format button) { font-family: ${siteFontFamily} !important; }` ); } } if (siteFontWeight) { const textareaSelector = expandCodeFontUsage ? "" : ", textarea:not(#skin_css):not(#floaty-textarea)"; rules.push( `body, body *, input:not([type="file"])${textareaSelector}, select, button:not(.comment-format button):not(ul.comment-format button) { font-weight: ${siteFontWeight} !important; }` ); } if (paragraphFontFamily) { const textareaExclusion = expandCodeFontUsage ? ":not(textarea)" : ""; if (headerFontFamily) { rules.push( `#workskin:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6), #workskin *:not(code):not(pre):not(tt):not(kbd):not(samp):not(var):not(h1):not(h2):not(h3):not(h4):not(h5):not(h6):not(h1 *):not(h2 *):not(h3 *):not(h4 *):not(h5 *):not(h6 *)${textareaExclusion} { font-family: ${paragraphFontFamily} !important; }` ); } else { rules.push( `#workskin, #workskin *:not(code):not(pre):not(tt):not(kbd):not(samp):not(var)${textareaExclusion} { font-family: ${paragraphFontFamily} !important; }` ); } } if (headerFontFamily) { rules.push( `h1, h1 *, h2, h2 *, h3, h3 *, h4, h4 *, h5, h5 *, h6, h6 *, .heading, .heading *, #workskin h1, #workskin h1 *, #workskin h2, #workskin h2 *, #workskin h3, #workskin h3 *, #workskin h4, #workskin h4 *, #workskin h5, #workskin h5 *, #workskin h6, #workskin h6 * { font-family: ${headerFontFamily} !important; }` ); } else if (paragraphFontFamily) { rules.push( `#chapters h3.title, #chapters h3.byline.heading, .chapter .preface h3.title, .chapter .preface h3.byline.heading, .preface h3.title, .preface h3.byline { font-family: ${paragraphFontFamily} !important; }` ); } if (headerFontWeight) { rules.push( `h1, h1 *, h2, h2 *, h3, h3 *, h4, h4 *, h5, h5 *, h6, h6 *, .heading, .heading *, #workskin h1, #workskin h1 *, #workskin h2, #workskin h2 *, #workskin h3, #workskin h3 *, #workskin h4, #workskin h4 *, #workskin h5, #workskin h5 *, #workskin h6, #workskin h6 * { font-weight: ${headerFontWeight} !important; }` ); } const codeRules = []; if (codeFontFamily) codeRules.push(`font-family: ${codeFontFamily} !important`); if (codeFontStyle && codeFontStyle !== "normal") codeRules.push(`font-style: ${codeFontStyle} !important`); if (codeFontSize) codeRules.push(`font-size: ${codeFontSize} !important`); if (codeRules.length > 0) { const baseCodeSelectors = "code, code *, pre, pre *, tt, tt *, kbd, kbd *, samp, samp *, var, var *, textarea#skin_css, .css.module blockquote pre, #floaty-textarea, #workskin code, #workskin code *, #workskin pre, #workskin pre *, #workskin tt, #workskin tt *, #workskin kbd, #workskin kbd *, #workskin samp, #workskin samp *, #workskin var, #workskin var *"; const codeSelectors = expandCodeFontUsage ? "code, code *, pre, pre *, tt, tt *, kbd, kbd *, samp, samp *, var, var *, textarea, textarea#skin_css, .css.module blockquote pre, #floaty-textarea, #workskin code, #workskin code *, #workskin pre, #workskin pre *, #workskin tt, #workskin tt *, #workskin kbd, #workskin kbd *, #workskin samp, #workskin samp *, #workskin var, #workskin var *, #workskin textarea" : baseCodeSelectors; rules.push(`${codeSelectors} { ${codeRules.join("; ")}; }`); } if (codeRules.length === 0) { rules.push( `code, code *, pre, pre *, tt, tt *, kbd, kbd *, samp, samp *, var, var *, #workskin code, #workskin code *, #workskin pre, #workskin pre *, #workskin tt, #workskin tt *, #workskin kbd, #workskin kbd *, #workskin samp, #workskin samp *, #workskin var, #workskin var * { font-family: monospace !important; }` ); if (expandCodeFontUsage) { rules.push( `textarea, #workskin textarea { font-family: monospace !important; }` ); } } rules.push( `#workskin .preface .title.heading, #workskin .preface .byline.heading, #workskin .preface .title, #workskin .preface .byline, #workskin .title.heading, #workskin .byline.heading { text-align: center !important; direction: ltr !important; }` ); rules.push( `#workskin pre { text-align: left !important; direction: ltr !important; }` ); rules.push( `#cmtFmtDialog #stdbutton label, ul.comment-format, ul.comment-format * { font-family: "FontAwesome", sans-serif !important; font-weight: normal !important; }`, `ul.actions.comment-format { text-align: left !important; }` ); cachedElements.siteStyle.textContent = rules.join("\n"); } // --- PARAGRAPH SPACING FIX --- const fixParagraphSpacing = (() => { function removeLeadingBrs(userstuff) { userstuff.querySelectorAll("p").forEach((p) => { let changed = true; while (changed) { changed = false; if (p.firstChild?.tagName === "BR") { p.firstChild.remove(); changed = true; } else if ( p.firstChild?.nodeType === Node.TEXT_NODE && !p.firstChild.textContent.trim() ) { p.firstChild.remove(); changed = true; } } }); } function removeTrailingBrs(userstuff) { userstuff.querySelectorAll("p").forEach((p) => { let changed = true; while (changed) { changed = false; if (p.lastChild?.tagName === "BR") { p.lastChild.remove(); changed = true; } else if ( p.lastChild?.nodeType === Node.TEXT_NODE && !p.lastChild.textContent.trim() ) { p.lastChild.remove(); changed = true; } } }); } function removeEmptyParagraphs(userstuff) { userstuff.querySelectorAll("p").forEach((p) => { const content = p.textContent?.replace(/\u00A0/g, "").trim(); if (!content && !p.querySelector("img, embed, iframe, video, br")) { p.remove(); } }); } function removeEmptyElement(el) { const content = el.textContent?.replace(/\u00A0/g, "").trim(); if ( !content && el.tagName !== "BR" && el.tagName !== "HR" && !el.querySelector("img, embed, iframe, video") ) { el.remove(); } } function reduceBrs(userstuff) { const brs = Array.from(userstuff.querySelectorAll("br")); for (let i = 0; i < brs.length; i++) { const br = brs[i]; let consecutiveCount = 1; let nextNode = br.nextSibling; while (nextNode) { if ( nextNode.nodeType === Node.ELEMENT_NODE && nextNode.tagName === "BR" ) { consecutiveCount++; nextNode = nextNode.nextSibling; } else if ( nextNode.nodeType === Node.TEXT_NODE && !nextNode.textContent.trim() ) { nextNode = nextNode.nextSibling; } else { break; } } if (consecutiveCount >= 3) { let toRemove = consecutiveCount - 2; nextNode = br.nextSibling; while (toRemove > 0 && nextNode) { const current = nextNode; nextNode = nextNode.nextSibling; if ( current.nodeType === Node.ELEMENT_NODE && current.tagName === "BR" ) { current.remove(); toRemove--; } } } } } const BLOCK_TAGS = [ "div", "blockquote", "ul", "ol", "table", "h1", "h2", "h3", "h4", "h5", "h6", ]; return function () { if (!WORKS_PAGE_REGEX.test(window.location.href)) return; document .querySelectorAll( "#workskin .userstuff:not([data-formatter-spacing-fixed])" ) .forEach((userstuff) => { userstuff.setAttribute("data-formatter-spacing-fixed", "true"); removeLeadingBrs(userstuff); removeTrailingBrs(userstuff); removeEmptyParagraphs(userstuff); BLOCK_TAGS.forEach((tag) => { userstuff.querySelectorAll(tag).forEach((child) => { removeEmptyElement(child); }); }); reduceBrs(userstuff); }); }; })(); // --- SETTINGS MENU --- function showFormatterMenu() { // Safety check: ensure library is loaded if (!window.AO3MenuHelpers) { console.error("[AO3: Site Wizard] Menu Helpers library not loaded"); alert( "Error: Menu Helpers library not loaded. Please check your userscript manager." ); return; } window.AO3MenuHelpers.removeAllDialogs(); const dialog = window.AO3MenuHelpers.createDialog( "🪄 Site Wizard Settings 🪄", { maxWidth: "700px", } ); // Site-Wide Display Section const siteSection = window.AO3MenuHelpers.createSection( "📱 Site-Wide Display" ); const siteFontSize = window.AO3MenuHelpers.createSliderWithValue({ id: "site-fontsize-input", label: "Base Font Size", min: 50, max: 200, step: 5, value: FORMATTER_CONFIG.siteFontSizePercent, unit: "%", tooltip: "Adjust the overall text size for the entire site (percentage of browser default)", }); siteSection.appendChild(siteFontSize); const siteFontFamily = window.AO3MenuHelpers.createTextInput({ id: "site-fontfamily-input", label: "General Text Font", value: FORMATTER_CONFIG.siteFontFamily, placeholder: "Figtree, sans-serif", tooltip: "Font for most site text", }); const siteFontWeight = window.AO3MenuHelpers.createTextInput({ id: "site-fontweight-input", label: "Font Weight", value: FORMATTER_CONFIG.siteFontWeight, placeholder: "400, normal", tooltip: "Boldness of general text", }); const siteFontRow = window.AO3MenuHelpers.createTwoColumnLayout( siteFontFamily, siteFontWeight ); siteSection.appendChild(siteFontRow); dialog.appendChild(siteSection); // Work Formatting Section const workSection = window.AO3MenuHelpers.createSection("📖 Work Formatting"); const workWidth = window.AO3MenuHelpers.createSliderWithValue({ id: "paragraph-width-slider", label: "Work Margin Width", min: 10, max: 100, step: 5, value: FORMATTER_CONFIG.paragraphWidthPercent, unit: "%", tooltip: "Maximum width of work reader", }); workSection.appendChild(workWidth); const workFontSize = window.AO3MenuHelpers.createSliderWithValue({ id: "paragraph-fontsize-slider", label: "Work Font Size", min: 50, max: 200, step: 5, value: FORMATTER_CONFIG.paragraphFontSizePercent, unit: "%", tooltip: "Size relative to site base size", }); workSection.appendChild(workFontSize); const workFont = window.AO3MenuHelpers.createTextInput({ id: "paragraph-fontfamily-input", label: "Work Font", value: FORMATTER_CONFIG.paragraphFontFamily, placeholder: "Figtree, sans-serif", tooltip: "Font family for reader", }); workSection.appendChild(workFont); const textAlign = window.AO3MenuHelpers.createSelect({ id: "paragraph-align-select", label: "Text Alignment", options: [ { value: "left", label: "Left Aligned", selected: FORMATTER_CONFIG.paragraphTextAlign === "left", }, { value: "justify", label: "Justified", selected: FORMATTER_CONFIG.paragraphTextAlign === "justify", }, { value: "right", label: "Right Aligned", selected: FORMATTER_CONFIG.paragraphTextAlign === "right", }, ], tooltip: "How text is aligned within paragraphs", }); const lineSpacing = window.AO3MenuHelpers.createNumberInput({ id: "paragraph-gap-input", label: "Line Spacing", value: FORMATTER_CONFIG.paragraphGap, min: 0, step: 0.1, tooltip: "Vertical space between paragraphs (multiplier). Default is 1.286.", }); const alignSpacingRow = window.AO3MenuHelpers.createTwoColumnLayout( textAlign, lineSpacing ); workSection.appendChild(alignSpacingRow); const fixSpacing = window.AO3MenuHelpers.createCheckbox({ id: "fix-paragraph-spacing-checkbox", label: "Fix excessive paragraph spacing", checked: FORMATTER_CONFIG.fixParagraphSpacing, tooltip: "Remove unnecessary blank space between paragraphs", }); workSection.appendChild(fixSpacing); dialog.appendChild(workSection); // Element-Specific Fonts Section const elementSection = window.AO3MenuHelpers.createSection( "🎯 Element-Specific Fonts" ); const headerFont = window.AO3MenuHelpers.createTextInput({ id: "header-fontfamily-input", label: "Header Font", value: FORMATTER_CONFIG.headerFontFamily, placeholder: "Figtree, sans-serif", tooltip: "Font for headings (H1-H6)", }); const headerWeight = window.AO3MenuHelpers.createTextInput({ id: "header-fontweight-input", label: "Header Weight", value: FORMATTER_CONFIG.headerFontWeight, placeholder: "700, bold", tooltip: "Boldness of header text", }); const headerRow = window.AO3MenuHelpers.createTwoColumnLayout( headerFont, headerWeight ); elementSection.appendChild(headerRow); const codeFont = window.AO3MenuHelpers.createTextInput({ id: "code-fontfamily-input", label: "Code/Monospace Font", value: FORMATTER_CONFIG.codeFontFamily, placeholder: "Victor Mono Medium, monospace", tooltip: "Font for code blocks and preformatted text", }); elementSection.appendChild(codeFont); const codeFontSize = window.AO3MenuHelpers.createTextInput({ id: "code-fontsize-input", label: "Code Font Size", value: FORMATTER_CONFIG.codeFontSize, placeholder: "0.9em, 14px", tooltip: "Size relative to surrounding text", }); const codeFontStyle = window.AO3MenuHelpers.createSelect({ id: "code-fontstyle-select", label: "Code Font Style", options: [ { value: "normal", label: "Normal", selected: !FORMATTER_CONFIG.codeFontStyle || FORMATTER_CONFIG.codeFontStyle === "normal", }, { value: "italic", label: "Italic", selected: FORMATTER_CONFIG.codeFontStyle === "italic", }, ], tooltip: "Style for code text", }); const codeRow = window.AO3MenuHelpers.createTwoColumnLayout( codeFontSize, codeFontStyle ); elementSection.appendChild(codeRow); const expandCodeFont = window.AO3MenuHelpers.createCheckbox({ id: "expand-code-font-checkbox", label: "Apply code font to comments", checked: FORMATTER_CONFIG.expandCodeFontUsage, tooltip: "Applies code font to all textareas. Requires a code/monospace font to be specified above.", }); elementSection.appendChild(expandCodeFont); dialog.appendChild(elementSection); // Buttons const buttons = window.AO3MenuHelpers.createButtonGroup([ { text: "Save", id: "formatter-save" }, { text: "Cancel", id: "formatter-cancel" }, ]); dialog.appendChild(buttons); // Reset Link const resetLink = window.AO3MenuHelpers.createResetLink( "Reset to Default Settings", () => { FORMATTER_CONFIG = { ...DEFAULT_FORMATTER_CONFIG }; saveFormatterConfig(); dialog.remove(); applyParagraphWidth(); } ); dialog.appendChild(resetLink); // Event Handlers dialog.querySelector("#formatter-save").addEventListener("click", () => { FORMATTER_CONFIG.siteFontSizePercent = window.AO3MenuHelpers.getValue("site-fontsize-input") || DEFAULT_FORMATTER_CONFIG.siteFontSizePercent; FORMATTER_CONFIG.siteFontFamily = window.AO3MenuHelpers.getValue("site-fontfamily-input") || ""; FORMATTER_CONFIG.siteFontWeight = window.AO3MenuHelpers.getValue("site-fontweight-input") || ""; FORMATTER_CONFIG.paragraphWidthPercent = window.AO3MenuHelpers.getValue("paragraph-width-slider") || DEFAULT_FORMATTER_CONFIG.paragraphWidthPercent; FORMATTER_CONFIG.paragraphFontSizePercent = window.AO3MenuHelpers.getValue("paragraph-fontsize-slider") || DEFAULT_FORMATTER_CONFIG.paragraphFontSizePercent; FORMATTER_CONFIG.paragraphTextAlign = window.AO3MenuHelpers.getValue("paragraph-align-select") || DEFAULT_FORMATTER_CONFIG.paragraphTextAlign; FORMATTER_CONFIG.paragraphFontFamily = window.AO3MenuHelpers.getValue("paragraph-fontfamily-input") || ""; FORMATTER_CONFIG.paragraphGap = window.AO3MenuHelpers.getValue("paragraph-gap-input") || DEFAULT_FORMATTER_CONFIG.paragraphGap; FORMATTER_CONFIG.fixParagraphSpacing = window.AO3MenuHelpers.getValue("fix-paragraph-spacing-checkbox") ?? false; FORMATTER_CONFIG.headerFontFamily = window.AO3MenuHelpers.getValue("header-fontfamily-input") || ""; FORMATTER_CONFIG.headerFontWeight = window.AO3MenuHelpers.getValue("header-fontweight-input") || ""; FORMATTER_CONFIG.codeFontFamily = window.AO3MenuHelpers.getValue("code-fontfamily-input") || ""; FORMATTER_CONFIG.codeFontStyle = window.AO3MenuHelpers.getValue("code-fontstyle-select") || "normal"; FORMATTER_CONFIG.codeFontSize = window.AO3MenuHelpers.getValue("code-fontsize-input") || ""; FORMATTER_CONFIG.expandCodeFontUsage = window.AO3MenuHelpers.getValue("expand-code-font-checkbox") ?? false; saveFormatterConfig(); dialog.remove(); applyParagraphWidth(); if (FORMATTER_CONFIG.paragraphTextAlign === "right") { location.reload(); } }); dialog.querySelector("#formatter-cancel").addEventListener("click", () => { dialog.remove(); }); document.body.appendChild(dialog); } // --- SHARED MENU MANAGEMENT --- function initSharedMenu() { if (window.AO3MenuHelpers) { window.AO3MenuHelpers.addToSharedMenu({ id: "opencfg_site_wizard", text: "Site Wizard", onClick: showFormatterMenu, }); } } // --- INITIALIZATION --- loadFormatterConfig(); console.log("[AO3: Site Wizard] loaded."); function initStyles() { if (document.head) { applyParagraphWidth(); } else { const observer = new MutationObserver(() => { if (document.head) { observer.disconnect(); applyParagraphWidth(); } }); observer.observe(document.documentElement, { childList: true }); } } function runParagraphSpacingFixIfEnabled() { if ( FORMATTER_CONFIG.fixParagraphSpacing && WORKS_PAGE_REGEX.test(window.location.href) ) { fixParagraphSpacing(); } } initStyles(); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { runParagraphSpacingFixIfEnabled(); initSharedMenu(); }); } else { runParagraphSpacingFixIfEnabled(); initSharedMenu(); } })();