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.
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
+10
-1
@@ -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;
|
||||
|
||||
+61
-4
@@ -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; }
|
||||
|
||||
@@ -60,6 +60,30 @@
|
||||
</div>
|
||||
</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">
|
||||
<h2>Playback</h2>
|
||||
<div class="opt-row opt-row--inline">
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+26
-4
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user