feat: add color presets + fix badge hover effect

- Add global color presets: save/apply/delete sets of {bg, border, text}
  colors as reusable presets, shown as mini badges in their own colors.
- Popover: preset badges under preview with + to save, click to apply,
  × on hover to delete.
- Popup: dedicated Color Presets section with same functionality,
  applying to light subreddit badge colors.
- Storage: colorPresets array added to settings defaults.
- Fix badge hover: change brightness(0.9) to brightness(1.1) so hover
  brightens (activates) instead of dimming.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 18:00:54 -04:00
parent fb0c03d46a
commit 48db2fc70e
9 changed files with 2053 additions and 23 deletions

View File

@@ -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 `<input type="color">` 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 `<input type="color">` 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

View File

@@ -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;

View File

@@ -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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -58,9 +58,53 @@ The native `<input type="color">` delegates to the OS-level color picker. On mac
### Solution
Replace all `<input type="color">` 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 `<input type="color">` 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 `<input type="color">` 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 `<canvas>` element, redrawn whenever the hue changes. The canvas renders a two-dimensional gradient:
@@ -90,19 +134,23 @@ The gradient is drawn by filling the canvas with the current hue at full saturat
**Size:** The SV square fills the available width minus padding. In the 260px popover, this is ~230px. In the 320px popup, ~290px. Height is fixed at 140px regardless of width — this keeps the popover from growing too tall while still providing a usable picking area. The result is a landscape rectangle, not a true square.
### Hue Bar
#### Hue Bar
A horizontal strip below the SV square. The background is a static CSS `linear-gradient` cycling through the hue spectrum (red → yellow → green → cyan → blue → magenta → red). A thin vertical indicator shows the current hue position. Click or drag to change hue, which redraws the SV square.
**Height:** 14px. Full width of the picker.
### Interaction
#### Interaction
- **Click** on the SV square sets saturation and value immediately.
- **Drag** on the SV square continuously updates saturation and value (tracks `mousemove` after `mousedown`, stops on `mouseup` or mouse leaving the document).
- **Click/drag** on the hue bar changes hue and redraws the SV square.
- The hex text input stays bidirectionally synced: valid hex input updates the picker cursor and hue position; picker changes update the hex input.
### Cross-Tab Sync
Switching tabs while a color is selected carries the current color to the new tab. For the Custom tab, the SV cursor and hue bar position update to reflect the current hex value. For the Palette and Named Colors tabs, if the current hex matches a swatch exactly, that swatch is highlighted.
### Integration with Popover
Each color row in the popover currently has:
@@ -168,8 +216,8 @@ When the picker opens inside the popover, the popover grows taller. If this woul
| File | Change |
|---|---|
| `content/tweaks/color-picker.js` | **New file.** HSV↔hex conversion, canvas SV square rendering, hue bar, mouse tracking, create/open/close/destroy API. Exposes `window.RedditTweaks.colorPicker`. |
| `content/styles/badges.css` | Add CSS for `.rt-color-picker`, `.rt-color-picker-sv` (canvas), `.rt-color-picker-hue` (bar), `.rt-color-picker-sv-cursor`, `.rt-color-picker-hue-indicator`. Light and dark mode variants. |
| `content/tweaks/color-picker.js` | **New file.** Tabbed picker widget: Palette tab (Tailwind color data + grid rendering), Named Colors tab (CSS named colors data + grid), Custom tab (HSV↔hex conversion, canvas SV square, hue bar, mouse tracking). Tab persistence. create/open/close/destroy API. Exposes `window.RedditTweaks.colorPicker`. |
| `content/styles/badges.css` | Add CSS for `.rt-color-picker` (container), tab bar (`.rt-picker-tabs`, `.rt-picker-tab`), palette/named grids (`.rt-picker-grid`, `.rt-picker-swatch`), custom tab (`.rt-picker-sv`, `.rt-picker-hue`, cursors/indicators), current color footer. Light and dark mode variants. |
| `content/tweaks/color-popover.js` | Replace `<input type="color" class="rt-color-swatch">` with `<div class="rt-color-swatch">`. On swatch click, open picker. Wire picker's `onChange` to update hex input, swatch color, and live preview. Close picker on mode toggle. |
| `popup/popup.html` | Add `<script src="../content/tweaks/color-picker.js"></script>` before `popup.js`. |
| `popup/popup.js` | Replace `<input type="color">` creation with swatch `<div>` + picker integration for global colors and per-subreddit overrides. |
@@ -184,10 +232,13 @@ When the picker opens inside the popover, the popover grows taller. If this woul
- **Picker open + mode toggle:** Switching light/dark mode in the popover closes any open picker and loads the new mode's values into the swatches.
- **Popup scroll:** The picker is inline in the DOM flow, so it scrolls naturally with the popup's controls.
- **Multiple pickers:** Only one picker open at a time in any context (popover or popup). Opening a new one closes the previous.
- **Hex input while picker is open:** Typing a valid hex into the text input updates the picker's cursor and hue position in real time.
- **Invalid hex while picker is open:** The hex input shows the error border (existing behavior). The picker position is not updated until a valid hex is entered.
- **Hex input while picker is open:** Typing a valid hex into the text input updates the active tab's state in real time (Custom tab moves cursor; Palette/Named tab highlights matching swatch if any).
- **Invalid hex while picker is open:** The hex input shows the error border (existing behavior). The picker state is not updated until a valid hex is entered.
- **Very small viewport:** The SV square has a minimum size of 100×100px. If the container is too narrow, the picker still works but may feel cramped.
- **Color precision:** HSV→hex conversion rounds to the nearest integer per channel. This can cause minor rounding differences when round-tripping hex→HSV→hex, but the difference is imperceptible (at most ±1 per channel).
- **Tab persistence scope:** The remembered tab is global (shared across all picker instances in the same page context). Popup and content script have separate persistence since they're separate JS contexts.
- **Palette swatch count:** The full Tailwind palette (22 families × 11 shades = 242 swatches) plus gray families. The grid container is scrollable at a fixed height to keep the picker compact.
- **Named colors sort:** Alphabetical by CSS name. No search/filter — the grid is small enough (~148 entries) to scan visually.
## Testing
@@ -196,15 +247,20 @@ Manual testing additions (extends the existing 20-item checklist):
1. Hovering over a subreddit badge smoothly expands the badge to reveal the gear (no gap at rest).
2. The comments badge shifts right during the expansion and back when hover ends.
3. The animation is ~150ms and feels snappy, not laggy.
4. Clicking a color swatch in the popover opens the SV+Hue picker inline.
5. Dragging on the SV square updates the hex input and live preview in real time.
6. Dragging on the hue bar changes the SV square's base color.
7. Only one picker is open at a time — clicking another swatch closes the first.
8. Typing a valid hex updates the picker cursor position.
9. Typing an invalid hex shows the error border but doesn't move the picker.
10. Closing the popover while the picker is open works cleanly.
11. Mode toggle closes any open picker.
12. In the popup, all global color pickers use the custom picker.
13. In the popup, per-subreddit color pickers use the custom picker.
14. The picker renders correctly in dark mode.
15. Color values round-trip correctly (pick → save → reopen → same position).
4. Clicking a color swatch in the popover opens the tabbed picker inline.
5. The Palette tab shows a scrollable grid of Tailwind colors organized by hue.
6. Clicking a palette swatch immediately selects that color and updates the hex input + preview.
7. The Named Colors tab shows all 148 CSS named colors with labels.
8. Clicking a named color swatch selects it immediately.
9. The Custom tab shows the SV+Hue picker; dragging updates hex + preview in real time.
10. Switching tabs carries the current color to the new tab (Custom tab updates cursor position).
11. The picker remembers the last-used tab across open/close cycles.
12. Only one picker is open at a time — clicking another swatch closes the first.
13. Typing a valid hex updates the active tab's state.
14. Typing an invalid hex shows the error border but doesn't update the picker.
15. Closing the popover while the picker is open works cleanly.
16. Mode toggle closes any open picker.
17. In the popup, all global color pickers use the custom tabbed picker.
18. In the popup, per-subreddit color pickers use the custom tabbed picker.
19. The picker renders correctly in dark mode (all three tabs).
20. Color values round-trip correctly (pick → save → reopen → same position/tab).

View File

@@ -17,6 +17,7 @@
"dark.commentsBorderColor": "#888888",
"dark.commentsTextColor": "#cccccc",
subredditColors: {},
colorPresets: [],
};
function migrateSubredditColors(colors) {

View File

@@ -210,6 +210,80 @@ fieldset input[type="text"] {
cursor: pointer;
}
/* --- Preset badges --- */
.rt-preset-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 4px;
}
.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;
}
/* --- Color Picker Widget --- */
.rt-color-picker {
margin: 8px 0;

View File

@@ -58,6 +58,15 @@
<button id="resetDark" class="btn-secondary">Reset Dark Defaults</button>
</div>
<div class="section">
<h2>Color Presets</h2>
<p class="section-hint">Saved color sets. Click to apply to light subreddit badge.</p>
<div class="rt-preset-bar">
<div id="presetList" class="rt-preset-list"></div>
<button id="addPreset" class="rt-preset-add" type="button" title="Save light subreddit colors as preset">+</button>
</div>
</div>
<div class="section">
<h2>Per-Subreddit Overrides</h2>
<p class="section-hint">Set custom subreddit badge colors per mode.</p>

View File

@@ -220,6 +220,63 @@
return overrides;
}
function renderPopupPresets(presets) {
var list = document.getElementById("presetList");
list.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 to light subreddit colors";
badge.addEventListener("click", function () {
setColorValue(document.getElementById("light.subredditBgColor"), p.bg);
setColorValue(document.getElementById("light.subredditBorderColor"), p.border);
setColorValue(document.getElementById("light.subredditTextColor"), p.text);
closePicker();
saveFromControls();
});
var del = document.createElement("span");
del.className = "rt-preset-delete";
del.textContent = "\u00d7";
del.addEventListener("click", function (e) {
e.stopPropagation();
deletePopupPreset(index);
});
badge.appendChild(del);
list.appendChild(badge);
})(i);
}
}
async function addPopupPreset() {
var preset = {
bg: getColorValue(document.getElementById("light.subredditBgColor")),
border: getColorValue(document.getElementById("light.subredditBorderColor")),
text: getColorValue(document.getElementById("light.subredditTextColor")),
};
var settings = await window.RedditTweaks.loadSettings();
var presets = settings.colorPresets || [];
presets.push(preset);
settings.colorPresets = presets;
await window.RedditTweaks.saveSettings(settings);
renderPopupPresets(presets);
}
async function deletePopupPreset(index) {
var settings = await window.RedditTweaks.loadSettings();
var presets = settings.colorPresets || [];
presets.splice(index, 1);
settings.colorPresets = presets;
await window.RedditTweaks.saveSettings(settings);
renderPopupPresets(presets);
}
async function saveFromControls() {
var settings = {
enabled: document.getElementById("enabled").checked,
@@ -255,6 +312,9 @@
}
});
renderPopupPresets(settings.colorPresets || []);
document.getElementById("addPreset").addEventListener("click", addPopupPreset);
document.getElementById("addSubreddit").addEventListener("click", function () {
closePicker();
addSubredditEntry("", {});