feat(spa): theme toggle (dark/light/auto) with flash-suppression

- uiStore: themeMode (dark|light|auto), persisted to localStorage,
  follows OS preference in auto mode via matchMedia listener
- main.ts: initialize theme before first paint
- AppTopbar: cycle button shows next-state icon (sun/desktop/moon)
- tokens.css: .wp-theme-switching disables transitions during swap
- 5 new uiStore tests; full suite 82 vitest green
This commit is contained in:
DumiFlex
2026-04-27 04:02:38 +03:00
parent 8c03e72d69
commit 1b7576a55f
5 changed files with 168 additions and 17 deletions
+48
View File
@@ -0,0 +1,48 @@
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useUiStore } from "../stores/uiStore";
beforeEach(() => {
setActivePinia(createPinia());
document.documentElement.className = "";
localStorage.clear();
vi.useFakeTimers();
});
describe("uiStore — theme cycle", () => {
it("defaults to dark when no preference is stored", () => {
const ui = useUiStore();
expect(ui.themeMode).toBe("dark");
expect(ui.resolvedTheme).toBe("dark");
});
it("cycles dark → light → auto → dark", () => {
const ui = useUiStore();
ui.cycleTheme(); expect(ui.themeMode).toBe("light");
ui.cycleTheme(); expect(ui.themeMode).toBe("auto");
ui.cycleTheme(); expect(ui.themeMode).toBe("dark");
});
it("persists the chosen mode to localStorage", () => {
const ui = useUiStore();
ui.setThemeMode("light");
expect(localStorage.getItem("wp-theme-mode")).toBe("light");
});
it("applies wp-dark / wp-theme-light classes on initialize and removes flash class after window", () => {
const ui = useUiStore();
ui.setThemeMode("light");
ui.initializeTheme();
expect(document.documentElement.classList.contains("wp-theme-light")).toBe(true);
expect(document.documentElement.classList.contains("wp-theme-switching")).toBe(true);
vi.advanceTimersByTime(150);
expect(document.documentElement.classList.contains("wp-theme-switching")).toBe(false);
});
it("reads a previously stored mode on init", () => {
localStorage.setItem("wp-theme-mode", "auto");
const ui = useUiStore();
expect(ui.themeMode).toBe("auto");
});
});
+28 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from "vue";
import Button from "primevue/button";
import { useRouter } from "vue-router";
import { useUiStore } from "../stores/uiStore";
@@ -7,6 +8,20 @@ const ui = useUiStore();
const router = useRouter();
const version = "1.4.0-dev";
const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
/** Icon for the theme button reflects the *next* state in the cycle. */
const themeIcon = computed(() => {
switch (ui.themeMode) {
case "dark": return "pi pi-sun"; // next: light
case "light": return "pi pi-desktop"; // next: auto
default: return "pi pi-moon"; // next: dark
}
});
const themeLabel = computed(() =>
ui.themeMode === "dark" ? "Switch to light theme" :
ui.themeMode === "light" ? "Switch to auto (system) theme" :
"Switch to dark theme",
);
</script>
<template>
@@ -24,6 +39,12 @@ const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
</RouterLink>
</div>
<div class="topbar-right">
<Button
:icon="themeIcon" text rounded severity="secondary"
:aria-label="themeLabel"
:title="themeLabel"
@click="ui.cycleTheme"
/>
<Button
icon="pi pi-cog" text rounded severity="secondary"
aria-label="Settings"
@@ -35,7 +56,7 @@ const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
<style scoped>
.topbar {
height: 56px;
height: var(--wp-topbar-h, 56px);
background: var(--wp-bg);
border-bottom: 1px solid var(--wp-border);
display: flex;
@@ -55,8 +76,12 @@ const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
color: var(--wp-text);
font-weight: 600;
}
.brand:hover { color: #fff; }
.brand:hover { color: var(--wp-text); filter: brightness(1.1); }
.brand-logo { width: 24px; height: 24px; }
.brand-name { letter-spacing: 0.2px; }
.brand-version { font-size: 11px; color: var(--wp-text3); font-weight: 400; }
.brand-version {
font-size: 11px;
color: var(--wp-text-dim, var(--wp-text3));
font-weight: 400;
}
</style>
+7 -7
View File
@@ -9,16 +9,16 @@ import "./styles/theme.css";
import "./styles/tailwind.css";
import App from "./App.vue";
import router from "./router";
// Force PrimeVue dark mode by adding the matching class on the html root.
// PrimeVue 4 treats `darkModeSelector` as a CSS selector that toggles dark
// scope tokens; `":root"` is not a recognized variant, so we use a class
// and apply it eagerly. Light mode is not supported in v1.
document.documentElement.classList.add("wp-dark");
import { useUiStore } from "./stores/uiStore";
const app = createApp(App);
app.use(createPinia());
const pinia = createPinia();
app.use(pinia);
app.use(router);
// Apply persisted theme before mount so the first paint is correct.
// `useUiStore` reads localStorage and listens to OS preference changes.
useUiStore(pinia).initializeTheme();
app.use(PrimeVue, {
theme: {
preset: WildcardPreset,
+73 -7
View File
@@ -1,20 +1,86 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { computed, ref, watch } from "vue";
type Theme = "dark" | "light";
/** User-selected theme mode. `"auto"` follows the OS `prefers-color-scheme`. */
export type ThemeMode = "dark" | "light" | "auto";
const STORAGE_KEY = "wp-theme-mode";
const FLASH_SUPPRESS_MS = 120;
function readStoredTheme(): ThemeMode {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === "dark" || v === "light" || v === "auto") return v;
} catch {
/* localStorage unavailable */
}
return "dark";
}
function systemPrefersDark(): boolean {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-color-scheme: dark)").matches;
}
export const useUiStore = defineStore("ui", () => {
const theme = ref<Theme>("dark");
const themeMode = ref<ThemeMode>(readStoredTheme());
const sidebarCollapsed = ref(false);
function toggleTheme() {
theme.value = theme.value === "dark" ? "light" : "dark";
document.documentElement.classList.toggle("light-theme", theme.value === "light");
/** Resolved theme — `"auto"` collapsed to dark/light via OS preference. */
const resolvedTheme = computed<"dark" | "light">(() =>
themeMode.value === "auto"
? (systemPrefersDark() ? "dark" : "light")
: themeMode.value,
);
function applyThemeToDocument(mode: "dark" | "light") {
const html = document.documentElement;
// Briefly suppress transitions so the swap paints in one frame.
html.classList.add("wp-theme-switching");
html.classList.toggle("wp-dark", mode === "dark");
html.classList.toggle("wp-theme-light", mode === "light");
window.setTimeout(() => html.classList.remove("wp-theme-switching"), FLASH_SUPPRESS_MS);
}
function setThemeMode(mode: ThemeMode) {
themeMode.value = mode;
try { localStorage.setItem(STORAGE_KEY, mode); } catch { /* ignore */ }
}
/** Cycle dark → light → auto → dark. */
function cycleTheme() {
const next: ThemeMode =
themeMode.value === "dark" ? "light"
: themeMode.value === "light" ? "auto"
: "dark";
setThemeMode(next);
}
function initializeTheme() {
applyThemeToDocument(resolvedTheme.value);
// React to OS theme changes when in auto mode.
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => {
if (themeMode.value === "auto") applyThemeToDocument(resolvedTheme.value);
};
mq.addEventListener("change", onChange);
}
watch(resolvedTheme, (m) => applyThemeToDocument(m));
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
}
return { theme, sidebarCollapsed, toggleTheme, toggleSidebar };
return {
themeMode,
resolvedTheme,
sidebarCollapsed,
cycleTheme,
setThemeMode,
initializeTheme,
toggleSidebar,
};
});
+12
View File
@@ -207,3 +207,15 @@ html.wp-theme-light {
.wp-theme-dark {
color-scheme: dark;
}
/* ---------- Theme-switch flash suppression ----------
Briefly added by uiStore.cycleTheme() so the dark/light swap paints in
one frame instead of every transition fading independently. */
.wp-theme-switching,
.wp-theme-switching *,
.wp-theme-switching *::before,
.wp-theme-switching *::after {
transition: none !important;
animation-duration: 0s !important;
animation-delay: 0s !important;
}