From 1db724073be7edd595874d306b6ae6a481f295b6 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 16 Apr 2026 18:40:22 -0700 Subject: [PATCH] Add follow-os light/dark theme. Closes #199. --- frontend/src/components/StatusBar.tsx | 36 ++++++++--- frontend/src/main.tsx | 4 +- frontend/src/test/setup.ts | 5 +- frontend/src/test/statusBar.test.tsx | 55 ++++++++++++++++- frontend/src/test/theme.test.ts | 87 +++++++++++++++++++++++++++ frontend/src/utils/theme.ts | 63 +++++++++++++++++-- 6 files changed, 235 insertions(+), 15 deletions(-) create mode 100644 frontend/src/test/theme.test.ts diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index f6487b3..067e352 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -12,7 +12,7 @@ import type { HealthStatus, RadioConfig } from '../types'; import { api } from '../api'; import { toast } from './ui/sonner'; import { handleKeyboardActivate } from '../utils/a11y'; -import { applyTheme, getSavedTheme, THEME_CHANGE_EVENT } from '../utils/theme'; +import { applyTheme, getEffectiveTheme, THEME_CHANGE_EVENT } from '../utils/theme'; import { BATTERY_DISPLAY_CHANGE_EVENT, getShowBatteryPercent, @@ -92,7 +92,9 @@ export function StatusBar({ ? 'Radio OK' : 'Radio Disconnected'; const [reconnecting, setReconnecting] = useState(false); - const [currentTheme, setCurrentTheme] = useState(getSavedTheme); + // Track the *effective* theme (follow-os is resolved to original/light) so the + // toggle icon and action match what the user currently sees rendered. + const [currentTheme, setCurrentTheme] = useState(getEffectiveTheme); const [pulseEnabled, setPulseEnabled] = useState(getStatusDotPulseEnabled); const [pulseKind, setPulseKind] = useState(null); @@ -129,14 +131,32 @@ export function StatusBar({ }, [pulseEnabled]); useEffect(() => { - const handleThemeChange = (event: Event) => { - const themeId = (event as CustomEvent).detail; - setCurrentTheme(typeof themeId === 'string' && themeId ? themeId : getSavedTheme()); - }; + const syncEffective = () => setCurrentTheme(getEffectiveTheme()); + window.addEventListener(THEME_CHANGE_EVENT, syncEffective); + + // When saved theme is "follow-os", OS appearance changes alter the effective + // theme without firing a THEME_CHANGE_EVENT, so also watch matchMedia. + const mql = + typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-color-scheme: light)') + : null; + if (mql) { + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', syncEffective); + } else if (typeof (mql as MediaQueryList).addListener === 'function') { + (mql as MediaQueryList).addListener(syncEffective); + } + } - window.addEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener); return () => { - window.removeEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener); + window.removeEventListener(THEME_CHANGE_EVENT, syncEffective); + if (mql) { + if (typeof mql.removeEventListener === 'function') { + mql.removeEventListener('change', syncEffective); + } else if (typeof (mql as MediaQueryList).removeListener === 'function') { + (mql as MediaQueryList).removeListener(syncEffective); + } + } }; }, []); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2bafefd..c0c5d4e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,11 +4,13 @@ import { App } from './App'; import './index.css'; import './themes.css'; import './styles.css'; -import { getSavedTheme, applyTheme } from './utils/theme'; +import { getSavedTheme, applyTheme, initFollowOSListener } from './utils/theme'; import { applyFontScale, getSavedFontScale } from './utils/fontScale'; // Apply saved theme before first render applyTheme(getSavedTheme()); +// Re-apply when the OS color-scheme preference changes, if on "Follow OS". +initFollowOSListener(); applyFontScale(getSavedFontScale()); createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 339b76d..0fe259b 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -8,9 +8,12 @@ class ResizeObserver { globalThis.ResizeObserver = ResizeObserver; -// Several components call matchMedia at import time for responsive detection +// Several components call matchMedia at import time for responsive detection. +// Use a configurable descriptor so individual tests can override the stub. if (typeof globalThis.matchMedia === 'undefined') { Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + writable: true, value: (query: string) => ({ matches: false, media: query, diff --git a/frontend/src/test/statusBar.test.tsx b/frontend/src/test/statusBar.test.tsx index 21127a5..2a21985 100644 --- a/frontend/src/test/statusBar.test.tsx +++ b/frontend/src/test/statusBar.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { StatusBar } from '../components/StatusBar'; import type { HealthStatus } from '../types'; @@ -77,4 +77,57 @@ describe('StatusBar', () => { expect(localStorage.getItem('remoteterm-theme')).toBe('original'); expect(document.documentElement.dataset.theme).toBeUndefined(); }); + + describe('with Follow OS theme saved', () => { + const originalMatchMedia = globalThis.matchMedia; + + afterEach(() => { + globalThis.matchMedia = originalMatchMedia; + }); + + // Stub matchMedia so prefers-color-scheme: light returns the desired value. + const setPrefersLight = (isLight: boolean) => { + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: (query: string) => ({ + matches: query.includes('light') ? isLight : !isLight, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); + }; + + it('clicking toggle while OS prefers dark overrides follow-os into explicit light', () => { + setPrefersLight(false); + localStorage.setItem('remoteterm-theme', 'follow-os'); + + render(); + + // OS is dark → effective is original → toggle offers "Switch to light theme" + const toggle = screen.getByRole('button', { name: 'Switch to light theme' }); + fireEvent.click(toggle); + + expect(localStorage.getItem('remoteterm-theme')).toBe('light'); + expect(document.documentElement.dataset.theme).toBe('light'); + }); + + it('clicking toggle while OS prefers light overrides follow-os into explicit dark', () => { + setPrefersLight(true); + localStorage.setItem('remoteterm-theme', 'follow-os'); + + render(); + + // OS is light → effective is light → toggle offers "Switch to classic theme" + const toggle = screen.getByRole('button', { name: 'Switch to classic theme' }); + fireEvent.click(toggle); + + expect(localStorage.getItem('remoteterm-theme')).toBe('original'); + expect(document.documentElement.dataset.theme).toBeUndefined(); + }); + }); }); diff --git a/frontend/src/test/theme.test.ts b/frontend/src/test/theme.test.ts new file mode 100644 index 0000000..2d7ec34 --- /dev/null +++ b/frontend/src/test/theme.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + FOLLOW_OS_THEME_ID, + THEMES, + applyTheme, + getEffectiveTheme, + getSavedTheme, +} from '../utils/theme'; + +const originalMatchMedia = globalThis.matchMedia; + +function stubPrefersLight(isLight: boolean) { + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: (query: string) => ({ + matches: query.includes('light') ? isLight : !isLight, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); +} + +describe('theme module', () => { + beforeEach(() => { + localStorage.clear(); + delete document.documentElement.dataset.theme; + }); + + afterEach(() => { + globalThis.matchMedia = originalMatchMedia; + }); + + it('exposes an OS-following theme in the selectable list', () => { + const followOS = THEMES.find((t) => t.id === FOLLOW_OS_THEME_ID); + expect(followOS).toBeDefined(); + expect(followOS?.name).toBeTruthy(); + }); + + it('applyTheme("follow-os") resolves to light when OS prefers light', () => { + stubPrefersLight(true); + + applyTheme(FOLLOW_OS_THEME_ID); + + // Saved value is the follow-os preference, but the DOM reflects the resolved theme. + expect(localStorage.getItem('remoteterm-theme')).toBe(FOLLOW_OS_THEME_ID); + expect(getSavedTheme()).toBe(FOLLOW_OS_THEME_ID); + expect(document.documentElement.dataset.theme).toBe('light'); + expect(getEffectiveTheme()).toBe('light'); + }); + + it('applyTheme("follow-os") resolves to original (dark) when OS prefers dark', () => { + stubPrefersLight(false); + + applyTheme(FOLLOW_OS_THEME_ID); + + expect(localStorage.getItem('remoteterm-theme')).toBe(FOLLOW_OS_THEME_ID); + // Original has no data-theme attribute, it's the default. + expect(document.documentElement.dataset.theme).toBeUndefined(); + expect(getEffectiveTheme()).toBe('original'); + }); + + it('applyTheme updates the PWA meta theme-color to match the effective theme', () => { + // Seed the meta tag (jsdom base template has none). + const meta = document.createElement('meta'); + meta.setAttribute('name', 'theme-color'); + meta.setAttribute('content', '#000000'); + document.head.appendChild(meta); + + stubPrefersLight(true); + applyTheme(FOLLOW_OS_THEME_ID); + // Light theme's metaThemeColor + expect(meta.getAttribute('content')).toBe('#F8F7F4'); + + stubPrefersLight(false); + applyTheme(FOLLOW_OS_THEME_ID); + // Original theme's metaThemeColor + expect(meta.getAttribute('content')).toBe('#111419'); + + meta.remove(); + }); +}); diff --git a/frontend/src/utils/theme.ts b/frontend/src/utils/theme.ts index a3d0790..c150dc9 100644 --- a/frontend/src/utils/theme.ts +++ b/frontend/src/utils/theme.ts @@ -9,6 +9,8 @@ export interface Theme { export const THEME_CHANGE_EVENT = 'remoteterm-theme-change'; +export const FOLLOW_OS_THEME_ID = 'follow-os'; + export const THEMES: Theme[] = [ { id: 'original', @@ -22,6 +24,13 @@ export const THEMES: Theme[] = [ swatches: ['#F8F7F4', '#FFFFFF', '#1B7D4E', '#EDEBE7', '#D97706', '#3B82F6'], metaThemeColor: '#F8F7F4', }, + { + id: FOLLOW_OS_THEME_ID, + name: 'OS Light/Dark Mode', + // Top row: light theme preview colors; bottom row: original (dark) preview colors + swatches: ['#F8F7F4', '#FFFFFF', '#1B7D4E', '#111419', '#181b21', '#27a05c'], + metaThemeColor: '#111419', + }, { id: 'ios', name: 'iPhone', @@ -94,6 +103,23 @@ export function getSavedTheme(): string { } } +/** Resolves "Follow OS" to a concrete theme id by inspecting the OS color-scheme preference. */ +function resolveFollowOS(): 'original' | 'light' { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return 'original'; + } + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'original'; +} + +/** + * Returns the concrete theme id currently applied to the document. + * Unlike getSavedTheme, this resolves 'follow-os' to 'original' or 'light'. + */ +export function getEffectiveTheme(): string { + const saved = getSavedTheme(); + return saved === FOLLOW_OS_THEME_ID ? resolveFollowOS() : saved; +} + export function applyTheme(themeId: string): void { try { localStorage.setItem(THEME_KEY, themeId); @@ -101,14 +127,16 @@ export function applyTheme(themeId: string): void { // localStorage may be unavailable } - if (themeId === 'original') { + const effective = themeId === FOLLOW_OS_THEME_ID ? resolveFollowOS() : themeId; + + if (effective === 'original') { delete document.documentElement.dataset.theme; } else { - document.documentElement.dataset.theme = themeId; + document.documentElement.dataset.theme = effective; } - // Update PWA theme-color meta tag - const theme = THEMES.find((t) => t.id === themeId); + // Update PWA theme-color meta tag — reflect the effective (rendered) theme. + const theme = THEMES.find((t) => t.id === effective); if (theme) { const meta = document.querySelector('meta[name="theme-color"]'); if (meta) { @@ -117,6 +145,33 @@ export function applyTheme(themeId: string): void { } if (typeof window !== 'undefined') { + // Detail is the saved theme id (including 'follow-os'); listeners that need + // the rendered appearance should call getEffectiveTheme(). window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: themeId })); } } + +let followOSInitialized = false; + +/** + * Installs a one-time listener on prefers-color-scheme so that when the user is + * on "Follow OS", OS appearance changes re-apply the theme. Safe to call once + * from app bootstrap. + */ +export function initFollowOSListener(): void { + if (followOSInitialized) return; + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + followOSInitialized = true; + const mql = window.matchMedia('(prefers-color-scheme: light)'); + const handler = () => { + if (getSavedTheme() === FOLLOW_OS_THEME_ID) { + applyTheme(FOLLOW_OS_THEME_ID); + } + }; + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', handler); + } else if (typeof (mql as MediaQueryList).addListener === 'function') { + // Safari < 14 fallback + (mql as MediaQueryList).addListener(handler); + } +}