Greasy Fork is available in English.
Make AO3 easier to read: customize fonts and sizes, set site colors, adjust work reader margins, fix spacing issues, and configure text alignment preferences.
// ==UserScript==
// @name AO3: Site Wizard
// @version 3.6.1
// @description Make AO3 easier to read: customize fonts and sizes, set site colors, 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/1757286/AO3%3A%20Menu%20Helpers%20Library.js?v=2.1.7
// @grant none
// @run-at document-start
// @namespace http://greasyfork.icu/users/1498004
// ==/UserScript==
(function () {
"use strict";
// --- CONSTANTS ---
const FORMATTER_CONFIG_KEY = "ao3_wizard_config";
const DEFAULT_FORMATTER_CONFIG = {
paragraphWidthPercent: 70,
paragraphFontSizePercent: 100,
paragraphTextAlign: "",
paragraphFontFamily: "",
fixParagraphSpacing: true,
removeIndentation: false,
paragraphGap: 1.286,
siteFontFamily: "",
siteFontWeight: "",
siteFontSizePercent: 100,
headerFontFamily: "",
headerFontWeight: "",
codeFontFamily: "",
codeFontStyle: "normal",
codeFontSize: "",
expandCodeFontUsage: false,
applyCodeFontToDates: false,
applyCodeFontSizeToDates: false,
applyCodeFontStyleToDates: false,
backgroundColor: "",
textColor: "",
headerColor: "",
accentColor: "",
logoColor: "",
};
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 = `
${paragraphTextAlign ? `#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;
}
#chapters .userstuff,
#chapters .userstuff p,
#chapters .userstuff li,
#chapters .userstuff blockquote,
.chapter .userstuff,
.chapter .userstuff p,
.chapter .userstuff li,
.chapter .userstuff blockquote {
font-size: inherit !important;
}
#workskin p {
margin-bottom: ${paragraphGap}em !important;
}
${paragraphTextAlign ? `#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,
applyCodeFontToDates,
applyCodeFontSizeToDates,
applyCodeFontStyleToDates,
backgroundColor,
textColor,
headerColor,
accentColor,
} = 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},
#chapters .userstuff,
#chapters .userstuff p,
#chapters .userstuff li,
#chapters .userstuff blockquote,
.chapter .userstuff,
.chapter .userstuff p,
.chapter .userstuff li,
.chapter .userstuff blockquote { font-family: ${paragraphFontFamily} !important; }`
);
} else {
rules.push(
`#workskin, #workskin *:not(code):not(pre):not(tt):not(kbd):not(samp):not(var)${textareaExclusion},
#chapters .userstuff,
#chapters .userstuff p,
#chapters .userstuff li,
#chapters .userstuff blockquote,
.chapter .userstuff,
.chapter .userstuff p,
.chapter .userstuff li,
.chapter .userstuff blockquote { 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;
}`
);
if (applyCodeFontToDates) {
const dateFontFamily = codeFontFamily || "Consolas, Monaco, Courier, monospace";
const dateRules = [`font-family: ${dateFontFamily} !important`];
if (applyCodeFontSizeToDates && codeFontSize)
dateRules.push(`font-size: ${codeFontSize} !important`);
if (applyCodeFontStyleToDates && codeFontStyle && codeFontStyle !== "normal")
dateRules.push(`font-style: ${codeFontStyle} !important`);
rules.push(
`.splash .news .meta, .splash .news .meta *, .datetime, .datetime * { ${dateRules.join("; ")}; }`
);
}
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");
applyColorStyles();
}
function applyColorStyles() {
const colorStyleId = "ao3-color-style";
let colorStyle = document.getElementById(colorStyleId);
const { backgroundColor, textColor, headerColor, accentColor, logoColor } =
FORMATTER_CONFIG;
// Remove existing color styles if all colors are blank
if (
!backgroundColor &&
!textColor &&
!headerColor &&
!accentColor &&
!logoColor
) {
if (colorStyle) {
colorStyle.remove();
}
return;
}
// Create style element if it doesn't exist
if (!colorStyle) {
colorStyle = getOrCreateStyle(colorStyleId);
if (!colorStyle) return;
}
const rules = [];
const rootVars = [];
// Build CSS custom properties
if (backgroundColor)
rootVars.push(`--background-color: ${backgroundColor}`);
if (textColor) rootVars.push(`--text-color: ${textColor}`);
if (headerColor) rootVars.push(`--header-color: ${headerColor}`);
if (accentColor) rootVars.push(`--accent-color: ${accentColor}`);
if (logoColor) rootVars.push(`--logo-filter: ${logoColor}`);
// Add single :root declaration
if (rootVars.length > 0) {
rules.push(`:root {\n ${rootVars.join(";\n ")};\n}`);
}
// Apply styles matching ao3_sw_colors.css structure exactly
if (backgroundColor) {
rules.push(`#outer {\n background-color: var(--background-color);\n}`);
rules.push(
`.listbox .index {\n background: var(--background-color);\n}`
);
}
if (headerColor) {
rules.push(`#header .primary,
#footer,
.autocomplete .dropdown ul li:hover,
.autocomplete .dropdown li.selected,
a.tag:hover,
.listbox .heading a.tag:visited:hover,
.splash .favorite li:nth-of-type(2n+1) a:hover,
.splash .favorite li:nth-of-type(2n+1) a:focus,
#tos_prompt .heading {
background-image: none;
background-color: var(--header-color);
}`);
}
if (textColor) {
rules.push(`h2,
a,
a:link,
a:visited,
a:hover,
#header a,
#header a:visited,
#header .primary .open a,
#header .primary .dropdown:hover a,
#header .primary .dropdown a:focus,
#header .primary .menu a,
#dashboard a,
#dashboard span,
a.tag,
.listbox>.heading,
.listbox .heading a:visited,
.filters dt a:hover {
color: var(--text-color);
}
.qtip-content, .notice:not(.required), .comment_notice, .kudos_notice, ul.notes, .caution, .notice a, .error, .comment_error, .kudos_error, .alert.flash, form .notice {
color: #2a2a2a !important;
}`);
}
// Always apply white text to userscripts dropdown menu
rules.push(`#scriptconfig > a:nth-child(1) {
color: #fff !important;
}`);
if (headerColor) {
rules.push(`#dashboard,
#dashboard.own {
border-color: var(--header-color);
}`);
}
if (accentColor) {
rules.push(`#dashboard a:hover,
#dashboard .current,
li.relationships a {
background: var(--accent-color);
}`);
}
if (accentColor) {
rules.push(`table,
thead td,
#header .actions a:hover,
#header .actions a:focus,
#header .dropdown:hover a,
#header .open a,
#header .menu,
#small_login,
fieldset,
form dl,
fieldset dl dl,
fieldset fieldset fieldset,
fieldset fieldset dl dl,
.ui-sortable li,
.ui-sortable li:hover,
dd.hideme,
form blockquote.userstuff,
dl.index dd,
.statistics .index li:nth-of-type(2n),
.listbox,
fieldset fieldset.listbox,
.item dl.visibility,
.reading h4.viewed,
.comment h4.byline,
.splash .favorite li:nth-of-type(2n+1) a,
.splash .module div.account,
.search [role="tooltip"] {
background: var(--accent-color);
border-color: var(--accent-color);
}`);
// Preserve box-shadow for fieldset elements
rules.push(`fieldset {
box-shadow: inset 1px 0 5px rgba(0, 0, 0, 0.5);
}`);
}
if (headerColor) {
rules.push(`#header .heading a,
#header .user a:hover,
#header .user a:focus,
#dashboard a:hover,
.actions a:hover,
.actions button:hover,
.actions input:hover,
.actions a:focus,
.actions button:focus,
.actions input:focus,
label.action:hover,
.action:hover,
.action:focus,
a.cloud1,
a.cloud2,
a.cloud3,
a.cloud4,
a.cloud5,
a.cloud6,
a.cloud7,
a.cloud8,
a.work,
.blurb h4 a:link,
.splash .module h3,
.splash .browse li a::before {
color: var(--header-color);
}`);
}
if (textColor) {
rules.push(`body,
.toggled form,
.dynamic form,
.secondary,
.dropdown,
#header .search,
form dd.required,
.post .required .warnings,
dd.required,
.required .autocomplete,
span.series .divider,
.filters .expander,
.userstuff h2 {
color: var(--text-color);
}`);
}
if (accentColor) {
rules.push(`li.blurb,
fieldset,
form dl,
thead,
tfoot,
tfoot td,
th,
tr:hover,
col.name,
#dashboard ul,
.toggled form,
.dynamic form,
form.verbose legend,
.verbose form legend,
.secondary,
.work.navigation .download,
.javascript .work.navigation .download .secondary,
dl.meta,
.bookmark .user,
div.comment,
li.comment,
.comment div.icon,
.splash .news li,
.userstuff blockquote {
border-color: var(--accent-color);
}`);
}
if (backgroundColor) {
rules.push(`body,
.toggled form,
.dynamic form,
.secondary,
.dropdown,
th,
tr:hover,
col.name,
div.dynamic,
fieldset fieldset,
fieldset dl dl,
form blockquote.userstuff,
form.verbose legend,
.verbose form legend,
#modal,
.work.navigation .download,
.javascript .work.navigation .download .secondary,
.own,
.draft,
.draft .wrapper,
.unread,
.child,
.unwrangled,
.unreviewed,
.thread .even,
.listbox .index,
.nomination dt,
#tos_prompt {
background: var(--background-color);
}`);
}
if (textColor) {
rules.push(`form dt,
.filters .group dt.bookmarker,
.faq .categories h3,
.splash .module h3,
.userstuff h3 {
border-color: var(--text-color);
}`);
}
if (logoColor) {
rules.push(`#header .logo { filter: var(--logo-filter); }`);
}
colorStyle.textContent = rules.join("\n\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);
});
};
})();
const removeParaIndentation = (() => {
return function () {
if (!WORKS_PAGE_REGEX.test(window.location.href)) return;
document
.querySelectorAll(
"#workskin .userstuff:not([data-formatter-indent-fixed])"
)
.forEach((userstuff) => {
userstuff.setAttribute("data-formatter-indent-fixed", "true");
userstuff.querySelectorAll("p").forEach((p) => {
// Walk into inline wrappers (span, em, strong, etc.) to find first text node
let node = p.firstChild;
while (node && node.nodeType === Node.ELEMENT_NODE && node.childNodes.length > 0) {
const tag = node.tagName.toLowerCase();
// Stop at block-level elements
if (["br", "hr", "div", "blockquote", "ul", "ol", "table"].includes(tag)) break;
node = node.firstChild;
}
if (node && node.nodeType === Node.TEXT_NODE) {
node.textContent = node.textContent.replace(/^[\u00A0 \t]+/, "");
}
});
});
};
})();
// --- 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: "",
label: "Default",
selected: !FORMATTER_CONFIG.paragraphTextAlign,
},
{
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);
const removeIndentation = window.AO3MenuHelpers.createCheckbox({
id: "remove-indentation-checkbox",
label: "Remove paragraph indentation",
checked: FORMATTER_CONFIG.removeIndentation,
tooltip: "Remove manual indentation (non-breaking spaces) added at the start of paragraphs by some authors.",
});
workSection.appendChild(removeIndentation);
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);
const applyCodeFontDates = window.AO3MenuHelpers.createCheckbox({
id: "apply-code-font-dates-checkbox",
label: "Apply code font to dates",
checked: FORMATTER_CONFIG.applyCodeFontToDates,
tooltip:
"Applies code/monospace font to date and news metadata elements (.datetime, .splash .news .meta).",
});
elementSection.appendChild(applyCodeFontDates);
const dateSubOptions = document.createElement("div");
dateSubOptions.style.paddingLeft = "1.5em";
dateSubOptions.style.display = FORMATTER_CONFIG.applyCodeFontToDates ? "" : "none";
const applyCodeFontDatesInput = applyCodeFontDates.querySelector("input[type='checkbox']");
if (applyCodeFontDatesInput) {
applyCodeFontDatesInput.addEventListener("change", () => {
dateSubOptions.style.display = applyCodeFontDatesInput.checked ? "" : "none";
});
}
const applyCodeFontSizeDates = window.AO3MenuHelpers.createCheckbox({
id: "apply-code-font-size-dates-checkbox",
label: "Apply code font size to dates",
checked: FORMATTER_CONFIG.applyCodeFontSizeToDates,
tooltip: "Applies the Code Font Size setting to date elements. Requires a code font size to be specified above.",
});
dateSubOptions.appendChild(applyCodeFontSizeDates);
const applyCodeFontStyleDates = window.AO3MenuHelpers.createCheckbox({
id: "apply-code-font-style-dates-checkbox",
label: "Apply code font style to dates",
checked: FORMATTER_CONFIG.applyCodeFontStyleToDates,
tooltip: "Applies the Code Font Style setting (e.g. italic) to date elements.",
});
dateSubOptions.appendChild(applyCodeFontStyleDates);
elementSection.appendChild(dateSubOptions);
dialog.appendChild(elementSection);
// Colors Section
const colorSection = window.AO3MenuHelpers.createSection("🎨 Colors");
const colorNotes = document.createElement("p");
colorNotes.className = "notes";
colorNotes.innerHTML =
'You may wish to refer to this <a href="https://www.w3schools.com/colors/colors_names.asp">handy list of colors</a>.';
colorSection.appendChild(colorNotes);
const backgroundGroup = window.AO3MenuHelpers.createSettingGroup();
backgroundGroup.appendChild(
window.AO3MenuHelpers.createLabel(
"Background Color",
"sw_background_color"
)
);
const backgroundInput = document.createElement("input");
backgroundInput.type = "text";
backgroundInput.id = "sw_background_color";
backgroundInput.value = FORMATTER_CONFIG.backgroundColor;
backgroundInput.placeholder = "#fff";
backgroundGroup.appendChild(backgroundInput);
const textGroup = window.AO3MenuHelpers.createSettingGroup();
textGroup.appendChild(
window.AO3MenuHelpers.createLabel("Text Color", "sw_foreground_color")
);
const textInput = document.createElement("input");
textInput.type = "text";
textInput.id = "sw_foreground_color";
textInput.value = FORMATTER_CONFIG.textColor;
textInput.placeholder = "#2a2a2a";
textGroup.appendChild(textInput);
const headerGroup = window.AO3MenuHelpers.createSettingGroup();
headerGroup.appendChild(
window.AO3MenuHelpers.createLabel("Header Color", "sw_headercolor")
);
const headerInput = document.createElement("input");
headerInput.type = "text";
headerInput.id = "sw_headercolor";
headerInput.value = FORMATTER_CONFIG.headerColor;
headerInput.placeholder = "#900";
headerGroup.appendChild(headerInput);
const accentGroup = window.AO3MenuHelpers.createSettingGroup();
const accentLabel = window.AO3MenuHelpers.createLabel(
"Accent Color ",
"sw_accent_color"
);
const accentTooltip = window.AO3MenuHelpers.createTooltip(
"Skins wizard accent color"
);
accentLabel.appendChild(accentTooltip);
accentGroup.appendChild(accentLabel);
const accentInput = document.createElement("input");
accentInput.type = "text";
accentInput.id = "sw_accent_color";
accentInput.value = FORMATTER_CONFIG.accentColor;
accentInput.placeholder = "#ddd";
accentGroup.appendChild(accentInput);
const firstRow = window.AO3MenuHelpers.createTwoColumnLayout(
backgroundGroup,
textGroup
);
colorSection.appendChild(firstRow);
const secondRow = window.AO3MenuHelpers.createTwoColumnLayout(
headerGroup,
accentGroup
);
colorSection.appendChild(secondRow);
const logoGroup = window.AO3MenuHelpers.createSettingGroup();
const logoLabel = window.AO3MenuHelpers.createLabel(
"Logo Color ",
"sw_logo_color"
);
const logoTooltip = window.AO3MenuHelpers.createTooltip(
"Change the color of the AO3 logo.Requires a CSS filter value (without 'filter:'). Generate at https://angel-rs.github.io/css-color-filter-generator/."
);
logoLabel.appendChild(logoTooltip);
// Add clickable link emoji
const linkEmoji = document.createElement("a");
linkEmoji.href = "https://angel-rs.github.io/css-color-filter-generator/";
linkEmoji.target = "_blank";
linkEmoji.textContent = " 🔗";
linkEmoji.style.marginLeft = "4px";
linkEmoji.style.fontSize = "0.8em";
logoLabel.appendChild(linkEmoji);
logoGroup.appendChild(logoLabel);
const logoInput = document.createElement("input");
logoInput.type = "text";
logoInput.id = "sw_logo_color";
logoInput.value = FORMATTER_CONFIG.logoColor;
logoInput.placeholder =
"brightness(0) saturate(100%) invert(7%) sepia(83%) saturate(5831%) hue-rotate(358deg) brightness(108%) contrast(109%)";
logoGroup.appendChild(logoInput);
colorSection.appendChild(logoGroup);
dialog.appendChild(colorSection);
// 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);
// Export/Import Settings
const exportBtn = document.createElement("button");
exportBtn.id = "formatter-export";
exportBtn.textContent = "Export Settings";
exportBtn.style.marginRight = "8px";
const fileInput = window.AO3MenuHelpers.createFileInput({
id: "formatter-import",
buttonText: "Import Settings",
accept: "application/json",
onChange: (file) => {
const reader = new FileReader();
reader.onload = function (evt) {
try {
const importedConfig = JSON.parse(evt.target.result);
if (typeof importedConfig !== "object" || !importedConfig)
throw new Error("Invalid JSON");
const validConfig = { ...DEFAULT_FORMATTER_CONFIG };
Object.keys(validConfig).forEach((key) => {
if (importedConfig.hasOwnProperty(key))
validConfig[key] = importedConfig[key];
});
FORMATTER_CONFIG = validConfig;
saveFormatterConfig();
alert("Settings imported! Reloading...");
location.reload();
} catch (err) {
alert("Import failed: " + (err && err.message ? err.message : err));
}
};
reader.readAsText(file);
},
});
const importExportContainer = document.createElement("div");
importExportContainer.className = "reset-link";
importExportContainer.style.marginTop = "18px";
importExportContainer.appendChild(exportBtn);
importExportContainer.appendChild(fileInput.button);
importExportContainer.appendChild(fileInput.input);
dialog.appendChild(importExportContainer);
exportBtn.addEventListener("click", function () {
try {
const now = new Date();
const pad = (n) => n.toString().padStart(2, "0");
const yyyy = now.getFullYear();
const mm = pad(now.getMonth() + 1);
const dd = pad(now.getDate());
const dateStr = `${yyyy}-${mm}-${dd}`;
const filename = `ao3_site_wizard_config_${dateStr}.json`;
const blob = new Blob([JSON.stringify(FORMATTER_CONFIG, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
} catch (e) {
alert("Export failed: " + (e && e.message ? e.message : e));
}
});
// 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.removeIndentation =
window.AO3MenuHelpers.getValue("remove-indentation-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;
FORMATTER_CONFIG.applyCodeFontToDates =
window.AO3MenuHelpers.getValue("apply-code-font-dates-checkbox") ?? false;
FORMATTER_CONFIG.applyCodeFontSizeToDates =
window.AO3MenuHelpers.getValue("apply-code-font-size-dates-checkbox") ?? false;
FORMATTER_CONFIG.applyCodeFontStyleToDates =
window.AO3MenuHelpers.getValue("apply-code-font-style-dates-checkbox") ?? false;
FORMATTER_CONFIG.backgroundColor =
window.AO3MenuHelpers.getValue("sw_background_color") || "";
FORMATTER_CONFIG.textColor =
window.AO3MenuHelpers.getValue("sw_foreground_color") || "";
FORMATTER_CONFIG.headerColor =
window.AO3MenuHelpers.getValue("sw_headercolor") || "";
FORMATTER_CONFIG.accentColor =
window.AO3MenuHelpers.getValue("sw_accent_color") || "";
FORMATTER_CONFIG.logoColor =
window.AO3MenuHelpers.getValue("sw_logo_color") || "";
saveFormatterConfig();
dialog.remove();
applyParagraphWidth();
applyColorStyles();
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();
}
}
function runIndentRemovalIfEnabled() {
if (
FORMATTER_CONFIG.removeIndentation &&
WORKS_PAGE_REGEX.test(window.location.href)
) {
removeParaIndentation();
}
}
initStyles();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
runParagraphSpacingFixIfEnabled();
runIndentRemovalIfEnabled();
initSharedMenu();
});
} else {
runParagraphSpacingFixIfEnabled();
runIndentRemovalIfEnabled();
initSharedMenu();
}
})();