# Firefox Reddit Tweaks Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Firefox extension that relocates subreddit name and comment count on old Reddit into styled badge elements above each post title. **Architecture:** Pure CSS + vanilla JS content script (Manifest V2). No frameworks, no build step. Content scripts share global scope via the manifest's `content_scripts.js` array. CSS is injected at parse time to prevent FOUC. Settings stored in `browser.storage.local`. **Tech Stack:** Vanilla JavaScript, CSS, Firefox WebExtension API (Manifest V2) ## Global Constraints - Target: old Reddit only (`*://old.reddit.com/*` and `*://www.reddit.com/*`) - Manifest V2 (not V3) - No ES module imports in content scripts — all JS files listed in manifest `content_scripts.js`, shared global scope - All injected DOM elements use the `rt-` class prefix - All CSS custom properties use the `--rt-` prefix - Storage: `browser.storage.local` (not sync) - Permissions: `storage`, `activeTab` - Post selector: `div.thing.link` (never bare `div.thing`) - Zero-comment edge case: when original text is `"comment"`, badge displays `"0 comments"` - Master toggle change triggers `location.reload()`, not teardown - Color settings changes apply live via `storage.onChanged` without reload - Dark mode detection: `.res-nightmode` on ``, observed reactively via MutationObserver --- ## File Structure | File | Responsibility | |---|---| | `manifest.json` | Extension manifest — permissions, content scripts (CSS + JS), browser action | | `lib/settings.js` | Defaults, load/save helpers for `browser.storage.local`. Shared by content scripts and popup | | `content/styles/badges.css` | Badge appearance (light + dark), CSS custom properties with fallbacks, hiding rules for original elements (FOUC prevention) | | `content/tweaks/badges.js` | Badge tweak module — DOM manipulation, MutationObservers (content + dark mode), storage change listener | | `content/main.js` | Entry point — loads settings, checks `enabled`, calls badge tweak | | `popup/popup.html` | Settings popup HTML structure | | `popup/popup.css` | Settings popup styling | | `popup/popup.js` | Settings popup logic — binds controls to storage, handles per-subreddit overrides | | `icons/icon-48.png` | Toolbar icon (48×48) | | `icons/icon-96.png` | Toolbar icon (96×96) | --- ### Task 1: Extension Skeleton (Manifest + Settings + Icons) **Files:** - Create: `manifest.json` - Create: `lib/settings.js` - Create: `icons/icon-48.png` - Create: `icons/icon-96.png` **Interfaces:** - Consumes: nothing (first task) - Produces: - `window.RedditTweaks.DEFAULTS` — object with all default setting values (see settings table in spec) - `window.RedditTweaks.loadSettings()` — returns `Promise` with merged defaults + stored values - `window.RedditTweaks.saveSettings(settings)` — returns `Promise`, writes to `browser.storage.local` - `window.RedditTweaks.resetSettings()` — returns `Promise`, clears storage - [ ] **Step 1: Create `manifest.json`** ```json { "manifest_version": 2, "name": "Reddit Tweaks", "version": "1.0.0", "description": "Reskins old Reddit post listings with badge-style subreddit and comment indicators.", "permissions": ["storage", "activeTab"], "icons": { "48": "icons/icon-48.png", "96": "icons/icon-96.png" }, "browser_action": { "default_icon": "icons/icon-48.png", "default_popup": "popup/popup.html", "default_title": "Reddit Tweaks Settings" }, "content_scripts": [ { "matches": ["*://old.reddit.com/*", "*://www.reddit.com/*"], "css": ["content/styles/badges.css"], "js": [ "lib/settings.js", "content/tweaks/badges.js", "content/main.js" ], "run_at": "document_idle" } ] } ``` - [ ] **Step 2: Create `lib/settings.js`** ```js (function () { "use strict"; const DEFAULTS = { enabled: true, darkMode: "auto", "light.subredditBgColor": "transparent", "light.subredditBorderColor": "#c84a20", "light.subredditTextColor": "#c84a20", "light.commentsBgColor": "#e0e0e0", "light.commentsBorderColor": "#4a4a4a", "light.commentsTextColor": "#4a4a4a", "dark.subredditBgColor": "transparent", "dark.subredditBorderColor": "#ff6b3d", "dark.subredditTextColor": "#ff6b3d", "dark.commentsBgColor": "#3a3a3a", "dark.commentsBorderColor": "#888888", "dark.commentsTextColor": "#cccccc", subredditColors: {}, }; async function loadSettings() { const stored = await browser.storage.local.get(null); return Object.assign({}, DEFAULTS, stored); } async function saveSettings(settings) { const toStore = {}; for (const key of Object.keys(settings)) { if (key in DEFAULTS) { toStore[key] = settings[key]; } } await browser.storage.local.set(toStore); } async function resetSettings() { await browser.storage.local.clear(); } window.RedditTweaks = window.RedditTweaks || {}; window.RedditTweaks.DEFAULTS = DEFAULTS; window.RedditTweaks.loadSettings = loadSettings; window.RedditTweaks.saveSettings = saveSettings; window.RedditTweaks.resetSettings = resetSettings; })(); ``` - [ ] **Step 3: Create placeholder icons** Generate simple 48×48 and 96×96 PNG icons. These can be single-color placeholder squares (e.g., orange `#c84a20` background with a white "RT" text or just a solid color). Use any available method — canvas script, ImageMagick, or a minimal valid PNG. Place at `icons/icon-48.png` and `icons/icon-96.png`. - [ ] **Step 4: Verify extension loads in Firefox** The extension needs the content script files to exist (even if empty) or Firefox will error. Create empty placeholder files: ```bash mkdir -p content/styles content/tweaks touch content/styles/badges.css content/tweaks/badges.js content/main.js ``` Then verify the manifest is valid JSON: ```bash python3 -c "import json; json.load(open('manifest.json')); print('valid')" ``` Expected: `valid` - [ ] **Step 5: Commit** ```bash git add manifest.json lib/settings.js icons/ content/styles/badges.css content/tweaks/badges.js content/main.js git commit -m "feat: extension skeleton with manifest, settings module, and placeholder icons" ``` --- ### Task 2: Badge Styling (CSS) **Files:** - Create: `content/styles/badges.css` (replace the empty placeholder) **Interfaces:** - Consumes: nothing (pure CSS, no JS dependencies) - Produces: - CSS rules for `.reddit-tweaks-badges`, `.rt-badge`, `.rt-badge--subreddit`, `.rt-badge--comments` - CSS rules hiding original elements: `.thing.link .tagline .subreddit` and `.thing.link .flat-list .comments` set to `display: none` - Dark mode variant via `.rt-dark` class on `.reddit-tweaks-badges` - CSS custom properties: `--rt-sub-bg`, `--rt-sub-border`, `--rt-sub-text`, `--rt-com-bg`, `--rt-com-border`, `--rt-com-text` - [ ] **Step 1: Write `content/styles/badges.css`** ```css /* --- FOUC prevention: hide original elements immediately --- */ .thing.link .tagline .subreddit, .thing.link .flat-list .comments { display: none !important; } /* --- Badge container --- */ .reddit-tweaks-badges { margin-bottom: 4px; line-height: 1.6; } /* --- Shared badge styles --- */ .rt-badge { display: inline-block; border: 1px solid; border-radius: 3px; padding: 2px 6px; font-size: 11px; font-weight: bold; text-decoration: none; vertical-align: middle; margin-right: 6px; cursor: pointer; } .rt-badge:hover { opacity: 0.8; } /* --- Light mode (default) --- */ .rt-badge--subreddit { background: var(--rt-sub-bg, transparent); border-color: var(--rt-sub-border, #c84a20); color: var(--rt-sub-text, #c84a20); } .rt-badge--comments { background: var(--rt-com-bg, #e0e0e0); border-color: var(--rt-com-border, #4a4a4a); color: var(--rt-com-text, #4a4a4a); } /* --- Dark mode --- */ .rt-dark .rt-badge--subreddit { background: var(--rt-sub-bg, transparent); border-color: var(--rt-sub-border, #ff6b3d); color: var(--rt-sub-text, #ff6b3d); } .rt-dark .rt-badge--comments { background: var(--rt-com-bg, #3a3a3a); border-color: var(--rt-com-border, #888888); color: var(--rt-com-text, #cccccc); } ``` - [ ] **Step 2: Verify CSS is valid** ```bash python3 -c " css = open('content/styles/badges.css').read() print(f'CSS file: {len(css)} chars') assert '.thing.link' in css, 'missing post selector' assert '.rt-badge' in css, 'missing badge class' assert '.rt-dark' in css, 'missing dark mode' assert '--rt-sub-bg' in css, 'missing CSS custom properties' assert 'display: none' in css, 'missing FOUC hide rules' print('all checks passed') " ``` Expected: `all checks passed` - [ ] **Step 3: Commit** ```bash git add content/styles/badges.css git commit -m "feat: badge CSS with light/dark mode, custom properties, and FOUC prevention" ``` --- ### Task 3: Badge DOM Manipulation **Files:** - Create: `content/tweaks/badges.js` (replace the empty placeholder) - Create: `content/main.js` (replace the empty placeholder) **Interfaces:** - Consumes: - `window.RedditTweaks.loadSettings()` → `Promise` (from `lib/settings.js`, Task 1) - `window.RedditTweaks.DEFAULTS` → object (from `lib/settings.js`, Task 1) - CSS classes from Task 2: `.reddit-tweaks-badges`, `.rt-badge`, `.rt-badge--subreddit`, `.rt-badge--comments`, `.rt-dark` - CSS custom properties from Task 2: `--rt-sub-bg`, `--rt-sub-border`, `--rt-sub-text`, `--rt-com-bg`, `--rt-com-border`, `--rt-com-text` - Produces: - `window.RedditTweaks.badgeTweak.apply(settings)` — processes all unprocessed `div.thing.link` posts, starts MutationObservers - `window.RedditTweaks.badgeTweak.updateColors(settings)` — re-applies CSS custom properties to all badge containers - [ ] **Step 1: Write `content/tweaks/badges.js`** ```js (function () { "use strict"; let contentObserver = null; let darkModeObserver = null; let currentSettings = null; function isDarkMode(settings) { if (settings.darkMode === "light") return false; if (settings.darkMode === "dark") return true; return document.body.classList.contains("res-nightmode"); } function getColorPrefix(settings) { return isDarkMode(settings) ? "dark" : "light"; } function applyColorsToContainer(container, settings, subredditName) { const prefix = getColorPrefix(settings); if (isDarkMode(settings)) { container.classList.add("rt-dark"); } else { container.classList.remove("rt-dark"); } const overrides = subredditName && settings.subredditColors[subredditName]; container.style.setProperty("--rt-sub-bg", overrides?.bg || settings[prefix + ".subredditBgColor"]); container.style.setProperty("--rt-sub-border", overrides?.border || settings[prefix + ".subredditBorderColor"]); container.style.setProperty("--rt-sub-text", overrides?.text || settings[prefix + ".subredditTextColor"]); container.style.setProperty("--rt-com-bg", settings[prefix + ".commentsBgColor"]); container.style.setProperty("--rt-com-border", settings[prefix + ".commentsBorderColor"]); container.style.setProperty("--rt-com-text", settings[prefix + ".commentsTextColor"]); } function normalizeCommentText(text) { const trimmed = text.trim(); if (trimmed === "comment") return "0 comments"; return trimmed; } function processPost(post, settings) { if (post.hasAttribute("data-rt-processed")) return; const entry = post.querySelector(".entry"); if (!entry) return; const container = document.createElement("div"); container.className = "reddit-tweaks-badges"; let subredditName = null; const subLink = post.querySelector(".tagline .subreddit"); if (subLink) { const badge = document.createElement("a"); badge.className = "rt-badge rt-badge--subreddit"; badge.href = subLink.href; badge.textContent = subLink.textContent.trim(); subredditName = subLink.textContent.trim().replace(/^r\//, ""); container.appendChild(badge); } const commentsLink = post.querySelector(".flat-list .comments"); if (commentsLink) { const badge = document.createElement("a"); badge.className = "rt-badge rt-badge--comments"; badge.href = commentsLink.href; badge.textContent = normalizeCommentText(commentsLink.textContent); container.appendChild(badge); } if (container.children.length === 0) return; applyColorsToContainer(container, settings, subredditName); entry.insertBefore(container, entry.firstChild); post.setAttribute("data-rt-processed", "true"); } function processPosts(settings) { const posts = document.querySelectorAll("div.thing.link:not([data-rt-processed])"); posts.forEach(function (post) { processPost(post, settings); }); } function getObserverTarget() { return ( document.getElementById("siteTable") || document.querySelector('.content[role="main"]') || document.body ); } function startContentObserver(settings) { if (contentObserver) contentObserver.disconnect(); const target = getObserverTarget(); contentObserver = new MutationObserver(function (mutations) { let hasNewPosts = false; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { if (node.matches && node.matches("div.thing.link")) { hasNewPosts = true; } else if (node.querySelector && node.querySelector("div.thing.link")) { hasNewPosts = true; } } } } if (hasNewPosts) processPosts(currentSettings); }); contentObserver.observe(target, { childList: true, subtree: true }); } function startDarkModeObserver() { if (darkModeObserver) darkModeObserver.disconnect(); darkModeObserver = new MutationObserver(function () { if (currentSettings && currentSettings.darkMode === "auto") { updateColors(currentSettings); } }); darkModeObserver.observe(document.body, { attributes: true, attributeFilter: ["class"], }); } function updateColors(settings) { currentSettings = settings; const containers = document.querySelectorAll(".reddit-tweaks-badges"); containers.forEach(function (container) { const subBadge = container.querySelector(".rt-badge--subreddit"); const subredditName = subBadge ? subBadge.textContent.trim().replace(/^r\//, "") : null; applyColorsToContainer(container, settings, subredditName); }); } function apply(settings) { currentSettings = settings; processPosts(settings); startContentObserver(settings); startDarkModeObserver(); } window.RedditTweaks = window.RedditTweaks || {}; window.RedditTweaks.badgeTweak = { apply: apply, updateColors: updateColors, }; })(); ``` - [ ] **Step 2: Write `content/main.js`** ```js (function () { "use strict"; async function init() { const settings = await window.RedditTweaks.loadSettings(); if (!settings.enabled) return; window.RedditTweaks.badgeTweak.apply(settings); browser.storage.onChanged.addListener(function (changes) { if (changes.enabled) { location.reload(); return; } window.RedditTweaks.loadSettings().then(function (updated) { window.RedditTweaks.badgeTweak.updateColors(updated); }); }); } init(); })(); ``` - [ ] **Step 3: Verify JS is valid** ```bash node --check lib/settings.js && echo "settings.js: valid" node --check content/tweaks/badges.js && echo "badges.js: valid" node --check content/main.js && echo "main.js: valid" ``` Expected: all three report `valid` - [ ] **Step 4: Commit** ```bash git add content/tweaks/badges.js content/main.js git commit -m "feat: badge DOM manipulation with MutationObservers and dark mode detection" ``` --- ### Task 4: Settings Popup UI **Files:** - Create: `popup/popup.html` - Create: `popup/popup.css` - Create: `popup/popup.js` **Interfaces:** - Consumes: - `window.RedditTweaks.DEFAULTS` → object (from `lib/settings.js`, Task 1) - `window.RedditTweaks.loadSettings()` → `Promise` (from `lib/settings.js`, Task 1) - `window.RedditTweaks.saveSettings(settings)` → `Promise` (from `lib/settings.js`, Task 1) - `window.RedditTweaks.resetSettings()` → `Promise` (from `lib/settings.js`, Task 1) - Produces: complete settings popup (no API consumed by other tasks) - [ ] **Step 1: Create `popup/popup.html`** ```html ``` Note: `transparent` is not a valid `` value. The popup.js code must handle this: when loading settings, map `"transparent"` to `#ffffff` (or a sensible stand-in) for the color picker; when saving, if the default for that field is `"transparent"`, provide a "Use transparent" checkbox next to the background color pickers. Alternatively, use a text input instead of a color picker for background fields. Choose the approach that keeps the UI simplest — a text input with a color preview swatch is recommended for fields that may be `"transparent"`. - [ ] **Step 2: Create `popup/popup.css`** ```css * { box-sizing: border-box; margin: 0; padding: 0; } body { width: 320px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 13px; color: #1a1a1a; background: #ffffff; } .popup-container { padding: 16px; } h1 { font-size: 16px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #e0e0e0; } h2 { font-size: 13px; font-weight: 600; margin-bottom: 8px; } .section { margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #f0f0f0; } .section:last-child { border-bottom: none; margin-bottom: 0; } .toggle-row { display: flex; justify-content: space-between; align-items: center; cursor: pointer; } label { display: block; margin-bottom: 4px; } select { width: 100%; padding: 4px 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 13px; margin-top: 4px; } fieldset { border: 1px solid #e0e0e0; border-radius: 4px; padding: 8px 12px; margin-bottom: 8px; } legend { font-weight: 600; font-size: 12px; padding: 0 4px; } fieldset label { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-size: 12px; } fieldset input[type="color"] { width: 40px; height: 24px; border: 1px solid #ccc; border-radius: 3px; padding: 1px; cursor: pointer; } fieldset input[type="text"] { width: 80px; padding: 2px 6px; border: 1px solid #ccc; border-radius: 3px; font-size: 12px; font-family: monospace; } .btn-secondary { background: #f0f0f0; border: 1px solid #ccc; border-radius: 3px; padding: 4px 10px; font-size: 12px; cursor: pointer; margin-top: 4px; } .btn-secondary:hover { background: #e0e0e0; } .btn-danger { background: #d9534f; color: white; border: 1px solid #c9302c; border-radius: 3px; padding: 6px 12px; font-size: 12px; cursor: pointer; width: 100%; } .btn-danger:hover { background: #c9302c; } .subreddit-entry { display: flex; gap: 4px; align-items: center; margin-bottom: 6px; padding: 6px; background: #fafafa; border: 1px solid #e0e0e0; border-radius: 4px; } .subreddit-entry input[type="text"] { flex: 1; min-width: 0; } .subreddit-entry .btn-remove { background: none; border: none; color: #999; cursor: pointer; font-size: 16px; padding: 0 4px; } .subreddit-entry .btn-remove:hover { color: #d9534f; } ``` - [ ] **Step 3: Create `popup/popup.js`** ```js (function () { "use strict"; const COLOR_KEYS = [ "light.subredditBorderColor", "light.subredditTextColor", "light.commentsBgColor", "light.commentsBorderColor", "light.commentsTextColor", "dark.subredditBorderColor", "dark.subredditTextColor", "dark.commentsBgColor", "dark.commentsBorderColor", "dark.commentsTextColor", ]; const BG_KEYS = [ "light.subredditBgColor", "dark.subredditBgColor", ]; function colorToHex(val) { if (!val || val === "transparent") return "#ffffff"; return val; } function populateControls(settings) { document.getElementById("enabled").checked = settings.enabled; document.getElementById("darkMode").value = settings.darkMode; COLOR_KEYS.forEach(function (key) { const el = document.getElementById(key); if (el) el.value = colorToHex(settings[key]); }); BG_KEYS.forEach(function (key) { const el = document.getElementById(key); if (el) { el.value = settings[key] === "transparent" ? "" : settings[key]; el.placeholder = "transparent"; } }); renderSubredditOverrides(settings.subredditColors || {}); } function renderSubredditOverrides(overrides) { const list = document.getElementById("subredditList"); list.innerHTML = ""; Object.entries(overrides).forEach(function ([name, colors]) { addSubredditEntry(name, colors); }); } function addSubredditEntry(name, colors) { const list = document.getElementById("subredditList"); const entry = document.createElement("div"); entry.className = "subreddit-entry"; const nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.placeholder = "subreddit"; nameInput.value = name || ""; const bgInput = document.createElement("input"); bgInput.type = "color"; bgInput.value = colorToHex(colors?.bg); bgInput.title = "Background"; const borderInput = document.createElement("input"); borderInput.type = "color"; borderInput.value = colorToHex(colors?.border); borderInput.title = "Border"; const textInput = document.createElement("input"); textInput.type = "color"; textInput.value = colorToHex(colors?.text); textInput.title = "Text"; const removeBtn = document.createElement("button"); removeBtn.className = "btn-remove"; removeBtn.textContent = "×"; removeBtn.addEventListener("click", function () { entry.remove(); saveFromControls(); }); [nameInput, bgInput, borderInput, textInput].forEach(function (input) { input.addEventListener("change", saveFromControls); }); entry.appendChild(nameInput); entry.appendChild(bgInput); entry.appendChild(borderInput); entry.appendChild(textInput); entry.appendChild(removeBtn); list.appendChild(entry); } function collectSubredditOverrides() { const overrides = {}; const entries = document.querySelectorAll(".subreddit-entry"); entries.forEach(function (entry) { const inputs = entry.querySelectorAll("input"); const name = inputs[0].value.trim().replace(/^r\//, ""); if (name) { overrides[name] = { bg: inputs[1].value, border: inputs[2].value, text: inputs[3].value, }; } }); return overrides; } function saveFromControls() { const settings = { enabled: document.getElementById("enabled").checked, darkMode: document.getElementById("darkMode").value, subredditColors: collectSubredditOverrides(), }; COLOR_KEYS.forEach(function (key) { const el = document.getElementById(key); if (el) settings[key] = el.value; }); BG_KEYS.forEach(function (key) { const el = document.getElementById(key); if (el) { settings[key] = el.value.trim() || "transparent"; } }); window.RedditTweaks.saveSettings(settings); } async function init() { const settings = await window.RedditTweaks.loadSettings(); populateControls(settings); document.getElementById("enabled").addEventListener("change", saveFromControls); document.getElementById("darkMode").addEventListener("change", saveFromControls); COLOR_KEYS.forEach(function (key) { const el = document.getElementById(key); if (el) el.addEventListener("input", saveFromControls); }); BG_KEYS.forEach(function (key) { const el = document.getElementById(key); if (el) el.addEventListener("input", saveFromControls); }); document.getElementById("addSubreddit").addEventListener("click", function () { addSubredditEntry("", {}); }); document.getElementById("resetLight").addEventListener("click", async function () { const settings = await window.RedditTweaks.loadSettings(); const DEFAULTS = window.RedditTweaks.DEFAULTS; Object.keys(DEFAULTS).forEach(function (key) { if (key.startsWith("light.")) settings[key] = DEFAULTS[key]; }); await window.RedditTweaks.saveSettings(settings); populateControls(settings); }); document.getElementById("resetDark").addEventListener("click", async function () { const settings = await window.RedditTweaks.loadSettings(); const DEFAULTS = window.RedditTweaks.DEFAULTS; Object.keys(DEFAULTS).forEach(function (key) { if (key.startsWith("dark.")) settings[key] = DEFAULTS[key]; }); await window.RedditTweaks.saveSettings(settings); populateControls(settings); }); document.getElementById("resetAll").addEventListener("click", async function () { if (confirm("Reset all settings to defaults?")) { await window.RedditTweaks.resetSettings(); const settings = await window.RedditTweaks.loadSettings(); populateControls(settings); } }); } init(); })(); ``` - [ ] **Step 4: Verify HTML references are correct** ```bash python3 -c " import os html = open('popup/popup.html').read() assert 'popup.css' in html, 'missing CSS reference' assert 'popup.js' in html, 'missing JS reference' assert '../lib/settings.js' in html, 'missing settings.js reference' print('all references present') for f in ['popup/popup.html', 'popup/popup.css', 'popup/popup.js']: assert os.path.exists(f), f'missing {f}' print('all popup files exist') " ``` Expected: both checks pass - [ ] **Step 5: Commit** ```bash git add popup/ git commit -m "feat: settings popup with color pickers, dark mode, and subreddit overrides" ``` --- ## Post-Implementation After all tasks are complete, verify the full extension by loading it as a temporary add-on in Firefox via `about:debugging` → "This Firefox" → "Load Temporary Add-on" → select `manifest.json`. Then run through the test checklist from the spec (items 1–15).