# Changelog
All notable changes to **RangerHQ Tuner** are documented here.
Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — versioning: [SemVer](https://semver.org/).
---
## [Unreleased]
### Planned — Long-term
- `.m3u` parser alongside `.pls` to widen future-adapter compatibility
- Station artwork lazy-load + fallback to family helmet
- Better error UI for failed streams ("Stream unavailable, try another")
- `.crx` packaging instructions in README for sideload users
- Second source adapter stub to exercise the multi-source UI
---
## [0.5.3] — 2026-06-10 — User-customizable Quick Stations + history export ("app is done")
Per David: *"this app is kinda done"* — meaning the product surface is feature-complete. After this version, polish remains but no headline features are missing.
### Added — User-customizable Quick Stations
Pure-data architecture: the NewTab Quick Stations chip row is now driven by `tuner.quickStations` in `chrome.storage.local` rather than a hardcoded `QUICK_IDS` array. **Default 8 (tidy 2-row layout, matches v0.4.0 look).** User can pick any subset of all 46 SomaFM channels via a new Options page card.
**Where:** new "Quick Stations" card on the Options page (sits between Appearance and Playback). Layout: helper text + selected-count badge + scrollable checkbox list + Reset-to-defaults button.
**Each list row** shows: checkbox + station name + genre pill (accent green, uppercase) + the SomaFM channel description (the same string available as a chip-hover tooltip on the NewTab — now properly visible at the time of picking).
**Live sync:** ticking a checkbox writes to `chrome.storage.local`. The NewTab `chrome.storage.onChanged` listener picks up the change and re-renders the Quick Stations chip row instantly without a reload. Cross-surface consistency is the same pattern as theme + station selection.
**Robust fresh-install fallback:** if Options is opened BEFORE the NewTab has ever populated the station catalogue cache, `initQuickStationsUI` fetches SomaFM channels directly via `listAllStations()` then caches the result back to `tuner.stationsCache`. Future Options opens are then instant.
**Empty-pick case:** if the user unticks everything, the Quick Stations row on the NewTab shows *"No Quick Stations picked — set some in ⚙ Settings."* instead of a silently empty section.
### Added — History export (JSON + CSV)
Two new buttons in the Local Data card: **Export history (JSON)** and **Export history (CSV)**.
- **JSON**: full pretty-printed array of `tuner.history` entries. Best for programmatic re-import or backup.
- **CSV**: RFC 4180 quoted (every cell quoted, doubled quotes for embedded quotes). Header: `artist,title,station,station_id,when_iso`. Opens cleanly in Excel / Numbers / Google Sheets.
Filename: `rangerhq-tuner-history-YYYY-MM-DD.{ext}`.
Empty-history case shows an error toast ("No history to export yet — play some music first"). Success case shows a green toast with the export count and format.
No new permissions — uses standard `Blob` + `URL.createObjectURL` + a programmatic `` click. The entire export runs client-side in the Options page; nothing is sent off-device.
### New / changed files
- `src/lib/quick-stations.js` (NEW, ~55 lines) — `DEFAULT_QUICK_IDS` constant + `getQuickStations / setQuickStations / resetQuickStations` helpers. Same defensive `storage()` accessor pattern as `lib/history.js` and `lib/theme.js`.
- `src/newtab/newtab.js` — removed hardcoded `QUICK_IDS` array, replaced with module-level `quickIds = []` populated during `init()` from `getQuickStations()` and re-loaded when `chrome.storage.onChanged` fires on `tuner.quickStations`. `renderQuick()` shows the empty-pick fallback message.
- `src/options/options.html` — new "Quick Stations" card + two export buttons in the Local Data card.
- `src/options/options.css` — ~90 lines for the picker (scrollable list, row layout, accent-coloured count badge, hover background, brand scrollbar).
- `src/options/options.js` — `initQuickStationsUI` builds the checkbox list, `onQuickStationToggle` persists picks, `exportHistoryAs` generates the download Blob. Plus the storage.onChanged listener was made async to support cross-surface picker sync.
Total: 5 files, +372 / -25 lines (1 new file).
### Not changed
- No new permissions
- No new host_permissions
- No new external libraries / SDKs
- No data migration required
- Existing user state (current station / volume / history / favourites / theme) survives intact
- Defaults match v0.4.0's curated Quick Stations exactly — users who never open Options get the same look they had before
### Workflow note
This release was bundled into v0.5.0's eventual Web Store upload — when v0.5.0 clears re-review, we build a v0.5.3 ZIP directly from main and upload that instead. Users at v0.4.0 jump straight to v0.5.3 (skipping v0.5.0, v0.5.1, v0.5.2) and get all the polish in a single update notification.
---
## [0.5.2] — 2026-06-10 — Quick Stations fix: replace retired BAGeL with PopTron
Hotfix for v0.5.1's Quick Stations row. David flagged: *"the stations still look a bit off"* — turned out only 13 chips were rendering instead of the intended 14. Investigation showed **BAGeL Radio has been retired from SomaFM's current `channels.json` catalogue** — the render loop's "drop missing slugs silently" behaviour (working as designed) made the chip just disappear.
### Changed
- `src/newtab/newtab.js` `QUICK_IDS` array: `'bagel'` → `'poptron'`. PopTron (alternative / alt-rock) fills the same indie/alt-rock genre slot BAGeL covered, and is currently a live SomaFM channel.
### Result
14 chips visible again. Layout reads cleaner — 6+6+2 instead of 6+6+1 (ThistleRadio no longer alone on its own row).
### No other change
- No CSS, no permissions, no dependencies, no behaviour change
- Single one-line edit in `QUICK_IDS`
- Still held from Chrome Web Store upload until v0.5.0 clears re-review
### Lesson — SomaFM channel catalogue is mutable
Channels can be retired at any time. The render loop's "drop missing slugs silently" pattern protects against this, but the developer-side responsibility is to verify slugs against the live `channels.json` before adding to QUICK_IDS:
```bash
curl -s https://somafm.com/channels.json | python3 -c "import json,sys; print([c['id'] for c in json.load(sys.stdin)['channels']])"
```
---
## [0.5.1] — 2026-06-10 — Quick Stations expansion (8 → 14)
Third update of the day. **Tagged on Gitea but held from Chrome Web Store upload until v0.5.0 clears re-review** — we want the v0.5.0 features (light mode + theme toggle + back-link + first-run UX hint) landing in users' browsers before this stations expansion piles on top.
### Added — 6 more channels in the NewTab Quick Stations chip row
Was reading sparse with only 8 chips. The row had space for more. Added six channels for broader genre coverage + two on-brand picks:
| Slug | Channel | Why |
|---|---|---|
| `defcon` | DEF CON Radio | Electronic / hacker culture — David's personal favourite, on-brand for a cybersecurity-adjacent extension |
| `beatblender` | Beat Blender | Electronic / breakbeat — popular channel |
| `bootliquor` | Boot Liquor | Americana / outlaw country — adds country/twang coverage |
| `u80s` | Underground 80s | 80s indie / synthwave — adds decade coverage |
| `bagel` | BAGeL Radio | Indie / alt-rock — guitar-driven indie |
| `thistle` | ThistleRadio | Celtic / folk — 🇮🇪 Dublin-built nod |
Total chip count: 8 → 14. No new files, no new permissions, no new dependencies, no code change other than the `QUICK_IDS` array in `src/newtab/newtab.js`.
The render loop already drops any slug missing from SomaFM's `channels.json` catalogue, so if a channel is retired upstream the chip just disappears silently — no error, no broken state.
### Files touched
- `src/newtab/newtab.js` — `QUICK_IDS` array updated from 8 to 14 entries, with per-slug genre comments. +19 / -8 lines.
Total: 1 file, +19/-8 lines.
### Not changed
- No new permissions
- No new dependencies
- No CSS / HTML changes
- No data migration
### Shipping note
`v0.5.1` is tagged on Gitea on 2026-06-10 but **deliberately held from the Chrome Web Store** until `v0.5.0` clears re-review (currently pending). David's reasoning: *"we will ship to web store after the last one gets accepted, that one is important so a user knows what to do"* — the v0.5.0 first-run hint + light mode features are higher-value for new users; this stations expansion is a smaller polish that should land after.
---
## [0.5.0] — 2026-06-10 — Light mode + theme toggle + Options back-link
Second post-launch update, ~36 hours after v0.4.0 cleared the Chrome Web Store. Three bundled improvements, all UX polish:
### Added — Light mode support across all three surfaces
**Phase 1 — Auto-follow OS theme.** Each stylesheet (popup, NewTab, Options) gets a `@media (prefers-color-scheme: light)` block that flips the `:root` CSS variables to a light palette while preserving brand identity. Every existing rule using `var(--*)` tokens carries over unchanged.
Light palette:
- `--bg` `#f6f4ed` cream-leaning off-white
- `--bg-soft` `#ece5d2` slightly darker for header bars
- `--bg-row` `#ddd6c0` hover backgrounds
- `--bg-row-hi` `#c8c0a8` active rows
- `--fg` `#2a2f28` dark forest-green-leaning text
- `--accent` `#2a7d3e` darker brand green (preserves "RangerHQ green" identity)
- `--cream` `#b8861a` darker amber (readable on light)
- `--danger` `#b53a2b` darker red (readable on light)
NewTab helmet watermark opacity bumped 0.025 → 0.04 in light mode (helmet contrasts differently on light bg). Wired via a new `--watermark-opacity` CSS variable.
State pills, primary play button, toasts, and danger button hover all get text-colour overrides for both modes so they stay readable when their background becomes solid accent / cream / danger.
### Added — Manual Auto / Dark / Light theme toggle (Phase 2)
**Three-radio group in a new "Appearance" card on the Options page.** Stored in `chrome.storage.local` under `tuner.theme`. Defaults to "auto" (follow OS).
- **Auto** — follows OS via `@media (prefers-color-scheme: light)` (covers ~95% of users)
- **Dark** — forces dark palette regardless of OS
- **Light** — forces light palette regardless of OS
This exists because there are three independent theme layers (OS, browser-chrome, page CSS) that can disagree. `@media (prefers-color-scheme: ...)` reads the OS layer only, NOT the browser-chrome theme. So users with mismatched layers (e.g. macOS light + Chrome dark theme installed) can override the auto-follow.
Implementation: `data-theme` attribute on `` ( "light" / "dark" / removed for auto). `html[data-theme="..."]` CSS selectors beat both the default `:root` and the `@media :root` on specificity.
New file: `src/lib/theme.js` — shared `getTheme/setTheme/applyTheme/initTheme` helpers used by all three entry-point scripts.
The choice syncs across surfaces via `chrome.storage.onChanged`: pick Light in Options → popup + NewTab flip instantly without reload. Theme applies BEFORE first paint (each init() calls `initTheme()` first) so there's no dark flash on light-mode-preferring browsers.
### Added — Options page → Tuner back-link
The Options page header (helmet + "RangerHQ Tuner — Options") is now a single clickable anchor pointing at `newtab.html`. Subtle `←` glyph to the left of the helmet; hover shifts the arrow left and tints it accent green. Same-tab navigation — user came IN via Options, goes OUT into the player UI in the same tab. No accumulating Tuner tabs.
Fixes UX gap David flagged on 2026-06-09 night: *"when i click the settings button i go to settings but we have no back link to radio."*
### Files touched
- `src/lib/theme.js` (NEW, ~55 lines)
- `src/popup/popup.css` (+50 lines — light + dark override blocks)
- `src/popup/popup.js` (+8 lines — initTheme call + storage.onChanged hook)
- `src/newtab/newtab.css` (+50 lines — light + dark override blocks)
- `src/newtab/newtab.js` (+8 lines — same wiring)
- `src/options/options.html` (+24 lines — Appearance card)
- `src/options/options.css` (+90 lines — light + dark blocks + radio styling)
- `src/options/options.js` (+32 lines — radio handlers + cross-surface sync)
Total: 8 files, ~340 lines added.
### Not changed
- No new permissions
- No new host_permissions
- No new external libraries
- No data migration required
- Existing user state (current station / volume / history / favourites) survives intact
### Reviewer expectations
Same-account update with no permission change. Same-day review expected (~hours to 24h), similar to v0.4.0's 24h-ish re-review window per [[reference_chrome_web_store_rules]].
---
## [0.4.0] — 2026-06-09 — First-run UX hint
First post-launch update, shipped the same day v0.3.0 went LIVE on the Chrome Web Store. Pure UX polish — zero new permissions, zero new code dependencies, zero behaviour change for existing users.
### Added — Discoverable "pick a station to begin" affordance
Triggered by David's own 30-second panic on first Web Store install (uninstalled the dev build, installed from the Web Store fresh, hit Play, got nothing, then realised "ah, I need to pick a station first"). The product worked correctly — the dev build had a seeded station from hours of testing, the fresh install does not — but the 30-second panic exposed a real first-run-UX gap.
**Two layered cues, both pure CSS driven by a `body.is-first-run` class:**
1. **Subtle accent-green glow pulses** around the station list (popup) and the Quick Stations chip row (NewTab). Uses a 2.4-second `box-shadow` keyframe at low alpha (0.18-0.25) — visible but not noisy.
2. **Bouncing ↓ arrow** appended to the "Pick a station to begin" text in both surfaces. Uses an `::after` pseudo-element with a 1.8-second `translateY` keyframe.
The `is-first-run` class is toggled by a tiny `reflectFirstRunHint()` function called from:
- `init()` once stations + `currentStation` are resolved
- `onPickStation()` the moment a user picks
- the `chrome.storage.onChanged` listener when another surface picks (so the hint disappears on both surfaces simultaneously via cross-surface sync)
Existing users with a stored `tuner.currentStationId` never see either cue — the class only attaches when `currentStation` is null.
### Files touched
- `src/popup/popup.css` (+35 lines — keyframes + `.is-first-run` rules)
- `src/popup/popup.js` (+11 lines — `reflectFirstRunHint()` + 3 call sites)
- `src/newtab/newtab.css` (+36 lines — same idea, NewTab-namespaced)
- `src/newtab/newtab.js` (+10 lines — same pattern)
Total: 4 files, +92 lines, 0 deletions.
### Not changed
- No new permissions
- No new host_permissions
- No new external libraries
- No change to `manifest.json` beyond the version bump and CHANGELOG-referenced URL
- No data migration required (no storage shape change)
### Same-day context
This update ships the same day:
- v0.3.0 went LIVE on the Chrome Web Store (~17:08 Dublin, ~15.5h after submission)
- RangerHQ Radio v1.0.0 stability milestone went LIVE on WordPress.org (~21:51 Dublin)
- David received a PhD-prep signal from his Research in Computing lecturer at NCI Dublin
A solid day.
---
## [0.3.0] — 2026-06-08
### Added — Track history + 4-button search + Options page (Web Store target)
This is the version that goes to the Chrome Web Store. Mirrors the rangerhq-radio WordPress plugin's history feature so the family stays coherent across surfaces.
#### Track history
- **`src/lib/history.js`** — helpers for history/favourites/search-URL/dedup/cap/clear, mirroring `inc/history.php` in the WP plugin.
- **Storage:** `tuner.history` (FIFO, capped at 500 by default) and `tuner.favourites` (uncapped) in `chrome.storage.local`. Cap configurable 50–500 on the Options page.
- **Entry shape** (mirror WP plugin exactly): `{ artist, title, station, stationId, at }` where `at = Date.now()`.
- **Dedup against the LAST entry only** so consecutive identical now-playing reads don't pile up.
- **Skip `(unknown)` artist** — SomaFM's dead-air/promo placeholder doesn't pollute the log.
#### Metadata polling
- **`src/offscreen/offscreen.js`** — the offscreen document (where audio lives) now polls SomaFM's per-channel song endpoint every 25 s while playing. On `PLAY`, polling starts (immediate first fetch + interval). On `PAUSE` or `ERROR`, polling stops. Within-session dedup prevents redundant logs when the same song stays current across polls.
- **Why the offscreen doc, not the SW:** the SW gets killed when idle; the offscreen doc is alive while audio plays. This is the right home for the loop.
- New message types in `src/lib/messages.js`: `TRACK_LOGGED`, `FAVOURITE_TOGGLED`, `STORAGE_WIPED`.
#### 4-button search (the headline feature)
- Every track row in History and Favourites gets four search-link buttons that open the major services' public search pages in a new tab:
| Service | URL pattern |
|---|---|
| Spotify | `https://open.spotify.com/search/{enc}` |
| YouTube | `https://www.youtube.com/results?search_query={enc}` |
| Apple Music | `https://music.apple.com/search?term={enc}` |
| Bandcamp | `https://bandcamp.com/search?q={enc}` |
Where `enc = encodeURIComponent(artist + " " + title)`. **Zero auth, zero API keys, zero quota, zero ToS gray area.** RangerHQ Tuner doesn't play these services — it opens the search results page and the user picks. Same approach as the rangerhq-radio WP plugin.
- Each link is brand-coloured (muted on the dark palette) — Spotify green, YouTube red, Apple pink, Bandcamp cyan. `target="_blank"` + `rel="noopener noreferrer"`.
#### Tabbed New Tab Page
- The New Tab Page's bottom section is now a **3-tab control**: **Stations · History · Favourites**, plus a ⚙ icon that opens the Options page.
- History tab: newest first, with track title, artist (accent green), `station · 2m ago` meta line, star toggle, and the 4 search links.
- Favourites tab: same shape, all your starred tracks.
- Each tab has its own "Clear …" button in the toolbar (confirm dialog).
#### Options page (NEW)
- Registered via `"options_page": "src/options/options.html"` in the manifest. Opens via `chrome://extensions` → details → Extension options OR via the ⚙ on the New Tab Page.
- **Local data card:** live stats (History size, Favourites size, Stations cached, Total `tuner.*` bytes), history-cap slider (50–500 step 50), and three buttons:
- `Clear history` — drops `tuner.history` only.
- `Clear favourites` — drops `tuner.favourites` only.
- `Clear EVERYTHING` (danger styling) — wipes every `tuner.*` key in `chrome.storage.local`.
- **Playback card:** current volume %, last-played station name (read-only).
- **About card:** version, Gitea repo link, davidtkeane.com link, SomaFM link, licence reminder.
- A small toast message confirms each destructive action.
- The page listens for `chrome.storage.onChanged` so stats stay live if you wipe from somewhere else.
#### Manifest
- Version: `0.2.0` → `0.3.0`.
- Description updated to mention the 4-button search affordance (helps Web Store reviewers see the single-purpose statement matches the listing).
- Added `"options_page": "src/options/options.html"`.
- **No new permissions, no new host permissions.** Still `["offscreen", "storage"]` + `somafm.com` only. Search-link clicks open in a new browser tab — that's the user's navigation, not an API call from the extension.
#### Cross-surface state sync
The popup and the New Tab Page now stay in lockstep without manual reloads. Both surfaces subscribe to `chrome.storage.onChanged` and re-render when:
- `tuner.currentStationId` changes (someone in the other surface picked a station)
- `tuner.isPlaying` changes (play/pause from anywhere)
- `tuner.history` or `tuner.favourites` mutates (re-render the relevant tab)
Single source of truth = `chrome.storage.local`. No more drift between popup and New Tab.
#### Storage gateway architecture (MV3 safety net)
Offscreen documents don't reliably have access to `chrome.storage` across Chrome versions, so all storage writes from the offscreen audio host are routed through the service worker via a new `LOG_TRACK_REQUEST` message type. The SW (which always has `chrome.storage`) does the actual `tuner.history` write and broadcasts `TRACK_LOGGED` back to the UI surfaces.
As belt-and-braces, every function in `src/lib/history.js` now checks for `chrome.storage` availability before using it and returns sensible defaults if absent — so the module is safe to import from any extension context.
#### Metadata latency fix (parallelised audio + polling)
When PLAY arrives at the offscreen doc, metadata polling now starts **immediately** in parallel with the audio HTTP buffer fill, rather than waiting for `audio.play()` to resolve. First-track display time drops from ~10-15 seconds (audio buffer + then poll) to ~1-2 seconds (poll racing in parallel with buffer fill).
#### Popup quick-link nav
The toolbar popup now has a three-button nav row above the footer:
- **⎘ Open in tab** — `chrome.tabs.create({url: chrome.runtime.getURL('src/newtab/newtab.html')})` opens the full Tuner UI as a regular pinnable tab.
- **♪ History** — same as Open in tab but with `#history` hash so the History pane is pre-selected. `newtab.js` reads `location.hash` on init and respects it.
- **⚙ Settings** — `chrome.runtime.openOptionsPage()` opens the Options page directly.
No new permissions: `chrome.tabs.create()` works on our own extension URLs without the `tabs` permission. The Web Store permission profile stays `["offscreen", "storage"]` + `somafm.com` host only.
#### Why this is the Web Store version
- Clear single-purpose statement now obvious: *"Plays SomaFM internet radio, logs heard tracks, and provides search-link shortcuts to Spotify/YouTube/Apple Music/Bandcamp."*
- Privacy story stays clean: "collects nothing, stores everything locally, no remote storage" — every Options-page checkbox stays "does not collect" on the Dashboard.
- The four service buttons are pure `` link-outs — Web Store reviewers can verify they don't embed third-party SDKs.
- All inline styles are gone (lessons from `rangerhq-buddy v0.1.5`).
- Accessibility: every button has an `aria-label`, every interactive element is reachable by keyboard, focus rings visible, contrast ≥ 4.5:1.
- Storage operations are SW-gated, so even Chrome versions with the offscreen-doc storage quirk run cleanly.
---
## [0.2.0] — 2026-06-08
### Added — New Tab Page override (Tier 2.5)
Replace Chrome's default New Tab Page with a RangerHQ-branded landing that surfaces the player, current track, a quick-station chip row, and a searchable browse list. Same `chrome.offscreen` audio pipeline as the popup — the New Tab Page is just a second view onto the same playback state. Live ticking clock (HH:MM in bold + small dim seconds) updates every second; date renders in the user's locale.
Note: v0.2.0 was a working local build but was never tagged on Gitea — its features ship to the world together with v0.3.0 in a single commit.
- **`manifest.json`**: added
```json
"chrome_url_overrides": { "newtab": "src/newtab/newtab.html" }
```
Description string updated to mention the optional New Tab Page replacement (Web Store reviewers prefer overrides to be disclosed in the description).
- **`src/newtab/newtab.html`** — full-viewport landing with header (helmet + clock), large now-playing block, generous Play/Volume controls, quick-station chips, searchable browse list, footer linking to `davidtkeane.com` + Gitea.
- **`src/newtab/newtab.css`** — same earthy palette as the popup but laid out for a full screen. Subtle helmet watermark (2.5% opacity) in the background. Responsive shrink-to-fit at `max-height: 700px`.
- **`src/newtab/newtab.js`** — reuses `src/lib/messages.js`, `src/sources/index.js`, and the same `chrome.storage.local` keys as the popup. State stays consistent: pick a station in the popup, refresh new tab → same station selected, same play state. Adds a clock that updates every 30 s.
**Quick-pick stations:** the chip row surfaces the 8 most-loved SomaFM channels (Groove Salad, Drone Zone, Indie Pop Rocks!, Secret Agent, Space Station Soma, Lush, Deep Space One, Fluid). One click → playing.
**Why this feature exists:** validates [[user_browser_resident_tools]] — David has the browser open all day, every new tab is a touchpoint. Every `Cmd+T` now opens a RangerHQ-branded landing that's one click from playing radio. The Web Store reviewer is also more likely to grant a clear single-purpose override than a vague one — having a useful New Tab Page is good signal alongside the toolbar player.
**Permissions unchanged:** no new permissions or hosts added. The override is a pure UI choice; no expanded API surface.
---
## [0.1.0] — 2026-06-08
### Added — Tier 1 MVP (Buddy is alive — er, Tuner is alive)
**Buddy's browser cousin lives.** First release of RangerHQ Tuner — a Chrome Manifest V3 extension that plays SomaFM internet radio from your toolbar. Sibling to [`rangerhq-radio`](https://git.davidtkeane.com/ranger/rangerhq-radio) (the WordPress version live on wp.org since 2026-06-04). Same brand idea, different surface: WP version lives in admin pages, Chrome version lives one toolbar-click away no matter what you're doing.
#### Architecture
- **Manifest V3** — uses the `chrome.offscreen` API to host the `