Skip to content

Commit 611a7a0

Browse files
committed
Implements default theme based on user-preferense, bumped to v1.3.0
1 parent 9321ffd commit 611a7a0

File tree

5 files changed

+127
-15
lines changed

5 files changed

+127
-15
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "networking-toolbox",
33
"private": true,
4-
"version": "1.2.0",
4+
"version": "1.3.0",
55
"type": "module",
66
"scripts": {
77
"dev": "vite dev",

src/app.html

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,48 @@
1919
<script>
2020
(function () {
2121
try {
22-
// Apply theme
23-
const theme = localStorage.getItem('theme') || 'dark';
22+
// Get saved theme from localStorage
23+
const savedTheme = localStorage.getItem('theme');
24+
25+
let theme;
26+
if (savedTheme) {
27+
// Use saved theme if it exists
28+
theme = savedTheme;
29+
} else {
30+
// No saved theme - check system preference
31+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
32+
const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
33+
if (prefersLight) {
34+
theme = 'light';
35+
} else if (prefersDark) {
36+
theme = 'dark';
37+
} else {
38+
theme = 'ocean';
39+
}
40+
}
41+
42+
// Apply theme class (dark theme doesn't need a class)
2443
if (theme !== 'dark') {
2544
document.documentElement.classList.add('theme-' + theme);
2645
}
2746
} catch (e) {
2847
// Ignore localStorage errors (private browsing, etc.)
48+
// Fall back to system preference or ocean
49+
try {
50+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
51+
const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
52+
53+
if (prefersLight) {
54+
document.documentElement.classList.add('theme-light');
55+
} else if (!prefersDark) {
56+
// Neither dark nor light preferred - use ocean
57+
document.documentElement.classList.add('theme-ocean');
58+
}
59+
// If prefersDark, no class needed (dark is default)
60+
} catch (e2) {
61+
// If all fails, use ocean
62+
document.documentElement.classList.add('theme-ocean');
63+
}
2964
}
3065
})();
3166
</script>

src/lib/config/customizable-settings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export const DEFAULT_NAVBAR_DISPLAY: NavbarDisplayMode = (env.NTB_NAVBAR_DISPLAY
5353
* Default theme
5454
* Options: 'dark', 'light', 'midnight', 'arctic', 'ocean', 'purple', 'cyberpunk', 'terminal', 'lightpurple', 'muteddark', 'solarized', 'nord', 'gruvbox', 'tokyonight', 'catppuccin', 'everforest', 'sunset', 'dracula'
5555
*/
56-
export const DEFAULT_THEME: ThemeOption = (env.NTB_DEFAULT_THEME as ThemeOption) ?? 'dark';
56+
export const DEFAULT_THEME: ThemeOption = (env.NTB_DEFAULT_THEME as ThemeOption) ?? 'ocean';
5757

5858
/**
5959
* Default language

src/lib/stores/theme.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,22 @@ function isValidTheme(theme: unknown): theme is string {
139139
return themes.some((t) => t.id === theme && t.available);
140140
}
141141

142+
function getSystemPreferredTheme(): ThemeOption {
143+
if (!browser) return 'ocean';
144+
145+
// Check if user has a color scheme preference
146+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
147+
const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches;
148+
149+
if (prefersLight) return 'light';
150+
if (prefersDark) return 'dark';
151+
152+
// No preference or preference not supported
153+
return 'ocean';
154+
}
155+
142156
function createThemeStore() {
143-
const defaultTheme = isValidTheme(DEFAULT_THEME) ? DEFAULT_THEME : 'dark';
157+
const defaultTheme = isValidTheme(DEFAULT_THEME) ? DEFAULT_THEME : 'ocean';
144158
const { subscribe, set, update } = writable<ThemeOption>(defaultTheme);
145159

146160
return {
@@ -149,12 +163,17 @@ function createThemeStore() {
149163
// Initialize theme from localStorage or default
150164
init: () => {
151165
if (browser) {
152-
const saved = storage.getItem(STORAGE_KEY, {
153-
defaultValue: defaultTheme,
154-
validate: isValidTheme,
155-
serialize: false,
156-
});
157-
const initialTheme = saved as ThemeOption;
166+
const savedTheme = localStorage.getItem(STORAGE_KEY);
167+
168+
let initialTheme: ThemeOption;
169+
170+
// If no saved theme, use system preference
171+
if (!savedTheme) {
172+
initialTheme = getSystemPreferredTheme();
173+
} else {
174+
// Use saved theme if valid
175+
initialTheme = isValidTheme(savedTheme) ? savedTheme : getSystemPreferredTheme();
176+
}
158177

159178
set(initialTheme);
160179

tests/unit/lib/stores/theme.test.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ vi.mock('$app/environment', () => ({
99

1010
// Mock customizable settings
1111
vi.mock('$lib/config/customizable-settings', () => ({
12-
DEFAULT_THEME: 'dark',
12+
DEFAULT_THEME: 'ocean',
1313
}));
1414

1515
describe('theme store', () => {
1616
let mockLocalStorage: Record<string, string> = {};
1717
let documentHeadAppendChildSpy: any;
18+
let mockMatchMedia: any;
1819

1920
beforeEach(() => {
2021
// Reset localStorage mock
@@ -35,6 +36,15 @@ describe('theme store', () => {
3536
key: (index: number) => Object.keys(mockLocalStorage)[index] || null,
3637
} as Storage;
3738

39+
// Mock matchMedia for prefers-color-scheme
40+
mockMatchMedia = vi.fn((query: string) => ({
41+
matches: false,
42+
media: query,
43+
addEventListener: vi.fn(),
44+
removeEventListener: vi.fn(),
45+
}));
46+
global.window.matchMedia = mockMatchMedia;
47+
3848
// Mock document element for theme classes
3949
global.document = {
4050
documentElement: {
@@ -84,10 +94,10 @@ describe('theme store', () => {
8494
expect(ids).toContain('arctic');
8595
});
8696

87-
it('initializes with default theme', async () => {
97+
it('initializes with default theme when not in browser', async () => {
8898
const { theme } = await import('../../../../src/lib/stores/theme');
8999
const value = get(theme);
90-
expect(value).toBe('dark');
100+
expect(value).toBe('ocean');
91101
});
92102

93103
it('init sets theme from localStorage if valid', async () => {
@@ -99,15 +109,63 @@ describe('theme store', () => {
99109
expect(get(theme)).toBe('light');
100110
});
101111

102-
it('init falls back to default if localStorage theme is invalid', async () => {
112+
it('init falls back to system preference if localStorage theme is invalid', async () => {
103113
mockLocalStorage['theme'] = 'invalid-theme';
114+
mockMatchMedia.mockImplementation((query: string) => ({
115+
matches: query === '(prefers-color-scheme: dark)',
116+
media: query,
117+
addEventListener: vi.fn(),
118+
removeEventListener: vi.fn(),
119+
}));
120+
const { theme } = await import('../../../../src/lib/stores/theme');
121+
122+
const initialTheme = theme.init();
123+
expect(initialTheme).toBe('dark');
124+
expect(get(theme)).toBe('dark');
125+
});
126+
127+
it('init uses system preference (light) when no saved theme', async () => {
128+
mockMatchMedia.mockImplementation((query: string) => ({
129+
matches: query === '(prefers-color-scheme: light)',
130+
media: query,
131+
addEventListener: vi.fn(),
132+
removeEventListener: vi.fn(),
133+
}));
134+
const { theme } = await import('../../../../src/lib/stores/theme');
135+
136+
const initialTheme = theme.init();
137+
expect(initialTheme).toBe('light');
138+
expect(get(theme)).toBe('light');
139+
});
140+
141+
it('init uses system preference (dark) when no saved theme', async () => {
142+
mockMatchMedia.mockImplementation((query: string) => ({
143+
matches: query === '(prefers-color-scheme: dark)',
144+
media: query,
145+
addEventListener: vi.fn(),
146+
removeEventListener: vi.fn(),
147+
}));
104148
const { theme } = await import('../../../../src/lib/stores/theme');
105149

106150
const initialTheme = theme.init();
107151
expect(initialTheme).toBe('dark');
108152
expect(get(theme)).toBe('dark');
109153
});
110154

155+
it('init uses ocean when no saved theme and no system preference', async () => {
156+
mockMatchMedia.mockImplementation((query: string) => ({
157+
matches: false,
158+
media: query,
159+
addEventListener: vi.fn(),
160+
removeEventListener: vi.fn(),
161+
}));
162+
const { theme } = await import('../../../../src/lib/stores/theme');
163+
164+
const initialTheme = theme.init();
165+
expect(initialTheme).toBe('ocean');
166+
expect(get(theme)).toBe('ocean');
167+
});
168+
111169
it('setTheme updates store and localStorage', async () => {
112170
const { theme } = await import('../../../../src/lib/stores/theme');
113171
theme.init();

0 commit comments

Comments
 (0)