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