From cd31b6f2d8271e929db60368bcb3c1af8a85197a Mon Sep 17 00:00:00 2001 From: mlandry Date: Tue, 14 Oct 2025 15:14:22 -0400 Subject: [PATCH] fix: race condition with using getComputedStyle primary color for dynamic activity grid darkening (#76) * Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening Instead just use the color from the current theme directly. Tested works on initial load and theme changes. Fixes https://github.com/gabehf/Koito/issues/75 * Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name Split name out of the Theme struct to simplify custom theme saving/reading --- client/app/components/ActivityGrid.tsx | 37 ++-- .../components/themeSwitcher/ThemeOption.tsx | 9 +- .../themeSwitcher/ThemeSwitcher.tsx | 22 +-- client/app/providers/ThemeProvider.tsx | 86 +++++---- client/app/routes/ThemeHelper.tsx | 4 - client/app/styles/themes.css.ts | 171 ++++++++---------- 6 files changed, 156 insertions(+), 173 deletions(-) diff --git a/client/app/components/ActivityGrid.tsx b/client/app/components/ActivityGrid.tsx index 16953df..966b4b5 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -1,15 +1,14 @@ import { useQuery } from "@tanstack/react-query" import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api" import Popup from "./Popup" -import { useEffect, useState } from "react" +import { useState } from "react" import { useTheme } from "~/hooks/useTheme" import ActivityOptsSelector from "./ActivityOptsSelector" +import type { Theme } from "~/styles/themes.css" -function getPrimaryColor(): string { - const value = getComputedStyle(document.documentElement) - .getPropertyValue('--color-primary') - .trim(); +function getPrimaryColor(theme: Theme): string { + const value = theme.primary; const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/); if (rgbMatch) { const [, r, g, b] = rgbMatch.map(Number); @@ -23,14 +22,13 @@ function getPrimaryColor(): string { return value; } - interface Props { - step?: string - range?: number - month?: number - year?: number - artistId?: number - albumId?: number + step?: string + range?: number + month?: number + year?: number + artistId?: number + albumId?: number trackId?: number configurable?: boolean autoAdjust?: boolean @@ -47,7 +45,6 @@ export default function ActivityGrid({ configurable = false, }: Props) { - const [color, setColor] = useState(getPrimaryColor()) const [stepState, setStep] = useState(step) const [rangeState, setRange] = useState(range) @@ -68,15 +65,9 @@ export default function ActivityGrid({ }); - const { theme } = useTheme(); - useEffect(() => { - const raf = requestAnimationFrame(() => { - const color = getPrimaryColor() - setColor(color); - }); - - return () => cancelAnimationFrame(raf); - }, [theme]); + const { theme, themeName } = useTheme(); + const color = getPrimaryColor(theme); + if (isPending) { return ( @@ -133,7 +124,7 @@ export default function ActivityGrid({ } v = Math.min(v, t) - if (theme === "pearl") { + if (themeName === "pearl") { // special case for the only light theme lol // could be generalized by pragmatically comparing the // lightness of the bg vs the primary but eh diff --git a/client/app/components/themeSwitcher/ThemeOption.tsx b/client/app/components/themeSwitcher/ThemeOption.tsx index 224fcce..51b9acf 100644 --- a/client/app/components/themeSwitcher/ThemeOption.tsx +++ b/client/app/components/themeSwitcher/ThemeOption.tsx @@ -1,19 +1,20 @@ -import type { Theme } from "~/providers/ThemeProvider"; +import type { Theme } from "~/styles/themes.css"; interface Props { theme: Theme + themeName: string setTheme: Function } -export default function ThemeOption({ theme, setTheme }: Props) { +export default function ThemeOption({ theme, themeName, setTheme }: Props) { const capitalizeFirstLetter = (s: string) => { return s.charAt(0).toUpperCase() + s.slice(1); } return ( -
setTheme(theme.name)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}> -
{capitalizeFirstLetter(theme.name)}
+
setTheme(themeName)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}> +
{capitalizeFirstLetter(themeName)}
diff --git a/client/app/components/themeSwitcher/ThemeSwitcher.tsx b/client/app/components/themeSwitcher/ThemeSwitcher.tsx index 14eda1e..fdc8709 100644 --- a/client/app/components/themeSwitcher/ThemeSwitcher.tsx +++ b/client/app/components/themeSwitcher/ThemeSwitcher.tsx @@ -6,7 +6,7 @@ import ThemeOption from './ThemeOption'; import { AsyncButton } from '../AsyncButton'; export function ThemeSwitcher() { - const { theme, setTheme } = useTheme(); + const { theme, themeName, setTheme } = useTheme(); const initialTheme = { bg: "#1e1816", bgSecondary: "#2f2623", @@ -30,30 +30,22 @@ export function ThemeSwitcher() { const handleCustomTheme = () => { console.log(custom) try { - const theme = JSON.parse(custom) - theme.name = "custom" - setCustomTheme(theme) - delete theme.name - setCustom(JSON.stringify(theme, null, " ")) - console.log(theme) + const themeData = JSON.parse(custom) + setCustomTheme(themeData) + setCustom(JSON.stringify(themeData, null, " ")) + console.log(themeData) } catch(err) { console.log(err) } } - useEffect(() => { - if (theme) { - setTheme(theme) - } - }, [theme]); - return (

Select Theme

- {themes.map((t) => ( - + {Object.entries(themes).map(([name, themeData]) => ( + ))}
diff --git a/client/app/providers/ThemeProvider.tsx b/client/app/providers/ThemeProvider.tsx index 52d9ef9..1a4f9e8 100644 --- a/client/app/providers/ThemeProvider.tsx +++ b/client/app/providers/ThemeProvider.tsx @@ -1,9 +1,10 @@ import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react'; -import { type Theme } from '~/styles/themes.css'; +import { type Theme, themes } from '~/styles/themes.css'; import { themeVars } from '~/styles/vars.css'; interface ThemeContextValue { - theme: string; + themeName: string; + theme: Theme; setTheme: (theme: string) => void; setCustomTheme: (theme: Theme) => void; getCustomTheme: () => Theme | undefined; @@ -29,6 +30,18 @@ function clearCustomThemeVars() { } } +function getStoredCustomTheme(): Theme | undefined { + const themeStr = localStorage.getItem('custom-theme'); + if (!themeStr) return undefined; + try { + const parsed = JSON.parse(themeStr); + const { name, ...theme } = parsed; + return theme as Theme; + } catch { + return undefined; + } +} + export function ThemeProvider({ theme: initialTheme, children, @@ -36,57 +49,60 @@ export function ThemeProvider({ theme: string; children: ReactNode; }) { - const [theme, setThemeName] = useState(initialTheme); + const [themeName, setThemeName] = useState(initialTheme); + const [currentTheme, setCurrentTheme] = useState(() => { + if (initialTheme === 'custom') { + const customTheme = getStoredCustomTheme(); + return customTheme || themes.yuu; + } + return themes[initialTheme] || themes.yuu; + }); - const setTheme = (theme: string) => { - setThemeName(theme) + const setTheme = (newThemeName: string) => { + setThemeName(newThemeName); + if (newThemeName === 'custom') { + const customTheme = getStoredCustomTheme(); + if (customTheme) { + setCurrentTheme(customTheme); + } else { + // Fallback to default theme if no custom theme found + setThemeName('yuu'); + setCurrentTheme(themes.yuu); + } + } else { + const foundTheme = themes[newThemeName]; + if (foundTheme) { + setCurrentTheme(foundTheme); + } + } } const setCustomTheme = useCallback((customTheme: Theme) => { localStorage.setItem('custom-theme', JSON.stringify(customTheme)); applyCustomThemeVars(customTheme); - setTheme('custom'); + setThemeName('custom'); + setCurrentTheme(customTheme); }, []); - const getCustomTheme = (): Theme | undefined => { - const themeStr = localStorage.getItem('custom-theme'); - if (!themeStr) { - return undefined - } - try { - let theme = JSON.parse(themeStr) as Theme - return theme - } catch (err) { - return undefined - } + const getCustomTheme = (): Theme | undefined => { + return getStoredCustomTheme(); } useEffect(() => { const root = document.documentElement; - root.setAttribute('data-theme', theme); - localStorage.setItem('theme', theme) - console.log(theme) + root.setAttribute('data-theme', themeName); + localStorage.setItem('theme', themeName); - if (theme === 'custom') { - const saved = localStorage.getItem('custom-theme'); - if (saved) { - try { - const parsed = JSON.parse(saved) as Theme; - applyCustomThemeVars(parsed); - } catch (err) { - console.error('Invalid custom theme in localStorage', err); - } - } else { - setTheme('yuu') - } + if (themeName === 'custom') { + applyCustomThemeVars(currentTheme); } else { - clearCustomThemeVars() + clearCustomThemeVars(); } - }, [theme]); + }, [themeName, currentTheme]); return ( - + {children} ); diff --git a/client/app/routes/ThemeHelper.tsx b/client/app/routes/ThemeHelper.tsx index 6452a08..fc5b7e4 100644 --- a/client/app/routes/ThemeHelper.tsx +++ b/client/app/routes/ThemeHelper.tsx @@ -12,7 +12,6 @@ import { themes, type Theme } from "~/styles/themes.css" export default function ThemeHelper() { const initialTheme = { - name: "custom", bg: "#1e1816", bgSecondary: "#2f2623", bgTertiary: "#453733", @@ -36,9 +35,6 @@ export default function ThemeHelper() { console.log(custom) try { const theme = JSON.parse(custom) as Theme - if (theme.name !== "custom") { - throw new Error("theme name must be 'custom'") - } console.log(theme) setCustomTheme(theme) } catch(err) { diff --git a/client/app/styles/themes.css.ts b/client/app/styles/themes.css.ts index eee6b27..22f48f0 100644 --- a/client/app/styles/themes.css.ts +++ b/client/app/styles/themes.css.ts @@ -2,11 +2,10 @@ import { globalStyle } from "@vanilla-extract/css" import { themeVars } from "./vars.css" export type Theme = { - name: string, - bg: string - bgSecondary: string + bg: string + bgSecondary: string bgTertiary: string - fg: string + fg: string fgSecondary: string fgTertiary: string primary: string @@ -23,9 +22,8 @@ export const THEME_KEYS = [ '--color' ] -export const themes: Theme[] = [ - { - name: "yuu", +export const themes: Record = { + yuu: { bg: "#1e1816", bgSecondary: "#2f2623", bgTertiary: "#453733", @@ -41,8 +39,7 @@ export const themes: Theme[] = [ success: "#8fc48f", info: "#87b8dd", }, - { - name: "varia", + varia: { bg: "rgb(25, 25, 29)", bgSecondary: "#222222", bgTertiary: "#333333", @@ -58,8 +55,7 @@ export const themes: Theme[] = [ success: "#4caf50", info: "#2196f3", }, - { - name: "midnight", + midnight: { bg: "rgb(8, 15, 24)", bgSecondary: "rgb(15, 27, 46)", bgTertiary: "rgb(15, 41, 70)", @@ -75,8 +71,7 @@ export const themes: Theme[] = [ success: "#4caf50", info: "#2196f3", }, - { - name: "catppuccin", + catppuccin: { bg: "#1e1e2e", bgSecondary: "#181825", bgTertiary: "#11111b", @@ -92,8 +87,7 @@ export const themes: Theme[] = [ success: "#a6e3a1", info: "#89dceb", }, - { - name: "autumn", + autumn: { bg: "rgb(44, 25, 18)", bgSecondary: "rgb(70, 40, 18)", bgTertiary: "#4b2f1c", @@ -109,8 +103,7 @@ export const themes: Theme[] = [ success: "#6b8e23", info: "#c084fc", }, - { - name: "black", + black: { bg: "#000000", bgSecondary: "#1a1a1a", bgTertiary: "#2a2a2a", @@ -126,8 +119,7 @@ export const themes: Theme[] = [ success: "#4caf50", info: "#2196f3", }, - { - name: "wine", + wine: { bg: "#23181E", bgSecondary: "#2C1C25", bgTertiary: "#422A37", @@ -143,97 +135,92 @@ export const themes: Theme[] = [ success: "#bbf7d0", info: "#bae6fd", }, - { - name: "pearl", - bg: "#FFFFFF", - bgSecondary: "#EEEEEE", - bgTertiary: "#E0E0E0", - fg: "#333333", - fgSecondary: "#555555", + pearl: { + bg: "#FFFFFF", + bgSecondary: "#EEEEEE", + bgTertiary: "#E0E0E0", + fg: "#333333", + fgSecondary: "#555555", fgTertiary: "#777777", - primary: "#007BFF", + primary: "#007BFF", primaryDim: "#0056B3", - accent: "#28A745", - accentDim: "#1E7E34", - error: "#DC3545", - warning: "#FFC107", - success: "#28A745", - info: "#17A2B8", + accent: "#28A745", + accentDim: "#1E7E34", + error: "#DC3545", + warning: "#FFC107", + success: "#28A745", + info: "#17A2B8", }, - { - name: "asuka", - bg: "#3B1212", - bgSecondary: "#471B1B", - bgTertiary: "#020202", - fg: "#F1E9E6", - fgSecondary: "#CCB6AE", + asuka: { + bg: "#3B1212", + bgSecondary: "#471B1B", + bgTertiary: "#020202", + fg: "#F1E9E6", + fgSecondary: "#CCB6AE", fgTertiary: "#9F8176", - primary: "#F1E9E6", + primary: "#F1E9E6", primaryDim: "#CCB6AE", - accent: "#41CE41", - accentDim: "#3BA03B", - error: "#DC143C", - warning: "#FFD700", - success: "#32CD32", - info: "#1E90FF", + accent: "#41CE41", + accentDim: "#3BA03B", + error: "#DC143C", + warning: "#FFD700", + success: "#32CD32", + info: "#1E90FF", }, - { - name: "urim", - bg: "#101713", - bgSecondary: "#1B2921", - bgTertiary: "#273B30", - fg: "#D2E79E", - fgSecondary: "#B4DA55", + urim: { + bg: "#101713", + bgSecondary: "#1B2921", + bgTertiary: "#273B30", + fg: "#D2E79E", + fgSecondary: "#B4DA55", fgTertiary: "#7E9F2A", - primary: "#ead500", + primary: "#ead500", primaryDim: "#C1B210", - accent: "#28A745", - accentDim: "#1E7E34", - error: "#EE5237", - warning: "#FFC107", - success: "#28A745", - info: "#17A2B8", + accent: "#28A745", + accentDim: "#1E7E34", + error: "#EE5237", + warning: "#FFC107", + success: "#28A745", + info: "#17A2B8", }, - { - name: "match", - bg: "#071014", - bgSecondary: "#0A181E", - bgTertiary: "#112A34", - fg: "#ebeaeb", - fgSecondary: "#BDBDBD", + match: { + bg: "#071014", + bgSecondary: "#0A181E", + bgTertiary: "#112A34", + fg: "#ebeaeb", + fgSecondary: "#BDBDBD", fgTertiary: "#A2A2A2", - primary: "#fda827", + primary: "#fda827", primaryDim: "#C78420", - accent: "#277CFD", - accentDim: "#1F60C1", - error: "#F14426", - warning: "#FFC107", - success: "#28A745", - info: "#17A2B8", + accent: "#277CFD", + accentDim: "#1F60C1", + error: "#F14426", + warning: "#FFC107", + success: "#28A745", + info: "#17A2B8", }, - { - name: "lemon", - bg: "#1a171a", - bgSecondary: "#2E272E", - bgTertiary: "#443844", - fg: "#E6E2DC", - fgSecondary: "#B2ACA1", + lemon: { + bg: "#1a171a", + bgSecondary: "#2E272E", + bgTertiary: "#443844", + fg: "#E6E2DC", + fgSecondary: "#B2ACA1", fgTertiary: "#968F82", - primary: "#f5c737", + primary: "#f5c737", primaryDim: "#C29D2F", - accent: "#277CFD", - accentDim: "#1F60C1", - error: "#F14426", - warning: "#FFC107", - success: "#28A745", - info: "#17A2B8", + accent: "#277CFD", + accentDim: "#1F60C1", + error: "#F14426", + warning: "#FFC107", + success: "#28A745", + info: "#17A2B8", } -]; +}; export default themes -themes.forEach((theme) => { - const selector = `[data-theme="${theme.name}"]` +Object.entries(themes).forEach(([name, theme]) => { + const selector = `[data-theme="${name}"]` globalStyle(selector, { vars: {