From 41f42f10480fdbab1505cf7fddc0156d2f545841 Mon Sep 17 00:00:00 2001 From: David Keane Date: Tue, 9 Jun 2026 23:42:42 +0100 Subject: [PATCH 1/3] feat(v0.4.1-prep): back link from Options page to Tuner main UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/options/options.css | 24 +++++++++++++++++++++++- src/options/options.html | 5 +++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/options/options.css b/src/options/options.css index aeba427..395fb7a 100644 --- a/src/options/options.css +++ b/src/options/options.css @@ -40,7 +40,29 @@ html, body { .opt-brand { display: flex; align-items: center; - gap: 12px; + gap: 10px; + text-decoration: none; + color: inherit; + padding: 4px 8px; + margin: -4px -8px; + border-radius: var(--radius); +} + +.opt-brand:hover { + background: var(--bg-row); +} + +.opt-brand:hover .opt-back { + color: var(--accent); + transform: translateX(-2px); +} + +.opt-back { + font-size: 18px; + color: var(--fg-muted); + line-height: 1; + font-weight: 400; + transition: color 120ms ease-out, transform 120ms ease-out; } .opt-helmet { diff --git a/src/options/options.html b/src/options/options.html index 67ffb41..7d4b971 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -9,10 +9,11 @@
- + v—
From 5510cebde1ad62e9727ed24ed4849d09be60c57e Mon Sep 17 00:00:00 2001 From: David Keane Date: Tue, 9 Jun 2026 23:54:09 +0100 Subject: [PATCH 2/3] feat(v0.5.0-prep): light mode support across all three surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/newtab/newtab.css | 31 ++++++++++++++++++++++++++++++- src/options/options.css | 28 ++++++++++++++++++++++++++++ src/popup/popup.css | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/newtab/newtab.css b/src/newtab/newtab.css index 6174fb7..5b1acb9 100644 --- a/src/newtab/newtab.css +++ b/src/newtab/newtab.css @@ -17,6 +17,35 @@ --danger: #c9685b; --radius: 8px; --maxw: 760px; + --watermark-opacity: 0.025; +} + +/* ────────────────────────────────────────────────────────────────────── + Light mode (v0.5.0) — auto-follows OS theme. Same approach as popup: + override :root vars so every existing rule using var(--*) keeps + working unchanged. Watermark opacity bumped slightly since the + helmet contrasts more against light bg. + ────────────────────────────────────────────────────────────────────── */ +@media (prefers-color-scheme: light) { + :root { + --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; + } + + /* State pills — keep them readable when their background is solid */ + .nt-state-pill[data-state="playing"] { color: #fff; } + .nt-state-pill[data-state="buffering"] { color: #fff; } + .nt-state-pill[data-state="error"] { color: #fff; } + .nt-btn--primary[aria-pressed="true"] { color: #fff; } } * { box-sizing: border-box; } @@ -40,7 +69,7 @@ body::before { background-repeat: no-repeat; background-position: center; background-size: min(60vh, 600px); - opacity: 0.025; + opacity: var(--watermark-opacity); pointer-events: none; z-index: 0; } diff --git a/src/options/options.css b/src/options/options.css index 395fb7a..3ea38b2 100644 --- a/src/options/options.css +++ b/src/options/options.css @@ -16,6 +16,34 @@ --radius: 6px; } +/* ────────────────────────────────────────────────────────────────────── + Light mode (v0.5.0) — auto-follows OS theme. Same approach as popup + + newtab: flip the :root vars only. Existing rules using var(--*) + carry over unchanged. Toast text + danger-button hover adjusted for + light-bg readability. + ────────────────────────────────────────────────────────────────────── */ +@media (prefers-color-scheme: light) { + :root { + --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; + } + + /* Toast — keep readable when its background is solid accent / danger */ + .opt-toast { 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; } +} + * { box-sizing: border-box; } html, body { diff --git a/src/popup/popup.css b/src/popup/popup.css index 93fc4a8..dca6449 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -14,6 +14,38 @@ --cream: #f4e9b7; --danger: #c9685b; --radius: 6px; + + /* First-run hint pulse colour (RGB tuple — alpha applied in keyframes). */ + --pulse-rgb: 109, 191, 122; +} + +/* ────────────────────────────────────────────────────────────────────── + Light mode (v0.5.0) — auto-follows OS theme via prefers-color-scheme. + Flips backgrounds + foregrounds to a cream-leaning palette while + preserving the brand green as the accent. Every existing rule that + uses the var(--*) tokens keeps working unchanged. + ────────────────────────────────────────────────────────────────────── */ +@media (prefers-color-scheme: light) { + :root { + --bg: #f6f4ed; /* cream-leaning off-white */ + --bg-soft: #ece5d2; /* slightly darker for header bars */ + --bg-row: #ddd6c0; /* hover background */ + --bg-row-hi: #c8c0a8; /* active row background */ + --fg: #2a2f28; /* dark forest-green-leaning text */ + --fg-muted: #6a7064; /* muted secondary text */ + --accent: #2a7d3e; /* darker brand green for primary contrast */ + --accent-dim: #6dbf7a; /* lighter brand green for fills */ + --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; } } * { box-sizing: border-box; } From d0d5e76abee4f03c0d83c6e5750a36b1e9f86bb2 Mon Sep 17 00:00:00 2001 From: David Keane Date: Wed, 10 Jun 2026 00:13:07 +0100 Subject: [PATCH 3/3] feat(v0.5.0-prep): manual theme toggle (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ; 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. --- src/lib/theme.js | 55 ++++++++++++++++++++++++++++++++++ src/newtab/newtab.css | 25 ++++++++++++++-- src/newtab/newtab.js | 11 ++++++- src/options/options.css | 65 +++++++++++++++++++++++++++++++++++++--- src/options/options.html | 24 +++++++++++++++ src/options/options.js | 32 ++++++++++++++++++++ src/popup/popup.css | 30 ++++++++++++++++--- src/popup/popup.js | 9 ++++++ 8 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 src/lib/theme.js diff --git a/src/lib/theme.js b/src/lib/theme.js new file mode 100644 index 0000000..165fc5f --- /dev/null +++ b/src/lib/theme.js @@ -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 . +// - "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()); +} diff --git a/src/newtab/newtab.css b/src/newtab/newtab.css index 5b1acb9..199f5a4 100644 --- a/src/newtab/newtab.css +++ b/src/newtab/newtab.css @@ -26,6 +26,7 @@ working unchanged. Watermark opacity bumped slightly since the helmet contrasts more against light bg. ────────────────────────────────────────────────────────────────────── */ +/* OS-follow light mode */ @media (prefers-color-scheme: light) { :root { --bg: #f6f4ed; @@ -40,14 +41,34 @@ --danger: #b53a2b; --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="buffering"] { color: #fff; } .nt-state-pill[data-state="error"] { 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; } html, body { diff --git a/src/newtab/newtab.js b/src/newtab/newtab.js index af983d7..8f3dd8c 100644 --- a/src/newtab/newtab.js +++ b/src/newtab/newtab.js @@ -13,6 +13,7 @@ import { searchUrls, formatRelativeTime, entrySignature, } from '../lib/history.js'; +import { THEME_KEY, THEME_DEFAULT, initTheme, applyTheme } from '../lib/theme.js'; const els = { // Clock @@ -80,7 +81,11 @@ init().catch(err => { }); 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(); clockTimer = setInterval(tickClock, 1000); // 1 Hz — live seconds @@ -181,6 +186,10 @@ async function init() { chrome.storage.onChanged.addListener(async (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; diff --git a/src/options/options.css b/src/options/options.css index 3ea38b2..6b356c6 100644 --- a/src/options/options.css +++ b/src/options/options.css @@ -22,6 +22,7 @@ carry over unchanged. Toast text + danger-button hover adjusted for light-bg readability. ────────────────────────────────────────────────────────────────────── */ +/* OS-follow light mode */ @media (prefers-color-scheme: light) { :root { --bg: #f6f4ed; @@ -35,13 +36,69 @@ --cream: #b8861a; --danger: #b53a2b; } - - /* Toast — keep readable when its background is solid accent / danger */ .opt-toast { color: #fff; } .opt-toast[data-tone="error"] { color: #fff; } + .opt-btn--danger:hover { color: #fff; } +} - /* Danger button hover — text flips to white when bg goes red */ - .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; } diff --git a/src/options/options.html b/src/options/options.html index 7d4b971..1a012e7 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -60,6 +60,30 @@ +
+

Appearance

+
+ +
+ + + +
+
+
+

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;