// ==UserScript== // @name AO3: Site Wizard // @version 1.1.2 // @description Change fonts and font sizes across the site easily and fix paragraph spacing issues. // @author Blackbatcat // @match http://archiveofourown.org/* // @match https://archiveofourown.org/* // @license MIT // @grant none // @run-at document-start // @namespace https://greasyfork.org/users/1498004 // @downloadURL none // ==/UserScript== (function () { "use strict"; // --- SETTINGS STORAGE --- const FORMATTER_CONFIG_KEY = "ao3_formatter_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: "", codeFontSize: "", }; let FORMATTER_CONFIG = { ...DEFAULT_FORMATTER_CONFIG }; 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() { const percent = FORMATTER_CONFIG.paragraphWidthPercent; const fontSize = FORMATTER_CONFIG.paragraphFontSizePercent; const textAlign = FORMATTER_CONFIG.paragraphTextAlign; let fontFamily = FORMATTER_CONFIG.paragraphFontFamily; const gap = FORMATTER_CONFIG.paragraphGap; const paraStyleId = "ao3-formatter-paragraph-style"; let paraStyle = document.getElementById(paraStyleId); if (!paraStyle) { paraStyle = document.createElement("style"); paraStyle.id = paraStyleId; document.head.appendChild(paraStyle); } // Always apply styles to #workskin if present paraStyle.textContent = ` .userstuff { text-align: ${textAlign || "left"} !important; } #workskin { max-width: ${percent || 70}vw !important; font-size: ${fontSize || 100}% !important; } #workskin p { margin-bottom: ${gap || 1.286}em !important; ${fontFamily ? `font-family: ${fontFamily} !important;` : ""} } `; // If right alignment is selected, set dir="rtl" on #workskin const workskin = document.getElementById('workskin'); if (workskin) { if (textAlign === 'right') { workskin.setAttribute('dir', 'rtl'); } else { workskin.removeAttribute('dir'); } } // --- SITE-WIDE STYLES --- const siteStyleId = "ao3-sitewide-style"; let siteStyle = document.getElementById(siteStyleId); if (!siteStyle) { siteStyle = document.createElement("style"); siteStyle.id = siteStyleId; document.head.appendChild(siteStyle); } // Expanded selectors to cover more site elements const generalSelectors = ` body, input, textarea, select, button, .toggled form, .dynamic form, .secondary, .dropdown, blockquote, .prompt .blurb h6, .bookmark .user .meta, a.work, span.symbol, .heading .actions, .heading .action, .heading span.actions, button, span.unread, .replied, span.claimed, .actions span.defaulted, .splash .news .meta, .datetime, h5.fandoms.heading a.tag, dd.fandom.tags a, #dashboard, #header, #main, #footer, .navigation, .menu, .dropdown-menu, .blurb, .meta, .stats, .tags, .module, .wrapper, .region, li, span, div, a, p, label, .user, .current, .action, .notice, .comment, .thread, .work, .bookmark, .series, .pagination, .current `; const headerSelectors = `h1, h2, h3, h4, h5, h6, .heading`; const codeSelectors = `kbd, tt, code, var, pre, samp, textarea, textarea#skin_css, .css.module blockquote pre, #floaty-textarea`; // Build CSS with proper !important handling let siteStyleContent = ` html { font-size: ${FORMATTER_CONFIG.siteFontSizePercent || 100}% !important; } ${generalSelectors}, ${headerSelectors} { ${FORMATTER_CONFIG.siteFontFamily ? `font-family: ${FORMATTER_CONFIG.siteFontFamily} !important;` : ""} } ${generalSelectors} { ${FORMATTER_CONFIG.siteFontWeight ? `font-weight: ${FORMATTER_CONFIG.siteFontWeight} !important;` : ""} } ${headerSelectors} { ${FORMATTER_CONFIG.headerFontWeight ? `font-weight: ${FORMATTER_CONFIG.headerFontWeight} !important;` : ""} } ${codeSelectors} { ${FORMATTER_CONFIG.codeFontFamily ? `font-family: ${FORMATTER_CONFIG.codeFontFamily} !important;` : ""} ${FORMATTER_CONFIG.codeFontStyle ? `font-style: ${FORMATTER_CONFIG.codeFontStyle} !important;` : ""} ${FORMATTER_CONFIG.codeFontSize ? `font-size: ${FORMATTER_CONFIG.codeFontSize} !important;` : ""} } `; siteStyle.textContent = siteStyleContent; } // --- PARAGRAPH SPACING FIX --- function fixParagraphSpacing() { // Helper functions function stripBrs(el, leading = true, trailing = true) { if (leading) { while (el.firstChild && el.firstChild.tagName === "BR") { el.firstChild.remove(); } } if (trailing) { while (el.lastChild && el.lastChild.tagName === "BR") { el.lastChild.remove(); } } } function removeEmptyElement(el) { const content = el.textContent && 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) { let el = userstuff.querySelector("br + br + br"); while (el) { el.remove(); el = userstuff.querySelector("br + br + br"); } } document.querySelectorAll(".userstuff").forEach((userstuff) => { // Only run once per userstuff if (userstuff.getAttribute("data-formatter-spacing-fixed")) return; userstuff.setAttribute("data-formatter-spacing-fixed", "true"); // Clean up allowed tags ["p", "div", "span", "blockquote", "pre", "li", "ul", "ol", "table", "tr", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"].forEach((tag) => { userstuff.querySelectorAll(tag).forEach((child) => { stripBrs(child); removeEmptyElement(child); }); }); reduceBrs(userstuff); }); } // --- SETTINGS MENU --- function showFormatterMenu() { document.querySelectorAll(".ao3-formatter-menu-dialog").forEach((d) => d.remove()); // Get AO3 input field background color let inputBg = "#fffaf5"; const testInput = document.createElement("input"); document.body.appendChild(testInput); try { const computedBg = window.getComputedStyle(testInput).backgroundColor; if (computedBg && computedBg !== "rgba(0, 0, 0, 0)" && computedBg !== "transparent") { inputBg = computedBg; } } catch (e) {} testInput.remove(); const dialog = document.createElement("div"); dialog.className = "ao3-formatter-menu-dialog"; dialog.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: ${inputBg}; padding: 20px; border-radius: 8px; box-shadow: 0 0 20px rgba(0,0,0,0.2); z-index: 10000; width: 90%; max-width: 900px; max-height: 80vh; overflow-y: auto; font-family: inherit; font-size: inherit; color: inherit; box-sizing: border-box; `; // Add CSS for the improved layout const style = document.createElement("style"); style.textContent = ` .ao3-formatter-menu-dialog .settings-section { background: rgba(0,0,0,0.03); border-radius: 6px; padding: 15px; margin-bottom: 20px; border-left: 4px solid currentColor; } .ao3-formatter-menu-dialog .section-title { margin-top: 0; margin-bottom: 15px; font-size: 1.2em; font-weight: bold; color: inherit; opacity: 0.85; font-family: inherit; } .ao3-formatter-menu-dialog .setting-group { margin-bottom: 15px; } .ao3-formatter-menu-dialog .setting-label { display: block; margin-bottom: 6px; font-weight: bold; color: inherit; opacity: 0.9; } .ao3-formatter-menu-dialog .setting-description { display: block; margin-bottom: 8px; font-size: 0.9em; color: inherit; opacity: 0.6; line-height: 1.4; } .ao3-formatter-menu-dialog .two-column { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; } .ao3-formatter-menu-dialog .slider-with-value { display: flex; align-items: center; gap: 10px; } .ao3-formatter-menu-dialog .slider-with-value input[type="range"] { flex-grow: 1; } .ao3-formatter-menu-dialog .value-display { min-width: 40px; text-align: center; font-weight: bold; color: inherit; opacity: 0.6; } .ao3-formatter-menu-dialog .button-group { display: flex; justify-content: space-between; gap: 10px; margin-top: 20px; } .ao3-formatter-menu-dialog .button-group button { flex: 1; padding: 10px; color: inherit; opacity: 0.9; } .ao3-formatter-menu-dialog .reset-link { text-align: center; margin-top: 10px; color: inherit; opacity: 0.7; } `; document.head.appendChild(style); dialog.innerHTML = `

🪄 Site Wizard Settings 🪄

📱 Site-Wide Display

Adjust the overall text size for the entire site (percentage of browser default)
${FORMATTER_CONFIG.siteFontSizePercent}%
Font for most site text
Boldness of general text

📝 Work Formatting

Maximum width of work reader
${FORMATTER_CONFIG.paragraphWidthPercent}%
Size relative to site base size
${FORMATTER_CONFIG.paragraphFontSizePercent}%
How text is aligned within paragraphs
Vertical space between paragraphs (multiplier)
Font family for reader
Remove unnecessary blank space between paragraphs

🎯 Element-Specific Fonts

Font for headings (H1-H6)
Boldness of header text
Font for code blocks and preformatted text
Style for code text
Size relative to surrounding text
`; document.body.appendChild(dialog); // Add event listeners for sliders to update values in real-time const sliders = [ { slider: "site-fontsize-input", value: "site-fontsize-value" }, { slider: "paragraph-width-slider", value: "paragraph-width-value" }, { slider: "paragraph-fontsize-slider", value: "paragraph-fontsize-value" }, ]; sliders.forEach(({ slider, value }) => { const sliderEl = dialog.querySelector(`#${slider}`); const valueEl = dialog.querySelector(`#${value}`); if (sliderEl && valueEl) { sliderEl.addEventListener("input", () => { valueEl.textContent = sliderEl.value; }); } }); // Save button handler dialog.querySelector("#formatter-save").addEventListener("click", () => { // Get all values FORMATTER_CONFIG.siteFontSizePercent = parseInt(dialog.querySelector("#site-fontsize-input").value, 10) || DEFAULT_FORMATTER_CONFIG.siteFontSizePercent; FORMATTER_CONFIG.siteFontFamily = dialog.querySelector("#site-fontfamily-input").value.trim(); FORMATTER_CONFIG.siteFontWeight = dialog.querySelector("#site-fontweight-input").value.trim(); FORMATTER_CONFIG.paragraphWidthPercent = parseInt(dialog.querySelector("#paragraph-width-slider").value, 10) || DEFAULT_FORMATTER_CONFIG.paragraphWidthPercent; FORMATTER_CONFIG.paragraphFontSizePercent = parseInt(dialog.querySelector("#paragraph-fontsize-slider").value, 10) || DEFAULT_FORMATTER_CONFIG.paragraphFontSizePercent; FORMATTER_CONFIG.paragraphTextAlign = dialog.querySelector("#paragraph-align-select").value || DEFAULT_FORMATTER_CONFIG.paragraphTextAlign; FORMATTER_CONFIG.paragraphFontFamily = dialog.querySelector("#paragraph-fontfamily-input").value.trim(); FORMATTER_CONFIG.paragraphGap = parseFloat(dialog.querySelector("#paragraph-gap-input").value) || DEFAULT_FORMATTER_CONFIG.paragraphGap; FORMATTER_CONFIG.fixParagraphSpacing = dialog.querySelector("#fix-paragraph-spacing-checkbox").checked; FORMATTER_CONFIG.headerFontFamily = dialog.querySelector("#header-fontfamily-input").value.trim(); FORMATTER_CONFIG.headerFontWeight = dialog.querySelector("#header-fontweight-input").value.trim(); FORMATTER_CONFIG.codeFontFamily = dialog.querySelector("#code-fontfamily-input").value.trim(); FORMATTER_CONFIG.codeFontStyle = dialog.querySelector("#code-fontstyle-select").value; FORMATTER_CONFIG.codeFontSize = dialog.querySelector("#code-fontsize-input").value.trim(); saveFormatterConfig(); dialog.remove(); applyParagraphWidth(); }); // Cancel button handler dialog.querySelector("#formatter-cancel").addEventListener("click", () => { dialog.remove(); }); // Reset link handler dialog.querySelector("#resetFormatterSettingsLink").addEventListener("click", function (e) { e.preventDefault(); FORMATTER_CONFIG = { ...DEFAULT_FORMATTER_CONFIG }; saveFormatterConfig(); dialog.remove(); applyParagraphWidth(); }); } // --- SHARED MENU MANAGEMENT --- function initSharedMenu() { // Create shared menu object if it doesn't exist if (!window.AO3UserScriptMenu) { window.AO3UserScriptMenu = { items: [], register: function(item) { this.items.push(item); this.renderMenu(); }, renderMenu: function() { // Find or create menu container let menuContainer = document.getElementById('ao3-userscript-menu'); if (!menuContainer) { const headerMenu = document.querySelector("ul.primary.navigation.actions"); const searchItem = headerMenu ? headerMenu.querySelector("li.search") : null; if (!headerMenu || !searchItem) return; menuContainer = document.createElement("li"); menuContainer.className = "dropdown"; menuContainer.id = "ao3-userscript-menu"; const title = document.createElement("a"); title.href = "#"; title.textContent = "Userscripts"; menuContainer.appendChild(title); const menu = document.createElement("ul"); menu.className = "menu dropdown-menu"; menuContainer.appendChild(menu); headerMenu.insertBefore(menuContainer, searchItem); } // Render menu items const menu = menuContainer.querySelector("ul.menu"); if (menu) { menu.innerHTML = ""; this.items.forEach(item => { const li = document.createElement("li"); const a = document.createElement("a"); a.href = "#"; a.textContent = item.label; a.addEventListener("click", (e) => { e.preventDefault(); item.onClick(); }); li.appendChild(a); menu.appendChild(li); }); } } }; } // Register this script's menu item window.AO3UserScriptMenu.register({ label: "Site Wizard Settings", onClick: showFormatterMenu }); } // --- INITIALIZATION --- loadFormatterConfig(); // Apply styles immediately without waiting for DOMContentLoaded if (document.head) { applyParagraphWidth(); } else { // If head doesn't exist yet, wait for it const observer = new MutationObserver(function(mutations) { if (document.head) { observer.disconnect(); applyParagraphWidth(); } }); observer.observe(document.documentElement, { childList: true }); } // Run fixParagraphSpacing independently on page load if enabled function runParagraphSpacingFixIfEnabled() { if (FORMATTER_CONFIG.fixParagraphSpacing) { fixParagraphSpacing(); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', runParagraphSpacingFixIfEnabled); } else { runParagraphSpacingFixIfEnabled(); } // MutationObserver to process new .userstuff elements, only after document.body exists function setupUserstuffObserver() { if (!document.body) { document.addEventListener('DOMContentLoaded', setupUserstuffObserver); return; } const userstuffObserver = new MutationObserver((mutations) => { if (!FORMATTER_CONFIG.fixParagraphSpacing) return; mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1) { if (node.classList.contains('userstuff')) { // Only process if not already fixed if (!node.getAttribute('data-formatter-spacing-fixed')) { fixParagraphSpacing(); } } else { // Check descendants node.querySelectorAll && node.querySelectorAll('.userstuff').forEach((el) => { if (!el.getAttribute('data-formatter-spacing-fixed')) { fixParagraphSpacing(); } }); } } }); }); }); userstuffObserver.observe(document.body, { childList: true, subtree: true }); } if (document.body) { setupUserstuffObserver(); } else { document.addEventListener('DOMContentLoaded', setupUserstuffObserver); } // Initialize menu when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initSharedMenu); } else { initSharedMenu(); } })();