Skip to content

Commit 7537dfb

Browse files
committed
ui: same theme toggle
1 parent 59aff50 commit 7537dfb

5 files changed

Lines changed: 100 additions & 136 deletions

File tree

new-deepnotes/apps/web/index.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>DeepNotes</title>
7+
<script>
8+
(function () {
9+
const stored = localStorage.getItem("deepnotes-theme");
10+
const prefersDark = window.matchMedia(
11+
"(prefers-color-scheme: dark)"
12+
).matches;
13+
if (stored === "dark" || (!stored && prefersDark)) {
14+
document.documentElement.classList.add("dark");
15+
}
16+
})();
17+
</script>
718
</head>
819
<body>
920
<div id="app"></div>
Lines changed: 34 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,41 @@
11
<script setup lang="ts">
2-
import { Moon, Sun, SunMoon } from "lucide-vue-next";
3-
4-
import { useThemePreference } from "./useThemePreference";
5-
6-
const { preference } = useThemePreference();
7-
8-
const options: { value: "system" | "light" | "dark"; label: string }[] = [
9-
{ value: "system", label: "System" },
10-
{ value: "light", label: "Light" },
11-
{ value: "dark", label: "Dark" },
12-
];
13-
14-
function iconFor(mode: "system" | "light" | "dark") {
15-
if (mode === "light") return Sun;
16-
if (mode === "dark") return Moon;
17-
return SunMoon;
18-
}
2+
import { isDark, toggleTheme } from "./useThemePreference";
193
</script>
204

215
<template>
22-
<div
23-
class="border-input bg-background text-foreground focus-within:ring-ring/50 flex h-8 min-w-0 max-w-40 shrink-0 items-center gap-1.5 rounded-lg border px-2 py-1 focus-within:ring-2 focus-within:outline-none"
6+
<button
7+
type="button"
8+
class="hover:bg-muted inline-flex h-9 w-9 items-center justify-center rounded-md text-sm font-medium transition-colors"
9+
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
10+
@click="toggleTheme"
2411
>
25-
<component
26-
:is="iconFor(preference)"
27-
class="text-muted-foreground size-3.5 shrink-0"
28-
aria-hidden="true"
29-
/>
30-
<select
31-
id="theme-select"
32-
v-model="preference"
33-
class="min-w-0 flex-1 cursor-pointer appearance-none bg-transparent text-xs font-medium outline-none"
34-
aria-label="Color theme"
12+
<svg
13+
v-if="isDark"
14+
class="h-4 w-4"
15+
fill="none"
16+
viewBox="0 0 24 24"
17+
stroke="currentColor"
18+
stroke-width="2"
19+
>
20+
<path
21+
stroke-linecap="round"
22+
stroke-linejoin="round"
23+
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
24+
/>
25+
</svg>
26+
<svg
27+
v-else
28+
class="h-4 w-4"
29+
fill="none"
30+
viewBox="0 0 24 24"
31+
stroke="currentColor"
32+
stroke-width="2"
3533
>
36-
<option
37-
v-for="opt in options"
38-
:key="opt.value"
39-
:value="opt.value"
40-
>
41-
{{ opt.label }}
42-
</option>
43-
</select>
44-
</div>
34+
<path
35+
stroke-linecap="round"
36+
stroke-linejoin="round"
37+
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
38+
/>
39+
</svg>
40+
</button>
4541
</template>

new-deepnotes/apps/web/src/features/theme/useThemePreference.test.ts

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import {
66
it,
77
vi,
88
} from "vitest";
9-
import { hydrateThemeFromStorage, migrateStoredThemeKey } from "./useThemePreference";
9+
import { migrateLegacyTheme, toggleTheme } from "./useThemePreference";
1010

11-
const KEY = "deepnotes.theme";
11+
const NEW_KEY = "deepnotes-theme";
12+
const OLD_KEY = "deepnotes.theme";
1213

1314
describe("theme preference", () => {
1415
beforeEach(() => {
@@ -20,31 +21,31 @@ describe("theme preference", () => {
2021
vi.unstubAllGlobals();
2122
});
2223

23-
it("migrates legacy system value to auto", () => {
24-
localStorage.setItem(KEY, "system");
25-
migrateStoredThemeKey();
26-
expect(localStorage.getItem(KEY)).toBe("auto");
24+
it("migrates legacy dark value to new key", () => {
25+
localStorage.setItem(OLD_KEY, "dark");
26+
migrateLegacyTheme();
27+
expect(localStorage.getItem(NEW_KEY)).toBe("dark");
28+
expect(localStorage.getItem(OLD_KEY)).toBeNull();
2729
});
2830

29-
it("hydrate applies dark when stored dark", () => {
30-
localStorage.setItem(KEY, "dark");
31-
hydrateThemeFromStorage();
32-
expect(document.documentElement.classList.contains("dark")).toBe(true);
31+
it("migrates legacy non-dark value by removing old key only", () => {
32+
localStorage.setItem(OLD_KEY, "light");
33+
migrateLegacyTheme();
34+
expect(localStorage.getItem(NEW_KEY)).toBeNull();
35+
expect(localStorage.getItem(OLD_KEY)).toBeNull();
3336
});
3437

35-
it("hydrate follows prefers-color-scheme when auto", () => {
36-
localStorage.setItem(KEY, "auto");
37-
vi.stubGlobal(
38-
"matchMedia",
39-
vi.fn().mockImplementation((q: string) => ({
40-
matches: q.includes("dark"),
41-
media: q,
42-
addEventListener: vi.fn(),
43-
removeEventListener: vi.fn(),
44-
})),
45-
);
46-
47-
hydrateThemeFromStorage();
38+
it("toggle applies dark and persists", () => {
39+
toggleTheme();
4840
expect(document.documentElement.classList.contains("dark")).toBe(true);
41+
expect(localStorage.getItem(NEW_KEY)).toBe("dark");
42+
});
43+
44+
it("toggle removes dark and clears storage", () => {
45+
document.documentElement.classList.add("dark");
46+
localStorage.setItem(NEW_KEY, "dark");
47+
toggleTheme();
48+
expect(document.documentElement.classList.contains("dark")).toBe(false);
49+
expect(localStorage.getItem(NEW_KEY)).toBeNull();
4950
});
5051
});
Lines changed: 31 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,45 @@
1-
import { computed } from "vue";
2-
import { createSharedComposable } from "@vueuse/shared";
3-
import { useColorMode } from "@vueuse/core";
1+
import { ref } from "vue";
42

5-
const STORAGE_KEY = "deepnotes.theme";
3+
const STORAGE_KEY = "deepnotes-theme";
4+
const LEGACY_KEY = "deepnotes.theme";
65

7-
export type ThemePreference = "light" | "dark" | "system";
8-
9-
/** Map legacy value before VueUse hydration / useColorMode reads storage. */
10-
export function migrateStoredThemeKey(): void {
6+
/** One-time migration from the old `deepnotes.theme` key. */
7+
export function migrateLegacyTheme(): void {
118
if (typeof localStorage === "undefined") {
129
return;
1310
}
1411
try {
15-
const raw = localStorage.getItem(STORAGE_KEY);
16-
if (raw === "system") {
17-
localStorage.setItem(STORAGE_KEY, "auto");
18-
}
19-
} catch {
20-
/* ignore */
21-
}
22-
}
23-
24-
/**
25-
* Apply `.dark` from localStorage + `prefers-color-scheme` before `createApp`
26-
* to reduce incorrect-theme flash.
27-
*/
28-
export function hydrateThemeFromStorage(): void {
29-
if (typeof document === "undefined") {
30-
return;
31-
}
32-
migrateStoredThemeKey();
33-
34-
let stored: "light" | "dark" | "auto" = "auto";
35-
try {
36-
if (typeof localStorage !== "undefined") {
37-
const raw = localStorage.getItem(STORAGE_KEY);
38-
if (raw === "light" || raw === "dark") {
39-
stored = raw;
40-
} else if (raw === "auto") {
41-
stored = "auto";
12+
const old = localStorage.getItem(LEGACY_KEY);
13+
if (old) {
14+
if (old === "dark") {
15+
localStorage.setItem(STORAGE_KEY, "dark");
4216
}
17+
localStorage.removeItem(LEGACY_KEY);
4318
}
4419
} catch {
4520
/* ignore */
4621
}
22+
}
4723

48-
let prefersDark = false;
49-
try {
50-
prefersDark =
51-
typeof window !== "undefined" &&
52-
window.matchMedia("(prefers-color-scheme: dark)").matches;
53-
} catch {
54-
/* ignore */
24+
const _isDark = ref(
25+
typeof document !== "undefined" &&
26+
document.documentElement.classList.contains("dark")
27+
);
28+
29+
/** Reactive dark-mode state. */
30+
export const isDark = _isDark;
31+
32+
/** Toggle dark mode and persist to localStorage. */
33+
export function toggleTheme(): void {
34+
migrateLegacyTheme();
35+
const html = document.documentElement;
36+
const next = !html.classList.contains("dark");
37+
if (next) {
38+
html.classList.add("dark");
39+
localStorage.setItem(STORAGE_KEY, "dark");
40+
} else {
41+
html.classList.remove("dark");
42+
localStorage.removeItem(STORAGE_KEY);
5543
}
56-
57-
const dark =
58-
stored === "dark" || (stored === "auto" && prefersDark);
59-
document.documentElement.classList.toggle("dark", dark);
44+
_isDark.value = next;
6045
}
61-
62-
const useThemePreferenceBase = () => {
63-
migrateStoredThemeKey();
64-
65-
const colorMode = useColorMode({
66-
storageKey: STORAGE_KEY,
67-
selector: "html",
68-
attribute: "class",
69-
});
70-
71-
const preference = computed({
72-
get(): ThemePreference {
73-
const s = colorMode.store.value;
74-
return s === "auto" ? "system" : (s as "light" | "dark");
75-
},
76-
set(next: ThemePreference) {
77-
colorMode.store.value = next === "system" ? "auto" : next;
78-
},
79-
});
80-
81-
const resolvedDark = computed(() => colorMode.state.value === "dark");
82-
83-
return { preference, resolvedDark, colorMode };
84-
};
85-
86-
export const useThemePreference = createSharedComposable(useThemePreferenceBase);

new-deepnotes/apps/web/src/main.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,8 @@ import "highlight.js/styles/atom-one-dark.css";
44
import "katex/dist/katex.min.css";
55
import "./styles/globals.css";
66
import App from "./App.vue";
7-
import { hydrateThemeFromStorage } from "./features/theme/useThemePreference";
87
import { createAppRouter } from "./router";
98

10-
hydrateThemeFromStorage();
11-
129
const app = createApp(App);
1310
app.use(createAppRouter());
1411
app.mount("#app");

0 commit comments

Comments
 (0)