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).
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.
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.