From 1e193601de113a8560742b8b1c48dc51e38b96f0 Mon Sep 17 00:00:00 2001 From: Fahat Adam Date: Thu, 18 Jun 2026 14:51:16 +0100 Subject: [PATCH 1/3] fix: honor prefers-reduced-motion and improve sound manager defaults --- .../gamification/GamificationSettings.tsx | 70 ++++++------------- .../gamification/KingdomProgressWidget.tsx | 4 +- .../components/gamification/LevelUpModal.tsx | 6 +- .../gamification/XPGainAnimation.tsx | 12 ++-- src/app/stores/useUIStore.ts | 43 ++++++++++-- src/app/utils/soundManager.ts | 10 ++- 6 files changed, 83 insertions(+), 62 deletions(-) diff --git a/src/app/components/gamification/GamificationSettings.tsx b/src/app/components/gamification/GamificationSettings.tsx index 29a1fc7..c915e53 100644 --- a/src/app/components/gamification/GamificationSettings.tsx +++ b/src/app/components/gamification/GamificationSettings.tsx @@ -1,7 +1,8 @@ "use client"; -import { Volume2, VolumeX, Sparkles, SparklesIcon } from "lucide-react"; +import { Volume2, VolumeX, Sparkles, Accessibility } from "lucide-react"; import { useGamificationStore } from "@/app/stores/useGamificationStore"; +import { useUIStore } from "@/app/stores/useUIStore"; import { useSoundEffect } from "@/app/utils/soundManager"; import { Card, CardHeader, CardTitle, CardContent } from "../ui/Card"; import { useEffect } from "react"; @@ -11,45 +12,34 @@ interface GamificationSettingsProps { } export function GamificationSettings({ className }: GamificationSettingsProps) { - const soundEnabled = useGamificationStore((state) => state.soundEnabled); - const animationsEnabled = useGamificationStore((state) => state.animationsEnabled); - const soundVolume = useGamificationStore((state) => state.soundVolume); - const toggleSound = useGamificationStore((state) => state.toggleSound); - const toggleAnimations = useGamificationStore((state) => state.toggleAnimations); - const setSoundVolume = useGamificationStore((state) => state.setSoundVolume); + const soundEnabled = useUIStore((state) => state.soundEnabled); + const reducedMotion = useUIStore((state) => state.reducedMotion); + const setSoundEnabled = useUIStore((state) => state.setSoundEnabled); + const setReducedMotion = useUIStore((state) => state.setReducedMotion); const sound = useSoundEffect(); // Sync sound manager with store useEffect(() => { sound.setEnabled(soundEnabled); - sound.setVolume(soundVolume); - }, [soundEnabled, soundVolume, sound]); + }, [soundEnabled, sound]); const handleToggleSound = () => { - toggleSound(); - if (!soundEnabled) { + const nextState = !soundEnabled; + setSoundEnabled(nextState); + if (nextState) { // Play a test sound when enabling setTimeout(() => sound.play("success"), 100); } }; - const handleToggleAnimations = () => { - toggleAnimations(); - if (soundEnabled) { + const handleToggleReducedMotion = () => { + setReducedMotion(!reducedMotion); + if (soundEnabled && reducedMotion) { sound.play("click"); } }; - const handleVolumeChange = (e: React.ChangeEvent) => { - const newVolume = parseFloat(e.target.value); - setSoundVolume(newVolume); - // Play a test sound at the new volume - if (soundEnabled) { - sound.play("xpGain"); - } - }; - return ( @@ -88,48 +78,30 @@ export function GamificationSettings({ className }: GamificationSettingsProps) { - {/* Volume slider */} - {soundEnabled && ( -
- - -
- )} - - {/* Animations toggle */} + {/* Reduced Motion toggle */}
-
-

Animations

+

Reduced Motion

- Enable micro-animations and effects + Disable or tone down non-essential animations

diff --git a/src/app/components/gamification/KingdomProgressWidget.tsx b/src/app/components/gamification/KingdomProgressWidget.tsx index 4c94e27..7cb82a3 100644 --- a/src/app/components/gamification/KingdomProgressWidget.tsx +++ b/src/app/components/gamification/KingdomProgressWidget.tsx @@ -3,6 +3,7 @@ import { motion } from "framer-motion"; import { Crown, TrendingUp } from "lucide-react"; import { useGamificationStore, getNextLevelInfo } from "@/app/stores/useGamificationStore"; +import { useUIStore } from "@/app/stores/useUIStore"; import { Card } from "../ui/Card"; interface KingdomProgressWidgetProps { @@ -14,7 +15,8 @@ export function KingdomProgressWidget({ className, compact = false }: KingdomPro const level = useGamificationStore((state) => state.level); const xp = useGamificationStore((state) => state.xp); const kingdomTitle = useGamificationStore((state) => state.kingdomTitle); - const animationsEnabled = useGamificationStore((state) => state.animationsEnabled); + const reducedMotion = useUIStore((state) => state.reducedMotion); + const animationsEnabled = !reducedMotion; const { nextLevel, xpToNext, progress } = getNextLevelInfo(xp); const isMaxLevel = nextLevel === level; diff --git a/src/app/components/gamification/LevelUpModal.tsx b/src/app/components/gamification/LevelUpModal.tsx index 7a86671..0f6aed6 100644 --- a/src/app/components/gamification/LevelUpModal.tsx +++ b/src/app/components/gamification/LevelUpModal.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { X, Crown, Sparkles, Gift } from "lucide-react"; import { useGamificationStore } from "@/app/stores/useGamificationStore"; +import { useUIStore } from "@/app/stores/useUIStore"; import { useSoundEffect } from "@/app/utils/soundManager"; import { Button } from "../ui/Button"; import { useModalFocusTrap } from "../../hooks/useModalFocusTrap"; @@ -12,8 +13,9 @@ export function LevelUpModal() { const showModal = useGamificationStore((state) => state.showLevelUpModal); const pendingLevelUp = useGamificationStore((state) => state.pendingLevelUp); const dismissLevelUp = useGamificationStore((state) => state.dismissLevelUp); - const soundEnabled = useGamificationStore((state) => state.soundEnabled); - const animationsEnabled = useGamificationStore((state) => state.animationsEnabled); + const soundEnabled = useUIStore((state) => state.soundEnabled); + const reducedMotion = useUIStore((state) => state.reducedMotion); + const animationsEnabled = !reducedMotion; const modalRef = useRef(null); const closeButtonRef = useRef(null); diff --git a/src/app/components/gamification/XPGainAnimation.tsx b/src/app/components/gamification/XPGainAnimation.tsx index 5d7bcf4..adf9942 100644 --- a/src/app/components/gamification/XPGainAnimation.tsx +++ b/src/app/components/gamification/XPGainAnimation.tsx @@ -3,6 +3,7 @@ import { motion, AnimatePresence } from "framer-motion"; import { Sparkles } from "lucide-react"; import { useEffect, useState } from "react"; +import { useUIStore } from "@/app/stores/useUIStore"; interface XPGainAnimationProps { amount: number; @@ -18,6 +19,7 @@ export function XPGainAnimation({ position = "top", }: XPGainAnimationProps) { const [isVisible, setIsVisible] = useState(show); + const reducedMotion = useUIStore((state) => state.reducedMotion); useEffect(() => { if (show) { @@ -40,16 +42,16 @@ export function XPGainAnimation({ {isVisible && (
diff --git a/src/app/stores/useUIStore.ts b/src/app/stores/useUIStore.ts index 230388a..a16b1fc 100644 --- a/src/app/stores/useUIStore.ts +++ b/src/app/stores/useUIStore.ts @@ -13,7 +13,7 @@ */ import { create } from "zustand"; -import { devtools } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; // ─── Toast types ────────────────────────────────────────────────────────────── @@ -54,6 +54,9 @@ interface UIState { /** True while a global loading spinner should be shown */ isGlobalLoading: boolean; globalLoadingMessage: string | null; + /** Global settings for accessibility and comfort */ + soundEnabled: boolean; + reducedMotion: boolean; } interface UIActions { @@ -82,6 +85,11 @@ interface UIActions { showGlobalLoading: (message?: string) => void; hideGlobalLoading: () => void; + + // ── Settings ────────────────────────────────────────────────────────────── + + setSoundEnabled: (enabled: boolean) => void; + setReducedMotion: (reduced: boolean) => void; } export type UIStore = UIState & UIActions; @@ -103,19 +111,29 @@ const defaultModals = Object.fromEntries(ALL_MODALS.map((id) => [id, { isOpen: f // ─── Initial state ──────────────────────────────────────────────────────────── +const getInitialReducedMotion = () => { + if (typeof window !== "undefined") { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } + return false; +}; + const initialState: UIState = { modals: defaultModals, toasts: [], isGlobalLoading: false, globalLoadingMessage: null, + soundEnabled: true, + reducedMotion: getInitialReducedMotion(), }; // ─── Store ──────────────────────────────────────────────────────────────────── export const useUIStore = create()( devtools( - (set) => ({ - ...initialState, + persist( + (set) => ({ + ...initialState, // ── Modals ────────────────────────────────────────────────────────────── @@ -181,9 +199,24 @@ export const useUIStore = create()( hideGlobalLoading: () => set({ isGlobalLoading: false, globalLoadingMessage: null }, false, "ui/hideGlobalLoading"), + + // ── Settings ───────────────────────────────────────────────────────────── + + setSoundEnabled: (enabled) => + set({ soundEnabled: enabled }, false, "ui/setSoundEnabled"), + + setReducedMotion: (reduced) => + set({ reducedMotion: reduced }, false, "ui/setReducedMotion"), }), - { name: "UIStore" }, + { + name: "ui-store", + partialize: (state) => ({ + soundEnabled: state.soundEnabled, + reducedMotion: state.reducedMotion, + }), + }, ), + { name: "UIStore" }, ); // ─── Selectors ──────────────────────────────────────────────────────────────── @@ -192,3 +225,5 @@ export const selectModal = (id: ModalId) => (state: UIStore) => state.modals[id] export const selectToasts = (state: UIStore) => state.toasts; export const selectIsGlobalLoading = (state: UIStore) => state.isGlobalLoading; export const selectGlobalLoadingMessage = (state: UIStore) => state.globalLoadingMessage; +export const selectSoundEnabled = (state: UIStore) => state.soundEnabled; +export const selectReducedMotion = (state: UIStore) => state.reducedMotion; diff --git a/src/app/utils/soundManager.ts b/src/app/utils/soundManager.ts index ed34619..c672ba2 100644 --- a/src/app/utils/soundManager.ts +++ b/src/app/utils/soundManager.ts @@ -5,6 +5,8 @@ * Handles loading, playing, and managing audio files. */ +import { useUIStore } from "../stores/useUIStore"; + export type SoundEffect = | "levelUp" | "achievement" @@ -18,11 +20,17 @@ export type SoundEffect = class SoundManager { private sounds: Map = new Map(); private enabled: boolean = true; - private volume: number = 0.5; + private volume: number = 0.2; constructor() { if (typeof window !== "undefined") { this.initializeSounds(); + + // Sync with store + this.enabled = useUIStore.getState().soundEnabled; + useUIStore.subscribe((state) => { + this.enabled = state.soundEnabled; + }); } } From 9fa111b634a499281f8afd003f45a3c012dde11a Mon Sep 17 00:00:00 2001 From: Fahat Adam Date: Thu, 18 Jun 2026 23:38:17 +0100 Subject: [PATCH 2/3] fix: resolve PR comments on gamification settings --- .../gamification/AchievementsPanel.tsx | 4 ++- src/app/stores/useGamificationStore.ts | 36 ------------------- src/app/stores/useUIStore.ts | 15 +++++--- src/app/utils/soundManager.ts | 2 +- 4 files changed, 15 insertions(+), 42 deletions(-) diff --git a/src/app/components/gamification/AchievementsPanel.tsx b/src/app/components/gamification/AchievementsPanel.tsx index 0a00928..de8fc12 100644 --- a/src/app/components/gamification/AchievementsPanel.tsx +++ b/src/app/components/gamification/AchievementsPanel.tsx @@ -3,6 +3,7 @@ import { motion } from "framer-motion"; import { Lock, CheckCircle } from "lucide-react"; import { useGamificationStore } from "@/app/stores/useGamificationStore"; +import { useUIStore } from "@/app/stores/useUIStore"; import { Card, CardHeader, CardTitle, CardContent } from "../ui/Card"; interface AchievementsPanelProps { @@ -11,7 +12,8 @@ interface AchievementsPanelProps { export function AchievementsPanel({ className }: AchievementsPanelProps) { const achievements = useGamificationStore((state) => state.achievements); - const animationsEnabled = useGamificationStore((state) => state.animationsEnabled); + const reducedMotion = useUIStore((state) => state.reducedMotion); + const animationsEnabled = !reducedMotion; const unlockedCount = achievements.filter((a) => a.unlockedAt).length; const totalCount = achievements.length; diff --git a/src/app/stores/useGamificationStore.ts b/src/app/stores/useGamificationStore.ts index b2d3f75..cb4a247 100644 --- a/src/app/stores/useGamificationStore.ts +++ b/src/app/stores/useGamificationStore.ts @@ -41,11 +41,6 @@ interface GamificationState { kingdomTitle: KingdomLevel; achievements: Achievement[]; - // Settings - soundEnabled: boolean; - animationsEnabled: boolean; - soundVolume: number; // 0-1 - // UI state showLevelUpModal: boolean; pendingLevelUp: LevelUpReward | null; @@ -64,11 +59,6 @@ interface GamificationActions { unlockAchievement: (achievementId: string) => void; updateAchievementProgress: (achievementId: string, progress: number) => void; - // Settings - toggleSound: () => void; - toggleAnimations: () => void; - setSoundVolume: (volume: number) => void; - // Reset resetGamification: () => void; } @@ -191,9 +181,6 @@ const initialState: GamificationState = { xp: 0, kingdomTitle: "Peasant", achievements: INITIAL_ACHIEVEMENTS, - soundEnabled: true, - animationsEnabled: true, - soundVolume: 0.5, showLevelUpModal: false, pendingLevelUp: null, recentXPGain: null, @@ -297,26 +284,6 @@ export const useGamificationStore = create()( ); }, - toggleSound: () => { - set( - (state) => ({ soundEnabled: !state.soundEnabled }), - false, - "gamification/toggleSound", - ); - }, - - toggleAnimations: () => { - set( - (state) => ({ animationsEnabled: !state.animationsEnabled }), - false, - "gamification/toggleAnimations", - ); - }, - - setSoundVolume: (volume) => { - set({ soundVolume: Math.max(0, Math.min(1, volume)) }, false, "gamification/setVolume"); - }, - resetGamification: () => { set({ ...initialState }, false, "gamification/reset"); }, @@ -335,9 +302,6 @@ export const selectLevel = (state: GamificationStore) => state.level; export const selectXP = (state: GamificationStore) => state.xp; export const selectKingdomTitle = (state: GamificationStore) => state.kingdomTitle; export const selectAchievements = (state: GamificationStore) => state.achievements; -export const selectSoundEnabled = (state: GamificationStore) => state.soundEnabled; -export const selectAnimationsEnabled = (state: GamificationStore) => state.animationsEnabled; -export const selectSoundVolume = (state: GamificationStore) => state.soundVolume; export const selectShowLevelUpModal = (state: GamificationStore) => state.showLevelUpModal; export const selectPendingLevelUp = (state: GamificationStore) => state.pendingLevelUp; diff --git a/src/app/stores/useUIStore.ts b/src/app/stores/useUIStore.ts index a16b1fc..c318865 100644 --- a/src/app/stores/useUIStore.ts +++ b/src/app/stores/useUIStore.ts @@ -9,7 +9,7 @@ * - Toast notification queue * - Global (page-level) loading overlay * - * Design decision: no persistence — UI state should always start fresh. + * Design decision: most UI state starts fresh, but settings (sound/motion) are persisted. */ import { create } from "zustand"; @@ -214,10 +214,17 @@ export const useUIStore = create()( soundEnabled: state.soundEnabled, reducedMotion: state.reducedMotion, }), - }, + } ), - { name: "UIStore" }, -); + { name: "UIStore" } +)); + +if (typeof window !== "undefined") { + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + mediaQuery.addEventListener("change", (e) => { + useUIStore.getState().setReducedMotion(e.matches); + }); +} // ─── Selectors ──────────────────────────────────────────────────────────────── diff --git a/src/app/utils/soundManager.ts b/src/app/utils/soundManager.ts index c672ba2..a276211 100644 --- a/src/app/utils/soundManager.ts +++ b/src/app/utils/soundManager.ts @@ -25,7 +25,7 @@ class SoundManager { constructor() { if (typeof window !== "undefined") { this.initializeSounds(); - + // Sync with store this.enabled = useUIStore.getState().soundEnabled; useUIStore.subscribe((state) => { From c1db3668fe0c909183f0b31bd3a4a735304ef2af Mon Sep 17 00:00:00 2001 From: Fahat Adam Date: Fri, 19 Jun 2026 18:01:01 +0100 Subject: [PATCH 3/3] fix: resolve dangling imports and format useUIStore --- src/app/components/global_ui/GlobalXPGain.tsx | 5 +- src/app/stores/index.ts | 5 +- src/app/stores/useUIStore.ts | 166 +++++++++--------- 3 files changed, 91 insertions(+), 85 deletions(-) diff --git a/src/app/components/global_ui/GlobalXPGain.tsx b/src/app/components/global_ui/GlobalXPGain.tsx index 049ffdf..c63059c 100644 --- a/src/app/components/global_ui/GlobalXPGain.tsx +++ b/src/app/components/global_ui/GlobalXPGain.tsx @@ -2,13 +2,14 @@ import { useEffect, useState } from "react"; import { useGamificationStore } from "../../stores/useGamificationStore"; +import { useUIStore } from "../../stores/useUIStore"; import { XPGainAnimation } from "../gamification/XPGainAnimation"; import { useSoundEffect } from "../../utils/soundManager"; export function GlobalXPGain() { const recentXPGain = useGamificationStore((state) => state.recentXPGain); const clearRecentXPGain = useGamificationStore((state) => state.clearRecentXPGain); - const soundEnabled = useGamificationStore((state) => state.soundEnabled); + const soundEnabled = useUIStore((state) => state.soundEnabled); const sound = useSoundEffect(); const [queue, setQueue] = useState<{ amount: number; reason: string; id: number }[]>([]); @@ -20,6 +21,7 @@ export function GlobalXPGain() { useEffect(() => { if (recentXPGain) { + // eslint-disable-next-line react-hooks/set-state-in-effect setQueue((prev) => { // Prevent duplicate enqueue if ( @@ -37,6 +39,7 @@ export function GlobalXPGain() { useEffect(() => { if (!activeGain && queue.length > 0) { const next = queue[0]; + // eslint-disable-next-line react-hooks/set-state-in-effect setActiveGain(next); setQueue((prev) => prev.slice(1)); if (soundEnabled) { diff --git a/src/app/stores/index.ts b/src/app/stores/index.ts index e05255b..a88b54d 100644 --- a/src/app/stores/index.ts +++ b/src/app/stores/index.ts @@ -34,6 +34,8 @@ export { selectToasts, selectIsGlobalLoading, selectGlobalLoadingMessage, + selectSoundEnabled, + selectReducedMotion, } from "./useUIStore"; export type { Toast, ToastVariant, ModalId, UIStore } from "./useUIStore"; @@ -43,9 +45,6 @@ export { selectXP, selectKingdomTitle, selectAchievements, - selectSoundEnabled, - selectAnimationsEnabled, - selectSoundVolume, selectShowLevelUpModal, selectPendingLevelUp, getNextLevelInfo, diff --git a/src/app/stores/useUIStore.ts b/src/app/stores/useUIStore.ts index c318865..159b104 100644 --- a/src/app/stores/useUIStore.ts +++ b/src/app/stores/useUIStore.ts @@ -135,89 +135,93 @@ export const useUIStore = create()( (set) => ({ ...initialState, - // ── Modals ────────────────────────────────────────────────────────────── - - openModal: (id, data) => - set( - (state) => ({ - modals: { - ...state.modals, - [id]: { isOpen: true, data }, - }, - }), - false, - `ui/openModal:${id}`, - ), - - closeModal: (id) => - set( - (state) => ({ - modals: { - ...state.modals, - [id]: { isOpen: false, data: undefined }, - }, - }), - false, - `ui/closeModal:${id}`, - ), - - closeAllModals: () => set({ modals: defaultModals }, false, "ui/closeAllModals"), - - // ── Toasts ────────────────────────────────────────────────────────────── - - addToast: (toast) => { - const id = toast.id ?? `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - const duration = toast.duration ?? 4000; - const fullToast: Toast = { - ...toast, - id, - duration, - }; - set((state) => ({ toasts: [...state.toasts, fullToast] }), false, "ui/addToast"); - return id; - }, - - dismissToast: (id) => - set( - (state) => ({ - toasts: state.toasts.filter((t) => t.id !== id), - }), - false, - "ui/dismissToast", - ), - - clearToasts: () => set({ toasts: [] }, false, "ui/clearToasts"), - - // ── Global loading ─────────────────────────────────────────────────────── - - showGlobalLoading: (message) => - set( - { isGlobalLoading: true, globalLoadingMessage: message ?? null }, - false, - "ui/showGlobalLoading", - ), - - hideGlobalLoading: () => - set({ isGlobalLoading: false, globalLoadingMessage: null }, false, "ui/hideGlobalLoading"), - - // ── Settings ───────────────────────────────────────────────────────────── - - setSoundEnabled: (enabled) => - set({ soundEnabled: enabled }, false, "ui/setSoundEnabled"), - - setReducedMotion: (reduced) => - set({ reducedMotion: reduced }, false, "ui/setReducedMotion"), - }), - { - name: "ui-store", - partialize: (state) => ({ - soundEnabled: state.soundEnabled, - reducedMotion: state.reducedMotion, + // ── Modals ────────────────────────────────────────────────────────────── + + openModal: (id, data) => + set( + (state) => ({ + modals: { + ...state.modals, + [id]: { isOpen: true, data }, + }, + }), + false, + `ui/openModal:${id}`, + ), + + closeModal: (id) => + set( + (state) => ({ + modals: { + ...state.modals, + [id]: { isOpen: false, data: undefined }, + }, + }), + false, + `ui/closeModal:${id}`, + ), + + closeAllModals: () => set({ modals: defaultModals }, false, "ui/closeAllModals"), + + // ── Toasts ────────────────────────────────────────────────────────────── + + addToast: (toast) => { + const id = toast.id ?? `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const duration = toast.duration ?? 4000; + const fullToast: Toast = { + ...toast, + id, + duration, + }; + set((state) => ({ toasts: [...state.toasts, fullToast] }), false, "ui/addToast"); + return id; + }, + + dismissToast: (id) => + set( + (state) => ({ + toasts: state.toasts.filter((t) => t.id !== id), + }), + false, + "ui/dismissToast", + ), + + clearToasts: () => set({ toasts: [] }, false, "ui/clearToasts"), + + // ── Global loading ─────────────────────────────────────────────────────── + + showGlobalLoading: (message) => + set( + { isGlobalLoading: true, globalLoadingMessage: message ?? null }, + false, + "ui/showGlobalLoading", + ), + + hideGlobalLoading: () => + set( + { isGlobalLoading: false, globalLoadingMessage: null }, + false, + "ui/hideGlobalLoading", + ), + + // ── Settings ───────────────────────────────────────────────────────────── + + setSoundEnabled: (enabled) => set({ soundEnabled: enabled }, false, "ui/setSoundEnabled"), + + setReducedMotion: (reduced) => + set({ reducedMotion: reduced }, false, "ui/setReducedMotion"), }), - } + { + name: "ui-store", + partialize: (state) => ({ + soundEnabled: state.soundEnabled, + reducedMotion: state.reducedMotion, + }), + }, + ), + { name: "UIStore" }, ), - { name: "UIStore" } -)); +); if (typeof window !== "undefined") { const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");