3 Commits

Author SHA1 Message Date
ranger c3e3cc16fd chore: v0.5.0 — bump version + CHANGELOG entry
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.
2026-06-10 00:22:16 +01:00
ranger 7c476315f5 Merge v0.5.0-prep — light mode + back-link + theme toggle 2026-06-10 00:21:11 +01:00
ranger d0d5e76abe feat(v0.5.0-prep): manual theme toggle (Phase 2)
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.
2026-06-10 00:13:07 +01:00
10 changed files with 314 additions and 14 deletions
+73 -2
View File
@@ -7,12 +7,83 @@ Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — versi
## [Unreleased] ## [Unreleased]
### Planned — Tier 2 polish (next) ### Planned — Future polish
- Light mode support (`prefers-color-scheme` + flipped palette across popup / NewTab / Options)
- `.m3u` parser alongside `.pls` to widen future-adapter compatibility - `.m3u` parser alongside `.pls` to widen future-adapter compatibility
- Station artwork lazy-load + fallback to family helmet - Station artwork lazy-load + fallback to family helmet
- Better error UI for failed streams ("Stream unavailable, try another") - Better error UI for failed streams ("Stream unavailable, try another")
- `.crx` packaging instructions in README for sideload users - `.crx` packaging instructions in README for sideload users
- Second source adapter stub to exercise the multi-source UI
---
## [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 `<html>` ( "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]].
--- ---
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "RangerHQ Tuner", "name": "RangerHQ Tuner",
"version": "0.4.0", "version": "0.5.0",
"description": "Lightweight SomaFM radio player. Logs what plays. One-click search to Spotify, YouTube, Apple Music, Bandcamp. No telemetry.", "description": "Lightweight SomaFM radio player. Logs what plays. One-click search to Spotify, YouTube, Apple Music, Bandcamp. No telemetry.",
"author": "David Keane", "author": "David Keane",
"homepage_url": "https://davidtkeane.com/rangerhq-tuner", "homepage_url": "https://davidtkeane.com/rangerhq-tuner",
+55
View File
@@ -0,0 +1,55 @@
// RangerHQ Tuner — Theme helpers (v0.5.0)
//
// Three modes:
// auto — follow OS via @media (prefers-color-scheme: light)
// dark — force dark regardless of OS
// light — force light regardless of OS
//
// Stored in chrome.storage.local under `tuner.theme`. Default: auto.
//
// Application strategy: set/remove `data-theme` attribute on <html>.
// - "auto" → attribute removed → @media in each stylesheet decides
// - "dark" → data-theme="dark" → CSS forces dark vars
// - "light" → data-theme="light" → CSS forces light vars
//
// Used from popup.js, newtab.js, and options.js on init + on cross-surface
// sync.
export const THEME_KEY = 'tuner.theme';
export const THEME_AUTO = 'auto';
export const THEME_DARK = 'dark';
export const THEME_LIGHT = 'light';
export const THEME_DEFAULT = THEME_AUTO;
export const VALID_THEMES = [THEME_AUTO, THEME_DARK, THEME_LIGHT];
/** Read the stored theme preference, falling back to "auto". */
export async function getTheme() {
if (typeof chrome === 'undefined' || !chrome.storage?.local) return THEME_DEFAULT;
const o = await chrome.storage.local.get(THEME_KEY);
const v = o[THEME_KEY];
return VALID_THEMES.includes(v) ? v : THEME_DEFAULT;
}
/** Persist a theme preference. Any cross-surface listeners will pick it up. */
export async function setTheme(theme) {
if (!VALID_THEMES.includes(theme)) theme = THEME_DEFAULT;
if (typeof chrome === 'undefined' || !chrome.storage?.local) return theme;
await chrome.storage.local.set({ [THEME_KEY]: theme });
return theme;
}
/** Apply a theme to the current document. Pass "auto" to clear the override. */
export function applyTheme(theme) {
const t = VALID_THEMES.includes(theme) ? theme : THEME_DEFAULT;
const html = document.documentElement;
if (t === THEME_AUTO) {
html.removeAttribute('data-theme');
} else {
html.setAttribute('data-theme', t);
}
}
/** Convenience: read the stored theme and apply it. */
export async function initTheme() {
applyTheme(await getTheme());
}
+23 -2
View File
@@ -26,6 +26,7 @@
working unchanged. Watermark opacity bumped slightly since the working unchanged. Watermark opacity bumped slightly since the
helmet contrasts more against light bg. helmet contrasts more against light bg.
────────────────────────────────────────────────────────────────────── */ ────────────────────────────────────────────────────────────────────── */
/* OS-follow light mode */
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
--bg: #f6f4ed; --bg: #f6f4ed;
@@ -40,14 +41,34 @@
--danger: #b53a2b; --danger: #b53a2b;
--watermark-opacity: 0.04; --watermark-opacity: 0.04;
} }
/* State pills — keep them readable when their background is solid */
.nt-state-pill[data-state="playing"] { color: #fff; } .nt-state-pill[data-state="playing"] { color: #fff; }
.nt-state-pill[data-state="buffering"] { color: #fff; } .nt-state-pill[data-state="buffering"] { color: #fff; }
.nt-state-pill[data-state="error"] { color: #fff; } .nt-state-pill[data-state="error"] { color: #fff; }
.nt-btn--primary[aria-pressed="true"] { color: #fff; } .nt-btn--primary[aria-pressed="true"] { color: #fff; }
} }
/* Manual override — force LIGHT */
html[data-theme="light"] {
--bg: #f6f4ed; --bg-soft: #ece5d2; --bg-row: #ddd6c0; --bg-row-hi: #c8c0a8;
--fg: #2a2f28; --fg-muted: #6a7064; --accent: #2a7d3e; --accent-dim: #6dbf7a;
--cream: #b8861a; --danger: #b53a2b; --watermark-opacity: 0.04;
}
html[data-theme="light"] .nt-state-pill[data-state="playing"] { color: #fff; }
html[data-theme="light"] .nt-state-pill[data-state="buffering"] { color: #fff; }
html[data-theme="light"] .nt-state-pill[data-state="error"] { color: #fff; }
html[data-theme="light"] .nt-btn--primary[aria-pressed="true"] { color: #fff; }
/* Manual override — force DARK */
html[data-theme="dark"] {
--bg: #0f1411; --bg-soft: #1a221c; --bg-row: #1f2823; --bg-row-hi: #2a3530;
--fg: #e8e4d4; --fg-muted: #97a094; --accent: #6dbf7a; --accent-dim: #2a7d3e;
--cream: #f4e9b7; --danger: #c9685b; --watermark-opacity: 0.025;
}
html[data-theme="dark"] .nt-state-pill[data-state="playing"] { color: var(--bg); }
html[data-theme="dark"] .nt-state-pill[data-state="buffering"] { color: var(--bg); }
html[data-theme="dark"] .nt-state-pill[data-state="error"] { color: var(--cream); }
html[data-theme="dark"] .nt-btn--primary[aria-pressed="true"] { color: var(--bg); }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { html, body {
+10 -1
View File
@@ -13,6 +13,7 @@ import {
searchUrls, formatRelativeTime, searchUrls, formatRelativeTime,
entrySignature, entrySignature,
} from '../lib/history.js'; } from '../lib/history.js';
import { THEME_KEY, THEME_DEFAULT, initTheme, applyTheme } from '../lib/theme.js';
const els = { const els = {
// Clock // Clock
@@ -80,7 +81,11 @@ init().catch(err => {
}); });
async function init() { async function init() {
// Clock first — visible even before stations load. // Theme first — apply stored preference before anything paints to avoid
// a brief dark flash if the user has picked light.
await initTheme();
// Clock — visible even before stations load.
tickClock(); tickClock();
clockTimer = setInterval(tickClock, 1000); // 1 Hz — live seconds clockTimer = setInterval(tickClock, 1000); // 1 Hz — live seconds
@@ -181,6 +186,10 @@ async function init() {
chrome.storage.onChanged.addListener(async (changes, area) => { chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local') return; if (area !== 'local') return;
if (changes[THEME_KEY]) {
applyTheme(changes[THEME_KEY].newValue || THEME_DEFAULT);
}
if (changes[STORAGE_KEYS.currentId] && stations.length) { if (changes[STORAGE_KEYS.currentId] && stations.length) {
const newId = changes[STORAGE_KEYS.currentId].newValue; const newId = changes[STORAGE_KEYS.currentId].newValue;
currentStation = stations.find(s => s.id === newId) || null; currentStation = stations.find(s => s.id === newId) || null;
+61 -4
View File
@@ -22,6 +22,7 @@
carry over unchanged. Toast text + danger-button hover adjusted for carry over unchanged. Toast text + danger-button hover adjusted for
light-bg readability. light-bg readability.
────────────────────────────────────────────────────────────────────── */ ────────────────────────────────────────────────────────────────────── */
/* OS-follow light mode */
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
--bg: #f6f4ed; --bg: #f6f4ed;
@@ -35,15 +36,71 @@
--cream: #b8861a; --cream: #b8861a;
--danger: #b53a2b; --danger: #b53a2b;
} }
/* Toast — keep readable when its background is solid accent / danger */
.opt-toast { color: #fff; } .opt-toast { color: #fff; }
.opt-toast[data-tone="error"] { color: #fff; } .opt-toast[data-tone="error"] { color: #fff; }
/* Danger button hover — text flips to white when bg goes red */
.opt-btn--danger:hover { color: #fff; } .opt-btn--danger:hover { color: #fff; }
} }
/* Manual override — force LIGHT */
html[data-theme="light"] {
--bg: #f6f4ed; --bg-soft: #ece5d2; --bg-row: #ddd6c0; --bg-row-hi: #c8c0a8;
--fg: #2a2f28; --fg-muted: #6a7064; --accent: #2a7d3e; --accent-dim: #6dbf7a;
--cream: #b8861a; --danger: #b53a2b;
}
html[data-theme="light"] .opt-toast { color: #fff; }
html[data-theme="light"] .opt-toast[data-tone="error"] { color: #fff; }
html[data-theme="light"] .opt-btn--danger:hover { color: #fff; }
/* Manual override — force DARK */
html[data-theme="dark"] {
--bg: #0f1411; --bg-soft: #1a221c; --bg-row: #1f2823; --bg-row-hi: #2a3530;
--fg: #e8e4d4; --fg-muted: #97a094; --accent: #6dbf7a; --accent-dim: #2a7d3e;
--cream: #f4e9b7; --danger: #c9685b;
}
html[data-theme="dark"] .opt-toast { color: var(--bg); }
html[data-theme="dark"] .opt-toast[data-tone="error"] { color: var(--cream); }
html[data-theme="dark"] .opt-btn--danger:hover { color: var(--cream); }
/* Theme toggle UI (v0.5.0 Phase 2) */
.opt-theme-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.opt-theme-row label {
flex: 1;
min-width: 90px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
margin: 0;
padding: 8px 12px;
border: 1px solid var(--bg-row-hi);
border-radius: var(--radius);
background: var(--bg-row);
cursor: pointer;
transition: border-color 120ms ease-out;
}
.opt-theme-row label:hover {
border-color: var(--accent);
}
.opt-theme-row input[type="radio"] {
margin: 0;
accent-color: var(--accent);
}
.opt-theme-row label:has(input:checked) {
border-color: var(--accent);
background: var(--bg-row-hi);
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { html, body {
+24
View File
@@ -60,6 +60,30 @@
</div> </div>
</section> </section>
<section class="opt-card">
<h2>Appearance</h2>
<div class="opt-row">
<label>
Theme
<span class="opt-help">Auto follows your OS setting. Dark / Light overrides it.</span>
</label>
<div class="opt-theme-row" role="radiogroup" aria-label="Theme">
<label>
<input type="radio" name="opt-theme" value="auto" id="opt-theme-auto">
<span>Auto</span>
</label>
<label>
<input type="radio" name="opt-theme" value="dark" id="opt-theme-dark">
<span>Dark</span>
</label>
<label>
<input type="radio" name="opt-theme" value="light" id="opt-theme-light">
<span>Light</span>
</label>
</div>
</div>
</section>
<section class="opt-card"> <section class="opt-card">
<h2>Playback</h2> <h2>Playback</h2>
<div class="opt-row opt-row--inline"> <div class="opt-row opt-row--inline">
+32
View File
@@ -9,6 +9,10 @@ import {
getHistoryCap, setHistoryCap, getHistoryCap, setHistoryCap,
clearHistory, clearFavourites, clearAll, clearHistory, clearFavourites, clearAll,
} from '../lib/history.js'; } from '../lib/history.js';
import {
THEME_KEY, THEME_DEFAULT, VALID_THEMES,
getTheme, setTheme, applyTheme,
} from '../lib/theme.js';
const STORAGE_KEYS = { const STORAGE_KEYS = {
stations: 'tuner.stationsCache', stations: 'tuner.stationsCache',
@@ -39,6 +43,11 @@ init().catch(err => {
async function init() { async function init() {
els.version.textContent = `v${chrome.runtime.getManifest().version}`; els.version.textContent = `v${chrome.runtime.getManifest().version}`;
// Theme — apply stored preference + wire the radio group + listen for
// cross-surface changes (popup/newtab pick a theme → Options reflects).
await initThemeUI();
await refreshStats(); await refreshStats();
await refreshPlayback(); await refreshPlayback();
@@ -86,6 +95,12 @@ async function init() {
// Refresh stats live if storage changes from another surface // Refresh stats live if storage changes from another surface
chrome.storage.onChanged.addListener((changes, area) => { chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return; if (area !== 'local') return;
if (changes[THEME_KEY]) {
const v = changes[THEME_KEY].newValue || THEME_DEFAULT;
applyTheme(v);
const radio = document.querySelector(`input[name="opt-theme"][value="${v}"]`);
if (radio) radio.checked = true;
}
if (Object.keys(changes).some(k => k.startsWith('tuner.'))) { if (Object.keys(changes).some(k => k.startsWith('tuner.'))) {
refreshStats(); refreshStats();
refreshPlayback(); refreshPlayback();
@@ -93,6 +108,23 @@ async function init() {
}); });
} }
/** Apply the stored theme, check the matching radio, wire change handlers. */
async function initThemeUI() {
const current = await getTheme();
applyTheme(current);
const checked = document.querySelector(`input[name="opt-theme"][value="${current}"]`);
if (checked) checked.checked = true;
for (const r of document.querySelectorAll('input[name="opt-theme"]')) {
r.addEventListener('change', async (e) => {
const v = e.target.value;
if (!VALID_THEMES.includes(v)) return;
await setTheme(v);
applyTheme(v);
toast(`Theme: ${v}`);
});
}
}
async function refreshStats() { async function refreshStats() {
const [history, favs, all] = await Promise.all([ const [history, favs, all] = await Promise.all([
getHistory(), getHistory(),
+26 -4
View File
@@ -25,6 +25,8 @@
preserving the brand green as the accent. Every existing rule that preserving the brand green as the accent. Every existing rule that
uses the var(--*) tokens keeps working unchanged. uses the var(--*) tokens keeps working unchanged.
────────────────────────────────────────────────────────────────────── */ ────────────────────────────────────────────────────────────────────── */
/* Shared light palette block — used as a mixin by the @media (OS-follow)
rule below AND by the html[data-theme="light"] manual override. */
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
--bg: #f6f4ed; /* cream-leaning off-white */ --bg: #f6f4ed; /* cream-leaning off-white */
@@ -38,16 +40,36 @@
--cream: #b8861a; /* darker amber — readable on light bg */ --cream: #b8861a; /* darker amber — readable on light bg */
--danger: #b53a2b; /* darker red — readable on light bg */ --danger: #b53a2b; /* darker red — readable on light bg */
} }
/* State pills — light-mode adjustments so playing/buffering states
stay readable. The "playing" green stays bright but text flips white;
"buffering" cream gets dark text since the cream is now amber. */
.tuner-state[data-state="playing"] { color: #fff; } .tuner-state[data-state="playing"] { color: #fff; }
.tuner-state[data-state="buffering"] { color: #fff; } .tuner-state[data-state="buffering"] { color: #fff; }
.tuner-state[data-state="error"] { color: #fff; } .tuner-state[data-state="error"] { color: #fff; }
.btn-primary[aria-pressed="true"] { color: #fff; } .btn-primary[aria-pressed="true"] { color: #fff; }
} }
/* Manual override — force LIGHT palette regardless of OS. The attribute
selector beats both :root and the @media rule's :root on specificity. */
html[data-theme="light"] {
--bg: #f6f4ed; --bg-soft: #ece5d2; --bg-row: #ddd6c0; --bg-row-hi: #c8c0a8;
--fg: #2a2f28; --fg-muted: #6a7064; --accent: #2a7d3e; --accent-dim: #6dbf7a;
--cream: #b8861a; --danger: #b53a2b;
}
html[data-theme="light"] .tuner-state[data-state="playing"] { color: #fff; }
html[data-theme="light"] .tuner-state[data-state="buffering"] { color: #fff; }
html[data-theme="light"] .tuner-state[data-state="error"] { color: #fff; }
html[data-theme="light"] .btn-primary[aria-pressed="true"] { color: #fff; }
/* Manual override — force DARK palette regardless of OS. Overrides the
@media block when OS is light but user explicitly wants dark. */
html[data-theme="dark"] {
--bg: #0f1411; --bg-soft: #1a221c; --bg-row: #1f2823; --bg-row-hi: #2a3530;
--fg: #e8e4d4; --fg-muted: #97a094; --accent: #6dbf7a; --accent-dim: #2a7d3e;
--cream: #f4e9b7; --danger: #c9685b;
}
html[data-theme="dark"] .tuner-state[data-state="playing"] { color: var(--bg); }
html[data-theme="dark"] .tuner-state[data-state="buffering"] { color: var(--bg); }
html[data-theme="dark"] .tuner-state[data-state="error"] { color: var(--cream); }
html[data-theme="dark"] .btn-primary[aria-pressed="true"] { color: var(--bg); }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { html, body {
+9
View File
@@ -4,6 +4,7 @@
import { TARGETS, TYPES } from '../lib/messages.js'; import { TARGETS, TYPES } from '../lib/messages.js';
import { listAllStations, getSource } from '../sources/index.js'; import { listAllStations, getSource } from '../sources/index.js';
import { THEME_KEY, THEME_DEFAULT, initTheme, applyTheme } from '../lib/theme.js';
const els = { const els = {
statePill: document.getElementById('state-pill'), statePill: document.getElementById('state-pill'),
@@ -42,6 +43,10 @@ init().catch(err => {
}); });
async function init() { async function init() {
// 0. Apply theme preference (v0.5.0) before anything paints, to avoid
// a brief dark flash if the user has picked light.
await initTheme();
// 1. Hydrate UI from cached state so the popup renders fast. // 1. Hydrate UI from cached state so the popup renders fast.
const stored = await chrome.storage.local.get(Object.values(STORAGE_KEYS)); const stored = await chrome.storage.local.get(Object.values(STORAGE_KEYS));
if (typeof stored[STORAGE_KEYS.volume] === 'number') { if (typeof stored[STORAGE_KEYS.volume] === 'number') {
@@ -118,6 +123,10 @@ async function init() {
chrome.storage.onChanged.addListener((changes, area) => { chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return; if (area !== 'local') return;
if (changes[THEME_KEY]) {
applyTheme(changes[THEME_KEY].newValue || THEME_DEFAULT);
}
if (changes[STORAGE_KEYS.currentId] && stations.length) { if (changes[STORAGE_KEYS.currentId] && stations.length) {
const newId = changes[STORAGE_KEYS.currentId].newValue; const newId = changes[STORAGE_KEYS.currentId].newValue;
currentStation = stations.find(s => s.id === newId) || null; currentStation = stations.find(s => s.id === newId) || null;