diff --git a/chat-summaries/2026-08-26_17-20-summary.md b/chat-summaries/2026-08-26_17-20-summary.md
new file mode 100644
index 0000000..4c4be15
--- /dev/null
+++ b/chat-summaries/2026-08-26_17-20-summary.md
@@ -0,0 +1,33 @@
+# Badge Polish: Gear Animation + Custom Color Picker
+
+**Date:** 2026-08-26
+**Commits:** 2abd18e..8462148 (6 commits)
+
+## Task Description
+
+Two improvements to the subreddit badge system:
+1. Animate the gear icon reveal on hover (remove permanent padding gap)
+2. Replace all native `` elements with a custom tabbed inline color picker
+
+## Changes Made
+
+### New Files
+- `content/tweaks/color-picker.js` — Tabbed color picker widget with three panels: Palette (Tailwind/shadcn colors), Named Colors (148 CSS named colors), Custom (HSV picker with canvas SV square + hue bar). Exposes `window.RedditTweaks.colorPicker.create()` API.
+
+### Modified Files
+- `content/styles/badges.css` — Gear animation CSS (padding-right 6px→18px on hover, 150ms transition). Swatch div selector replacing `input[type="color"]`. Full picker widget CSS with dark mode support.
+- `content/tweaks/color-popover.js` — Replaced native color inputs with swatch divs. Integrated custom picker between color rows and preview. Added popover repositioning when picker opens.
+- `popup/popup.html` — Replaced 12 `` elements with swatch divs. Added color-picker.js script tag.
+- `popup/popup.js` — Full rewrite to use swatch divs + custom picker. Added `getColorValue`/`setColorValue` helpers, debounced saves during drag, per-subreddit dirty-mode tracking via `onBeforeChange` callback.
+- `popup/popup.css` — Updated swatch selectors. Added picker widget CSS. Added subreddit entry swatch styles.
+- `manifest.json` — Added `color-picker.js` to content scripts array.
+
+### Specs & Plans
+- `docs/superpowers/specs/2026-08-26-badge-polish-design.md` — Updated design spec with tabbed picker interface
+- `docs/superpowers/plans/2026-08-26-badge-polish.md` — Implementation plan
+
+## Follow-up Items
+- Manual browser verification on old.reddit.com (not possible in this session)
+- `prefers-reduced-motion` media query for gear animation (accessibility)
+- Touch/pointer events for Custom HSV tab (currently mouse-only)
+- Escape key could close picker first, then popover on second press
diff --git a/content/styles/badges.css b/content/styles/badges.css
index bd5bd74..b5e82dd 100644
--- a/content/styles/badges.css
+++ b/content/styles/badges.css
@@ -25,7 +25,7 @@ html.rt-enabled .thing.link .flat-list li:has(> .comments) {
}
.rt-badge:hover {
- filter: brightness(0.9);
+ filter: brightness(1.1);
}
/* --- Subreddit badge: relative for gear positioning --- */
@@ -316,6 +316,96 @@ html.rt-enabled .thing.link .flat-list li:has(> .comments) {
color: #ccc;
}
+/* --- Preset badges --- */
+.rt-popover-presets {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 0;
+ border-top: 1px solid #eee;
+ flex-wrap: wrap;
+}
+
+.rt-preset-list {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.rt-preset-badge {
+ display: inline-block;
+ position: relative;
+ border: 1px solid;
+ border-radius: 3px;
+ padding: 2px 6px;
+ font-size: 10px;
+ font-weight: bold;
+ cursor: pointer;
+ line-height: 1.4;
+ user-select: none;
+}
+
+.rt-preset-badge:hover {
+ filter: brightness(1.1);
+}
+
+.rt-preset-delete {
+ display: none;
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ width: 14px;
+ height: 14px;
+ background: #d9534f;
+ color: white;
+ border-radius: 50%;
+ font-size: 10px;
+ line-height: 14px;
+ text-align: center;
+ cursor: pointer;
+}
+
+.rt-preset-badge:hover .rt-preset-delete {
+ display: block;
+}
+
+.rt-preset-add {
+ width: 22px;
+ height: 22px;
+ border: 1px dashed #ccc;
+ border-radius: 3px;
+ background: none;
+ color: #999;
+ font-size: 14px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ padding: 0;
+}
+
+.rt-preset-add:hover {
+ border-color: #999;
+ color: #666;
+ background: #f5f5f5;
+}
+
+.rt-color-popover.rt-dark .rt-popover-presets {
+ border-top-color: #444;
+}
+
+.rt-color-popover.rt-dark .rt-preset-add {
+ border-color: #555;
+ color: #888;
+}
+
+.rt-color-popover.rt-dark .rt-preset-add:hover {
+ border-color: #888;
+ color: #ccc;
+ background: #333;
+}
+
/* --- Color Picker Widget --- */
.rt-color-picker {
margin: 8px 0;
diff --git a/content/tweaks/color-popover.js b/content/tweaks/color-popover.js
index 680b5ad..f7c8a7a 100644
--- a/content/tweaks/color-popover.js
+++ b/content/tweaks/color-popover.js
@@ -105,6 +105,21 @@
var previewSection = el.querySelector(".rt-popover-preview");
el.insertBefore(picker.el, previewSection);
+ var presetsDiv = document.createElement("div");
+ presetsDiv.className = "rt-popover-presets";
+ var presetList = document.createElement("div");
+ presetList.className = "rt-preset-list";
+ var presetAddBtn = document.createElement("button");
+ presetAddBtn.className = "rt-preset-add";
+ presetAddBtn.type = "button";
+ presetAddBtn.textContent = "+";
+ presetAddBtn.title = "Save current colors as preset";
+ presetAddBtn.addEventListener("click", saveAsPreset);
+ presetsDiv.appendChild(presetList);
+ presetsDiv.appendChild(presetAddBtn);
+ el.querySelector(".rt-popover-footer").insertAdjacentElement("beforebegin", presetsDiv);
+ els.presetList = presetList;
+
els.closeBtn.addEventListener("click", close);
els.cancelBtn.addEventListener("click", close);
els.saveBtn.addEventListener("click", save);
@@ -234,6 +249,73 @@
els.preview.style.color = text;
}
+ function renderPresets(presets) {
+ els.presetList.innerHTML = "";
+ for (var i = 0; i < presets.length; i++) {
+ (function (index) {
+ var p = presets[index];
+ var badge = document.createElement("span");
+ badge.className = "rt-preset-badge";
+ badge.style.background = p.bg;
+ badge.style.borderColor = p.border;
+ badge.style.color = p.text;
+ badge.textContent = "Aa";
+ badge.title = "Click to apply";
+ badge.addEventListener("click", function () {
+ applyPreset(p);
+ });
+
+ var del = document.createElement("span");
+ del.className = "rt-preset-delete";
+ del.textContent = "\u00d7";
+ del.addEventListener("click", function (e) {
+ e.stopPropagation();
+ removePreset(index);
+ });
+ badge.appendChild(del);
+ els.presetList.appendChild(badge);
+ })(i);
+ }
+ }
+
+ function applyPreset(p) {
+ markModeDirty(selectedMode);
+ var props = ["bg", "border", "text"];
+ for (var i = 0; i < props.length; i++) {
+ var prop = props[i];
+ els.rows[prop].swatch.style.backgroundColor = p[prop];
+ els.rows[prop].hex.value = p[prop];
+ els.rows[prop].hex.classList.remove("rt-input-error");
+ }
+ updatePreview();
+ if (picker && picker.isOpen() && activeProp) {
+ picker.setColor(els.rows[activeProp].hex.value);
+ }
+ }
+
+ async function saveAsPreset() {
+ var preset = {
+ bg: els.rows.bg.hex.value,
+ border: els.rows.border.hex.value,
+ text: els.rows.text.hex.value,
+ };
+ var settings = await window.RedditTweaks.loadSettings();
+ var presets = settings.colorPresets || [];
+ presets.push(preset);
+ settings.colorPresets = presets;
+ await window.RedditTweaks.saveSettings(settings);
+ renderPresets(presets);
+ }
+
+ async function removePreset(index) {
+ var settings = await window.RedditTweaks.loadSettings();
+ var presets = settings.colorPresets || [];
+ presets.splice(index, 1);
+ settings.colorPresets = presets;
+ await window.RedditTweaks.saveSettings(settings);
+ renderPresets(presets);
+ }
+
function positionPopover(anchorEl) {
var rect = anchorEl.getBoundingClientRect();
var gap = 6;
@@ -311,6 +393,7 @@
applyDarkModeToPopover(settings);
loadModeIntoInputs();
updatePreview();
+ renderPresets(settings.colorPresets || []);
positionPopover(badgeEl);
}
diff --git a/docs/superpowers/plans/2026-08-26-badge-polish.md b/docs/superpowers/plans/2026-08-26-badge-polish.md
new file mode 100644
index 0000000..4580699
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-26-badge-polish.md
@@ -0,0 +1,1624 @@
+# Badge Polish: Gear Animation + Custom Color Picker — 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:** Animate the gear icon reveal on badge hover and replace all native `` elements with a tabbed inline color picker (Palette, Named Colors, Custom HSV).
+
+**Architecture:** The gear animation is a CSS-only change (2 lines). The custom color picker is a new vanilla JS module (`color-picker.js`) that builds a tabbed widget with three panels: a Tailwind/shadcn palette grid, CSS named colors grid, and an HSV picker with canvas-rendered SV square + hue bar. The module exposes a `create()` factory that returns a picker instance with `open/close/setColor/isOpen/destroy` methods and an `.el` property for DOM insertion. The picker is integrated into the inline popover (`color-popover.js`) and the extension popup (`popup.js`).
+
+**Tech Stack:** Vanilla JS, HTML5 Canvas, CSS, Firefox WebExtension APIs (manifest v2).
+
+## Global Constraints
+
+- No external dependencies — vanilla JS only, no build step
+- Must work in both Firefox content script and popup contexts
+- Colors stored as lowercase `#rrggbb` hex strings
+- All CSS classes prefixed with `rt-` to avoid conflicts with Reddit's styles
+- Dark mode support via `.rt-dark` class on ancestor elements
+- Spec: `docs/superpowers/specs/2026-08-26-badge-polish-design.md`
+
+---
+
+### Task 1: Gear Icon Animation
+
+**Files:**
+- Modify: `content/styles/badges.css:32-38`
+
+**Interfaces:**
+- Consumes: nothing
+- Produces: nothing (CSS-only, no API changes)
+
+- [ ] **Step 1: Update `.rt-badge--subreddit` and add hover rule**
+
+In `content/styles/badges.css`, replace the `.rt-badge--subreddit` block:
+
+```css
+.rt-badge--subreddit {
+ position: relative;
+ padding-right: 18px;
+ background: var(--rt-sub-bg, #fde8e0);
+ border-color: var(--rt-sub-border, #c84a20);
+ color: var(--rt-sub-text, #c84a20);
+}
+```
+
+with:
+
+```css
+.rt-badge--subreddit {
+ position: relative;
+ padding-right: 6px;
+ transition: padding-right 0.15s;
+ background: var(--rt-sub-bg, #fde8e0);
+ border-color: var(--rt-sub-border, #c84a20);
+ color: var(--rt-sub-text, #c84a20);
+}
+
+.rt-badge--subreddit:hover {
+ padding-right: 18px;
+}
+```
+
+- [ ] **Step 2: Verify manually**
+
+Load `old.reddit.com` with the extension enabled. Verify:
+1. Subreddit badges have no gap at rest (padding matches left side).
+2. Hovering over a subreddit badge smoothly expands it to reveal the gear icon (~150ms).
+3. The adjacent comments badge shifts right during expansion.
+4. On mouse-out, the badge shrinks and the gear fades out.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add content/styles/badges.css
+git commit -m "feat: animate gear icon reveal on badge hover"
+```
+
+---
+
+### Task 2: Color Picker Widget + CSS
+
+**Files:**
+- Create: `content/tweaks/color-picker.js`
+- Modify: `content/styles/badges.css` (append picker CSS at end)
+- Modify: `popup/popup.css` (append picker CSS at end)
+- Modify: `manifest.json:20-25`
+
+**Interfaces:**
+- Consumes: nothing
+- Produces: `window.RedditTweaks.colorPicker` with:
+ - `create()` → returns `{ el: HTMLElement, open(hex, onChange), close(), setColor(hex), isOpen(), destroy() }`
+
+- [ ] **Step 1: Create `content/tweaks/color-picker.js`**
+
+```javascript
+(function () {
+ "use strict";
+
+ var SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
+
+ var PALETTE = [
+ ["slate","#f8fafc","#f1f5f9","#e2e8f0","#cbd5e1","#94a3b8","#64748b","#475569","#334155","#1e293b","#0f172a","#020617"],
+ ["gray","#f9fafb","#f3f4f6","#e5e7eb","#d1d5db","#9ca3af","#6b7280","#4b5563","#374151","#1f2937","#111827","#030712"],
+ ["zinc","#fafafa","#f4f4f5","#e4e4e7","#d4d4d8","#a1a1aa","#71717a","#52525b","#3f3f46","#27272a","#18181b","#09090b"],
+ ["neutral","#fafafa","#f5f5f5","#e5e5e5","#d4d4d4","#a3a3a3","#737373","#525252","#404040","#262626","#171717","#0a0a0a"],
+ ["stone","#fafaf9","#f5f5f4","#e7e5e4","#d6d3d1","#a8a29e","#78716c","#57534e","#44403c","#292524","#1c1917","#0c0a09"],
+ ["red","#fef2f2","#fee2e2","#fecaca","#fca5a5","#f87171","#ef4444","#dc2626","#b91c1c","#991b1b","#7f1d1d","#450a0a"],
+ ["orange","#fff7ed","#ffedd5","#fed7aa","#fdba74","#fb923c","#f97316","#ea580c","#c2410c","#9a3412","#7c2d12","#431407"],
+ ["amber","#fffbeb","#fef3c7","#fde68a","#fcd34d","#fbbf24","#f59e0b","#d97706","#b45309","#92400e","#78350f","#451a03"],
+ ["yellow","#fefce8","#fef9c3","#fef08a","#fde047","#facc15","#eab308","#ca8a04","#a16207","#854d0e","#713f12","#422006"],
+ ["lime","#f7fee7","#ecfccb","#d9f99d","#bef264","#a3e635","#84cc16","#65a30d","#4d7c0f","#3f6212","#365314","#1a2e05"],
+ ["green","#f0fdf4","#dcfce7","#bbf7d0","#86efac","#4ade80","#22c55e","#16a34a","#15803d","#166534","#14532d","#052e16"],
+ ["emerald","#ecfdf5","#d1fae5","#a7f3d0","#6ee7b7","#34d399","#10b981","#059669","#047857","#065f46","#064e3b","#022c22"],
+ ["teal","#f0fdfa","#ccfbf1","#99f6e4","#5eead4","#2dd4bf","#14b8a6","#0d9488","#0f766e","#115e59","#134e4a","#042f2e"],
+ ["cyan","#ecfeff","#cffafe","#a5f3fc","#67e8f9","#22d3ee","#06b6d4","#0891b2","#0e7490","#155e75","#164e63","#083344"],
+ ["sky","#f0f9ff","#e0f2fe","#bae6fd","#7dd3fc","#38bdf8","#0ea5e9","#0284c7","#0369a1","#075985","#0c4a6e","#082f49"],
+ ["blue","#eff6ff","#dbeafe","#bfdbfe","#93c5fd","#60a5fa","#3b82f6","#2563eb","#1d4ed8","#1e40af","#1e3a8a","#172554"],
+ ["indigo","#eef2ff","#e0e7ff","#c7d2fe","#a5b4fc","#818cf8","#6366f1","#4f46e5","#4338ca","#3730a3","#312e81","#1e1b4b"],
+ ["violet","#f5f3ff","#ede9fe","#ddd6fe","#c4b5fd","#a78bfa","#8b5cf6","#7c3aed","#6d28d9","#5b21b6","#4c1d95","#2e1065"],
+ ["purple","#faf5ff","#f3e8ff","#e9d5ff","#d8b4fe","#c084fc","#a855f7","#9333ea","#7e22ce","#6b21a8","#581c87","#3b0764"],
+ ["fuchsia","#fdf4ff","#fae8ff","#f5d0fe","#f0abfc","#e879f9","#d946ef","#c026d3","#a21caf","#86198f","#701a75","#4a044e"],
+ ["pink","#fdf2f8","#fce7f3","#fbcfe8","#f9a8d4","#f472b6","#ec4899","#db2777","#be185d","#9d174d","#831843","#500724"],
+ ["rose","#fff1f2","#ffe4e6","#fecdd3","#fda4af","#fb7185","#f43f5e","#e11d48","#be123c","#9f1239","#881337","#4c0519"],
+ ];
+
+ var NAMED_COLORS = [
+ ["aliceblue","#f0f8ff"],["antiquewhite","#faebd7"],["aqua","#00ffff"],
+ ["aquamarine","#7fffd4"],["azure","#f0ffff"],["beige","#f5f5dc"],
+ ["bisque","#ffe4c4"],["black","#000000"],["blanchedalmond","#ffebcd"],
+ ["blue","#0000ff"],["blueviolet","#8a2be2"],["brown","#a52a2a"],
+ ["burlywood","#deb887"],["cadetblue","#5f9ea0"],["chartreuse","#7fff00"],
+ ["chocolate","#d2691e"],["coral","#ff7f50"],["cornflowerblue","#6495ed"],
+ ["cornsilk","#fff8dc"],["crimson","#dc143c"],["cyan","#00ffff"],
+ ["darkblue","#00008b"],["darkcyan","#008b8b"],["darkgoldenrod","#b8860b"],
+ ["darkgray","#a9a9a9"],["darkgreen","#006400"],["darkkhaki","#bdb76b"],
+ ["darkmagenta","#8b008b"],["darkolivegreen","#556b2f"],["darkorange","#ff8c00"],
+ ["darkorchid","#9932cc"],["darkred","#8b0000"],["darksalmon","#e9967a"],
+ ["darkseagreen","#8fbc8f"],["darkslateblue","#483d8b"],["darkslategray","#2f4f4f"],
+ ["darkturquoise","#00ced1"],["darkviolet","#9400d3"],["deeppink","#ff1493"],
+ ["deepskyblue","#00bfff"],["dimgray","#696969"],["dodgerblue","#1e90ff"],
+ ["firebrick","#b22222"],["floralwhite","#fffaf0"],["forestgreen","#228b22"],
+ ["fuchsia","#ff00ff"],["gainsboro","#dcdcdc"],["ghostwhite","#f8f8ff"],
+ ["gold","#ffd700"],["goldenrod","#daa520"],["gray","#808080"],
+ ["green","#008000"],["greenyellow","#adff2f"],["honeydew","#f0fff0"],
+ ["hotpink","#ff69b4"],["indianred","#cd5c5c"],["indigo","#4b0082"],
+ ["ivory","#fffff0"],["khaki","#f0e68c"],["lavender","#e6e6fa"],
+ ["lavenderblush","#fff0f5"],["lawngreen","#7cfc00"],["lemonchiffon","#fffacd"],
+ ["lightblue","#add8e6"],["lightcoral","#f08080"],["lightcyan","#e0ffff"],
+ ["lightgoldenrodyellow","#fafad2"],["lightgray","#d3d3d3"],["lightgreen","#90ee90"],
+ ["lightpink","#ffb6c1"],["lightsalmon","#ffa07a"],["lightseagreen","#20b2aa"],
+ ["lightskyblue","#87cefa"],["lightslategray","#778899"],["lightsteelblue","#b0c4de"],
+ ["lightyellow","#ffffe0"],["lime","#00ff00"],["limegreen","#32cd32"],
+ ["linen","#faf0e6"],["magenta","#ff00ff"],["maroon","#800000"],
+ ["mediumaquamarine","#66cdaa"],["mediumblue","#0000cd"],["mediumorchid","#ba55d3"],
+ ["mediumpurple","#9370db"],["mediumseagreen","#3cb371"],["mediumslateblue","#7b68ee"],
+ ["mediumspringgreen","#00fa9a"],["mediumturquoise","#48d1cc"],["mediumvioletred","#c71585"],
+ ["midnightblue","#191970"],["mintcream","#f5fffa"],["mistyrose","#ffe4e1"],
+ ["moccasin","#ffe4b5"],["navajowhite","#ffdead"],["navy","#000080"],
+ ["oldlace","#fdf5e6"],["olive","#808000"],["olivedrab","#6b8e23"],
+ ["orange","#ffa500"],["orangered","#ff4500"],["orchid","#da70d6"],
+ ["palegoldenrod","#eee8aa"],["palegreen","#98fb98"],["paleturquoise","#afeeee"],
+ ["palevioletred","#db7093"],["papayawhip","#ffefd5"],["peachpuff","#ffdab9"],
+ ["peru","#cd853f"],["pink","#ffc0cb"],["plum","#dda0dd"],
+ ["powderblue","#b0e0e6"],["purple","#800080"],["rebeccapurple","#663399"],
+ ["red","#ff0000"],["rosybrown","#bc8f8f"],["royalblue","#4169e1"],
+ ["saddlebrown","#8b4513"],["salmon","#fa8072"],["sandybrown","#f4a460"],
+ ["seagreen","#2e8b57"],["seashell","#fff5ee"],["sienna","#a0522d"],
+ ["silver","#c0c0c0"],["skyblue","#87ceeb"],["slateblue","#6a5acd"],
+ ["slategray","#708090"],["snow","#fffafa"],["springgreen","#00ff7f"],
+ ["steelblue","#4682b4"],["tan","#d2b48c"],["teal","#008080"],
+ ["thistle","#d8bfd8"],["tomato","#ff6347"],["turquoise","#40e0d0"],
+ ["violet","#ee82ee"],["wheat","#f5deb3"],["white","#ffffff"],
+ ["whitesmoke","#f5f5f5"],["yellow","#ffff00"],["yellowgreen","#9acd32"],
+ ];
+
+ function hsvToRgb(h, s, v) {
+ var c = v * s;
+ var x = c * (1 - Math.abs((h / 60) % 2 - 1));
+ var m = v - c;
+ var r, g, b;
+ if (h < 60) { r = c; g = x; b = 0; }
+ else if (h < 120) { r = x; g = c; b = 0; }
+ else if (h < 180) { r = 0; g = c; b = x; }
+ else if (h < 240) { r = 0; g = x; b = c; }
+ else if (h < 300) { r = x; g = 0; b = c; }
+ else { r = c; g = 0; b = x; }
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
+ }
+
+ function rgbToHex(r, g, b) {
+ return "#" + [r, g, b].map(function (c) {
+ return c.toString(16).padStart(2, "0");
+ }).join("");
+ }
+
+ function hexToRgb(hex) {
+ return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
+ }
+
+ function rgbToHsv(r, g, b) {
+ r /= 255; g /= 255; b /= 255;
+ var max = Math.max(r, g, b);
+ var min = Math.min(r, g, b);
+ var d = max - min;
+ var h, s, v;
+ v = max;
+ s = max === 0 ? 0 : d / max;
+ if (d === 0) {
+ h = 0;
+ } else if (max === r) {
+ h = 60 * (((g - b) / d) % 6);
+ } else if (max === g) {
+ h = 60 * ((b - r) / d + 2);
+ } else {
+ h = 60 * ((r - g) / d + 4);
+ }
+ if (h < 0) h += 360;
+ return [h, s, v];
+ }
+
+ function hsvToHex(h, s, v) {
+ var rgb = hsvToRgb(h, s, v);
+ return rgbToHex(rgb[0], rgb[1], rgb[2]);
+ }
+
+ function hexToHsv(hex) {
+ var rgb = hexToRgb(hex);
+ return rgbToHsv(rgb[0], rgb[1], rgb[2]);
+ }
+
+ var lastTab = "palette";
+
+ function buildPalettePanel(onSelect) {
+ var panel = document.createElement("div");
+ panel.className = "rt-picker-panel";
+ var grid = document.createElement("div");
+ grid.className = "rt-picker-grid rt-picker-grid--palette";
+ PALETTE.forEach(function (family) {
+ var row = document.createElement("div");
+ row.className = "rt-picker-grid-row";
+ for (var i = 1; i < family.length; i++) {
+ var swatch = document.createElement("div");
+ swatch.className = "rt-picker-swatch";
+ swatch.style.backgroundColor = family[i];
+ swatch.setAttribute("data-hex", family[i].toLowerCase());
+ swatch.title = family[0] + "-" + SHADES[i - 1];
+ swatch.addEventListener("click", (function (hex) {
+ return function () { onSelect(hex); };
+ })(family[i].toLowerCase()));
+ row.appendChild(swatch);
+ }
+ grid.appendChild(row);
+ });
+ panel.appendChild(grid);
+ return panel;
+ }
+
+ function buildNamedPanel(onSelect) {
+ var panel = document.createElement("div");
+ panel.className = "rt-picker-panel";
+ var grid = document.createElement("div");
+ grid.className = "rt-picker-grid rt-picker-grid--named";
+ NAMED_COLORS.forEach(function (entry) {
+ var wrap = document.createElement("div");
+ wrap.className = "rt-picker-named-item";
+ var swatch = document.createElement("div");
+ swatch.className = "rt-picker-swatch";
+ swatch.style.backgroundColor = entry[1];
+ swatch.setAttribute("data-hex", entry[1].toLowerCase());
+ var label = document.createElement("span");
+ label.className = "rt-picker-named-label";
+ label.textContent = entry[0];
+ wrap.appendChild(swatch);
+ wrap.appendChild(label);
+ wrap.addEventListener("click", (function (hex) {
+ return function () { onSelect(hex); };
+ })(entry[1].toLowerCase()));
+ grid.appendChild(wrap);
+ });
+ panel.appendChild(grid);
+ return panel;
+ }
+
+ function create() {
+ var state = { h: 0, s: 1, v: 1, hex: "#ff0000", onChange: null, visible: false };
+
+ var el = document.createElement("div");
+ el.className = "rt-color-picker";
+ el.style.display = "none";
+
+ var tabBar = document.createElement("div");
+ tabBar.className = "rt-picker-tabs";
+ var tabDefs = [
+ { id: "palette", label: "Palette" },
+ { id: "named", label: "Named" },
+ { id: "custom", label: "Custom" },
+ ];
+ var tabBtns = {};
+ tabDefs.forEach(function (t) {
+ var btn = document.createElement("button");
+ btn.className = "rt-picker-tab";
+ btn.textContent = t.label;
+ btn.type = "button";
+ btn.addEventListener("click", function () { switchTab(t.id); });
+ tabBar.appendChild(btn);
+ tabBtns[t.id] = btn;
+ });
+ el.appendChild(tabBar);
+
+ var palettePanel = buildPalettePanel(function (hex) { setColorInternal(hex); });
+ el.appendChild(palettePanel);
+
+ var namedPanel = buildNamedPanel(function (hex) { setColorInternal(hex); });
+ el.appendChild(namedPanel);
+
+ var customPanel = document.createElement("div");
+ customPanel.className = "rt-picker-panel rt-picker-panel--custom";
+ var svWrap = document.createElement("div");
+ svWrap.className = "rt-picker-sv-wrap";
+ var svCanvas = document.createElement("canvas");
+ svCanvas.className = "rt-picker-sv";
+ svCanvas.height = 140;
+ var svCursor = document.createElement("div");
+ svCursor.className = "rt-picker-sv-cursor";
+ svWrap.appendChild(svCanvas);
+ svWrap.appendChild(svCursor);
+ customPanel.appendChild(svWrap);
+
+ var hueBar = document.createElement("div");
+ hueBar.className = "rt-picker-hue";
+ var hueIndicator = document.createElement("div");
+ hueIndicator.className = "rt-picker-hue-indicator";
+ hueBar.appendChild(hueIndicator);
+ customPanel.appendChild(hueBar);
+ el.appendChild(customPanel);
+
+ var panels = { palette: palettePanel, named: namedPanel, custom: customPanel };
+
+ function switchTab(id) {
+ lastTab = id;
+ tabDefs.forEach(function (t) {
+ panels[t.id].style.display = t.id === id ? "" : "none";
+ tabBtns[t.id].classList.toggle("rt-picker-tab--active", t.id === id);
+ });
+ if (id === "custom") {
+ resizeSV();
+ drawSV();
+ updateSVCursor();
+ updateHueIndicator();
+ }
+ if (id === "palette") highlightSwatch(palettePanel);
+ if (id === "named") highlightSwatch(namedPanel);
+ }
+
+ function resizeSV() {
+ var w = svWrap.clientWidth;
+ if (w < 100) w = 100;
+ svCanvas.width = w;
+ svCanvas.height = 140;
+ }
+
+ function drawSV() {
+ var ctx = svCanvas.getContext("2d");
+ var w = svCanvas.width;
+ var h = svCanvas.height;
+ ctx.fillStyle = "hsl(" + state.h + ", 100%, 50%)";
+ ctx.fillRect(0, 0, w, h);
+ var wg = ctx.createLinearGradient(0, 0, w, 0);
+ wg.addColorStop(0, "rgba(255,255,255,1)");
+ wg.addColorStop(1, "rgba(255,255,255,0)");
+ ctx.fillStyle = wg;
+ ctx.fillRect(0, 0, w, h);
+ var bg = ctx.createLinearGradient(0, 0, 0, h);
+ bg.addColorStop(0, "rgba(0,0,0,0)");
+ bg.addColorStop(1, "rgba(0,0,0,1)");
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, w, h);
+ }
+
+ function updateSVCursor() {
+ svCursor.style.left = (state.s * svCanvas.width) + "px";
+ svCursor.style.top = ((1 - state.v) * svCanvas.height) + "px";
+ }
+
+ function updateHueIndicator() {
+ hueIndicator.style.left = ((state.h / 360) * hueBar.clientWidth) + "px";
+ }
+
+ function highlightSwatch(panel) {
+ var prev = panel.querySelector(".rt-picker-swatch--selected");
+ if (prev) prev.classList.remove("rt-picker-swatch--selected");
+ var match = panel.querySelector("[data-hex=\"" + state.hex + "\"]");
+ if (match) match.classList.add("rt-picker-swatch--selected");
+ }
+
+ function setColorInternal(hex) {
+ state.hex = hex.toLowerCase();
+ var hsv = hexToHsv(state.hex);
+ state.h = hsv[0]; state.s = hsv[1]; state.v = hsv[2];
+ if (lastTab === "custom") { drawSV(); updateSVCursor(); updateHueIndicator(); }
+ if (lastTab === "palette") highlightSwatch(palettePanel);
+ if (lastTab === "named") highlightSwatch(namedPanel);
+ if (state.onChange) state.onChange(state.hex);
+ }
+
+ function updateFromHSV() {
+ state.hex = hsvToHex(state.h, state.s, state.v);
+ if (state.onChange) state.onChange(state.hex);
+ }
+
+ function onSVMouse(e) {
+ var rect = svCanvas.getBoundingClientRect();
+ state.s = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ state.v = Math.max(0, Math.min(1, 1 - (e.clientY - rect.top) / rect.height));
+ updateSVCursor();
+ updateFromHSV();
+ }
+
+ svWrap.addEventListener("mousedown", function (e) {
+ e.preventDefault();
+ onSVMouse(e);
+ function onMove(ev) { onSVMouse(ev); }
+ function onUp() { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }
+ document.addEventListener("mousemove", onMove);
+ document.addEventListener("mouseup", onUp);
+ });
+
+ function onHueMouse(e) {
+ var rect = hueBar.getBoundingClientRect();
+ state.h = Math.max(0, Math.min(360, ((e.clientX - rect.left) / rect.width) * 360));
+ updateHueIndicator();
+ drawSV();
+ updateFromHSV();
+ }
+
+ hueBar.addEventListener("mousedown", function (e) {
+ e.preventDefault();
+ onHueMouse(e);
+ function onMove(ev) { onHueMouse(ev); }
+ function onUp() { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }
+ document.addEventListener("mousemove", onMove);
+ document.addEventListener("mouseup", onUp);
+ });
+
+ function open(hex, onChange) {
+ state.onChange = onChange;
+ setColor(hex || "#ff0000");
+ el.style.display = "";
+ state.visible = true;
+ switchTab(lastTab);
+ }
+
+ function close() {
+ el.style.display = "none";
+ state.visible = false;
+ state.onChange = null;
+ }
+
+ function setColor(hex) {
+ if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) hex = "#ff0000";
+ state.hex = hex.toLowerCase();
+ var hsv = hexToHsv(state.hex);
+ state.h = hsv[0]; state.s = hsv[1]; state.v = hsv[2];
+ if (state.visible) {
+ if (lastTab === "custom") { drawSV(); updateSVCursor(); updateHueIndicator(); }
+ if (lastTab === "palette") highlightSwatch(palettePanel);
+ if (lastTab === "named") highlightSwatch(namedPanel);
+ }
+ }
+
+ function isOpen() { return state.visible; }
+
+ function destroy() { if (el.parentNode) el.parentNode.removeChild(el); }
+
+ return { el: el, open: open, close: close, setColor: setColor, isOpen: isOpen, destroy: destroy };
+ }
+
+ window.RedditTweaks = window.RedditTweaks || {};
+ window.RedditTweaks.colorPicker = { create: create };
+})();
+```
+
+- [ ] **Step 2: Append picker CSS to `content/styles/badges.css`**
+
+Add at the end of the file:
+
+```css
+/* --- Color Picker Widget --- */
+.rt-color-picker {
+ margin: 8px 0;
+ font-size: 12px;
+}
+
+.rt-picker-tabs {
+ display: flex;
+ border-bottom: 1px solid #ccc;
+ margin-bottom: 8px;
+}
+
+.rt-picker-tab {
+ flex: 1;
+ padding: 4px 8px;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ font-size: 11px;
+ font-family: inherit;
+ cursor: pointer;
+ color: #666;
+}
+
+.rt-picker-tab:hover {
+ color: #333;
+}
+
+.rt-picker-tab--active {
+ color: #1a1a1a;
+ border-bottom-color: #4a90d9;
+ font-weight: 600;
+}
+
+.rt-picker-panel {
+ max-height: 180px;
+ overflow-y: auto;
+}
+
+.rt-picker-grid--palette {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.rt-picker-grid-row {
+ display: flex;
+ gap: 2px;
+}
+
+.rt-picker-swatch {
+ width: 16px;
+ height: 16px;
+ border-radius: 2px;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.rt-picker-swatch:hover {
+ border-color: rgba(0, 0, 0, 0.4);
+ transform: scale(1.2);
+ z-index: 1;
+ position: relative;
+}
+
+.rt-picker-swatch--selected {
+ border: 2px solid #1a1a1a;
+ box-shadow: 0 0 0 1px white;
+}
+
+.rt-picker-grid--named {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ padding: 2px 0;
+}
+
+.rt-picker-named-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ cursor: pointer;
+ width: 40px;
+}
+
+.rt-picker-named-item .rt-picker-swatch {
+ width: 24px;
+ height: 18px;
+}
+
+.rt-picker-named-label {
+ font-size: 8px;
+ color: #888;
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 100%;
+ line-height: 1.2;
+}
+
+.rt-picker-panel--custom {
+ max-height: none;
+ overflow: visible;
+}
+
+.rt-picker-sv-wrap {
+ position: relative;
+ margin-bottom: 6px;
+}
+
+.rt-picker-sv {
+ display: block;
+ width: 100%;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ cursor: crosshair;
+}
+
+.rt-picker-sv-cursor {
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ border: 2px solid white;
+ border-radius: 50%;
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
+ pointer-events: none;
+ transform: translate(-50%, -50%);
+}
+
+.rt-picker-hue {
+ position: relative;
+ height: 14px;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ background: linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);
+ cursor: crosshair;
+}
+
+.rt-picker-hue-indicator {
+ position: absolute;
+ top: -1px;
+ width: 2px;
+ height: calc(100% + 2px);
+ background: white;
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
+ pointer-events: none;
+ transform: translateX(-50%);
+}
+
+/* Picker dark mode */
+.rt-dark .rt-picker-tabs,
+.rt-color-popover.rt-dark .rt-picker-tabs {
+ border-bottom-color: #555;
+}
+
+.rt-dark .rt-picker-tab,
+.rt-color-popover.rt-dark .rt-picker-tab {
+ color: #999;
+}
+
+.rt-dark .rt-picker-tab:hover,
+.rt-color-popover.rt-dark .rt-picker-tab:hover {
+ color: #ccc;
+}
+
+.rt-dark .rt-picker-tab--active,
+.rt-color-popover.rt-dark .rt-picker-tab--active {
+ color: #e0e0e0;
+ border-bottom-color: #4a90d9;
+}
+
+.rt-dark .rt-picker-swatch,
+.rt-color-popover.rt-dark .rt-picker-swatch {
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+.rt-dark .rt-picker-swatch:hover,
+.rt-color-popover.rt-dark .rt-picker-swatch:hover {
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+.rt-dark .rt-picker-swatch--selected,
+.rt-color-popover.rt-dark .rt-picker-swatch--selected {
+ border-color: #e0e0e0;
+ box-shadow: 0 0 0 1px #333;
+}
+
+.rt-dark .rt-picker-named-label,
+.rt-color-popover.rt-dark .rt-picker-named-label {
+ color: #777;
+}
+
+.rt-dark .rt-picker-sv,
+.rt-color-popover.rt-dark .rt-picker-sv {
+ border-color: #555;
+}
+
+.rt-dark .rt-picker-hue,
+.rt-color-popover.rt-dark .rt-picker-hue {
+ border-color: #555;
+}
+```
+
+- [ ] **Step 3: Append picker CSS to `popup/popup.css`**
+
+Add the same picker CSS block from Step 2 at the end of `popup/popup.css`. The dark mode rules won't apply in the popup (no `.rt-dark` ancestor) but are harmless.
+
+- [ ] **Step 4: Update `manifest.json`**
+
+Add `content/tweaks/color-picker.js` to the content scripts JS array, **before** `color-popover.js` (the popover depends on the picker):
+
+```json
+"js": [
+ "lib/settings.js",
+ "content/tweaks/badges.js",
+ "content/tweaks/color-picker.js",
+ "content/tweaks/color-popover.js",
+ "content/main.js"
+]
+```
+
+- [ ] **Step 5: Verify manually**
+
+Load `old.reddit.com`, open the browser console, and verify:
+1. `window.RedditTweaks.colorPicker` exists and has a `create` method.
+2. `var p = window.RedditTweaks.colorPicker.create();` returns an object with `el`, `open`, `close`, `setColor`, `isOpen`, `destroy`.
+3. `document.body.appendChild(p.el); p.open("#ff0000", function(h){ console.log(h); });` shows the picker with three tabs.
+4. Clicking palette swatches logs hex values.
+5. Clicking named color swatches logs hex values.
+6. Dragging on the Custom tab's SV square/hue bar logs hex values.
+7. Clean up: `p.destroy();`
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add content/tweaks/color-picker.js content/styles/badges.css popup/popup.css manifest.json
+git commit -m "feat: add tabbed color picker widget (palette, named colors, custom HSV)"
+```
+
+---
+
+### Task 3: Popover Integration
+
+**Files:**
+- Modify: `content/tweaks/color-popover.js`
+- Modify: `content/styles/badges.css:180-187` (update swatch selector)
+
+**Interfaces:**
+- Consumes: `window.RedditTweaks.colorPicker.create()` → `{ el, open, close, setColor, isOpen, destroy }`
+- Produces: Updated popover using custom picker instead of native ``
+
+- [ ] **Step 1: Update swatch CSS selector in `badges.css`**
+
+Replace the selector for the native color input:
+
+```css
+.rt-popover-row input[type="color"] {
+ width: 28px;
+ height: 20px;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ padding: 1px;
+ cursor: pointer;
+}
+```
+
+with:
+
+```css
+.rt-popover-row .rt-color-swatch {
+ width: 28px;
+ height: 20px;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ cursor: pointer;
+ display: inline-block;
+ vertical-align: middle;
+}
+```
+
+Also replace the dark mode variant:
+
+```css
+.rt-color-popover.rt-dark .rt-popover-row input[type="color"] {
+ border-color: #555;
+}
+```
+
+with:
+
+```css
+.rt-color-popover.rt-dark .rt-popover-row .rt-color-swatch {
+ border-color: #555;
+}
+```
+
+- [ ] **Step 2: Rewrite `color-popover.js`**
+
+Replace the entire file with the updated version. Key changes from the previous version:
+- `` → `
` in the HTML template
+- Add `var picker = null;` and `var activeProp = null;` at module scope
+- Create picker lazily in `createPopover()` and insert between body and preview
+- Swatch `input` event → `click` handler that opens/toggles picker
+- Hex `input` handler syncs picker via `picker.setColor()` when open
+- `loadModeIntoInputs()` sets `swatch.style.backgroundColor` instead of `.value`
+- `updatePreview()` and `storeCurrentEdits()` read from `hex.value` instead of `swatch.value`
+- Mode toggle closes picker
+- `close()` closes picker and resets `activeProp`
+- `onDocumentMouseDown` excludes clicks inside `picker.el`
+- Remove the `active.type === "color"` check (no longer relevant)
+
+Full replacement content for `content/tweaks/color-popover.js`:
+
+```javascript
+(function () {
+ "use strict";
+
+ var popoverEl = null;
+ var currentSubreddit = null;
+ var currentAnchor = null;
+ var editState = { light: null, dark: null };
+ var selectedMode = "light";
+ var dirtyModes = { light: false, dark: false };
+ var hadOverride = { light: false, dark: false };
+ var picker = null;
+ var activeProp = null;
+
+ var els = {};
+
+ function getHelpers() {
+ return {
+ isDarkMode: window.RedditTweaks.badgeTweak.isDarkMode,
+ getColorPrefix: window.RedditTweaks.badgeTweak.getColorPrefix,
+ };
+ }
+
+ function hexValid(val) {
+ return /^#[0-9a-f]{6}$/i.test(val);
+ }
+
+ function colorToHex(val) {
+ if (!val) return "#ffffff";
+ return val;
+ }
+
+ function createPopover() {
+ var el = document.createElement("div");
+ el.className = "rt-color-popover";
+
+ el.innerHTML = [
+ '",
+ '
',
+ ' ",
+ "
",
+ '
',
+ '
',
+ '
Background',
+ '
",
+ "
",
+ '
',
+ '
Border',
+ '
",
+ "
",
+ '
",
+ "
",
+ '
',
+ ' ',
+ "
",
+ '",
+ ].join("\n");
+
+ document.body.appendChild(el);
+ popoverEl = el;
+
+ els.title = el.querySelector(".rt-popover-title");
+ els.closeBtn = el.querySelector(".rt-popover-close");
+ els.modeSelect = el.querySelector(".rt-popover-mode-select");
+ els.preview = el.querySelector(".rt-popover-preview-badge");
+ els.saveBtn = el.querySelector(".rt-btn-save");
+ els.cancelBtn = el.querySelector(".rt-btn-cancel");
+ els.resetBtn = el.querySelector(".rt-btn-reset");
+
+ els.rows = {};
+ var rows = el.querySelectorAll(".rt-popover-row");
+ for (var i = 0; i < rows.length; i++) {
+ var row = rows[i];
+ var prop = row.getAttribute("data-prop");
+ els.rows[prop] = {
+ swatch: row.querySelector(".rt-color-swatch"),
+ hex: row.querySelector(".rt-color-hex"),
+ };
+ }
+
+ picker = window.RedditTweaks.colorPicker.create();
+ var previewSection = el.querySelector(".rt-popover-preview");
+ el.insertBefore(picker.el, previewSection);
+
+ els.closeBtn.addEventListener("click", close);
+ els.cancelBtn.addEventListener("click", close);
+ els.saveBtn.addEventListener("click", save);
+ els.resetBtn.addEventListener("click", resetToDefault);
+
+ els.modeSelect.addEventListener("change", function () {
+ markModeDirty(selectedMode);
+ storeCurrentEdits();
+ selectedMode = els.modeSelect.value;
+ closePicker();
+ loadModeIntoInputs();
+ updatePreview();
+ });
+
+ var props = ["bg", "border", "text"];
+ for (var p = 0; p < props.length; p++) {
+ (function (prop) {
+ var row = els.rows[prop];
+ row.swatch.addEventListener("click", function () {
+ onSwatchClick(prop);
+ });
+ row.hex.addEventListener("input", function () {
+ markModeDirty(selectedMode);
+ if (hexValid(row.hex.value)) {
+ row.swatch.style.backgroundColor = row.hex.value;
+ row.hex.classList.remove("rt-input-error");
+ if (picker.isOpen() && activeProp === prop) {
+ picker.setColor(row.hex.value);
+ }
+ updatePreview();
+ } else {
+ row.hex.classList.add("rt-input-error");
+ }
+ });
+ })(props[p]);
+ }
+
+ document.addEventListener("mousedown", onDocumentMouseDown, true);
+ document.addEventListener("keydown", onDocumentKeyDown, true);
+ }
+
+ function onSwatchClick(prop) {
+ if (picker.isOpen() && activeProp === prop) {
+ closePicker();
+ return;
+ }
+ closePicker();
+ activeProp = prop;
+ var row = els.rows[prop];
+ picker.open(row.hex.value, function (hex) {
+ markModeDirty(selectedMode);
+ row.swatch.style.backgroundColor = hex;
+ row.hex.value = hex;
+ row.hex.classList.remove("rt-input-error");
+ updatePreview();
+ });
+ }
+
+ function closePicker() {
+ if (picker && picker.isOpen()) picker.close();
+ activeProp = null;
+ }
+
+ function onDocumentMouseDown(e) {
+ if (!popoverEl || !popoverEl.classList.contains("rt-popover-visible")) return;
+ if (popoverEl.contains(e.target)) return;
+ close();
+ }
+
+ function onDocumentKeyDown(e) {
+ if (e.key === "Escape" && popoverEl && popoverEl.classList.contains("rt-popover-visible")) {
+ close();
+ }
+ }
+
+ function getDefaultsForMode(settings, mode) {
+ return {
+ bg: settings[mode + ".subredditBgColor"],
+ border: settings[mode + ".subredditBorderColor"],
+ text: settings[mode + ".subredditTextColor"],
+ };
+ }
+
+ function getColorsForMode(settings, subredditName, mode) {
+ var entry = settings.subredditColors[subredditName];
+ if (entry && entry[mode]) {
+ return { bg: entry[mode].bg, border: entry[mode].border, text: entry[mode].text };
+ }
+ return getDefaultsForMode(settings, mode);
+ }
+
+ function markModeDirty(mode) {
+ dirtyModes[mode] = true;
+ }
+
+ function resetTracking() {
+ dirtyModes = { light: false, dark: false };
+ hadOverride = { light: false, dark: false };
+ }
+
+ function storeCurrentEdits() {
+ editState[selectedMode] = {
+ bg: els.rows.bg.hex.value,
+ border: els.rows.border.hex.value,
+ text: els.rows.text.hex.value,
+ };
+ }
+
+ function loadModeIntoInputs() {
+ var colors = editState[selectedMode];
+ var props = ["bg", "border", "text"];
+ for (var i = 0; i < props.length; i++) {
+ var prop = props[i];
+ var val = colorToHex(colors[prop]);
+ els.rows[prop].swatch.style.backgroundColor = val;
+ els.rows[prop].hex.value = val;
+ els.rows[prop].hex.classList.remove("rt-input-error");
+ }
+ }
+
+ function updatePreview() {
+ var bg = els.rows.bg.hex.value;
+ var border = els.rows.border.hex.value;
+ var text = els.rows.text.hex.value;
+ els.preview.style.background = bg;
+ els.preview.style.borderColor = border;
+ els.preview.style.color = text;
+ }
+
+ function positionPopover(anchorEl) {
+ var rect = anchorEl.getBoundingClientRect();
+ var gap = 6;
+
+ popoverEl.style.visibility = "hidden";
+ popoverEl.classList.add("rt-popover-visible");
+
+ var popRect = popoverEl.getBoundingClientRect();
+ var top = rect.bottom + window.scrollY + gap;
+ var left = rect.left + window.scrollX;
+
+ if (rect.bottom + gap + popRect.height > window.innerHeight) {
+ top = rect.top + window.scrollY - popRect.height - gap;
+ }
+
+ var maxLeft = document.documentElement.scrollWidth - popRect.width - 8;
+ if (left > maxLeft) left = maxLeft;
+ if (left < 8) left = 8;
+
+ popoverEl.style.top = top + "px";
+ popoverEl.style.left = left + "px";
+ popoverEl.style.visibility = "";
+ }
+
+ function applyDarkModeToPopover(settings) {
+ var helpers = getHelpers();
+ if (helpers.isDarkMode(settings)) {
+ popoverEl.classList.add("rt-dark");
+ } else {
+ popoverEl.classList.remove("rt-dark");
+ }
+ }
+
+ function open(badgeEl, subredditName, settings) {
+ if (!popoverEl) createPopover();
+
+ if (currentSubreddit === subredditName && popoverEl.classList.contains("rt-popover-visible")) {
+ close();
+ return;
+ }
+
+ currentSubreddit = subredditName;
+ currentAnchor = badgeEl;
+
+ resetTracking();
+ var existingEntry = settings.subredditColors[subredditName];
+ hadOverride.light = !!(existingEntry && existingEntry.light);
+ hadOverride.dark = !!(existingEntry && existingEntry.dark);
+
+ var helpers = getHelpers();
+ selectedMode = helpers.getColorPrefix(settings);
+
+ editState.light = getColorsForMode(settings, subredditName, "light");
+ editState.dark = getColorsForMode(settings, subredditName, "dark");
+
+ els.title.textContent = "r/" + subredditName;
+ els.modeSelect.value = selectedMode;
+ els.preview.textContent = "r/" + subredditName;
+
+ closePicker();
+ applyDarkModeToPopover(settings);
+ loadModeIntoInputs();
+ updatePreview();
+ positionPopover(badgeEl);
+ }
+
+ function close() {
+ if (!popoverEl) return;
+ closePicker();
+ popoverEl.classList.remove("rt-popover-visible");
+ currentSubreddit = null;
+ currentAnchor = null;
+ editState = { light: null, dark: null };
+ resetTracking();
+ }
+
+ async function save() {
+ if (!currentSubreddit) return;
+
+ storeCurrentEdits();
+
+ var settings = await window.RedditTweaks.loadSettings();
+ var entry = settings.subredditColors[currentSubreddit] || {};
+
+ if (dirtyModes.light || hadOverride.light) {
+ entry.light = editState.light;
+ }
+ if (dirtyModes.dark || hadOverride.dark) {
+ entry.dark = editState.dark;
+ }
+
+ if (!entry.light && !entry.dark) {
+ delete settings.subredditColors[currentSubreddit];
+ } else {
+ settings.subredditColors[currentSubreddit] = entry;
+ }
+ await window.RedditTweaks.saveSettings(settings);
+ close();
+ }
+
+ async function resetToDefault() {
+ if (!currentSubreddit) return;
+
+ var settings = await window.RedditTweaks.loadSettings();
+ delete settings.subredditColors[currentSubreddit];
+ await window.RedditTweaks.saveSettings(settings);
+
+ resetTracking();
+ editState.light = getDefaultsForMode(settings, "light");
+ editState.dark = getDefaultsForMode(settings, "dark");
+ closePicker();
+ loadModeIntoInputs();
+ updatePreview();
+ }
+
+ function refreshTheme(settings) {
+ if (!popoverEl || !popoverEl.classList.contains("rt-popover-visible")) return;
+ applyDarkModeToPopover(settings);
+ }
+
+ window.RedditTweaks = window.RedditTweaks || {};
+ window.RedditTweaks.colorPopover = {
+ open: open,
+ close: close,
+ refreshTheme: refreshTheme,
+ };
+})();
+```
+
+- [ ] **Step 3: Verify manually**
+
+Load `old.reddit.com`. Open the inline color popover by hovering a subreddit badge and clicking the gear. Verify:
+1. Color rows show swatch divs (colored rectangles) instead of native color pickers.
+2. Clicking a swatch opens the tabbed picker below the rows.
+3. Palette tab: clicking a Tailwind swatch updates the row's swatch + hex input + preview badge.
+4. Named tab: clicking a named color works the same.
+5. Custom tab: dragging the SV square and hue bar updates live.
+6. Clicking the same swatch toggles the picker closed.
+7. Clicking a different swatch switches the picker to that property.
+8. Typing a valid hex updates the picker's state.
+9. Mode toggle closes the picker and loads the other mode's colors.
+10. Cancel/Escape/click-outside dismisses the popover cleanly.
+11. Save persists the colors.
+12. Dark mode styling works (if RES night mode is available).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add content/tweaks/color-popover.js content/styles/badges.css
+git commit -m "feat: integrate tabbed color picker into inline popover"
+```
+
+---
+
+### Task 4: Popup Integration
+
+**Files:**
+- Modify: `popup/popup.html:72-73`
+- Modify: `popup/popup.js`
+- Modify: `popup/popup.css:85-92`
+
+**Interfaces:**
+- Consumes: `window.RedditTweaks.colorPicker.create()` → `{ el, open, close, setColor, isOpen, destroy }`
+- Produces: Updated popup with custom picker for all global and per-subreddit color controls
+
+- [ ] **Step 1: Add script tag to `popup.html`**
+
+Add the color-picker script before popup.js:
+
+```html
+
+
+
+```
+
+- [ ] **Step 2: Replace `
` with swatch divs in `popup.html`**
+
+Replace all 12 `
` elements with `
` elements. Each keeps its `id` attribute.
+
+Light Mode Colors section:
+
+```html
+
+
Light Mode Colors
+
+
+
+
+```
+
+Dark Mode Colors section:
+
+```html
+
+
Dark Mode Colors
+
+
+
+
+```
+
+- [ ] **Step 3: Update swatch CSS selector in `popup.css`**
+
+Replace:
+
+```css
+fieldset input[type="color"] {
+ width: 40px;
+ height: 24px;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ padding: 1px;
+ cursor: pointer;
+}
+```
+
+with:
+
+```css
+fieldset .rt-color-swatch {
+ width: 40px;
+ height: 24px;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ cursor: pointer;
+ display: inline-block;
+ vertical-align: middle;
+}
+```
+
+- [ ] **Step 4: Rewrite `popup.js`**
+
+Replace the entire file. Key changes from the previous version:
+- Add `getColorValue(el)` / `setColorValue(el, val)` helpers for reading/writing swatch div values
+- Create a shared picker instance
+- Wire swatch click handlers for global colors in `init()`
+- Replace `el.value` reads/writes with helper calls for color elements
+- Replace `
` creation in `addSubredditEntry()` with swatch divs
+- Replace `el.addEventListener("input", saveFromControls)` for color elements with picker-based flow
+
+Full replacement content for `popup/popup.js`:
+
+```javascript
+(function () {
+ "use strict";
+
+ var picker = null;
+ var activeSwatchEl = null;
+
+ var COLOR_KEYS = [
+ "light.subredditBgColor",
+ "light.subredditBorderColor",
+ "light.subredditTextColor",
+ "light.commentsBgColor",
+ "light.commentsBorderColor",
+ "light.commentsTextColor",
+ "dark.subredditBgColor",
+ "dark.subredditBorderColor",
+ "dark.subredditTextColor",
+ "dark.commentsBgColor",
+ "dark.commentsBorderColor",
+ "dark.commentsTextColor",
+ ];
+
+ function colorToHex(val) {
+ if (!val) return "#ffffff";
+ return val;
+ }
+
+ function getColorValue(el) {
+ return el.dataset.value || "#ffffff";
+ }
+
+ function setColorValue(el, val) {
+ el.dataset.value = val;
+ el.style.backgroundColor = val;
+ }
+
+ function openPickerForSwatch(swatchEl) {
+ if (!picker) {
+ picker = window.RedditTweaks.colorPicker.create();
+ }
+
+ if (picker.isOpen() && activeSwatchEl === swatchEl) {
+ closePicker();
+ return;
+ }
+ closePicker();
+
+ activeSwatchEl = swatchEl;
+ var parent = swatchEl.closest("label") || swatchEl.parentNode;
+ parent.parentNode.insertBefore(picker.el, parent.nextSibling);
+
+ picker.open(getColorValue(swatchEl), function (hex) {
+ setColorValue(swatchEl, hex);
+ saveFromControls();
+ });
+ }
+
+ function closePicker() {
+ if (picker && picker.isOpen()) picker.close();
+ activeSwatchEl = null;
+ }
+
+ function populateControls(settings) {
+ document.getElementById("enabled").checked = settings.enabled;
+ document.getElementById("darkMode").value = settings.darkMode;
+
+ COLOR_KEYS.forEach(function (key) {
+ var el = document.getElementById(key);
+ if (el) setColorValue(el, colorToHex(settings[key]));
+ });
+
+ renderSubredditOverrides(settings.subredditColors || {});
+ }
+
+ function renderSubredditOverrides(overrides) {
+ var list = document.getElementById("subredditList");
+ list.innerHTML = "";
+
+ Object.entries(overrides).forEach(function (pair) {
+ addSubredditEntry(pair[0], pair[1]);
+ });
+ }
+
+ function addSubredditEntry(name, modeColors) {
+ var list = document.getElementById("subredditList");
+ var entry = document.createElement("div");
+ entry.className = "subreddit-entry";
+
+ var header = document.createElement("div");
+ header.className = "subreddit-entry-header";
+
+ var nameInput = document.createElement("input");
+ nameInput.type = "text";
+ nameInput.placeholder = "subreddit";
+ nameInput.value = name || "";
+
+ var removeBtn = document.createElement("button");
+ removeBtn.className = "btn-remove";
+ removeBtn.textContent = "\u00d7";
+ removeBtn.addEventListener("click", function () {
+ closePicker();
+ entry.remove();
+ saveFromControls();
+ });
+
+ header.appendChild(nameInput);
+ header.appendChild(removeBtn);
+
+ var modeRow = document.createElement("div");
+ modeRow.className = "subreddit-entry-mode";
+
+ var modeSelect = document.createElement("select");
+ modeSelect.innerHTML = '
';
+ modeSelect.value = "light";
+ modeRow.appendChild(modeSelect);
+
+ var colorsDiv = document.createElement("div");
+ colorsDiv.className = "subreddit-entry-colors";
+
+ var lightColors = modeColors && modeColors.light ? modeColors.light : {};
+ var darkColors = modeColors && modeColors.dark ? modeColors.dark : {};
+
+ var bgSwatch = document.createElement("div");
+ bgSwatch.className = "rt-color-swatch";
+ setColorValue(bgSwatch, colorToHex(lightColors.bg));
+ bgSwatch.title = "Background";
+
+ var borderSwatch = document.createElement("div");
+ borderSwatch.className = "rt-color-swatch";
+ setColorValue(borderSwatch, colorToHex(lightColors.border));
+ borderSwatch.title = "Border";
+
+ var textSwatch = document.createElement("div");
+ textSwatch.className = "rt-color-swatch";
+ setColorValue(textSwatch, colorToHex(lightColors.text));
+ textSwatch.title = "Text";
+
+ colorsDiv.appendChild(bgSwatch);
+ colorsDiv.appendChild(borderSwatch);
+ colorsDiv.appendChild(textSwatch);
+
+ entry._colorState = {
+ light: { bg: getColorValue(bgSwatch), border: getColorValue(borderSwatch), text: getColorValue(textSwatch) },
+ dark: { bg: colorToHex(darkColors.bg), border: colorToHex(darkColors.border), text: colorToHex(darkColors.text) },
+ };
+ entry._hadOverride = {
+ light: !!(modeColors && modeColors.light),
+ dark: !!(modeColors && modeColors.dark),
+ };
+ entry._dirtyModes = { light: false, dark: false };
+
+ modeSelect.addEventListener("change", function () {
+ closePicker();
+ var prev = modeSelect.value === "dark" ? "light" : "dark";
+ entry._dirtyModes[prev] = true;
+ entry._colorState[prev] = { bg: getColorValue(bgSwatch), border: getColorValue(borderSwatch), text: getColorValue(textSwatch) };
+ var next = entry._colorState[modeSelect.value];
+ setColorValue(bgSwatch, next.bg);
+ setColorValue(borderSwatch, next.border);
+ setColorValue(textSwatch, next.text);
+ });
+
+ [bgSwatch, borderSwatch, textSwatch].forEach(function (swatch) {
+ swatch.addEventListener("click", function () {
+ openPickerForSwatch(swatch);
+ });
+ });
+
+ nameInput.addEventListener("change", function () {
+ saveFromControls();
+ });
+
+ entry.appendChild(header);
+ entry.appendChild(modeRow);
+ entry.appendChild(colorsDiv);
+
+ entry._nameInput = nameInput;
+ entry._bgSwatch = bgSwatch;
+ entry._borderSwatch = borderSwatch;
+ entry._textSwatch = textSwatch;
+ entry._modeSelect = modeSelect;
+
+ list.appendChild(entry);
+ }
+
+ function collectSubredditOverrides() {
+ var overrides = {};
+ var entries = document.querySelectorAll(".subreddit-entry");
+ entries.forEach(function (entry) {
+ var name = entry._nameInput.value.trim().replace(/^r\//, "");
+ if (!name) return;
+
+ var currentMode = entry._modeSelect.value;
+ entry._colorState[currentMode] = {
+ bg: getColorValue(entry._bgSwatch),
+ border: getColorValue(entry._borderSwatch),
+ text: getColorValue(entry._textSwatch),
+ };
+
+ var entryOverrides = {};
+ if (entry._hadOverride.light || entry._dirtyModes.light) {
+ entryOverrides.light = entry._colorState.light;
+ }
+ if (entry._hadOverride.dark || entry._dirtyModes.dark) {
+ entryOverrides.dark = entry._colorState.dark;
+ }
+
+ if (entryOverrides.light || entryOverrides.dark) {
+ overrides[name] = entryOverrides;
+ }
+ });
+ return overrides;
+ }
+
+ async function saveFromControls() {
+ var settings = {
+ enabled: document.getElementById("enabled").checked,
+ darkMode: document.getElementById("darkMode").value,
+ subredditColors: collectSubredditOverrides(),
+ };
+
+ COLOR_KEYS.forEach(function (key) {
+ var el = document.getElementById(key);
+ if (el) settings[key] = getColorValue(el);
+ });
+
+ try {
+ await window.RedditTweaks.saveSettings(settings);
+ } catch (err) {
+ console.error("Reddit Tweaks: failed to save settings", err);
+ }
+ }
+
+ async function init() {
+ var settings = await window.RedditTweaks.loadSettings();
+ populateControls(settings);
+
+ document.getElementById("enabled").addEventListener("change", saveFromControls);
+ document.getElementById("darkMode").addEventListener("change", saveFromControls);
+
+ COLOR_KEYS.forEach(function (key) {
+ var el = document.getElementById(key);
+ if (el) {
+ el.addEventListener("click", function () {
+ openPickerForSwatch(el);
+ });
+ }
+ });
+
+ document.getElementById("addSubreddit").addEventListener("click", function () {
+ closePicker();
+ addSubredditEntry("", {});
+ });
+
+ document.getElementById("resetLight").addEventListener("click", async function () {
+ closePicker();
+ var settings = await window.RedditTweaks.loadSettings();
+ var 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 () {
+ closePicker();
+ var settings = await window.RedditTweaks.loadSettings();
+ var 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?")) {
+ closePicker();
+ await window.RedditTweaks.resetSettings();
+ var settings = await window.RedditTweaks.loadSettings();
+ populateControls(settings);
+ }
+ });
+ }
+
+ init();
+})();
+```
+
+- [ ] **Step 5: Add swatch styles for subreddit entries to `popup.css`**
+
+The subreddit entry swatches need sizing. Add after the `.subreddit-entry-colors` block:
+
+```css
+.subreddit-entry-colors .rt-color-swatch {
+ width: 40px;
+ height: 24px;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ cursor: pointer;
+}
+```
+
+- [ ] **Step 6: Verify manually**
+
+Open the extension popup (click the toolbar icon). Verify:
+1. All global color controls show swatch divs (colored rectangles) instead of native color pickers.
+2. Clicking a global swatch opens the tabbed picker below it.
+3. All three tabs work: Palette, Named Colors, Custom.
+4. Selecting a color updates the swatch and saves settings.
+5. Per-subreddit entries also use swatch divs.
+6. Clicking a per-subreddit swatch opens the picker.
+7. Mode toggle in per-subreddit entries closes any open picker.
+8. Adding a new subreddit entry closes any open picker.
+9. Reset buttons work and close any open picker.
+10. Only one picker is open at a time across all swatches.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add popup/popup.html popup/popup.js popup/popup.css
+git commit -m "feat: integrate tabbed color picker into extension popup"
+```
diff --git a/docs/superpowers/specs/2026-08-26-badge-polish-design.md b/docs/superpowers/specs/2026-08-26-badge-polish-design.md
index d07664f..524f294 100644
--- a/docs/superpowers/specs/2026-08-26-badge-polish-design.md
+++ b/docs/superpowers/specs/2026-08-26-badge-polish-design.md
@@ -58,9 +58,53 @@ The native `
` delegates to the OS-level color picker. On mac
### Solution
-Replace all `
` elements — in both the inline popover and the extension popup — with a custom SV+Hue picker widget. The picker renders inline within the containing UI (popover or popup), provides full color control, and stays visually consistent with the extension's design.
+Replace all `
` elements — in both the inline popover and the extension popup — with a custom color picker widget. The picker renders inline within the containing UI (popover or popup), provides full color control, and stays visually consistent with the extension's design.
-### Picker Anatomy
+### Picker Structure — Tabbed Interface
+
+The picker uses three tabs, providing different ways to choose a color:
+
+```
+┌──────────────────────────────┐
+│ [Palette] [Named] [Custom] │ ← tab bar
+├──────────────────────────────┤
+│ │
+│ (active tab content) │
+│ │
+├──────────────────────────────┤
+│ [■ swatch] #c84a20 │ ← current color + hex input (always visible)
+└──────────────────────────────┘
+```
+
+The hex text input and a small swatch preview of the current color are always visible below the tab content, regardless of which tab is active. This lets the user see and edit the exact value at all times.
+
+**Tab persistence:** The picker remembers which tab was last used and reopens to that tab. The initial default (before any tab has been selected) is the Palette tab.
+
+### Tab 1: Palette (Tailwind/shadcn Colors)
+
+A grid of color swatches from the Tailwind CSS / shadcn color palette. The palette is organized by hue family (rows) with shades as columns.
+
+**Hue families (rows):** red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose. Gray-scale families (slate, gray, zinc, neutral, stone) are grouped in a separate section at the top or bottom.
+
+**Shades (columns):** 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950 (11 per row).
+
+**Layout:** Each swatch is a small square (~16×16px). The grid is scrollable vertically within a fixed-height container (~180px) to avoid the picker growing too tall. Hue family labels appear as small text on the left of each row, or omitted if space is tight (the colors are self-explanatory when grouped).
+
+**Interaction:** Clicking a swatch immediately selects that color — updates the hex input, the swatch preview, and calls `onChange`. The selected swatch gets a subtle highlight border.
+
+### Tab 2: Named Colors (HTML/CSS Named Colors)
+
+All 148 CSS named colors displayed as labeled swatches in a scrollable grid.
+
+**Layout:** Swatches are ~24×18px with the color name displayed below or beside each swatch in small text (~9px). The grid is sorted alphabetically by default. The container is scrollable within the same fixed height (~180px).
+
+**Interaction:** Same as the palette tab — clicking selects the color immediately.
+
+### Tab 3: Custom (SV+Hue Picker)
+
+The freeform picker for precise color selection.
+
+#### Picker Anatomy
```
┌──────────────────────────────┐
@@ -75,11 +119,11 @@ Replace all `
` elements — in both the inline popover and t
└──────────────────────────────┘
```
-### Color Model
+#### Color Model
The picker uses HSV internally (hue, saturation, value). HSV maps naturally to the SV square (X = saturation, Y = value) and hue bar (X = hue). Colors are converted to/from hex (`#rrggbb`) for storage, display, and sync with the hex text input.
-### SV Square
+#### SV Square
A `