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;