From df3058f3c4f8504d753d8e08a15a32676d875a4e Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Thu, 8 Oct 2026 16:14:27 -0700 Subject: [PATCH 1/2] fix: migrate to CSS-based Primer theme provider --- src/renderer/App.tsx | 3 +- src/renderer/__helpers__/visual-utils.tsx | 3 +- .../hooks/useAppearance.integration.test.tsx | 427 ++++++++++++++++++ src/renderer/hooks/useAppearance.test.ts | 4 +- src/renderer/hooks/useAppearance.ts | 2 +- src/renderer/utils/ui/theme.ts | 2 +- 6 files changed, 435 insertions(+), 6 deletions(-) create mode 100644 src/renderer/hooks/useAppearance.integration.test.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1054836a7..135053ab6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,7 +1,8 @@ import { useEffect } from 'react'; import { Navigate, Route, HashRouter as Router, Routes, useLocation } from 'react-router-dom'; -import { BaseStyles, ThemeProvider } from '@primer/react'; +import { BaseStyles } from '@primer/react'; +import { ThemeProvider } from '@primer/react/next'; import { QueryClientProvider } from '@tanstack/react-query'; diff --git a/src/renderer/__helpers__/visual-utils.tsx b/src/renderer/__helpers__/visual-utils.tsx index cec0834c8..be4106732 100644 --- a/src/renderer/__helpers__/visual-utils.tsx +++ b/src/renderer/__helpers__/visual-utils.tsx @@ -2,7 +2,8 @@ import { render } from '@testing-library/react'; import type { ReactElement } from 'react'; import { type InitialEntry, MemoryRouter } from 'react-router-dom'; -import { BaseStyles, ThemeProvider } from '@primer/react'; +import { BaseStyles } from '@primer/react'; +import { ThemeProvider } from '@primer/react/next'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; diff --git a/src/renderer/hooks/useAppearance.integration.test.tsx b/src/renderer/hooks/useAppearance.integration.test.tsx new file mode 100644 index 000000000..0a81da67f --- /dev/null +++ b/src/renderer/hooks/useAppearance.integration.test.tsx @@ -0,0 +1,427 @@ +import { act, cleanup, render, screen } from '@testing-library/react'; + +import { BaseStyles } from '@primer/react'; +import { ThemeProvider, useTheme } from '@primer/react/next'; + +import { Constants } from '../constants'; + +import { useSettingsStore } from '../stores'; + +import { DesignLanguage, type SettingsState, Theme } from '../types'; + +import { useAppearance } from './useAppearance'; + +vi.unmock('@primer/react/next'); + +const COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)'; +const CONTRAST_QUERY = '(prefers-contrast: more)'; +const TRANSPARENCY_QUERY = '(prefers-reduced-transparency: reduce)'; + +function createMediaQuery(media: string) { + const target = Object.assign(new EventTarget(), { media, matches: false, onchange: null }); + + return { + query: target as unknown as MediaQueryList, + setMatches(matches: boolean) { + target.matches = matches; + target.dispatchEvent(new Event('change')); + }, + }; +} + +function AppearanceProbe() { + useAppearance(); + const { colorMode, resolvedColorMode, colorScheme } = useTheme(); + + return ( +
+ ); +} + +function renderAppearance(settings: Partial = {}) { + useSettingsStore.setState(settings); + + return render( + + + + + , + ); +} + +function getThemeWrapper() { + const wrapper = screen.getByTestId('appearance').closest('[data-color-mode]'); + expect(wrapper).toBeInTheDocument(); + return wrapper!; +} + +describe('renderer/hooks/useAppearance real Primer provider integration', () => { + let mediaQueries: Map>; + let rootAttributes: Record; + + function mediaQuery(query: string) { + let media = mediaQueries.get(query); + if (!media) { + media = createMediaQuery(query); + mediaQueries.set(query, media); + } + return media; + } + + beforeEach(() => { + mediaQueries = new Map(); + vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQuery(query).query); + rootAttributes = Object.fromEntries( + ['data-theme', 'data-glass-material', 'class'].map((name) => [ + name, + document.documentElement.getAttribute(name), + ]), + ); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + for (const [name, value] of Object.entries(rootAttributes)) { + if (value === null) { + document.documentElement.removeAttribute(name); + } else { + document.documentElement.setAttribute(name, value); + } + } + }); + + it('keeps the theme attributes on a descendant wrapper around BaseStyles and content', () => { + renderAppearance({ theme: Theme.LIGHT }); + + const wrapper = getThemeWrapper(); + expect(wrapper).not.toBe(document.documentElement); + expect(wrapper).toHaveAttribute('data-component', 'ThemeProvider'); + expect(wrapper).toHaveAttribute('data-color-mode', 'light'); + expect(wrapper).toHaveAttribute('data-light-theme', 'light'); + expect(wrapper).toHaveAttribute('data-dark-theme', 'light'); + expect(wrapper.querySelector('[data-component="BaseStyles"]')).toContainElement( + screen.getByTestId('appearance'), + ); + expect(document.documentElement).toHaveAttribute('data-theme', 'classic'); + }); + + it.each([ + { theme: Theme.LIGHT, mode: 'day', scheme: 'light' }, + { theme: Theme.LIGHT_COLORBLIND, mode: 'day', scheme: 'light_colorblind' }, + { theme: Theme.LIGHT_TRITANOPIA, mode: 'day', scheme: 'light_tritanopia' }, + { theme: Theme.DARK, mode: 'night', scheme: 'dark' }, + { theme: Theme.DARK_COLORBLIND, mode: 'night', scheme: 'dark_colorblind' }, + { theme: Theme.DARK_TRITANOPIA, mode: 'night', scheme: 'dark_tritanopia' }, + { theme: Theme.DARK_DIMMED, mode: 'night', scheme: 'dark_dimmed' }, + ])('preserves the Classic $theme mapping', ({ theme, mode, scheme }) => { + renderAppearance({ designLanguage: DesignLanguage.CLASSIC, theme }); + + expect(screen.getByTestId('appearance')).toHaveAttribute('data-mode', mode); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-resolved-mode', mode); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', scheme); + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', mode === 'day' ? 'light' : 'dark'); + expect(getThemeWrapper()).toHaveAttribute('data-light-theme', scheme); + expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', scheme); + }); + + it('updates fixed color modes and native sources when settings change', async () => { + renderAppearance({ theme: Theme.LIGHT }); + + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'light'); + expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('light'); + + await act(async () => { + useSettingsStore.setState({ theme: Theme.DARK }); + }); + + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'dark'); + expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'dark'); + expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark'); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-mode', 'night'); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'dark'); + expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('dark'); + + await act(async () => { + useSettingsStore.setState({ theme: Theme.SYSTEM }); + }); + + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'auto'); + expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light'); + expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark'); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-mode', 'auto'); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-resolved-mode', 'day'); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'light'); + expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('system'); + }); + + it('responds to OS color-scheme changes without replacing System with a fixed mode', async () => { + renderAppearance({ theme: Theme.SYSTEM }); + + expect(screen.getByTestId('appearance')).toHaveAttribute('data-resolved-mode', 'day'); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'light'); + + await act(async () => { + mediaQuery(COLOR_SCHEME_QUERY).setMatches(true); + }); + + expect(screen.getByTestId('appearance')).toHaveAttribute('data-mode', 'auto'); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-resolved-mode', 'night'); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'dark'); + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'auto'); + expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light'); + expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark'); + expect(useSettingsStore.getState().theme).toBe(Theme.SYSTEM); + expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('system'); + expect(window.gitify.setNativeTheme).not.toHaveBeenCalledWith('dark'); + }); + + it.each([ + { theme: Theme.LIGHT, scheme: 'light' }, + { theme: Theme.DARK, scheme: 'dark' }, + ])('updates Classic $theme for the in-app contrast setting', async ({ theme, scheme }) => { + renderAppearance({ designLanguage: DesignLanguage.CLASSIC, theme }); + + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', scheme); + + await act(async () => { + useSettingsStore.setState({ increaseContrast: true }); + }); + + expect(screen.getByTestId('appearance')).toHaveAttribute( + 'data-scheme', + `${scheme}_high_contrast`, + ); + expect(getThemeWrapper()).toHaveAttribute('data-light-theme', `${scheme}_high_contrast`); + expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', `${scheme}_high_contrast`); + + await act(async () => { + useSettingsStore.setState({ increaseContrast: false }); + }); + + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', scheme); + }); + + it('updates and restores the Classic System scheme pair for live OS contrast changes', async () => { + renderAppearance({ designLanguage: DesignLanguage.CLASSIC, theme: Theme.SYSTEM }); + + await act(async () => { + mediaQuery(CONTRAST_QUERY).setMatches(true); + }); + + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'auto'); + expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light_high_contrast'); + expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark_high_contrast'); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'light_high_contrast'); + + await act(async () => { + mediaQuery(COLOR_SCHEME_QUERY).setMatches(true); + }); + + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'dark_high_contrast'); + + await act(async () => { + mediaQuery(CONTRAST_QUERY).setMatches(false); + }); + + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'auto'); + expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light'); + expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark'); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'dark'); + expect(useSettingsStore.getState().theme).toBe(Theme.SYSTEM); + }); + + describe('Glass', () => { + it.each([ + { theme: Theme.LIGHT, mode: 'day', scheme: 'light', wrapperMode: 'light' }, + { theme: Theme.DARK, mode: 'night', scheme: 'dark', wrapperMode: 'dark' }, + { theme: Theme.SYSTEM, mode: 'auto', scheme: 'light', wrapperMode: 'auto' }, + ])( + 'preserves $theme without Classic high-contrast schemes', + async ({ theme, mode, scheme, wrapperMode }) => { + await act(async () => { + renderAppearance({ designLanguage: DesignLanguage.GLASS, theme, increaseContrast: true }); + }); + + expect(screen.getByTestId('appearance')).toHaveAttribute('data-mode', mode); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', scheme); + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', wrapperMode); + expect(getThemeWrapper()).toHaveAttribute( + 'data-light-theme', + theme === Theme.SYSTEM ? 'light' : scheme, + ); + expect(getThemeWrapper()).toHaveAttribute( + 'data-dark-theme', + theme === Theme.SYSTEM ? 'dark' : scheme, + ); + }, + ); + + it.each([ + { theme: Theme.LIGHT_COLORBLIND, classicScheme: 'light_colorblind', glassScheme: 'light' }, + { theme: Theme.LIGHT_TRITANOPIA, classicScheme: 'light_tritanopia', glassScheme: 'light' }, + { theme: Theme.DARK_COLORBLIND, classicScheme: 'dark_colorblind', glassScheme: 'dark' }, + { theme: Theme.DARK_TRITANOPIA, classicScheme: 'dark_tritanopia', glassScheme: 'dark' }, + { theme: Theme.DARK_DIMMED, classicScheme: 'dark_dimmed', glassScheme: 'dark' }, + ])( + 'clamps and restores stored $theme without rewriting it', + async ({ theme, classicScheme, glassScheme }) => { + renderAppearance({ designLanguage: DesignLanguage.CLASSIC, theme }); + + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', classicScheme); + + await act(async () => { + useSettingsStore.setState({ designLanguage: DesignLanguage.GLASS }); + }); + + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', glassScheme); + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', glassScheme); + expect(useSettingsStore.getState().theme).toBe(theme); + expect(JSON.parse(localStorage.getItem(Constants.STORAGE.SETTINGS)!).state.theme).toBe( + theme, + ); + expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith(glassScheme); + + await act(async () => { + useSettingsStore.setState({ designLanguage: DesignLanguage.CLASSIC }); + }); + + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', classicScheme); + expect(useSettingsStore.getState().theme).toBe(theme); + expect(JSON.parse(localStorage.getItem(Constants.STORAGE.SETTINGS)!).state.theme).toBe( + theme, + ); + }, + ); + + it.each([ + { platform: 'macOS', macOS: true, linux: false, material: 'vibrancy' }, + { platform: 'Linux', macOS: false, linux: true, material: 'backdrop-filter' }, + { platform: 'Windows', macOS: false, linux: false, material: 'backdrop-filter' }, + ])( + 'preserves root attributes and material on $platform', + async ({ macOS, linux, material }) => { + vi.mocked(window.gitify.platform.isMacOS).mockReturnValue(macOS); + vi.mocked(window.gitify.platform.isLinux).mockReturnValue(linux); + + await act(async () => { + renderAppearance({ designLanguage: DesignLanguage.GLASS }); + }); + + expect(document.documentElement).toHaveAttribute('data-theme', 'glass'); + expect(document.documentElement).toHaveAttribute('data-glass-material', material); + expect(document.documentElement.classList.contains('gitify-linux')).toBe(linux); + expect(document.documentElement).toHaveClass('gitify-translucent'); + if (macOS) { + expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(true); + } else { + expect(window.gitify.setWindowVibrancy).not.toHaveBeenCalled(); + } + }, + ); + + it('updates status-color opt-in without applying it to Classic', async () => { + await act(async () => { + renderAppearance({ designLanguage: DesignLanguage.GLASS }); + }); + expect(document.documentElement).not.toHaveClass('gitify-colored-icons'); + + await act(async () => { + useSettingsStore.setState({ showStatusIconColors: true }); + }); + expect(document.documentElement).toHaveClass('gitify-colored-icons'); + + await act(async () => { + useSettingsStore.setState({ showStatusIconColors: false }); + }); + expect(document.documentElement).not.toHaveClass('gitify-colored-icons'); + + await act(async () => { + useSettingsStore.setState({ + designLanguage: DesignLanguage.CLASSIC, + showStatusIconColors: true, + }); + }); + expect(document.documentElement).toHaveAttribute('data-theme', 'classic'); + expect(document.documentElement).not.toHaveClass('gitify-colored-icons'); + }); + + it.each([ + { preference: 'reduced transparency', query: TRANSPARENCY_QUERY }, + { preference: 'increased contrast', query: CONTRAST_QUERY }, + ])('degrades and restores transparency for live OS $preference', async ({ query }) => { + await act(async () => { + renderAppearance({ designLanguage: DesignLanguage.GLASS, theme: Theme.LIGHT }); + }); + + expect(document.documentElement).toHaveClass('gitify-translucent', 'gitify-vibrant'); + + await act(async () => { + mediaQuery(query).setMatches(true); + }); + + expect(document.documentElement).not.toHaveClass('gitify-translucent'); + expect(document.documentElement).not.toHaveClass('gitify-vibrant'); + expect(window.gitify.setWindowVibrancy).toHaveBeenLastCalledWith(false); + expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'light'); + expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light'); + expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'light'); + + await act(async () => { + mediaQuery(query).setMatches(false); + }); + + expect(document.documentElement).toHaveClass('gitify-translucent', 'gitify-vibrant'); + expect(window.gitify.setWindowVibrancy).toHaveBeenLastCalledWith(true); + }); + + it('adds the vibrant class after native success and removes it before disabling vibrancy', async () => { + const application = Promise.withResolvers(); + vi.mocked(window.gitify.setWindowVibrancy).mockImplementation((enabled) => { + expect(document.documentElement).not.toHaveClass('gitify-vibrant'); + return enabled ? application.promise : Promise.resolve(undefined); + }); + + renderAppearance({ designLanguage: DesignLanguage.GLASS }); + + expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(true); + expect(document.documentElement).not.toHaveClass('gitify-vibrant'); + + await act(async () => { + application.resolve(undefined); + await application.promise; + }); + + expect(document.documentElement).toHaveClass('gitify-vibrant'); + + await act(async () => { + useSettingsStore.setState({ designLanguage: DesignLanguage.CLASSIC }); + }); + + expect(window.gitify.setWindowVibrancy).toHaveBeenLastCalledWith(false); + expect(document.documentElement).not.toHaveClass('gitify-vibrant'); + }); + + it('leaves the vibrant class absent when native application rejects', async () => { + const application = Promise.withResolvers(); + vi.mocked(window.gitify.setWindowVibrancy).mockReturnValueOnce(application.promise); + + renderAppearance({ designLanguage: DesignLanguage.GLASS }); + + expect(document.documentElement).not.toHaveClass('gitify-vibrant'); + + await act(async () => { + application.reject(new Error('Native material unavailable')); + }); + + expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(true); + expect(document.documentElement).not.toHaveClass('gitify-vibrant'); + }); + }); +}); diff --git a/src/renderer/hooks/useAppearance.test.ts b/src/renderer/hooks/useAppearance.test.ts index b221cc659..93cff665a 100644 --- a/src/renderer/hooks/useAppearance.test.ts +++ b/src/renderer/hooks/useAppearance.test.ts @@ -13,8 +13,8 @@ const primerTheme = vi.hoisted(() => ({ setNightScheme: vi.fn(), })); -vi.mock('@primer/react', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@primer/react/next', async (importOriginal) => ({ + ...(await importOriginal()), useTheme: () => primerTheme, })); diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts index d2ea7e782..4f4b7404c 100644 --- a/src/renderer/hooks/useAppearance.ts +++ b/src/renderer/hooks/useAppearance.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { useTheme } from '@primer/react'; +import { useTheme } from '@primer/react/next'; import type { NativeThemeSource } from '../../shared/events'; diff --git a/src/renderer/utils/ui/theme.ts b/src/renderer/utils/ui/theme.ts index 2f6a0f6b7..292b9602d 100644 --- a/src/renderer/utils/ui/theme.ts +++ b/src/renderer/utils/ui/theme.ts @@ -1,4 +1,4 @@ -import type { ThemeProviderProps } from '@primer/react'; +import type { ThemeProviderProps } from '@primer/react/next'; import { DesignLanguage, Theme } from '../../types'; From 9703d4d7eee303196d288a319a0a6bc94d0ab27a Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Thu, 8 Oct 2026 16:24:20 -0700 Subject: [PATCH 2/2] test: keep focused theme provider coverage in unit tests --- .../hooks/useAppearance.integration.test.tsx | 427 ------------------ src/renderer/hooks/useAppearance.test.ts | 121 ++++- 2 files changed, 119 insertions(+), 429 deletions(-) delete mode 100644 src/renderer/hooks/useAppearance.integration.test.tsx diff --git a/src/renderer/hooks/useAppearance.integration.test.tsx b/src/renderer/hooks/useAppearance.integration.test.tsx deleted file mode 100644 index 0a81da67f..000000000 --- a/src/renderer/hooks/useAppearance.integration.test.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import { act, cleanup, render, screen } from '@testing-library/react'; - -import { BaseStyles } from '@primer/react'; -import { ThemeProvider, useTheme } from '@primer/react/next'; - -import { Constants } from '../constants'; - -import { useSettingsStore } from '../stores'; - -import { DesignLanguage, type SettingsState, Theme } from '../types'; - -import { useAppearance } from './useAppearance'; - -vi.unmock('@primer/react/next'); - -const COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)'; -const CONTRAST_QUERY = '(prefers-contrast: more)'; -const TRANSPARENCY_QUERY = '(prefers-reduced-transparency: reduce)'; - -function createMediaQuery(media: string) { - const target = Object.assign(new EventTarget(), { media, matches: false, onchange: null }); - - return { - query: target as unknown as MediaQueryList, - setMatches(matches: boolean) { - target.matches = matches; - target.dispatchEvent(new Event('change')); - }, - }; -} - -function AppearanceProbe() { - useAppearance(); - const { colorMode, resolvedColorMode, colorScheme } = useTheme(); - - return ( -
- ); -} - -function renderAppearance(settings: Partial = {}) { - useSettingsStore.setState(settings); - - return render( - - - - - , - ); -} - -function getThemeWrapper() { - const wrapper = screen.getByTestId('appearance').closest('[data-color-mode]'); - expect(wrapper).toBeInTheDocument(); - return wrapper!; -} - -describe('renderer/hooks/useAppearance real Primer provider integration', () => { - let mediaQueries: Map>; - let rootAttributes: Record; - - function mediaQuery(query: string) { - let media = mediaQueries.get(query); - if (!media) { - media = createMediaQuery(query); - mediaQueries.set(query, media); - } - return media; - } - - beforeEach(() => { - mediaQueries = new Map(); - vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQuery(query).query); - rootAttributes = Object.fromEntries( - ['data-theme', 'data-glass-material', 'class'].map((name) => [ - name, - document.documentElement.getAttribute(name), - ]), - ); - }); - - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - for (const [name, value] of Object.entries(rootAttributes)) { - if (value === null) { - document.documentElement.removeAttribute(name); - } else { - document.documentElement.setAttribute(name, value); - } - } - }); - - it('keeps the theme attributes on a descendant wrapper around BaseStyles and content', () => { - renderAppearance({ theme: Theme.LIGHT }); - - const wrapper = getThemeWrapper(); - expect(wrapper).not.toBe(document.documentElement); - expect(wrapper).toHaveAttribute('data-component', 'ThemeProvider'); - expect(wrapper).toHaveAttribute('data-color-mode', 'light'); - expect(wrapper).toHaveAttribute('data-light-theme', 'light'); - expect(wrapper).toHaveAttribute('data-dark-theme', 'light'); - expect(wrapper.querySelector('[data-component="BaseStyles"]')).toContainElement( - screen.getByTestId('appearance'), - ); - expect(document.documentElement).toHaveAttribute('data-theme', 'classic'); - }); - - it.each([ - { theme: Theme.LIGHT, mode: 'day', scheme: 'light' }, - { theme: Theme.LIGHT_COLORBLIND, mode: 'day', scheme: 'light_colorblind' }, - { theme: Theme.LIGHT_TRITANOPIA, mode: 'day', scheme: 'light_tritanopia' }, - { theme: Theme.DARK, mode: 'night', scheme: 'dark' }, - { theme: Theme.DARK_COLORBLIND, mode: 'night', scheme: 'dark_colorblind' }, - { theme: Theme.DARK_TRITANOPIA, mode: 'night', scheme: 'dark_tritanopia' }, - { theme: Theme.DARK_DIMMED, mode: 'night', scheme: 'dark_dimmed' }, - ])('preserves the Classic $theme mapping', ({ theme, mode, scheme }) => { - renderAppearance({ designLanguage: DesignLanguage.CLASSIC, theme }); - - expect(screen.getByTestId('appearance')).toHaveAttribute('data-mode', mode); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-resolved-mode', mode); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', scheme); - expect(getThemeWrapper()).toHaveAttribute('data-color-mode', mode === 'day' ? 'light' : 'dark'); - expect(getThemeWrapper()).toHaveAttribute('data-light-theme', scheme); - expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', scheme); - }); - - it('updates fixed color modes and native sources when settings change', async () => { - renderAppearance({ theme: Theme.LIGHT }); - - expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'light'); - expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('light'); - - await act(async () => { - useSettingsStore.setState({ theme: Theme.DARK }); - }); - - expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'dark'); - expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'dark'); - expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark'); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-mode', 'night'); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'dark'); - expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('dark'); - - await act(async () => { - useSettingsStore.setState({ theme: Theme.SYSTEM }); - }); - - expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'auto'); - expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light'); - expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark'); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-mode', 'auto'); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-resolved-mode', 'day'); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'light'); - expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('system'); - }); - - it('responds to OS color-scheme changes without replacing System with a fixed mode', async () => { - renderAppearance({ theme: Theme.SYSTEM }); - - expect(screen.getByTestId('appearance')).toHaveAttribute('data-resolved-mode', 'day'); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'light'); - - await act(async () => { - mediaQuery(COLOR_SCHEME_QUERY).setMatches(true); - }); - - expect(screen.getByTestId('appearance')).toHaveAttribute('data-mode', 'auto'); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-resolved-mode', 'night'); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'dark'); - expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'auto'); - expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light'); - expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark'); - expect(useSettingsStore.getState().theme).toBe(Theme.SYSTEM); - expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('system'); - expect(window.gitify.setNativeTheme).not.toHaveBeenCalledWith('dark'); - }); - - it.each([ - { theme: Theme.LIGHT, scheme: 'light' }, - { theme: Theme.DARK, scheme: 'dark' }, - ])('updates Classic $theme for the in-app contrast setting', async ({ theme, scheme }) => { - renderAppearance({ designLanguage: DesignLanguage.CLASSIC, theme }); - - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', scheme); - - await act(async () => { - useSettingsStore.setState({ increaseContrast: true }); - }); - - expect(screen.getByTestId('appearance')).toHaveAttribute( - 'data-scheme', - `${scheme}_high_contrast`, - ); - expect(getThemeWrapper()).toHaveAttribute('data-light-theme', `${scheme}_high_contrast`); - expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', `${scheme}_high_contrast`); - - await act(async () => { - useSettingsStore.setState({ increaseContrast: false }); - }); - - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', scheme); - }); - - it('updates and restores the Classic System scheme pair for live OS contrast changes', async () => { - renderAppearance({ designLanguage: DesignLanguage.CLASSIC, theme: Theme.SYSTEM }); - - await act(async () => { - mediaQuery(CONTRAST_QUERY).setMatches(true); - }); - - expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'auto'); - expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light_high_contrast'); - expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark_high_contrast'); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'light_high_contrast'); - - await act(async () => { - mediaQuery(COLOR_SCHEME_QUERY).setMatches(true); - }); - - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'dark_high_contrast'); - - await act(async () => { - mediaQuery(CONTRAST_QUERY).setMatches(false); - }); - - expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'auto'); - expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light'); - expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark'); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'dark'); - expect(useSettingsStore.getState().theme).toBe(Theme.SYSTEM); - }); - - describe('Glass', () => { - it.each([ - { theme: Theme.LIGHT, mode: 'day', scheme: 'light', wrapperMode: 'light' }, - { theme: Theme.DARK, mode: 'night', scheme: 'dark', wrapperMode: 'dark' }, - { theme: Theme.SYSTEM, mode: 'auto', scheme: 'light', wrapperMode: 'auto' }, - ])( - 'preserves $theme without Classic high-contrast schemes', - async ({ theme, mode, scheme, wrapperMode }) => { - await act(async () => { - renderAppearance({ designLanguage: DesignLanguage.GLASS, theme, increaseContrast: true }); - }); - - expect(screen.getByTestId('appearance')).toHaveAttribute('data-mode', mode); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', scheme); - expect(getThemeWrapper()).toHaveAttribute('data-color-mode', wrapperMode); - expect(getThemeWrapper()).toHaveAttribute( - 'data-light-theme', - theme === Theme.SYSTEM ? 'light' : scheme, - ); - expect(getThemeWrapper()).toHaveAttribute( - 'data-dark-theme', - theme === Theme.SYSTEM ? 'dark' : scheme, - ); - }, - ); - - it.each([ - { theme: Theme.LIGHT_COLORBLIND, classicScheme: 'light_colorblind', glassScheme: 'light' }, - { theme: Theme.LIGHT_TRITANOPIA, classicScheme: 'light_tritanopia', glassScheme: 'light' }, - { theme: Theme.DARK_COLORBLIND, classicScheme: 'dark_colorblind', glassScheme: 'dark' }, - { theme: Theme.DARK_TRITANOPIA, classicScheme: 'dark_tritanopia', glassScheme: 'dark' }, - { theme: Theme.DARK_DIMMED, classicScheme: 'dark_dimmed', glassScheme: 'dark' }, - ])( - 'clamps and restores stored $theme without rewriting it', - async ({ theme, classicScheme, glassScheme }) => { - renderAppearance({ designLanguage: DesignLanguage.CLASSIC, theme }); - - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', classicScheme); - - await act(async () => { - useSettingsStore.setState({ designLanguage: DesignLanguage.GLASS }); - }); - - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', glassScheme); - expect(getThemeWrapper()).toHaveAttribute('data-color-mode', glassScheme); - expect(useSettingsStore.getState().theme).toBe(theme); - expect(JSON.parse(localStorage.getItem(Constants.STORAGE.SETTINGS)!).state.theme).toBe( - theme, - ); - expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith(glassScheme); - - await act(async () => { - useSettingsStore.setState({ designLanguage: DesignLanguage.CLASSIC }); - }); - - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', classicScheme); - expect(useSettingsStore.getState().theme).toBe(theme); - expect(JSON.parse(localStorage.getItem(Constants.STORAGE.SETTINGS)!).state.theme).toBe( - theme, - ); - }, - ); - - it.each([ - { platform: 'macOS', macOS: true, linux: false, material: 'vibrancy' }, - { platform: 'Linux', macOS: false, linux: true, material: 'backdrop-filter' }, - { platform: 'Windows', macOS: false, linux: false, material: 'backdrop-filter' }, - ])( - 'preserves root attributes and material on $platform', - async ({ macOS, linux, material }) => { - vi.mocked(window.gitify.platform.isMacOS).mockReturnValue(macOS); - vi.mocked(window.gitify.platform.isLinux).mockReturnValue(linux); - - await act(async () => { - renderAppearance({ designLanguage: DesignLanguage.GLASS }); - }); - - expect(document.documentElement).toHaveAttribute('data-theme', 'glass'); - expect(document.documentElement).toHaveAttribute('data-glass-material', material); - expect(document.documentElement.classList.contains('gitify-linux')).toBe(linux); - expect(document.documentElement).toHaveClass('gitify-translucent'); - if (macOS) { - expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(true); - } else { - expect(window.gitify.setWindowVibrancy).not.toHaveBeenCalled(); - } - }, - ); - - it('updates status-color opt-in without applying it to Classic', async () => { - await act(async () => { - renderAppearance({ designLanguage: DesignLanguage.GLASS }); - }); - expect(document.documentElement).not.toHaveClass('gitify-colored-icons'); - - await act(async () => { - useSettingsStore.setState({ showStatusIconColors: true }); - }); - expect(document.documentElement).toHaveClass('gitify-colored-icons'); - - await act(async () => { - useSettingsStore.setState({ showStatusIconColors: false }); - }); - expect(document.documentElement).not.toHaveClass('gitify-colored-icons'); - - await act(async () => { - useSettingsStore.setState({ - designLanguage: DesignLanguage.CLASSIC, - showStatusIconColors: true, - }); - }); - expect(document.documentElement).toHaveAttribute('data-theme', 'classic'); - expect(document.documentElement).not.toHaveClass('gitify-colored-icons'); - }); - - it.each([ - { preference: 'reduced transparency', query: TRANSPARENCY_QUERY }, - { preference: 'increased contrast', query: CONTRAST_QUERY }, - ])('degrades and restores transparency for live OS $preference', async ({ query }) => { - await act(async () => { - renderAppearance({ designLanguage: DesignLanguage.GLASS, theme: Theme.LIGHT }); - }); - - expect(document.documentElement).toHaveClass('gitify-translucent', 'gitify-vibrant'); - - await act(async () => { - mediaQuery(query).setMatches(true); - }); - - expect(document.documentElement).not.toHaveClass('gitify-translucent'); - expect(document.documentElement).not.toHaveClass('gitify-vibrant'); - expect(window.gitify.setWindowVibrancy).toHaveBeenLastCalledWith(false); - expect(screen.getByTestId('appearance')).toHaveAttribute('data-scheme', 'light'); - expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light'); - expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'light'); - - await act(async () => { - mediaQuery(query).setMatches(false); - }); - - expect(document.documentElement).toHaveClass('gitify-translucent', 'gitify-vibrant'); - expect(window.gitify.setWindowVibrancy).toHaveBeenLastCalledWith(true); - }); - - it('adds the vibrant class after native success and removes it before disabling vibrancy', async () => { - const application = Promise.withResolvers(); - vi.mocked(window.gitify.setWindowVibrancy).mockImplementation((enabled) => { - expect(document.documentElement).not.toHaveClass('gitify-vibrant'); - return enabled ? application.promise : Promise.resolve(undefined); - }); - - renderAppearance({ designLanguage: DesignLanguage.GLASS }); - - expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(true); - expect(document.documentElement).not.toHaveClass('gitify-vibrant'); - - await act(async () => { - application.resolve(undefined); - await application.promise; - }); - - expect(document.documentElement).toHaveClass('gitify-vibrant'); - - await act(async () => { - useSettingsStore.setState({ designLanguage: DesignLanguage.CLASSIC }); - }); - - expect(window.gitify.setWindowVibrancy).toHaveBeenLastCalledWith(false); - expect(document.documentElement).not.toHaveClass('gitify-vibrant'); - }); - - it('leaves the vibrant class absent when native application rejects', async () => { - const application = Promise.withResolvers(); - vi.mocked(window.gitify.setWindowVibrancy).mockReturnValueOnce(application.promise); - - renderAppearance({ designLanguage: DesignLanguage.GLASS }); - - expect(document.documentElement).not.toHaveClass('gitify-vibrant'); - - await act(async () => { - application.reject(new Error('Native material unavailable')); - }); - - expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(true); - expect(document.documentElement).not.toHaveClass('gitify-vibrant'); - }); - }); -}); diff --git a/src/renderer/hooks/useAppearance.test.ts b/src/renderer/hooks/useAppearance.test.ts index 93cff665a..60d7b14de 100644 --- a/src/renderer/hooks/useAppearance.test.ts +++ b/src/renderer/hooks/useAppearance.test.ts @@ -1,4 +1,8 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, cleanup, renderHook, screen, waitFor } from '@testing-library/react'; +import { createElement, type PropsWithChildren } from 'react'; + +import { BaseStyles } from '@primer/react'; +import { ThemeProvider, useTheme } from '@primer/react/next'; import { useSettingsStore } from '../stores'; @@ -15,7 +19,7 @@ const primerTheme = vi.hoisted(() => ({ vi.mock('@primer/react/next', async (importOriginal) => ({ ...(await importOriginal()), - useTheme: () => primerTheme, + useTheme: vi.fn(() => primerTheme), })); function mockPrefersContrast(matches: boolean) { @@ -30,6 +34,10 @@ function mockPrefersContrast(matches: boolean) { } describe('renderer/hooks/useAppearance.ts', () => { + beforeEach(() => { + vi.mocked(useTheme).mockImplementation(() => primerTheme); + }); + afterEach(() => { document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-glass-material'); @@ -211,4 +219,113 @@ describe('renderer/hooks/useAppearance.ts', () => { expect(document.documentElement.classList.contains('gitify-colored-icons')).toBe(false); }); + + describe('with ThemeProvider', () => { + let darkMedia: EventTarget & { matches: boolean }; + + beforeEach(async () => { + const actual = + await vi.importActual('@primer/react/next'); + vi.mocked(useTheme).mockImplementation(actual.useTheme); + + darkMedia = Object.assign(new EventTarget(), { matches: false }); + const otherMedia = Object.assign(new EventTarget(), { matches: false }); + vi.spyOn(window, 'matchMedia').mockImplementation( + (query) => + (query === '(prefers-color-scheme: dark)' ? darkMedia : otherMedia) as MediaQueryList, + ); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.mocked(useTheme).mockImplementation(() => primerTheme); + }); + + function renderWithTheme(theme: Theme) { + useSettingsStore.setState({ theme }); + + return renderHook( + () => { + useAppearance(); + return useTheme(); + }, + { + wrapper: ({ children }: PropsWithChildren) => + createElement( + ThemeProvider, + null, + createElement( + BaseStyles, + null, + createElement('div', { 'data-testid': 'theme-content' }, children), + ), + ), + }, + ); + } + + function getThemeWrapper() { + const wrapper = screen.getByTestId('theme-content').closest('[data-color-mode]'); + expect(wrapper).toBeInTheDocument(); + return wrapper!; + } + + it('applies appearance to the real provider and preserves its descendant wrapper', () => { + const { result } = renderWithTheme(Theme.LIGHT); + + expect(result.current.colorMode).toBe('day'); + expect(result.current.colorScheme).toBe('light'); + const wrapper = getThemeWrapper(); + expect(wrapper).not.toBe(document.documentElement); + expect(wrapper).toHaveAttribute('data-color-mode', 'light'); + expect(wrapper).toHaveAttribute('data-light-theme', 'light'); + expect(wrapper).toHaveAttribute('data-dark-theme', 'light'); + expect(wrapper.querySelector('[data-component="BaseStyles"]')).toContainElement( + screen.getByTestId('theme-content'), + ); + }); + + it('updates the real provider when settings change from light to dark', async () => { + const { result } = renderWithTheme(Theme.LIGHT); + + await act(async () => { + useSettingsStore.setState({ theme: Theme.DARK }); + }); + + expect(result.current.colorMode).toBe('night'); + expect(result.current.colorScheme).toBe('dark'); + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'dark'); + expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('dark'); + }); + + it('follows OS changes in System mode without changing the saved preference', async () => { + const { result } = renderWithTheme(Theme.SYSTEM); + expect(result.current.resolvedColorMode).toBe('day'); + + await act(async () => { + darkMedia.matches = true; + darkMedia.dispatchEvent(new Event('change')); + }); + + expect(result.current.colorMode).toBe('auto'); + expect(result.current.resolvedColorMode).toBe('night'); + expect(result.current.colorScheme).toBe('dark'); + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'auto'); + expect(getThemeWrapper()).toHaveAttribute('data-light-theme', 'light'); + expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark'); + expect(useSettingsStore.getState().theme).toBe(Theme.SYSTEM); + expect(window.gitify.setNativeTheme).toHaveBeenLastCalledWith('system'); + expect(window.gitify.setNativeTheme).not.toHaveBeenCalledWith('dark'); + }); + + it('applies an accessibility scheme through the real provider', () => { + const { result } = renderWithTheme(Theme.DARK_COLORBLIND); + + expect(result.current.colorMode).toBe('night'); + expect(result.current.colorScheme).toBe('dark_colorblind'); + expect(getThemeWrapper()).toHaveAttribute('data-color-mode', 'dark'); + expect(getThemeWrapper()).toHaveAttribute('data-dark-theme', 'dark_colorblind'); + }); + }); });