Playback
diff --git a/src/options/options.js b/src/options/options.js
index 8a83a51..e2cda8b 100644
--- a/src/options/options.js
+++ b/src/options/options.js
@@ -9,6 +9,10 @@ import {
getHistoryCap, setHistoryCap,
clearHistory, clearFavourites, clearAll,
} from '../lib/history.js';
+import {
+ THEME_KEY, THEME_DEFAULT, VALID_THEMES,
+ getTheme, setTheme, applyTheme,
+} from '../lib/theme.js';
const STORAGE_KEYS = {
stations: 'tuner.stationsCache',
@@ -39,6 +43,11 @@ init().catch(err => {
async function init() {
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 refreshPlayback();
@@ -86,6 +95,12 @@ async function init() {
// Refresh stats live if storage changes from another surface
chrome.storage.onChanged.addListener((changes, area) => {
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.'))) {
refreshStats();
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() {
const [history, favs, all] = await Promise.all([
getHistory(),
diff --git a/src/popup/popup.css b/src/popup/popup.css
index dca6449..5b8a29e 100644
--- a/src/popup/popup.css
+++ b/src/popup/popup.css
@@ -25,6 +25,8 @@
preserving the brand green as the accent. Every existing rule that
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) {
:root {
--bg: #f6f4ed; /* cream-leaning off-white */
@@ -38,16 +40,36 @@
--cream: #b8861a; /* darker amber — 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="buffering"] { color: #fff; }
.tuner-state[data-state="error"] { 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; }
html, body {
diff --git a/src/popup/popup.js b/src/popup/popup.js
index 5afe67b..b26f62f 100644
--- a/src/popup/popup.js
+++ b/src/popup/popup.js
@@ -4,6 +4,7 @@
import { TARGETS, TYPES } from '../lib/messages.js';
import { listAllStations, getSource } from '../sources/index.js';
+import { THEME_KEY, THEME_DEFAULT, initTheme, applyTheme } from '../lib/theme.js';
const els = {
statePill: document.getElementById('state-pill'),
@@ -42,6 +43,10 @@ init().catch(err => {
});
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.
const stored = await chrome.storage.local.get(Object.values(STORAGE_KEYS));
if (typeof stored[STORAGE_KEYS.volume] === 'number') {
@@ -118,6 +123,10 @@ async function init() {
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return;
+ if (changes[THEME_KEY]) {
+ applyTheme(changes[THEME_KEY].newValue || THEME_DEFAULT);
+ }
+
if (changes[STORAGE_KEYS.currentId] && stations.length) {
const newId = changes[STORAGE_KEYS.currentId].newValue;
currentStation = stations.find(s => s.id === newId) || null;