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 6174fb7..199f5a4 100644 --- a/src/newtab/newtab.css +++ b/src/newtab/newtab.css @@ -17,8 +17,58 @@ --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. + ────────────────────────────────────────────────────────────────────── */ +/* OS-follow light mode */ +@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; + } + .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 { @@ -40,7 +90,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/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 aeba427..6b356c6 100644 --- a/src/options/options.css +++ b/src/options/options.css @@ -16,6 +16,91 @@ --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. + ────────────────────────────────────────────────────────────────────── */ +/* OS-follow light mode */ +@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; + } + .opt-toast { color: #fff; } + .opt-toast[data-tone="error"] { 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; } html, body { @@ -40,7 +125,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..1a012e7 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -9,10 +9,11 @@