mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-06 13:33:02 +02:00
Add follow-os light/dark theme. Closes #199.
This commit is contained in:
@@ -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<StatusDotPulseKind | null>(null);
|
||||
|
||||
@@ -129,14 +131,32 @@ export function StatusBar({
|
||||
}, [pulseEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleThemeChange = (event: Event) => {
|
||||
const themeId = (event as CustomEvent<string>).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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(<StatusBar health={baseHealth} config={null} onSettingsClick={vi.fn()} />);
|
||||
|
||||
// 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(<StatusBar health={baseHealth} config={null} onSettingsClick={vi.fn()} />);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
87
frontend/src/test/theme.test.ts
Normal file
87
frontend/src/test/theme.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user