Previous commit (60331b8) added `await getQuickStations()` inside
the chrome.storage.onChanged callback, but the callback arrow
function was still synchronous. Node --check missed it (node parses
the file but doesn't fully simulate Chrome's stricter module
analysis); Chrome's parser caught it as 'Unexpected reserved word'
at the await keyword.
One-character fix: `(changes, area) =>` becomes
`async (changes, area) =>`.
Same pattern as the rest of the file (e.g. initThemeUI, the toggle
handlers) which are already async.
Two fixes in one commit, both responding to David's testing of
v0.5.3-prep:
1. Quick Stations 'Loading...' stuck
The picker showed 'Loading channel list…' indefinitely when the
user opened Options BEFORE ever opening the New Tab Page (so the
tuner.stationsCache hadn't been populated yet). Fix:
initQuickStationsUI now falls back to calling listAllStations()
directly if the cache is empty, then writes the result back to
cache so future opens are instant. New error message if the network
fetch fails: 'Could not load SomaFM channel list — check your
network connection and reopen Settings.'
2. History export (JSON + CSV)
Added two buttons in the Local data card 'Export history (JSON)'
and 'Export history (CSV)'. JSON dumps the full tuner.history array
pretty-printed; CSV uses RFC 4180 quoting (every cell quoted, doubled
quotes for embedded quotes) with header artist,title,station,
station_id,when_iso. Filename: rangerhq-tuner-history-YYYY-MM-DD.{ext}.
Empty-history case shows an error toast. Toast confirms count + format
on success.
Per David: 'this app is kinda done' after this, then upload to Web
Store once v0.5.0 clears.
David flagged 2026-06-10: '14 stations wraps 3 lines, original idea
was 2 lines tidy, but now thought of user selecting themselves' +
'maybe have a description of each station' + 'be great to keep the
code clean.'
Architecture: pure data, not code. Adds tuner.quickStations as a
new chrome.storage.local key. Default 8 (tidy 2-row layout per the
v0.4.0 picks). User can pick any subset of the 46 SomaFM channels
via a new Options page card.
NEW FILE
src/lib/quick-stations.js — DEFAULT_QUICK_IDS + storage helpers
(getQuickStations, setQuickStations, resetQuickStations) with the
same defensive 'fall back if chrome.storage missing' pattern as
history.js + theme.js.
CHANGED
src/newtab/newtab.js — removed hardcoded QUICK_IDS array, replaced
with module-level 'let quickIds = []' populated during init from
getQuickStations() + re-loaded when chrome.storage.onChanged fires
on tuner.quickStations. renderQuick() now uses module-level
quickIds and shows 'No Quick Stations picked — set some in
Settings' empty state if the array is empty.
src/options/options.html — new 'Quick Stations' card between
Appearance and Playback. Contains: helper text + selected-count
badge + scrollable <ul> + Reset-to-defaults button.
src/options/options.css — ~90 lines for the new picker: scrollable
list (max-height 360px), checkbox-name-description row layout,
accent-coloured count badge, subtle hover background, brand-styled
scrollbar.
src/options/options.js — initQuickStationsUI() reads cached
channel list from tuner.stationsCache, sorts alphabetically, builds
one row per channel with checkbox + name + genre pill + description.
Toggle handler writes the current pick set to chrome.storage.local.
Reset button confirms then calls resetQuickStations() + re-checks
boxes to match defaults. Cross-surface sync via storage.onChanged
re-syncs check state if changes happen elsewhere.
5 files (1 new), ~250 lines added. No new permissions, no new
dependencies.
BAGeL Radio has been retired from SomaFM (no longer in their public
channels.json). The render loop drops missing slugs silently, so the
chip just disappeared — David flagged 'stations look a bit off' and
the screenshot showed 13 chips instead of 14, wrapping awkwardly as
6+6+1 with ThistleRadio alone.
PopTron (alternative / alt-rock) fills the same indie/alt-rock genre
slot that BAGeL covered and is currently a live SomaFM channel.
One-line edit.
manifest.json 0.5.0 → 0.5.1. CHANGELOG.md entry for the
Quick Stations 8 → 14 expansion (merged from v0.5.1-prep,
commit 609e0ed).
Tagged on Gitea but NOT uploaded to Chrome Web Store until
v0.5.0 clears re-review. Per David: '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 + theme toggle are higher-value features and should land
in users' browsers before this stations expansion piles on top.
Adds 6 more SomaFM channels to the NewTab Quick Stations chip row.
Was reading sparse with only 8; the row had space for more. Selections
broaden genre coverage and add two on-brand picks:
+ defcon DEF CON Radio (electronic / hacker culture)
+ beatblender Beat Blender (electronic / breakbeat)
+ bootliquor Boot Liquor (americana / outlaw country)
+ u80s Underground 80s (80s indie / synthwave)
+ bagel BAGeL Radio (indie / alt-rock)
+ thistle ThistleRadio (celtic / folk — Dublin nod)
Total chip count: 8 → 14. No code change other than the QUICK_IDS
array. The render loop already drops any ID that's missing from
SomaFM's channels.json catalogue, so if a slug changes upstream
it just disappears silently.
DEF CON Radio = David's personal favourite (used as the test
station throughout v0.3.0 development). ThistleRadio = Irish-built
extension nodding to Celtic folk. Both feel right for the demographic
this product serves.
manifest.json 0.4.0 → 0.5.0. CHANGELOG.md entry documents three
bundled UX improvements:
- Light mode (Phase 1 auto-follow + Phase 2 manual toggle)
- Options → Tuner back-link
- Theme sync across all three surfaces
Zero new permissions, zero new dependencies, zero behaviour change
for existing users (theme defaults to auto = matches v0.4.0 in
dark-OS browsers, which is most installs).
Bundled because v0.4.0 was still in re-review during the work + ship
discipline calls for fewer larger updates over rapid-fire micro-
updates. Same-account re-review window per Web Store rules.
Adds an Auto / Dark / Light radio group to the Options page that
overrides the OS `prefers-color-scheme` setting. Stored in
chrome.storage.local under tuner.theme. Defaults to Auto.
Architecture:
- src/lib/theme.js (new, ~55 lines) — getTheme/setTheme/applyTheme/initTheme
helpers. applyTheme sets/removes `data-theme` attribute on <html>;
initTheme reads storage + applies.
- popup.js + newtab.js + options.js: call initTheme() FIRST in their
init() so the theme paints before anything else. All three also
listen for chrome.storage.onChanged on the THEME_KEY and live-apply
changes — pick Light in Options, popup + newtab flip instantly.
- options.html: new 'Appearance' card with 3 radio buttons (Auto/Dark/
Light) above the existing Playback card.
- options.css: styled radio group (pill-shaped, accent border on
:checked, hover state). Plus the Auto/Dark/Light CSS overrides
themselves.
- popup.css, newtab.css, options.css: each gets html[data-theme=light]
and html[data-theme=dark] blocks that override :root vars. Attribute
selector specificity beats both :root and the @media :root, so the
manual override wins when set.
UX:
- Default = Auto (follows OS via existing @media block)
- Pick Dark → overrides OS, forces dark palette
- Pick Light → overrides OS, forces light palette
- Selection persists across reloads, syncs across all three surfaces
7 files, ~150 lines added. No new permissions, no new dependencies.
Bundled with v0.4.1-prep back-link + Phase 1 OS-follow light mode for
the v0.5.0 release.
Implements the v0.4.0-plan-noted light mode using `@media
(prefers-color-scheme: light)` blocks that override the :root CSS
variables. The extension now auto-follows the user's OS theme —
no toggle, no setting, no preference UI required (Phase 2 toggle
deferred to a later release if anyone asks for it).
Light-mode palette preserves brand identity:
--bg #f6f4ed cream-leaning off-white
--bg-soft #ece5d2 header / footer bars
--bg-row #ddd6c0 hover backgrounds
--bg-row-hi #c8c0a8 active rows
--fg #2a2f28 dark forest-green-leaning text
--fg-muted #6a7064 secondary text
--accent #2a7d3e darker brand green (primary on light)
--accent-dim #6dbf7a lighter brand green (secondary)
--cream #b8861a darker amber — readable on light bg
--danger #b53a2b darker red — readable on light bg
Every existing CSS rule using var(--*) tokens carries over unchanged.
The only surface-specific overrides are:
- State pills (popup + newtab) — text colour flips to white when
the background becomes solid accent/cream/danger
- Primary play button (pressed state) — text colour flips
- Helmet watermark on NewTab — opacity bumped 0.025 → 0.04 via a
new --watermark-opacity var since the helmet contrasts differently
against the lighter background
- Toast (Options) — text colour flips to white on solid bg
- Danger button hover (Options) — text colour flips to white
Total: 3 files, +98 lines, 1 line modified.
Bundled with the v0.4.1-prep back-link fix already merged into this
branch. Together: a complete UX polish release (first-run hint shipped
in v0.4.0 plus back-link plus light mode = v0.5.0).
David flagged 2026-06-09 night: 'when i click the settings button i
go to settings but we have no back link to radio'. Real UX gap —
Options was a one-way trip. The popup auto-closes on launch and the
NewTab is in a different tab, so there was no obvious way back.
Fix: the existing brand header (helmet + 'RangerHQ Tuner — Options'
title) is now a single anchor pointing at newtab.html. Adds a small ←
glyph to the left of the helmet that hover-shifts left + colour-shifts
to accent green for a clear back-affordance.
Same-tab navigation (just an href, no chrome.tabs.create) so the user
doesn't accumulate Tuner tabs. They came IN via Options, they go OUT
into the player UI in the same tab.
Aria: the ← is hidden from screen readers (decorative); the title
attribute on the anchor gives the accessible label.
2 files, +24 lines, 4 lines modified in existing .opt-brand block.
Branch: v0.4.1-prep — not for immediate ship since v0.4.0 is still
in Web Store re-review. Bundle this with light-mode + other polish
into v0.5.0 OR ship as quick v0.4.1 patch after v0.4.0 clears.
manifest.json version 0.3.0 → 0.4.0. CHANGELOG.md entry for v0.4.0
documents the first-run UX hint feature (merged in b82f14e via
v0.4.0-prep branch). Zero new permissions, zero new dependencies,
zero behaviour change for existing users.
Shipped the same day v0.3.0 went LIVE on Chrome Web Store
(~15.5h after submission). Same-account update review window is
~24-48h per the reference_chrome_web_store_rules memory.
The 'pick a station to begin' state was too subtle on first install
(David's own 30-second panic moment when he uninstalled the dev build
and reinstalled from the Web Store, 2026-06-09 evening).
Two layered cues, both pure CSS driven by a body.is-first-run class:
1. Subtle accent-green glow pulses around:
- popup: the station list section
- newtab: the Quick Stations chip row
(rgba alpha 0.18-0.25, 2.4s ease-in-out infinite — visible but not noisy)
2. Bouncing ↓ arrow appended to the 'Pick a station to begin' text in
both surfaces (after-pseudo with translateY animation).
The is-first-run class is toggled in popup.js + newtab.js via a new
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)
Existing users with stored currentStationId never see either cue —
the class only attaches when currentStation is null.
Working on branch v0.4.0-prep so the live main (= what shipped to
Web Store v0.3.0) is unchanged. Merge to main when ready to bump the
manifest version + readme.txt + CHANGELOG.md for v0.4.0 release.
Approved + published 2026-06-09 ~17:08 Dublin, ~15.5 hours after
submission. Extension ID bmdmepddehnpbdffkblbjofgkllmpkgp. Install URL:
https://chromewebstore.google.com/detail/bmdmepddehnpbdffkblbjofgkllmpkgp
- Replaced static 'in review' badge with three LIVE auto-updating
Chrome Web Store badges (version, users count, rating)
- Added install-link callout above the description
Internal submission notes — paste copy for the Chrome Web Store
Dashboard plus reviewer-targeted permission justifications. No
reason for random repo visitors to read it.
Local file stays on disk for quick-reference during future
updates; .gitignore prevents accidental re-add.
David's first-pass through the Dashboard revealed that the
Single Purpose field, and each permission justification field,
have a 1000-char cap (not the much smaller cap I had assumed
from looking at older Web Store docs). Rewrote each to use
roughly 900 chars of the budget — gives reviewers the technical
detail they need to verify the code matches the claims.
- Single Purpose (970 chars) — 3 numbered things the extension does,
what it explicitly does not do (no SDK, no analytics, no remote).
- offscreen (976 chars) — why MV3 needs offscreen for audio, with
the developer.chrome.com pointer for the reviewer to verify.
- storage (867 chars) — per-key inventory + Options-page wipe path
+ privacy policy URL for cross-reference.
- somafm.com host (910 chars) — exact endpoints listed, what we
do NOT send with the requests, SomaFM's community-friendly stance.
- Added: 'I am not using remote code' note for the cert checkbox.
Dedicated Web Store listing icon, separate from the manifest icons
in the .zip. Same source helmet (src/assets/img/ranger.png) but
resized tighter to 116x116 with 6px breathing room on a #1a221c
square — reads better at listing thumbnail size than the toolbar
icon's heavier padding.
The Chrome Web Store enforces a 132-character limit on the manifest
description field. The v0.3.0 description was 195 chars and got
rejected on upload. Trimmed to the same wording as the listing short
description in WEB_STORE_SUBMISSION.md §2.
The longer 1500-char marketing description still lives in §3 of the
submission notes and goes in the Dashboard's 'Detailed description'
field at listing time.
Both generated from the RangerHQ helmet logo + Arial typography on the
extension's own #0f1411 dark palette so the Dashboard preview blends
naturally with the screenshots.
- promo-tile-440x280.png: helmet anchor left, "RangerHQ Tuner" cream
bold + "Indie radio in your toolbar" accent green + "SomaFM · No
telemetry · GPL v2+" muted footer. Web Store requires this size for
search/browse listing tile.
- marquee-1400x560.png: same idea scaled up. Optional asset; Google
promotes extensions with marquees more often.
ImageMagick recipe documented in store/screenshots/README.md.
Web Store-spec screenshots for Dashboard upload, generated from
David's screen grabs taken on the live v0.3.0 build.
Screenshots (1280×800 PNG, the Chrome Web Store required size):
1. Stations tab — DEF CON Radio + quick chips + browse list
2. History tab — the headline 4-button-search feature with 4
tracks already logged (Spotify / YouTube / Apple / Bandcamp)
3. Favourites tab — starred-track persistence demo
4. Toolbar popup — composed on a dark canvas, helmet icon
visible in the Chrome address bar above
ImageMagick recipe:
- NTP shots: crop bottom Chrome chrome (1378x950+0+0), resize
to width 1280, north-anchored extent to 1280x800
- Popup: resize to height 740, center on 1280x800 canvas
with palette-matching #0f1411 background
Source originals archived under store/source/ for regeneration.
No code changes — extension behaviour unchanged.
Prep for Chrome Web Store first submission. No code changes — v0.3.0
behaviour unchanged.
- LICENSE — full GPL v2 text, matches RangerHQ WP family.
- PRIVACY.md — canonical privacy policy. "Collects nothing, stores
everything locally" using Google's data-category vocabulary so the
Dashboard Privacy Practices tab can be ticked uniformly "does not
collect" across all 9 categories.
- WEB_STORE_SUBMISSION.md — paste-ready Dashboard copy: single-purpose
statement, 132-char short + 1500-char detailed description, permission
justifications for offscreen + storage + somafm.com host, build ZIP
command, screenshot brief, post-approval update workflow.
The public version of the privacy policy will live at
davidtkeane.com/rangerhq-tuner/privacy (HTML mirror of PRIVACY.md).
Web Store submission target. Mirrors rangerhq-radio's track-history pattern
(inc/history.php) so the family stays coherent across surfaces.
Highlights
- New Tab Page override (Tier 2.5) — Chrome's default new tab replaced with
a RangerHQ-branded landing showing the player, current track, quick chips,
searchable browse list, and now history + favourites tabs.
- Track history + favourites — capped FIFO 500, dedup against last entry,
skip "(unknown)" artist (SomaFM dead-air). Stored in chrome.storage.local
under tuner.history + tuner.favourites.
- 4-button search per entry — Spotify / YouTube / Apple Music / Bandcamp.
Pure public-search-URL link-outs in a new tab, NO auth, NO API keys, NO
quota, NO third-party SDK embedded.
- Options page (chrome://extensions → details → options) — live stats,
history cap slider (50-500), Clear history / Clear favourites / Clear
EVERYTHING buttons, About panel with Gitea + davidtkeane.com links.
- Popup nav row — Open in tab / History (#hash deep link) / Settings,
using chrome.tabs.create + chrome.runtime.openOptionsPage. No new perms.
- Cross-surface sync — popup ↔ newtab listen on chrome.storage.onChanged
for tuner.currentStationId / tuner.isPlaying / history / favourites.
- Storage gateway — offscreen doc can't reliably reach chrome.storage in
some Chrome versions, so it sends LOG_TRACK_REQUEST to the SW which
does the write. history.js also defensively guards every storage call.
- Metadata latency fix — polling now starts immediately on PLAY, in
parallel with audio buffer fill. First track display drops from
~10-15s to ~1-2s.
Permissions unchanged
- Still ["offscreen", "storage"] + somafm.com host only.
- chrome.tabs.create works on our own extension URLs without "tabs" perm.
- No webRequest, no <all_urls>, no third-party SDK.
Bumped from 0.1.0 (last tag on Gitea) directly to 0.3.0.
v0.2.0 (newtab + clock) was a working local build but never tagged;
its features ship together with v0.3.0 in this single commit.
Chrome MV3 extension, browser-resident sibling to rangerhq-radio
(WP plugin). Plays SomaFM via the chrome.offscreen API + a source-
adapter pattern at src/sources/.
Architecture highlights:
- Audio runs in offscreen document — SW would get killed.
- Source-adapter pattern locks Tier 1 contract (RadioSource interface
in src/sources/base-source.js). Adding a network = drop a file +
register one line in src/sources/index.js.
- Vanilla JS, no build step. Pure ES modules.
- No telemetry, no third-party JS. Outbound only to somafm.com.
- Narrow permissions: offscreen + storage + somafm.com host_perms.
No tabs, no <all_urls>, no webRequest.
22 files, ~30 min build following the saved plan at
~/.ranger-memory/projects/rangerhq-tuner-plan.md.
Tier 2 + Tier 3 (Web Store submission) not started.