From aced5c0384205fcf1288ac4753ebeffb123f0e07 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 02:12:48 +0200 Subject: [PATCH 01/27] feat(theme): add useAppearance hook with design-language root attributes --- src/renderer/components/GlobalEffects.tsx | 40 ++----------- src/renderer/hooks/useAppearance.test.ts | 32 ++++++++++ src/renderer/hooks/useAppearance.ts | 72 +++++++++++++++++++++++ 3 files changed, 108 insertions(+), 36 deletions(-) create mode 100644 src/renderer/hooks/useAppearance.test.ts create mode 100644 src/renderer/hooks/useAppearance.ts diff --git a/src/renderer/components/GlobalEffects.tsx b/src/renderer/components/GlobalEffects.tsx index 5b2b77945..ce3685af4 100644 --- a/src/renderer/components/GlobalEffects.tsx +++ b/src/renderer/components/GlobalEffects.tsx @@ -1,10 +1,9 @@ import { type FC, useEffect } from 'react'; -import { useTheme } from '@primer/react'; - import { useQueryClient } from '@tanstack/react-query'; import { useAccounts } from '../hooks/useAccounts'; +import { useAppearance } from '../hooks/useAppearance'; import { useNotifications } from '../hooks/useNotifications'; import { useOnlineStatus } from '../hooks/useOnlineStatus'; import { @@ -14,14 +13,6 @@ import { import { useAccountsStore, useFiltersStore, useSettingsStore } from '../stores'; import { setTrayIconColorAndTitle } from '../utils/system/tray'; -import { - DEFAULT_DAY_COLOR_SCHEME, - DEFAULT_DAY_HIGH_CONTRAST_COLOR_SCHEME, - DEFAULT_NIGHT_COLOR_SCHEME, - DEFAULT_NIGHT_HIGH_CONTRAST_COLOR_SCHEME, - mapThemeModeToColorMode, - mapThemeModeToColorScheme, -} from '../utils/ui/theme'; /** * Hosts all app-level side effects. Mount exactly once, inside the Primer @@ -42,14 +33,11 @@ export const GlobalEffects: FC = () => { const useUnreadActiveIcon = useSettingsStore((s) => s.useUnreadActiveIcon); const useAlternateIdleIcon = useSettingsStore((s) => s.useAlternateIdleIcon); - // Subscribe to theme related settings for useEffect dependencies - const theme = useSettingsStore((s) => s.theme); - const increaseContrast = useSettingsStore((s) => s.increaseContrast); - - const { setColorMode, setDayScheme, setNightScheme } = useTheme(); - const isOnline = useOnlineStatus(); + // Appearance side effects: color mode/scheme + design-language/material attributes + useAppearance(); + // Notification fetching side effects (sound / native alerts, inactivity refetch) const { status, notificationCount } = useNotifications({ withSideEffects: true }); @@ -59,26 +47,6 @@ export const GlobalEffects: FC = () => { // Global keyboard shortcut registration, reverting on failure useShortcutRegistration(); - // Theme - useEffect(() => { - const colorMode = mapThemeModeToColorMode(theme); - const colorScheme = mapThemeModeToColorScheme(theme, increaseContrast); - - setColorMode(colorMode); - - // When colorScheme is null (System theme), use appropriate fallbacks - // based on whether high contrast is enabled - const dayFallback = increaseContrast - ? DEFAULT_DAY_HIGH_CONTRAST_COLOR_SCHEME - : DEFAULT_DAY_COLOR_SCHEME; - const nightFallback = increaseContrast - ? DEFAULT_NIGHT_HIGH_CONTRAST_COLOR_SCHEME - : DEFAULT_NIGHT_COLOR_SCHEME; - - setDayScheme(colorScheme ?? dayFallback); - setNightScheme(colorScheme ?? nightFallback); - }, [theme, increaseContrast, setColorMode, setDayScheme, setNightScheme]); - // oxlint-disable-next-line react/exhaustive-deps -- We want to update the tray on setting or notification changes useEffect(() => { const trayCount = status === 'error' ? -1 : notificationCount; diff --git a/src/renderer/hooks/useAppearance.test.ts b/src/renderer/hooks/useAppearance.test.ts new file mode 100644 index 000000000..2d14471aa --- /dev/null +++ b/src/renderer/hooks/useAppearance.test.ts @@ -0,0 +1,32 @@ +import { renderHook } from '@testing-library/react'; + +import { useAppearance } from './useAppearance'; + +describe('renderer/hooks/useAppearance.ts', () => { + afterEach(() => { + document.documentElement.removeAttribute('data-theme'); + document.documentElement.removeAttribute('data-glass-material'); + }); + + it('marks the root with the Classic design language', () => { + renderHook(() => useAppearance()); + + expect(document.documentElement.getAttribute('data-theme')).toBe('classic'); + }); + + it('derives the glass material from the platform (vibrancy on macOS)', () => { + vi.mocked(window.gitify.platform.isMacOS).mockReturnValue(true); + + renderHook(() => useAppearance()); + + expect(document.documentElement.getAttribute('data-glass-material')).toBe('vibrancy'); + }); + + it('uses the backdrop-filter material off macOS', () => { + vi.mocked(window.gitify.platform.isMacOS).mockReturnValue(false); + + renderHook(() => useAppearance()); + + expect(document.documentElement.getAttribute('data-glass-material')).toBe('backdrop-filter'); + }); +}); diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts new file mode 100644 index 000000000..8892e42ec --- /dev/null +++ b/src/renderer/hooks/useAppearance.ts @@ -0,0 +1,72 @@ +import { useEffect } from 'react'; + +import { useTheme } from '@primer/react'; + +import { useSettingsStore } from '../stores'; + +import { + DEFAULT_DAY_COLOR_SCHEME, + DEFAULT_DAY_HIGH_CONTRAST_COLOR_SCHEME, + DEFAULT_NIGHT_COLOR_SCHEME, + DEFAULT_NIGHT_HIGH_CONTRAST_COLOR_SCHEME, + mapThemeModeToColorMode, + mapThemeModeToColorScheme, +} from '../utils/ui/theme'; + +/** + * Single seam for all appearance side effects. Must be called from within the + * Primer `ThemeProvider` (it consumes `useTheme`). + * + * Owns: + * - Color mode + day/night scheme application (Primer), preserving the + * "auto + high contrast" fallback composition. + * - The root design-language (`data-theme`) and platform material + * (`data-glass-material`) attributes that the CSS token layer keys off, + * alongside the `data-color-mode` attribute Primer's `ThemeProvider` owns. + * + * `data-theme` is currently always `classic` — a settings-driven design + * language is introduced later. `data-glass-material` is derived from the OS + * so `App.css` can branch base-transparency / blur behaviour declaratively. + * Every rule keyed off `data-glass-material` must also be scoped under + * `[data-theme="glass"]`, so these attributes are inert under Classic. + */ +export function useAppearance(): void { + const theme = useSettingsStore((s) => s.theme); + const increaseContrast = useSettingsStore((s) => s.increaseContrast); + + const { setColorMode, setDayScheme, setNightScheme } = useTheme(); + + useEffect(() => { + const colorMode = mapThemeModeToColorMode(theme); + const colorScheme = mapThemeModeToColorScheme(theme, increaseContrast); + + setColorMode(colorMode); + + // When colorScheme is null (System theme), use appropriate fallbacks + // based on whether high contrast is enabled + const dayFallback = increaseContrast + ? DEFAULT_DAY_HIGH_CONTRAST_COLOR_SCHEME + : DEFAULT_DAY_COLOR_SCHEME; + const nightFallback = increaseContrast + ? DEFAULT_NIGHT_HIGH_CONTRAST_COLOR_SCHEME + : DEFAULT_NIGHT_COLOR_SCHEME; + + setDayScheme(colorScheme ?? dayFallback); + setNightScheme(colorScheme ?? nightFallback); + }, [theme, increaseContrast, setColorMode, setDayScheme, setNightScheme]); + + useEffect(() => { + const root = document.documentElement; + + // The only design language today is Classic; a settings-driven language + // is wired in later. + root.setAttribute('data-theme', 'classic'); + + // Material fidelity is platform-decided: real vibrancy on macOS, CSS + // backdrop-filter elsewhere. Inert until a Glass language keys off it. + root.setAttribute( + 'data-glass-material', + window.gitify.platform.isMacOS() ? 'vibrancy' : 'backdrop-filter', + ); + }, []); +} From 60b76333b7f25ab7115e8bcb86c1a0c74dc38030 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 10:10:13 +0200 Subject: [PATCH 02/27] feat(theme): add design-language chrome token layer via @theme inline --- src/renderer/App.css | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/renderer/App.css b/src/renderer/App.css index 6e486a595..929efc0ef 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -7,6 +7,30 @@ /** Tailwind CSS Configuration */ @config "../../tailwind.config.mts"; +/** + * Design-language chrome tokens. + * + * A design language re-skins chrome (radius / shadow / blur) by re-pointing + * these `--gitify-*` vars under `[data-theme="..."]`, mirroring how colors are + * re-pointed under `[data-color-mode]` in tailwind.config.mts. Classic aliases + * Tailwind's own values, so it renders identically. `@theme inline` exposes the + * tokens as utilities that resolve to the live var (so they re-resolve under the + * active `[data-theme]`); plain `@theme` would freeze them at `:root`. + */ +:root { + --gitify-radius-sm: var(--radius-sm); + --gitify-radius-md: var(--radius-md); + --gitify-radius-full: calc(infinity * 1px); + --gitify-shadow-sm: var(--shadow-sm); +} + +@theme inline { + --radius-gitify-sm: var(--gitify-radius-sm); + --radius-gitify-md: var(--gitify-radius-md); + --radius-gitify-full: var(--gitify-radius-full); + --shadow-gitify-sm: var(--gitify-shadow-sm); +} + html, body, #root { From 15f324564070bdb82de5181135b2187a02da796b Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 10:15:24 +0200 Subject: [PATCH 03/27] refactor(theme): route renderer through app-owned ui/ layer with import boundary --- src/renderer/App.tsx | 5 +- src/renderer/components/Oops.tsx | 3 +- src/renderer/components/Sidebar.tsx | 2 +- .../components/avatars/AvatarWithFallback.tsx | 3 +- src/renderer/components/fields/Checkbox.tsx | 3 +- src/renderer/components/fields/RadioGroup.tsx | 3 +- src/renderer/components/fields/Tooltip.tsx | 2 +- .../components/filters/AccountFilter.tsx | 2 +- .../components/filters/FilterSection.tsx | 2 +- .../components/filters/ReasonFilter.tsx | 2 +- .../RequiresDetailedNotificationsWarning.tsx | 2 +- .../filters/ReviewRequestTypeFilter.tsx | 2 +- .../components/filters/SearchFilter.tsx | 2 +- .../filters/SearchFilterSuggestions.tsx | 3 +- .../components/filters/StateFilter.tsx | 2 +- .../components/filters/SubjectTypeFilter.tsx | 2 +- .../components/filters/TokenSearchInput.tsx | 3 +- .../components/filters/UserTypeFilter.tsx | 2 +- src/renderer/components/layout/Centered.tsx | 2 +- .../components/layout/EmojiSplash.tsx | 3 +- .../LoginWithPersonalAccessTokenForm.tsx | 2 +- .../components/metrics/LabelsPill.tsx | 2 +- .../components/metrics/MetricPill.tsx | 3 +- .../components/metrics/ReactionsPill.tsx | 2 +- .../notifications/AccountNotifications.tsx | 2 +- .../notifications/NotificationFooter.tsx | 3 +- .../notifications/NotificationHeader.tsx | 3 +- .../notifications/NotificationRow.tsx | 2 +- .../notifications/NotificationTitle.tsx | 3 +- .../notifications/RepositoryNotifications.tsx | 2 +- .../components/primitives/CustomCounter.tsx | 3 +- src/renderer/components/primitives/Footer.tsx | 2 +- src/renderer/components/primitives/Header.tsx | 2 +- .../components/primitives/HoverButton.tsx | 3 +- .../components/primitives/HoverGroup.tsx | 3 +- src/renderer/components/primitives/Title.tsx | 3 +- .../settings/AppearanceSettings.tsx | 2 +- .../settings/NotificationSettings.tsx | 2 +- .../components/settings/SettingsFooter.tsx | 2 +- .../components/settings/SettingsReset.tsx | 3 +- .../components/settings/SystemSettings.tsx | 2 +- .../components/settings/TraySettings.tsx | 3 +- src/renderer/components/ui/index.test.ts | 32 +++++++++++++ src/renderer/components/ui/index.ts | 47 +++++++++++++++++++ src/renderer/hooks/useAppearance.ts | 4 +- src/renderer/routes/AccountScopes.tsx | 2 +- src/renderer/routes/Accounts.tsx | 2 +- src/renderer/routes/Filters.tsx | 2 +- src/renderer/routes/Login.tsx | 3 +- src/renderer/routes/Settings.tsx | 2 +- .../routes/github/LoginWithDeviceFlow.tsx | 2 +- .../routes/github/LoginWithOAuthApp.tsx | 2 +- .../github/LoginWithPersonalAccessToken.tsx | 3 +- 53 files changed, 136 insertions(+), 69 deletions(-) create mode 100644 src/renderer/components/ui/index.test.ts create mode 100644 src/renderer/components/ui/index.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ffc100092..88bff146d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,8 +1,6 @@ import { useEffect } from 'react'; import { Navigate, Route, HashRouter as Router, Routes, useLocation } from 'react-router-dom'; -import { BaseStyles, ThemeProvider } from '@primer/react'; - import { QueryClientProvider } from '@tanstack/react-query'; import { AccountsRoute } from './routes/Accounts'; @@ -21,9 +19,10 @@ import { initializeStoreSubscriptions } from './stores/subscriptions'; import { GlobalEffects } from './components/GlobalEffects'; import { GlobalShortcuts } from './components/GlobalShortcuts'; +import { AppLayout } from './components/layout/AppLayout'; import './App.css'; -import { AppLayout } from './components/layout/AppLayout'; +import { BaseStyles, ThemeProvider } from './components/ui'; import { queryClient } from './utils/api/queryClient'; import { migrateLegacyStoreToZustand } from './utils/core/storage'; diff --git a/src/renderer/components/Oops.tsx b/src/renderer/components/Oops.tsx index 89a379ef2..6f00d6614 100644 --- a/src/renderer/components/Oops.tsx +++ b/src/renderer/components/Oops.tsx @@ -1,14 +1,13 @@ import { type FC, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Button } from '@primer/react'; - import { EmojiSplash } from './layout/EmojiSplash'; import type { GitifyError } from '../types'; import { Errors } from '../utils/core/errors'; import { randomElement } from '../utils/core/random'; +import { Button } from './ui'; interface OopsProps { error: GitifyError; diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 176d7042d..0e2d4c2cd 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -11,7 +11,6 @@ import { SyncIcon, XCircleIcon, } from '@primer/octicons-react'; -import { IconButton, Stack } from '@primer/react'; import { APPLICATION } from '../../shared/constants'; @@ -20,6 +19,7 @@ import { useShortcutActions } from '../hooks/useShortcutActions'; import { useAccountsStore, useFiltersStore, useSettingsStore } from '../stores'; import { LogoIcon } from './icons/LogoIcon'; +import { IconButton, Stack } from './ui'; export const Sidebar: FC = () => { const { status, notificationCount, hasUnreadNotifications } = useNotifications(); diff --git a/src/renderer/components/avatars/AvatarWithFallback.tsx b/src/renderer/components/avatars/AvatarWithFallback.tsx index 580543cf6..a7ad07f7b 100644 --- a/src/renderer/components/avatars/AvatarWithFallback.tsx +++ b/src/renderer/components/avatars/AvatarWithFallback.tsx @@ -1,12 +1,11 @@ import type React from 'react'; import { useState } from 'react'; -import { Avatar, Stack, Truncate } from '@primer/react'; - import { type Link, Size, type UserType } from '../../types'; import { isNonHumanUser } from '../../utils/notifications/filters/userType'; import { getDefaultUserIcon } from '../../utils/ui/icons'; +import { Avatar, Stack, Truncate } from '../ui'; export interface AvatarWithFallbackProps { src?: Link; diff --git a/src/renderer/components/fields/Checkbox.tsx b/src/renderer/components/fields/Checkbox.tsx index 8ed7c30c2..f8e9cf0c1 100644 --- a/src/renderer/components/fields/Checkbox.tsx +++ b/src/renderer/components/fields/Checkbox.tsx @@ -1,10 +1,9 @@ import type { FC, ReactNode } from 'react'; -import { Stack } from '@primer/react'; - import { CustomCounter } from '../primitives/CustomCounter'; import { cn } from '../../utils/ui/cn'; +import { Stack } from '../ui'; import { Tooltip } from './Tooltip'; export interface CheckboxProps { diff --git a/src/renderer/components/fields/RadioGroup.tsx b/src/renderer/components/fields/RadioGroup.tsx index aa6635bfb..d64fc851b 100644 --- a/src/renderer/components/fields/RadioGroup.tsx +++ b/src/renderer/components/fields/RadioGroup.tsx @@ -1,9 +1,8 @@ import type { ChangeEvent, FC, ReactNode } from 'react'; -import { Stack } from '@primer/react'; - import type { RadioGroupItem } from '../../types'; +import { Stack } from '../ui'; import { FieldLabel } from './FieldLabel'; import { Tooltip } from './Tooltip'; diff --git a/src/renderer/components/fields/Tooltip.tsx b/src/renderer/components/fields/Tooltip.tsx index f8b103a86..4224e2792 100644 --- a/src/renderer/components/fields/Tooltip.tsx +++ b/src/renderer/components/fields/Tooltip.tsx @@ -1,9 +1,9 @@ import { type FC, type ReactNode, useEffect, useRef, useState } from 'react'; import { QuestionIcon } from '@primer/octicons-react'; -import { AnchoredOverlay } from '@primer/react'; import { cn } from '../../utils/ui/cn'; +import { AnchoredOverlay } from '../ui'; export interface TooltipProps { name: string; diff --git a/src/renderer/components/filters/AccountFilter.tsx b/src/renderer/components/filters/AccountFilter.tsx index ff452e7d7..8273a9c74 100644 --- a/src/renderer/components/filters/AccountFilter.tsx +++ b/src/renderer/components/filters/AccountFilter.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react'; import { PersonIcon } from '@primer/octicons-react'; -import { Stack, Text } from '@primer/react'; import { useNotifications } from '../../hooks/useNotifications'; import { useAccountsStore, useFiltersStore } from '../../stores'; @@ -10,6 +9,7 @@ import { Checkbox } from '../fields/Checkbox'; import { Title } from '../primitives/Title'; import { getAccountUUID } from '../../utils/auth/utils'; +import { Stack, Text } from '../ui'; export const AccountFilter: FC = () => { const { notifications } = useNotifications(); diff --git a/src/renderer/components/filters/FilterSection.tsx b/src/renderer/components/filters/FilterSection.tsx index 9addf2d3c..64d991301 100644 --- a/src/renderer/components/filters/FilterSection.tsx +++ b/src/renderer/components/filters/FilterSection.tsx @@ -1,7 +1,6 @@ import { memo, type ReactNode, useMemo } from 'react'; import type { Icon } from '@primer/octicons-react'; -import { Stack, Text } from '@primer/react'; import { useNotifications } from '../../hooks/useNotifications'; import { type FiltersState, useFiltersStore, useSettingsStore } from '../../stores'; @@ -10,6 +9,7 @@ import { Checkbox } from '../fields/Checkbox'; import { Title } from '../primitives/Title'; import type { Filter } from '../../utils/notifications/filters'; +import { Stack, Text } from '../ui'; import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning'; export interface FilterSectionProps { diff --git a/src/renderer/components/filters/ReasonFilter.tsx b/src/renderer/components/filters/ReasonFilter.tsx index d3e3b5a30..c3edda1c1 100644 --- a/src/renderer/components/filters/ReasonFilter.tsx +++ b/src/renderer/components/filters/ReasonFilter.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react'; import { NoteIcon } from '@primer/octicons-react'; -import { Text } from '@primer/react'; import { reasonFilter } from '../../utils/notifications/filters'; +import { Text } from '../ui'; import { FilterSection } from './FilterSection'; export const ReasonFilter: FC = () => { diff --git a/src/renderer/components/filters/RequiresDetailedNotificationsWarning.tsx b/src/renderer/components/filters/RequiresDetailedNotificationsWarning.tsx index df4c8efd3..e17f57209 100644 --- a/src/renderer/components/filters/RequiresDetailedNotificationsWarning.tsx +++ b/src/renderer/components/filters/RequiresDetailedNotificationsWarning.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; -import { Text } from '@primer/react'; +import { Text } from '../ui'; export const RequiresDetailedNotificationWarning: FC = () => ( diff --git a/src/renderer/components/filters/ReviewRequestTypeFilter.tsx b/src/renderer/components/filters/ReviewRequestTypeFilter.tsx index 0cae52ea3..0c6ffd5c0 100644 --- a/src/renderer/components/filters/ReviewRequestTypeFilter.tsx +++ b/src/renderer/components/filters/ReviewRequestTypeFilter.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react'; import { GitPullRequestIcon } from '@primer/octicons-react'; -import { Text } from '@primer/react'; import { reviewRequestTypeFilter } from '../../utils/notifications/filters'; +import { Text } from '../ui'; import { FilterSection } from './FilterSection'; export const ReviewRequestTypeFilter: FC = () => { diff --git a/src/renderer/components/filters/SearchFilter.tsx b/src/renderer/components/filters/SearchFilter.tsx index de156d253..bd3561f71 100644 --- a/src/renderer/components/filters/SearchFilter.tsx +++ b/src/renderer/components/filters/SearchFilter.tsx @@ -8,7 +8,6 @@ import { RepoIcon, SearchIcon, } from '@primer/octicons-react'; -import { Stack, Text } from '@primer/react'; import { useFiltersStore, useSettingsStore } from '../../stores'; @@ -21,6 +20,7 @@ import { hasIncludeSearchFilters, } from '../../utils/notifications/filters/search'; import { cn } from '../../utils/ui/cn'; +import { Stack, Text } from '../ui'; import { RequiresDetailedNotificationWarning } from './RequiresDetailedNotificationsWarning'; import { TokenSearchInput } from './TokenSearchInput'; diff --git a/src/renderer/components/filters/SearchFilterSuggestions.tsx b/src/renderer/components/filters/SearchFilterSuggestions.tsx index 3a28538f5..14108f27a 100644 --- a/src/renderer/components/filters/SearchFilterSuggestions.tsx +++ b/src/renderer/components/filters/SearchFilterSuggestions.tsx @@ -1,7 +1,5 @@ import type { FC } from 'react'; -import { Popover, Stack, Text } from '@primer/react'; - import { useSettingsStore } from '../../stores'; import { Opacity } from '../../types'; @@ -12,6 +10,7 @@ import { SEARCH_DELIMITER, } from '../../utils/notifications/filters/search'; import { cn } from '../../utils/ui/cn'; +import { Popover, Stack, Text } from '../ui'; interface SearchFilterSuggestionsProps { open: boolean; diff --git a/src/renderer/components/filters/StateFilter.tsx b/src/renderer/components/filters/StateFilter.tsx index 83d1797d3..9373ea69c 100644 --- a/src/renderer/components/filters/StateFilter.tsx +++ b/src/renderer/components/filters/StateFilter.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react'; import { IssueOpenedIcon } from '@primer/octicons-react'; -import { Text } from '@primer/react'; import { stateFilter } from '../../utils/notifications/filters'; +import { Text } from '../ui'; import { FilterSection } from './FilterSection'; export const StateFilter: FC = () => { diff --git a/src/renderer/components/filters/SubjectTypeFilter.tsx b/src/renderer/components/filters/SubjectTypeFilter.tsx index 4d57fba97..16426dfeb 100644 --- a/src/renderer/components/filters/SubjectTypeFilter.tsx +++ b/src/renderer/components/filters/SubjectTypeFilter.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react'; import { BellIcon } from '@primer/octicons-react'; -import { Text } from '@primer/react'; import { subjectTypeFilter } from '../../utils/notifications/filters'; +import { Text } from '../ui'; import { FilterSection } from './FilterSection'; export const SubjectTypeFilter: FC = () => { diff --git a/src/renderer/components/filters/TokenSearchInput.tsx b/src/renderer/components/filters/TokenSearchInput.tsx index 3ea8b0c7a..bcdc2a5de 100644 --- a/src/renderer/components/filters/TokenSearchInput.tsx +++ b/src/renderer/components/filters/TokenSearchInput.tsx @@ -1,10 +1,9 @@ import { type FC, useState } from 'react'; -import { Stack, Text, TextInputWithTokens } from '@primer/react'; - import type { SearchToken } from '../../types'; import { parseSearchInput, SEARCH_DELIMITER } from '../../utils/notifications/filters/search'; +import { Stack, Text, TextInputWithTokens } from '../ui'; import { SearchFilterSuggestions } from './SearchFilterSuggestions'; interface TokenSearchInputProps { diff --git a/src/renderer/components/filters/UserTypeFilter.tsx b/src/renderer/components/filters/UserTypeFilter.tsx index 41a49b805..2b2972172 100644 --- a/src/renderer/components/filters/UserTypeFilter.tsx +++ b/src/renderer/components/filters/UserTypeFilter.tsx @@ -6,11 +6,11 @@ import { OrganizationIcon, PersonIcon, } from '@primer/octicons-react'; -import { Stack, Text } from '@primer/react'; import { Size } from '../../types'; import { userTypeFilter } from '../../utils/notifications/filters'; +import { Stack, Text } from '../ui'; import { FilterSection } from './FilterSection'; export const UserTypeFilter: FC = () => { diff --git a/src/renderer/components/layout/Centered.tsx b/src/renderer/components/layout/Centered.tsx index f26c1b266..a96256655 100644 --- a/src/renderer/components/layout/Centered.tsx +++ b/src/renderer/components/layout/Centered.tsx @@ -1,6 +1,6 @@ import type { FC, ReactNode } from 'react'; -import { Stack } from '@primer/react'; +import { Stack } from '../ui'; interface CenteredProps { children: ReactNode; diff --git a/src/renderer/components/layout/EmojiSplash.tsx b/src/renderer/components/layout/EmojiSplash.tsx index 5979bfcc6..608a88b23 100644 --- a/src/renderer/components/layout/EmojiSplash.tsx +++ b/src/renderer/components/layout/EmojiSplash.tsx @@ -1,9 +1,8 @@ import type { FC, ReactNode } from 'react'; -import { Stack } from '@primer/react'; - import { EmojiText } from '../primitives/EmojiText'; +import { Stack } from '../ui'; import { Centered } from './Centered'; interface EmojiSplashProps { diff --git a/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx b/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx index 38fbd2d10..4d40ea942 100644 --- a/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx +++ b/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx @@ -2,7 +2,6 @@ import { type FC, type ReactNode, useCallback, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { BookIcon, EyeClosedIcon, EyeIcon, KeyIcon, SignInIcon } from '@primer/octicons-react'; -import { Banner, Button, FormControl, Stack, Text, TextInput, Tooltip } from '@primer/react'; import { useLogins } from '../../hooks/useLogins'; @@ -17,6 +16,7 @@ import { isValidHostname } from '../../utils/auth/utils'; import { rendererLogError, toError } from '../../utils/core/logger'; import { getAdapter } from '../../utils/forges/registry'; import { openExternalLink } from '../../utils/system/comms'; +import { Banner, Button, FormControl, Stack, Text, TextInput, Tooltip } from '../ui'; interface LocationState { account?: Account; diff --git a/src/renderer/components/metrics/LabelsPill.tsx b/src/renderer/components/metrics/LabelsPill.tsx index 5276c5a1a..3d0e3cee9 100644 --- a/src/renderer/components/metrics/LabelsPill.tsx +++ b/src/renderer/components/metrics/LabelsPill.tsx @@ -1,10 +1,10 @@ import type { FC } from 'react'; import { TagIcon } from '@primer/octicons-react'; -import { IssueLabelToken, LabelGroup } from '@primer/react'; import { type GitifyLabels, IconColor } from '../../types'; +import { IssueLabelToken, LabelGroup } from '../ui'; import { MetricPill } from './MetricPill'; export interface LabelsPillProps { diff --git a/src/renderer/components/metrics/MetricPill.tsx b/src/renderer/components/metrics/MetricPill.tsx index b2dadde98..b06875dd6 100644 --- a/src/renderer/components/metrics/MetricPill.tsx +++ b/src/renderer/components/metrics/MetricPill.tsx @@ -1,10 +1,11 @@ import type { FC, ReactNode } from 'react'; import type { Icon } from '@primer/octicons-react'; -import { Label, Stack, Text, Tooltip } from '@primer/react'; import { type IconColor, Size } from '../../types'; +import { Label, Stack, Text, Tooltip } from '../ui'; + export interface MetricPillProps { contents: string | ReactNode; metric?: number; diff --git a/src/renderer/components/metrics/ReactionsPill.tsx b/src/renderer/components/metrics/ReactionsPill.tsx index 1ca1c7859..044e2ae5c 100644 --- a/src/renderer/components/metrics/ReactionsPill.tsx +++ b/src/renderer/components/metrics/ReactionsPill.tsx @@ -1,10 +1,10 @@ import type { FC } from 'react'; import { SmileyIcon } from '@primer/octicons-react'; -import { IssueLabelToken, LabelGroup } from '@primer/react'; import { type GitifyReactionGroup, IconColor } from '../../types'; +import { IssueLabelToken, LabelGroup } from '../ui'; import { MetricPill } from './MetricPill'; export interface ReactionsPillProps { diff --git a/src/renderer/components/notifications/AccountNotifications.tsx b/src/renderer/components/notifications/AccountNotifications.tsx index d07044585..04707cd37 100644 --- a/src/renderer/components/notifications/AccountNotifications.tsx +++ b/src/renderer/components/notifications/AccountNotifications.tsx @@ -1,7 +1,6 @@ import { type FC, type MouseEvent, useMemo, useState } from 'react'; import { GitPullRequestIcon, IssueOpenedIcon } from '@primer/octicons-react'; -import { Button, Stack } from '@primer/react'; import { useAccountsStore } from '../../stores'; @@ -21,6 +20,7 @@ import { getChevronDetails } from '../../utils/ui/display'; import { AllRead } from '../AllRead'; import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; import { Oops } from '../Oops'; +import { Button, Stack } from '../ui'; import { NotificationRow } from './NotificationRow'; import { RepositoryNotifications } from './RepositoryNotifications'; diff --git a/src/renderer/components/notifications/NotificationFooter.tsx b/src/renderer/components/notifications/NotificationFooter.tsx index cacdc51b9..a443c27c0 100644 --- a/src/renderer/components/notifications/NotificationFooter.tsx +++ b/src/renderer/components/notifications/NotificationFooter.tsx @@ -1,13 +1,12 @@ import type { FC, MouseEvent } from 'react'; -import { RelativeTime, Stack, Text } from '@primer/react'; - import { type GitifyNotification, Opacity, Size } from '../../types'; import { openUserProfile } from '../../utils/system/links'; import { cn } from '../../utils/ui/cn'; import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; import { MetricGroup } from '../metrics/MetricGroup'; +import { RelativeTime, Stack, Text } from '../ui'; export interface NotificationFooterProps { notification: GitifyNotification; diff --git a/src/renderer/components/notifications/NotificationHeader.tsx b/src/renderer/components/notifications/NotificationHeader.tsx index 072c39685..5e9595991 100644 --- a/src/renderer/components/notifications/NotificationHeader.tsx +++ b/src/renderer/components/notifications/NotificationHeader.tsx @@ -1,7 +1,5 @@ import type { FC, MouseEvent } from 'react'; -import { Stack } from '@primer/react'; - import { useSettingsStore } from '../../stores'; import { type GitifyNotification, Opacity, Size } from '../../types'; @@ -10,6 +8,7 @@ import { isGroupByDate } from '../../utils/notifications/group'; import { openRepository } from '../../utils/system/links'; import { cn } from '../../utils/ui/cn'; import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; +import { Stack } from '../ui'; export interface NotificationHeaderProps { notification: GitifyNotification; diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx index 086a14b3e..ff5051dbb 100644 --- a/src/renderer/components/notifications/NotificationRow.tsx +++ b/src/renderer/components/notifications/NotificationRow.tsx @@ -1,7 +1,6 @@ import { type FC, useState } from 'react'; import { BellSlashIcon, CheckIcon, ReadIcon } from '@primer/octicons-react'; -import { Stack, Text, Tooltip } from '@primer/react'; import { useNotifications } from '../../hooks/useNotifications'; import { useSettingsStore } from '../../stores'; @@ -19,6 +18,7 @@ import { isGroupByDate } from '../../utils/notifications/group'; import { shouldRemoveNotificationsFromState } from '../../utils/notifications/remove'; import { openNotification } from '../../utils/system/links'; import { cn } from '../../utils/ui/cn'; +import { Stack, Text, Tooltip } from '../ui'; import { NotificationFooter } from './NotificationFooter'; import { NotificationHeader } from './NotificationHeader'; import { NotificationTitle } from './NotificationTitle'; diff --git a/src/renderer/components/notifications/NotificationTitle.tsx b/src/renderer/components/notifications/NotificationTitle.tsx index 891c3c026..962e7a8d1 100644 --- a/src/renderer/components/notifications/NotificationTitle.tsx +++ b/src/renderer/components/notifications/NotificationTitle.tsx @@ -1,11 +1,10 @@ import type { FC } from 'react'; -import { Text } from '@primer/react'; - import { useSettingsStore } from '../../stores'; import { cn } from '../../utils/ui/cn'; import { parseInlineCode } from '../../utils/ui/display'; +import { Text } from '../ui'; interface NotificationTitleProps { title: string; diff --git a/src/renderer/components/notifications/RepositoryNotifications.tsx b/src/renderer/components/notifications/RepositoryNotifications.tsx index 989d14fdc..c1e46a0d7 100644 --- a/src/renderer/components/notifications/RepositoryNotifications.tsx +++ b/src/renderer/components/notifications/RepositoryNotifications.tsx @@ -1,7 +1,6 @@ import { type FC, type MouseEvent, useState } from 'react'; import { CheckIcon, ReadIcon } from '@primer/octicons-react'; -import { Button, Stack } from '@primer/react'; import { useNotifications } from '../../hooks/useNotifications'; @@ -16,6 +15,7 @@ import { openRepository } from '../../utils/system/links'; import { cn } from '../../utils/ui/cn'; import { getChevronDetails } from '../../utils/ui/display'; import { AvatarWithFallback } from '../avatars/AvatarWithFallback'; +import { Button, Stack } from '../ui'; import { NotificationRow } from './NotificationRow'; export interface RepositoryNotificationsProps { diff --git a/src/renderer/components/primitives/CustomCounter.tsx b/src/renderer/components/primitives/CustomCounter.tsx index 5182cacd8..836a15b9c 100644 --- a/src/renderer/components/primitives/CustomCounter.tsx +++ b/src/renderer/components/primitives/CustomCounter.tsx @@ -1,8 +1,7 @@ import type { FC } from 'react'; -import { Text } from '@primer/react'; - import { cn } from '../../utils/ui/cn'; +import { Text } from '../ui'; type CounterScheme = 'primary' | 'secondary' | 'empty'; diff --git a/src/renderer/components/primitives/Footer.tsx b/src/renderer/components/primitives/Footer.tsx index a60441fd0..9b44a0cdb 100644 --- a/src/renderer/components/primitives/Footer.tsx +++ b/src/renderer/components/primitives/Footer.tsx @@ -1,6 +1,6 @@ import type { FC, ReactNode } from 'react'; -import { Stack } from '@primer/react'; +import { Stack } from '../ui'; interface FooterProps { children: ReactNode; diff --git a/src/renderer/components/primitives/Header.tsx b/src/renderer/components/primitives/Header.tsx index cdd5aa1bf..ad43468c5 100644 --- a/src/renderer/components/primitives/Header.tsx +++ b/src/renderer/components/primitives/Header.tsx @@ -2,10 +2,10 @@ import type { FC } from 'react'; import { useNavigate } from 'react-router-dom'; import { ArrowLeftIcon, type Icon } from '@primer/octicons-react'; -import { IconButton, Stack } from '@primer/react'; import { useNotifications } from '../../hooks/useNotifications'; +import { IconButton, Stack } from '../ui'; import { Title } from './Title'; interface HeaderProps { diff --git a/src/renderer/components/primitives/HoverButton.tsx b/src/renderer/components/primitives/HoverButton.tsx index 5071d1676..926d0b965 100644 --- a/src/renderer/components/primitives/HoverButton.tsx +++ b/src/renderer/components/primitives/HoverButton.tsx @@ -1,7 +1,8 @@ import type { FC } from 'react'; import type { Icon } from '@primer/octicons-react'; -import { IconButton } from '@primer/react'; + +import { IconButton } from '../ui'; interface HoverButtonProps { label: string; diff --git a/src/renderer/components/primitives/HoverGroup.tsx b/src/renderer/components/primitives/HoverGroup.tsx index 065c5342f..74a3864cc 100644 --- a/src/renderer/components/primitives/HoverGroup.tsx +++ b/src/renderer/components/primitives/HoverGroup.tsx @@ -1,8 +1,7 @@ import type { FC, ReactNode } from 'react'; -import { Stack } from '@primer/react'; - import { cn } from '../../utils/ui/cn'; +import { Stack } from '../ui'; interface HoverGroupProps { children: ReactNode; diff --git a/src/renderer/components/primitives/Title.tsx b/src/renderer/components/primitives/Title.tsx index f4709c015..dc97cd93c 100644 --- a/src/renderer/components/primitives/Title.tsx +++ b/src/renderer/components/primitives/Title.tsx @@ -1,10 +1,11 @@ import type { FC, ReactNode } from 'react'; import type { Icon } from '@primer/octicons-react'; -import { Heading, Stack } from '@primer/react'; import { Tooltip } from '../fields/Tooltip'; +import { Heading, Stack } from '../ui'; + interface TitleProps { icon: Icon; children: string; diff --git a/src/renderer/components/settings/AppearanceSettings.tsx b/src/renderer/components/settings/AppearanceSettings.tsx index 188854459..3a1d25980 100644 --- a/src/renderer/components/settings/AppearanceSettings.tsx +++ b/src/renderer/components/settings/AppearanceSettings.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react'; import { PaintbrushIcon, SyncIcon, ZoomInIcon, ZoomOutIcon } from '@primer/octicons-react'; -import { Button, ButtonGroup, IconButton, Select, Stack, Text } from '@primer/react'; import { useAccountsStore, useSettingsStore } from '../../stores'; @@ -18,6 +17,7 @@ import { increaseZoom, resetZoomLevel, } from '../../utils/ui/zoom'; +import { Button, ButtonGroup, IconButton, Select, Stack, Text } from '../ui'; export const AppearanceSettings: FC = () => { // Account store values diff --git a/src/renderer/components/settings/NotificationSettings.tsx b/src/renderer/components/settings/NotificationSettings.tsx index 7c3f0ebea..0f119b61c 100644 --- a/src/renderer/components/settings/NotificationSettings.tsx +++ b/src/renderer/components/settings/NotificationSettings.tsx @@ -15,7 +15,6 @@ import { SyncIcon, TagIcon, } from '@primer/octicons-react'; -import { Button, ButtonGroup, IconButton, Stack, Text } from '@primer/react'; import { formatDuration } from 'date-fns/formatDuration'; import { millisecondsToMinutes } from 'date-fns/millisecondsToMinutes'; @@ -35,6 +34,7 @@ import { GroupBy, Size } from '../../types'; import { hasAlternateScopes, hasRecommendedScopes } from '../../utils/auth/scopes'; import { openGitHubParticipatingDocs } from '../../utils/system/links'; +import { Button, ButtonGroup, IconButton, Stack, Text } from '../ui'; export const NotificationSettings: FC = () => { const navigate = useNavigate(); diff --git a/src/renderer/components/settings/SettingsFooter.tsx b/src/renderer/components/settings/SettingsFooter.tsx index 9baf5e145..d3cc82f49 100644 --- a/src/renderer/components/settings/SettingsFooter.tsx +++ b/src/renderer/components/settings/SettingsFooter.tsx @@ -1,7 +1,6 @@ import { type FC, useEffect, useState } from 'react'; import { PersonIcon, XCircleIcon } from '@primer/octicons-react'; -import { Button, IconButton, Stack, Tooltip } from '@primer/react'; import { APPLICATION } from '../../../shared/constants'; @@ -11,6 +10,7 @@ import { Footer } from '../primitives/Footer'; import { getAppVersion } from '../../utils/system/comms'; import { openGitifyReleaseNotes } from '../../utils/system/links'; +import { Button, IconButton, Stack, Tooltip } from '../ui'; export const SettingsFooter: FC = () => { const [appVersion, setAppVersion] = useState(null); diff --git a/src/renderer/components/settings/SettingsReset.tsx b/src/renderer/components/settings/SettingsReset.tsx index 3fe9d34dd..305bc249a 100644 --- a/src/renderer/components/settings/SettingsReset.tsx +++ b/src/renderer/components/settings/SettingsReset.tsx @@ -1,10 +1,9 @@ import type { FC } from 'react'; -import { Button, Stack, useConfirm } from '@primer/react'; - import { useSettingsStore } from '../../stores'; import { rendererLogInfo } from '../../utils/core/logger'; +import { Button, Stack, useConfirm } from '../ui'; export const SettingsReset: FC = () => { const resetSettings = useSettingsStore((s) => s.reset); diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index 227edb593..4007f92b6 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -1,7 +1,6 @@ import { type FC, useEffect, useRef, useState } from 'react'; import { DeviceDesktopIcon, PencilIcon, SyncIcon } from '@primer/octicons-react'; -import { Banner, Button, ButtonGroup, IconButton, Stack, Text } from '@primer/react'; import { APPLICATION } from '../../../shared/constants'; @@ -27,6 +26,7 @@ import { } from '../../utils/ui/volume'; import { VolumeDownIcon } from '../icons/VolumeDownIcon'; import { VolumeUpIcon } from '../icons/VolumeUpIcon'; +import { Banner, Button, ButtonGroup, IconButton, Stack, Text } from '../ui'; const defaultSettings = DEFAULT_SETTINGS_STATE; diff --git a/src/renderer/components/settings/TraySettings.tsx b/src/renderer/components/settings/TraySettings.tsx index 66e4f67e3..fe9edb139 100644 --- a/src/renderer/components/settings/TraySettings.tsx +++ b/src/renderer/components/settings/TraySettings.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react'; import { DevicesIcon } from '@primer/octicons-react'; -import { Stack, Text } from '@primer/react'; import { APPLICATION } from '../../../shared/constants'; @@ -10,6 +9,8 @@ import { useSettingsStore } from '../../stores'; import { Checkbox } from '../fields/Checkbox'; import { Title } from '../primitives/Title'; +import { Stack, Text } from '../ui'; + export const TraySettings: FC = () => { // Setting store actions const toggleSetting = useSettingsStore((s) => s.toggleSetting); diff --git a/src/renderer/components/ui/index.test.ts b/src/renderer/components/ui/index.test.ts new file mode 100644 index 000000000..cda667125 --- /dev/null +++ b/src/renderer/components/ui/index.test.ts @@ -0,0 +1,32 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** + * Import boundary guard. + * + * The renderer must import Primer components through the app-owned `ui/` layer, + * never from `@primer/react` directly — otherwise the seam that lets a design + * language swap an implementation erodes and the two themes drift back together. + * + * `import type { ... } from '@primer/react'` is allowed anywhere (the app derives + * prop types from Primer's public surface); only value imports are restricted. + * + * This replaces a linter `no-restricted-imports` rule, which the bundled + * `vp lint` config does not expose for custom configuration. + */ +const RENDERER = path.join(process.cwd(), 'src/renderer'); +const UI_DIR = path.join(RENDERER, 'components', 'ui'); +const PRIMER_VALUE_IMPORT = /^import \{[^}]*\} from '@primer\/react'/m; + +describe('renderer/components/ui import boundary', () => { + it('has no direct @primer/react value imports outside components/ui/', () => { + const offenders = readdirSync(RENDERER, { recursive: true, encoding: 'utf8' }) + .filter((entry) => entry.endsWith('.ts') || entry.endsWith('.tsx')) + .map((entry) => path.join(RENDERER, entry)) + .filter((abs) => !abs.startsWith(UI_DIR)) + .filter((abs) => PRIMER_VALUE_IMPORT.test(readFileSync(abs, 'utf8'))) + .map((abs) => path.relative(process.cwd(), abs)); + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/renderer/components/ui/index.ts b/src/renderer/components/ui/index.ts new file mode 100644 index 000000000..eecd493ec --- /dev/null +++ b/src/renderer/components/ui/index.ts @@ -0,0 +1,47 @@ +/** + * App-owned UI layer. + * + * The renderer imports these instead of `@primer/react` directly, so a design + * language can later swap an implementation behind this seam without touching + * call sites (the D4 decision in the plan). Phase 1 (Classic) re-exports Primer + * unchanged, so rendering is byte-identical; the value of the layer today is the + * single, lint-enforced import boundary and the per-component swap point it + * creates. When a language needs a component to diverge structurally, replace + * that component's line here with a dedicated implementation module. + * + * `ThemeProvider` / `BaseStyles` / `useTheme` / `useConfirm` are re-exported too + * so ALL Primer value imports live under this directory: the provider stays as + * the token/attribute engine, and `useConfirm`'s dialog depends on it. + * + * Note: Primer *types* are intentionally still imported directly via + * `import type` at the two sites that need them (`derive from public props` + * convention) — the boundary isolates Primer components, not Primer types. + */ +export { + ActionList, + ActionMenu, + AnchoredOverlay, + Avatar, + Banner, + BaseStyles, + Button, + ButtonGroup, + FormControl, + Heading, + IconButton, + IssueLabelToken, + Label, + LabelGroup, + Popover, + RelativeTime, + Select, + Stack, + Text, + TextInput, + TextInputWithTokens, + ThemeProvider, + Tooltip, + Truncate, + useConfirm, + useTheme, +} from '@primer/react'; diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts index 8892e42ec..2b62217ce 100644 --- a/src/renderer/hooks/useAppearance.ts +++ b/src/renderer/hooks/useAppearance.ts @@ -1,9 +1,9 @@ import { useEffect } from 'react'; -import { useTheme } from '@primer/react'; - import { useSettingsStore } from '../stores'; +import { useTheme } from '../components/ui'; + import { DEFAULT_DAY_COLOR_SCHEME, DEFAULT_DAY_HIGH_CONTRAST_COLOR_SCHEME, diff --git a/src/renderer/routes/AccountScopes.tsx b/src/renderer/routes/AccountScopes.tsx index 8edf6e08b..0a8009736 100644 --- a/src/renderer/routes/AccountScopes.tsx +++ b/src/renderer/routes/AccountScopes.tsx @@ -2,7 +2,6 @@ import type { FC } from 'react'; import { useLocation } from 'react-router-dom'; import { AlertFillIcon, LinkExternalIcon, ShieldCheckIcon } from '@primer/octicons-react'; -import { Button, Label, Stack, Text } from '@primer/react'; import { Constants, OAUTH_SCOPE } from '../constants'; @@ -11,6 +10,7 @@ import { Contents } from '../components/layout/Contents'; import { Page } from '../components/layout/Page'; import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; +import { Button, Label, Stack, Text } from '../components/ui'; import type { Account } from '../types'; diff --git a/src/renderer/routes/Accounts.tsx b/src/renderer/routes/Accounts.tsx index cc9fba433..7257f824f 100644 --- a/src/renderer/routes/Accounts.tsx +++ b/src/renderer/routes/Accounts.tsx @@ -14,7 +14,6 @@ import { StarIcon, SyncIcon, } from '@primer/octicons-react'; -import { ActionList, ActionMenu, Button, IconButton, Stack, Text } from '@primer/react'; import { useLogins } from '../hooks/useLogins'; import { useNotifications } from '../hooks/useNotifications'; @@ -25,6 +24,7 @@ import { Contents } from '../components/layout/Contents'; import { Page } from '../components/layout/Page'; import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; +import { ActionList, ActionMenu, Button, IconButton, Stack, Text } from '../components/ui'; import { type Account, type GitifyError, IconColor, Size } from '../types'; import type { ForgeAdapter } from '../utils/forges/types'; diff --git a/src/renderer/routes/Filters.tsx b/src/renderer/routes/Filters.tsx index d42c64c75..040f24434 100644 --- a/src/renderer/routes/Filters.tsx +++ b/src/renderer/routes/Filters.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react'; import { FilterIcon, FilterRemoveIcon } from '@primer/octicons-react'; -import { Button, Stack, Tooltip } from '@primer/react'; import { useAccountsStore, useFiltersStore } from '../stores'; @@ -16,6 +15,7 @@ import { Contents } from '../components/layout/Contents'; import { Page } from '../components/layout/Page'; import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; +import { Button, Stack, Tooltip } from '../components/ui'; export const FiltersRoute: FC = () => { const clearFilters = useFiltersStore((s) => s.reset); diff --git a/src/renderer/routes/Login.tsx b/src/renderer/routes/Login.tsx index ff0c56548..cea77edb7 100644 --- a/src/renderer/routes/Login.tsx +++ b/src/renderer/routes/Login.tsx @@ -1,12 +1,11 @@ import { type FC, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Button, Heading, Stack, Text } from '@primer/react'; - import { useAccountsStore } from '../stores'; import { LogoIcon } from '../components/icons/LogoIcon'; import { Centered } from '../components/layout/Centered'; +import { Button, Heading, Stack, Text } from '../components/ui'; import { type Forge, Size } from '../types'; import type { ForgeAdapter, LoginMethodDescriptor } from '../utils/forges/types'; diff --git a/src/renderer/routes/Settings.tsx b/src/renderer/routes/Settings.tsx index 8944b6376..0d2a46212 100644 --- a/src/renderer/routes/Settings.tsx +++ b/src/renderer/routes/Settings.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react'; import { GearIcon } from '@primer/octicons-react'; -import { Stack } from '@primer/react'; import { Contents } from '../components/layout/Contents'; import { Page } from '../components/layout/Page'; @@ -12,6 +11,7 @@ import { SettingsFooter } from '../components/settings/SettingsFooter'; import { SettingsReset } from '../components/settings/SettingsReset'; import { SystemSettings } from '../components/settings/SystemSettings'; import { TraySettings } from '../components/settings/TraySettings'; +import { Stack } from '../components/ui'; export const SettingsRoute: FC = () => { return ( diff --git a/src/renderer/routes/github/LoginWithDeviceFlow.tsx b/src/renderer/routes/github/LoginWithDeviceFlow.tsx index fdad338e4..002707e7c 100644 --- a/src/renderer/routes/github/LoginWithDeviceFlow.tsx +++ b/src/renderer/routes/github/LoginWithDeviceFlow.tsx @@ -9,7 +9,6 @@ import { SignInIcon, SyncIcon, } from '@primer/octicons-react'; -import { Banner, Button, Heading, IconButton, Stack, Text } from '@primer/react'; import { Constants } from '../../constants'; @@ -19,6 +18,7 @@ import { Contents } from '../../components/layout/Contents'; import { Page } from '../../components/layout/Page'; import { Footer } from '../../components/primitives/Footer'; import { Header } from '../../components/primitives/Header'; +import { Banner, Button, Heading, IconButton, Stack, Text } from '../../components/ui'; import type { Account, Forge, Hostname, Link } from '../../types'; import type { DeviceFlowSession } from '../../utils/auth/types'; diff --git a/src/renderer/routes/github/LoginWithOAuthApp.tsx b/src/renderer/routes/github/LoginWithOAuthApp.tsx index d29ac39da..0d8ab0355 100644 --- a/src/renderer/routes/github/LoginWithOAuthApp.tsx +++ b/src/renderer/routes/github/LoginWithOAuthApp.tsx @@ -2,7 +2,6 @@ import { type FC, useCallback, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { BookIcon, EyeClosedIcon, EyeIcon, PersonIcon, SignInIcon } from '@primer/octicons-react'; -import { Banner, Button, FormControl, Stack, Text, TextInput, Tooltip } from '@primer/react'; import { Constants } from '../../constants'; @@ -12,6 +11,7 @@ import { Contents } from '../../components/layout/Contents'; import { Page } from '../../components/layout/Page'; import { Footer } from '../../components/primitives/Footer'; import { Header } from '../../components/primitives/Header'; +import { Banner, Button, FormControl, Stack, Text, TextInput, Tooltip } from '../../components/ui'; import type { Account, ClientID, ClientSecret, Forge, Token } from '../../types'; import type { LoginOAuthWebOptions } from '../../utils/auth/types'; diff --git a/src/renderer/routes/github/LoginWithPersonalAccessToken.tsx b/src/renderer/routes/github/LoginWithPersonalAccessToken.tsx index 36c8c997e..c9c74ce4e 100644 --- a/src/renderer/routes/github/LoginWithPersonalAccessToken.tsx +++ b/src/renderer/routes/github/LoginWithPersonalAccessToken.tsx @@ -1,8 +1,7 @@ import type { FC } from 'react'; -import { Text, Tooltip } from '@primer/react'; - import { LoginWithPersonalAccessTokenForm } from '../../components/login/LoginWithPersonalAccessTokenForm'; +import { Text, Tooltip } from '../../components/ui'; import { formatRecommendedOAuthScopes } from '../../utils/auth/scopes'; From bc29091a65070025dfc759b3cc43b387a9cad87b Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 10:31:58 +0200 Subject: [PATCH 04/27] feat(theme): add design-language settings axis with Classic/Glass selector --- src/renderer/__mocks__/state-mocks.ts | 2 + .../settings/AppearanceSettings.test.tsx | 27 +++++++++ .../settings/AppearanceSettings.tsx | 40 ++++++++++--- src/renderer/hooks/useAppearance.test.ts | 14 ++++- src/renderer/hooks/useAppearance.ts | 32 ++++++----- .../__snapshots__/Settings.test.tsx.snap | 57 +++++++++++++++++++ src/renderer/stores/defaults.ts | 2 + src/renderer/stores/useSettingsStore.test.ts | 42 +++++++++++++- src/renderer/types.ts | 14 +++++ src/renderer/utils/ui/theme.test.ts | 49 +++++++++++++++- src/renderer/utils/ui/theme.ts | 54 +++++++++++++++++- 11 files changed, 307 insertions(+), 26 deletions(-) diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index a9321221b..172305521 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -3,6 +3,7 @@ import { Constants } from '../constants'; import { type Account, type AppearanceSettingsState, + DesignLanguage, GroupBy, type KeyboardAcceleratorShortcut, type NotificationSettingsState, @@ -24,6 +25,7 @@ export const mockAuth: { accounts: Account[] } = { export const mockToken = 'token-123-456' as Token; const mockAppearanceSettings: AppearanceSettingsState = { + designLanguage: DesignLanguage.CLASSIC, theme: Theme.SYSTEM, increaseContrast: false, zoomPercentage: 100 as Percentage, diff --git a/src/renderer/components/settings/AppearanceSettings.test.tsx b/src/renderer/components/settings/AppearanceSettings.test.tsx index c5d174f69..bd3a84186 100644 --- a/src/renderer/components/settings/AppearanceSettings.test.tsx +++ b/src/renderer/components/settings/AppearanceSettings.test.tsx @@ -6,6 +6,8 @@ import { mockGitHubAppAccount } from '../../__mocks__/account-mocks'; import { useSettingsStore } from '../../stores'; +import { DesignLanguage } from '../../types'; + import * as zoom from '../../utils/ui/zoom'; import { AppearanceSettings } from './AppearanceSettings'; @@ -29,6 +31,31 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { expect(updateSettingSpy).toHaveBeenCalledWith('theme', 'LIGHT'); }); + it('should change the design language dropdown', async () => { + await act(async () => { + renderWithProviders(); + }); + + await userEvent.selectOptions(screen.getByTestId('settings-design-language'), 'glass'); + + expect(updateSettingSpy).toHaveBeenCalledTimes(1); + expect(updateSettingSpy).toHaveBeenCalledWith('designLanguage', DesignLanguage.GLASS); + }); + + it('hides accessibility color modes when Glass is the active design language', async () => { + await act(async () => { + renderWithProviders(, { + settings: { designLanguage: DesignLanguage.GLASS }, + }); + }); + + expect(screen.getByRole('option', { name: 'System' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Dark default' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Soft dark' })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Light colorblind' })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Dark Tritanopia' })).not.toBeInTheDocument(); + }); + it('should toggle increase contrast checkbox', async () => { await act(async () => { renderWithProviders(, { diff --git a/src/renderer/components/settings/AppearanceSettings.tsx b/src/renderer/components/settings/AppearanceSettings.tsx index 3a1d25980..a56a78a91 100644 --- a/src/renderer/components/settings/AppearanceSettings.tsx +++ b/src/renderer/components/settings/AppearanceSettings.tsx @@ -8,8 +8,9 @@ import { Checkbox } from '../fields/Checkbox'; import { FieldLabel } from '../fields/FieldLabel'; import { Title } from '../primitives/Title'; -import { Theme } from '../../types'; +import { DesignLanguage, Theme } from '../../types'; +import { resolveColorMode, supportedColorModes } from '../../utils/ui/theme'; import { canDecreaseZoom, canIncreaseZoom, @@ -28,8 +29,11 @@ export const AppearanceSettings: FC = () => { const updateSetting = useSettingsStore((s) => s.updateSetting); // Setting store values + const designLanguage = useSettingsStore((s) => s.designLanguage); const theme = useSettingsStore((s) => s.theme); const increaseContrast = useSettingsStore((s) => s.increaseContrast); + + const colorModeSupported = (mode: Theme) => supportedColorModes(designLanguage).includes(mode); const showAccountHeader = useSettingsStore((s) => s.showAccountHeader); const wrapNotificationTitle = useSettingsStore((s) => s.wrapNotificationTitle); const zoomPercentage = useSettingsStore((s) => s.zoomPercentage); @@ -39,26 +43,48 @@ export const AppearanceSettings: FC = () => { Appearance + + + + + diff --git a/src/renderer/hooks/useAppearance.test.ts b/src/renderer/hooks/useAppearance.test.ts index 2d14471aa..a0089f6da 100644 --- a/src/renderer/hooks/useAppearance.test.ts +++ b/src/renderer/hooks/useAppearance.test.ts @@ -1,5 +1,9 @@ import { renderHook } from '@testing-library/react'; +import { useSettingsStore } from '../stores'; + +import { DesignLanguage } from '../types'; + import { useAppearance } from './useAppearance'; describe('renderer/hooks/useAppearance.ts', () => { @@ -8,12 +12,20 @@ describe('renderer/hooks/useAppearance.ts', () => { document.documentElement.removeAttribute('data-glass-material'); }); - it('marks the root with the Classic design language', () => { + it('marks the root with the Classic design language by default', () => { renderHook(() => useAppearance()); expect(document.documentElement.getAttribute('data-theme')).toBe('classic'); }); + it('reflects the active design language on the root', () => { + useSettingsStore.setState({ designLanguage: DesignLanguage.GLASS }); + + renderHook(() => useAppearance()); + + expect(document.documentElement.getAttribute('data-theme')).toBe('glass'); + }); + it('derives the glass material from the platform (vibrancy on macOS)', () => { vi.mocked(window.gitify.platform.isMacOS).mockReturnValue(true); diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts index 2b62217ce..d4da1faf6 100644 --- a/src/renderer/hooks/useAppearance.ts +++ b/src/renderer/hooks/useAppearance.ts @@ -11,6 +11,7 @@ import { DEFAULT_NIGHT_HIGH_CONTRAST_COLOR_SCHEME, mapThemeModeToColorMode, mapThemeModeToColorScheme, + resolveColorMode, } from '../utils/ui/theme'; /** @@ -24,21 +25,25 @@ import { * (`data-glass-material`) attributes that the CSS token layer keys off, * alongside the `data-color-mode` attribute Primer's `ThemeProvider` owns. * - * `data-theme` is currently always `classic` — a settings-driven design - * language is introduced later. `data-glass-material` is derived from the OS - * so `App.css` can branch base-transparency / blur behaviour declaratively. - * Every rule keyed off `data-glass-material` must also be scoped under - * `[data-theme="glass"]`, so these attributes are inert under Classic. + * `data-theme` mirrors the active design language. `data-glass-material` is + * derived from the OS so `App.css` can branch base-transparency / blur + * behaviour declaratively. Every rule keyed off `data-glass-material` must also + * be scoped under `[data-theme="glass"]`, so these attributes are inert under + * Classic. */ export function useAppearance(): void { + const designLanguage = useSettingsStore((s) => s.designLanguage); const theme = useSettingsStore((s) => s.theme); const increaseContrast = useSettingsStore((s) => s.increaseContrast); const { setColorMode, setDayScheme, setNightScheme } = useTheme(); useEffect(() => { - const colorMode = mapThemeModeToColorMode(theme); - const colorScheme = mapThemeModeToColorScheme(theme, increaseContrast); + // Clamp the stored scheme to what the active language supports (identity for + // Classic, so existing users are unaffected). + const effectiveTheme = resolveColorMode(designLanguage, theme); + const colorMode = mapThemeModeToColorMode(effectiveTheme); + const colorScheme = mapThemeModeToColorScheme(effectiveTheme, increaseContrast); setColorMode(colorMode); @@ -53,18 +58,17 @@ export function useAppearance(): void { setDayScheme(colorScheme ?? dayFallback); setNightScheme(colorScheme ?? nightFallback); - }, [theme, increaseContrast, setColorMode, setDayScheme, setNightScheme]); + }, [designLanguage, theme, increaseContrast, setColorMode, setDayScheme, setNightScheme]); useEffect(() => { - const root = document.documentElement; - - // The only design language today is Classic; a settings-driven language - // is wired in later. - root.setAttribute('data-theme', 'classic'); + // The enum value is already the lower-cased attribute value. + document.documentElement.setAttribute('data-theme', designLanguage); + }, [designLanguage]); + useEffect(() => { // Material fidelity is platform-decided: real vibrancy on macOS, CSS // backdrop-filter elsewhere. Inert until a Glass language keys off it. - root.setAttribute( + document.documentElement.setAttribute( 'data-glass-material', window.gitify.platform.isMacOS() ? 'vibrancy' : 'backdrop-filter', ); diff --git a/src/renderer/routes/__snapshots__/Settings.test.tsx.snap b/src/renderer/routes/__snapshots__/Settings.test.tsx.snap index d1bbc7900..b7434d1a5 100644 --- a/src/renderer/routes/__snapshots__/Settings.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Settings.test.tsx.snap @@ -154,6 +154,63 @@ exports[`renderer/routes/Settings.tsx > should render itself & its children 1`] data-padding="none" data-wrap="nowrap" > +
+ + + + + +
{ expect(result.current).toMatchObject(DEFAULT_SETTINGS_STATE); }); }); + + describe('Design language (additive migration)', () => { + afterEach(() => { + localStorage.removeItem(Constants.STORAGE.SETTINGS); + }); + + test('should default the design language to Classic', () => { + const { result } = renderHook(() => useSettingsStore()); + + expect(result.current.designLanguage).toBe(DesignLanguage.CLASSIC); + }); + + test('should give an existing persisted blob the Classic default while preserving its theme', async () => { + // A blob persisted before designLanguage existed (zustand writes version 0). + localStorage.setItem( + Constants.STORAGE.SETTINGS, + JSON.stringify({ state: { theme: Theme.DARK_DIMMED, increaseContrast: true }, version: 0 }), + ); + + await act(async () => { + await useSettingsStore.persist.rehydrate(); + }); + + expect(useSettingsStore.getState().designLanguage).toBe(DesignLanguage.CLASSIC); + expect(useSettingsStore.getState().theme).toBe(Theme.DARK_DIMMED); + expect(useSettingsStore.getState().increaseContrast).toBe(true); + }); + + test('should update the design language', () => { + const { result } = renderHook(() => useSettingsStore()); + + act(() => { + result.current.updateSetting('designLanguage', DesignLanguage.GLASS); + }); + + expect(result.current.designLanguage).toBe(DesignLanguage.GLASS); + }); + }); }); diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 9a27a34ce..528bd0fb2 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -89,6 +89,9 @@ export type SettingsState = AppearanceSettingsState & * Settings related to the appearance of the application. */ export interface AppearanceSettingsState { + /** The active design language. Its value doubles as the root `data-theme`. */ + designLanguage: DesignLanguage; + /** The color scheme / accessibility palette (the color-mode axis). */ theme: Theme; increaseContrast: boolean; zoomPercentage: Percentage; @@ -137,6 +140,17 @@ export interface SystemSettingsState { keepWindowOnBlur: boolean; } +/** + * A design language is a full visual language (chrome, materials), distinct + * from the color-mode axis (`Theme`). Values are lower-cased because they double + * as the root `data-theme` attribute the CSS token layer keys off, mirroring the + * lower-cased `data-color-mode` convention. + */ +export enum DesignLanguage { + CLASSIC = 'classic', + GLASS = 'glass', +} + export enum Theme { SYSTEM = 'SYSTEM', LIGHT = 'LIGHT', diff --git a/src/renderer/utils/ui/theme.test.ts b/src/renderer/utils/ui/theme.test.ts index 2803acd83..01ff4a78a 100644 --- a/src/renderer/utils/ui/theme.test.ts +++ b/src/renderer/utils/ui/theme.test.ts @@ -1,4 +1,4 @@ -import { Theme } from '../../types'; +import { DesignLanguage, Theme } from '../../types'; import { DEFAULT_DAY_COLOR_SCHEME, @@ -7,6 +7,8 @@ import { DEFAULT_NIGHT_HIGH_CONTRAST_COLOR_SCHEME, mapThemeModeToColorMode, mapThemeModeToColorScheme, + resolveColorMode, + supportedColorModes, } from './theme'; describe('renderer/utils/theme.ts', () => { @@ -57,4 +59,49 @@ describe('renderer/utils/theme.ts', () => { expect(DEFAULT_DAY_COLOR_SCHEME).toBe('light'); expect(DEFAULT_NIGHT_COLOR_SCHEME).toBe('dark'); }); + + describe('supportedColorModes', () => { + it('Classic supports the full palette including accessibility variants', () => { + expect(supportedColorModes(DesignLanguage.CLASSIC)).toEqual([ + Theme.SYSTEM, + Theme.LIGHT, + Theme.LIGHT_COLORBLIND, + Theme.LIGHT_TRITANOPIA, + Theme.DARK, + Theme.DARK_COLORBLIND, + Theme.DARK_TRITANOPIA, + Theme.DARK_DIMMED, + ]); + }); + + it('Glass supports only light / dark / system', () => { + expect(supportedColorModes(DesignLanguage.GLASS)).toEqual([ + Theme.SYSTEM, + Theme.LIGHT, + Theme.DARK, + ]); + }); + }); + + describe('resolveColorMode', () => { + it('is the identity for Classic (every value supported)', () => { + for (const mode of supportedColorModes(DesignLanguage.CLASSIC)) { + expect(resolveColorMode(DesignLanguage.CLASSIC, mode)).toBe(mode); + } + }); + + it('keeps system / light / dark unchanged under Glass', () => { + expect(resolveColorMode(DesignLanguage.GLASS, Theme.SYSTEM)).toBe(Theme.SYSTEM); + expect(resolveColorMode(DesignLanguage.GLASS, Theme.LIGHT)).toBe(Theme.LIGHT); + expect(resolveColorMode(DesignLanguage.GLASS, Theme.DARK)).toBe(Theme.DARK); + }); + + it('clamps Classic-only variants to their base light/dark under Glass', () => { + expect(resolveColorMode(DesignLanguage.GLASS, Theme.LIGHT_COLORBLIND)).toBe(Theme.LIGHT); + expect(resolveColorMode(DesignLanguage.GLASS, Theme.LIGHT_TRITANOPIA)).toBe(Theme.LIGHT); + expect(resolveColorMode(DesignLanguage.GLASS, Theme.DARK_COLORBLIND)).toBe(Theme.DARK); + expect(resolveColorMode(DesignLanguage.GLASS, Theme.DARK_TRITANOPIA)).toBe(Theme.DARK); + expect(resolveColorMode(DesignLanguage.GLASS, Theme.DARK_DIMMED)).toBe(Theme.DARK); + }); + }); }); diff --git a/src/renderer/utils/ui/theme.ts b/src/renderer/utils/ui/theme.ts index b6231b7f7..883bd4ffa 100644 --- a/src/renderer/utils/ui/theme.ts +++ b/src/renderer/utils/ui/theme.ts @@ -1,6 +1,6 @@ import type { ThemeProviderProps } from '@primer/react'; -import { Theme } from '../../types'; +import { DesignLanguage, Theme } from '../../types'; // Derived from public @primer/react component props rather than internal types type ColorModeWithAuto = NonNullable; @@ -60,3 +60,55 @@ export function mapThemeModeToColorScheme( return increaseContrast ? `${base}_high_contrast` : base; } + +/** + * The color-mode (`Theme`) values a design language supports. Classic exposes + * the full palette including the accessibility variants; Glass offers only + * light / dark / system (its accessibility story is degradation to solid + * surfaces, not bespoke colorblind/tritanopia palettes). + */ +export function supportedColorModes(designLanguage: DesignLanguage): Theme[] { + if (designLanguage === DesignLanguage.GLASS) { + return [Theme.SYSTEM, Theme.LIGHT, Theme.DARK]; + } + + return [ + Theme.SYSTEM, + Theme.LIGHT, + Theme.LIGHT_COLORBLIND, + Theme.LIGHT_TRITANOPIA, + Theme.DARK, + Theme.DARK_COLORBLIND, + Theme.DARK_TRITANOPIA, + Theme.DARK_DIMMED, + ]; +} + +/** + * Clamp a stored color mode to the nearest value the active design language + * supports. Classic is the identity (every value is supported), so existing + * users are unaffected. For Glass, the accessibility/dimmed variants collapse + * to their base light or dark. + * + * The stored `theme` is never mutated by a language switch — clamping happens + * only at render/selection time — so switching Glass → Classic restores the + * user's original scheme (e.g. `dark_dimmed`) unless they explicitly picked a + * different one while on Glass. + */ +export function resolveColorMode(designLanguage: DesignLanguage, theme: Theme): Theme { + if (designLanguage !== DesignLanguage.GLASS) { + return theme; + } + + switch (theme) { + case Theme.SYSTEM: + case Theme.LIGHT: + case Theme.DARK: + return theme; + case Theme.LIGHT_COLORBLIND: + case Theme.LIGHT_TRITANOPIA: + return Theme.LIGHT; + default: + return Theme.DARK; + } +} From d87fd8899ca79231ccc686d607b119cbb9571da1 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 10:36:44 +0200 Subject: [PATCH 05/27] refactor(theme): trim non-essential comments --- src/renderer/App.css | 12 ++++------- src/renderer/components/ui/index.test.ts | 16 +++------------ src/renderer/components/ui/index.ts | 22 ++++++-------------- src/renderer/hooks/useAppearance.ts | 26 +++++------------------- src/renderer/types.ts | 10 ++------- src/renderer/utils/ui/theme.ts | 19 ++++------------- 6 files changed, 24 insertions(+), 81 deletions(-) diff --git a/src/renderer/App.css b/src/renderer/App.css index 929efc0ef..1cd54f1d7 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -8,14 +8,10 @@ @config "../../tailwind.config.mts"; /** - * Design-language chrome tokens. - * - * A design language re-skins chrome (radius / shadow / blur) by re-pointing - * these `--gitify-*` vars under `[data-theme="..."]`, mirroring how colors are - * re-pointed under `[data-color-mode]` in tailwind.config.mts. Classic aliases - * Tailwind's own values, so it renders identically. `@theme inline` exposes the - * tokens as utilities that resolve to the live var (so they re-resolve under the - * active `[data-theme]`); plain `@theme` would freeze them at `:root`. + * Design-language chrome tokens: a language re-skins radius/shadow/blur by + * re-pointing these `--gitify-*` vars under `[data-theme]`. Classic aliases + * Tailwind's own values. `@theme inline` (not plain `@theme`) is required so the + * utilities re-resolve under the active scope instead of freezing at `:root`. */ :root { --gitify-radius-sm: var(--radius-sm); diff --git a/src/renderer/components/ui/index.test.ts b/src/renderer/components/ui/index.test.ts index cda667125..19d2a7bab 100644 --- a/src/renderer/components/ui/index.test.ts +++ b/src/renderer/components/ui/index.test.ts @@ -1,19 +1,9 @@ import { readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; -/** - * Import boundary guard. - * - * The renderer must import Primer components through the app-owned `ui/` layer, - * never from `@primer/react` directly — otherwise the seam that lets a design - * language swap an implementation erodes and the two themes drift back together. - * - * `import type { ... } from '@primer/react'` is allowed anywhere (the app derives - * prop types from Primer's public surface); only value imports are restricted. - * - * This replaces a linter `no-restricted-imports` rule, which the bundled - * `vp lint` config does not expose for custom configuration. - */ +// Enforces the Primer import boundary in lieu of a `no-restricted-imports` lint +// rule, which the bundled `vp lint` config does not expose. Only value imports +// are restricted; `import type` from `@primer/react` is allowed anywhere. const RENDERER = path.join(process.cwd(), 'src/renderer'); const UI_DIR = path.join(RENDERER, 'components', 'ui'); const PRIMER_VALUE_IMPORT = /^import \{[^}]*\} from '@primer\/react'/m; diff --git a/src/renderer/components/ui/index.ts b/src/renderer/components/ui/index.ts index eecd493ec..bb7432c62 100644 --- a/src/renderer/components/ui/index.ts +++ b/src/renderer/components/ui/index.ts @@ -1,21 +1,11 @@ /** - * App-owned UI layer. + * App-owned UI layer. The renderer imports Primer components through here rather + * than `@primer/react` directly, so a design language can swap an implementation + * behind this seam. Phase 1 re-exports Primer unchanged; replace a line with a + * dedicated module when a component needs to diverge. * - * The renderer imports these instead of `@primer/react` directly, so a design - * language can later swap an implementation behind this seam without touching - * call sites (the D4 decision in the plan). Phase 1 (Classic) re-exports Primer - * unchanged, so rendering is byte-identical; the value of the layer today is the - * single, lint-enforced import boundary and the per-component swap point it - * creates. When a language needs a component to diverge structurally, replace - * that component's line here with a dedicated implementation module. - * - * `ThemeProvider` / `BaseStyles` / `useTheme` / `useConfirm` are re-exported too - * so ALL Primer value imports live under this directory: the provider stays as - * the token/attribute engine, and `useConfirm`'s dialog depends on it. - * - * Note: Primer *types* are intentionally still imported directly via - * `import type` at the two sites that need them (`derive from public props` - * convention) — the boundary isolates Primer components, not Primer types. + * Value imports are boundary-enforced (see index.test.ts); `import type` from + * `@primer/react` is still allowed at call sites. */ export { ActionList, diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts index d4da1faf6..82a02200e 100644 --- a/src/renderer/hooks/useAppearance.ts +++ b/src/renderer/hooks/useAppearance.ts @@ -15,21 +15,10 @@ import { } from '../utils/ui/theme'; /** - * Single seam for all appearance side effects. Must be called from within the - * Primer `ThemeProvider` (it consumes `useTheme`). + * Applies appearance side effects: Primer color mode/scheme plus the root + * `data-theme` (design language) and `data-glass-material` attributes. * - * Owns: - * - Color mode + day/night scheme application (Primer), preserving the - * "auto + high contrast" fallback composition. - * - The root design-language (`data-theme`) and platform material - * (`data-glass-material`) attributes that the CSS token layer keys off, - * alongside the `data-color-mode` attribute Primer's `ThemeProvider` owns. - * - * `data-theme` mirrors the active design language. `data-glass-material` is - * derived from the OS so `App.css` can branch base-transparency / blur - * behaviour declaratively. Every rule keyed off `data-glass-material` must also - * be scoped under `[data-theme="glass"]`, so these attributes are inert under - * Classic. + * Must be called from within the Primer `ThemeProvider` (it consumes `useTheme`). */ export function useAppearance(): void { const designLanguage = useSettingsStore((s) => s.designLanguage); @@ -39,16 +28,14 @@ export function useAppearance(): void { const { setColorMode, setDayScheme, setNightScheme } = useTheme(); useEffect(() => { - // Clamp the stored scheme to what the active language supports (identity for - // Classic, so existing users are unaffected). const effectiveTheme = resolveColorMode(designLanguage, theme); const colorMode = mapThemeModeToColorMode(effectiveTheme); const colorScheme = mapThemeModeToColorScheme(effectiveTheme, increaseContrast); setColorMode(colorMode); - // When colorScheme is null (System theme), use appropriate fallbacks - // based on whether high contrast is enabled + // System theme has no fixed scheme; fall back to a day/night pair that still + // honours high contrast. const dayFallback = increaseContrast ? DEFAULT_DAY_HIGH_CONTRAST_COLOR_SCHEME : DEFAULT_DAY_COLOR_SCHEME; @@ -61,13 +48,10 @@ export function useAppearance(): void { }, [designLanguage, theme, increaseContrast, setColorMode, setDayScheme, setNightScheme]); useEffect(() => { - // The enum value is already the lower-cased attribute value. document.documentElement.setAttribute('data-theme', designLanguage); }, [designLanguage]); useEffect(() => { - // Material fidelity is platform-decided: real vibrancy on macOS, CSS - // backdrop-filter elsewhere. Inert until a Glass language keys off it. document.documentElement.setAttribute( 'data-glass-material', window.gitify.platform.isMacOS() ? 'vibrancy' : 'backdrop-filter', diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 528bd0fb2..acb2266e7 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -89,9 +89,8 @@ export type SettingsState = AppearanceSettingsState & * Settings related to the appearance of the application. */ export interface AppearanceSettingsState { - /** The active design language. Its value doubles as the root `data-theme`. */ designLanguage: DesignLanguage; - /** The color scheme / accessibility palette (the color-mode axis). */ + /** The color-mode axis (color scheme / accessibility palette). */ theme: Theme; increaseContrast: boolean; zoomPercentage: Percentage; @@ -140,12 +139,7 @@ export interface SystemSettingsState { keepWindowOnBlur: boolean; } -/** - * A design language is a full visual language (chrome, materials), distinct - * from the color-mode axis (`Theme`). Values are lower-cased because they double - * as the root `data-theme` attribute the CSS token layer keys off, mirroring the - * lower-cased `data-color-mode` convention. - */ +/** Values are lower-cased because they double as the root `data-theme` attribute. */ export enum DesignLanguage { CLASSIC = 'classic', GLASS = 'glass', diff --git a/src/renderer/utils/ui/theme.ts b/src/renderer/utils/ui/theme.ts index 883bd4ffa..c1ae01a98 100644 --- a/src/renderer/utils/ui/theme.ts +++ b/src/renderer/utils/ui/theme.ts @@ -61,12 +61,7 @@ export function mapThemeModeToColorScheme( return increaseContrast ? `${base}_high_contrast` : base; } -/** - * The color-mode (`Theme`) values a design language supports. Classic exposes - * the full palette including the accessibility variants; Glass offers only - * light / dark / system (its accessibility story is degradation to solid - * surfaces, not bespoke colorblind/tritanopia palettes). - */ +/** The `Theme` values a design language exposes (Glass: light/dark/system only). */ export function supportedColorModes(designLanguage: DesignLanguage): Theme[] { if (designLanguage === DesignLanguage.GLASS) { return [Theme.SYSTEM, Theme.LIGHT, Theme.DARK]; @@ -85,15 +80,9 @@ export function supportedColorModes(designLanguage: DesignLanguage): Theme[] { } /** - * Clamp a stored color mode to the nearest value the active design language - * supports. Classic is the identity (every value is supported), so existing - * users are unaffected. For Glass, the accessibility/dimmed variants collapse - * to their base light or dark. - * - * The stored `theme` is never mutated by a language switch — clamping happens - * only at render/selection time — so switching Glass → Classic restores the - * user's original scheme (e.g. `dark_dimmed`) unless they explicitly picked a - * different one while on Glass. + * Clamps a stored color mode to what the active language supports (identity for + * Classic). The stored `theme` is never mutated — clamping is render-time only — + * so switching Glass → Classic restores the user's original scheme. */ export function resolveColorMode(designLanguage: DesignLanguage, theme: Theme): Theme { if (designLanguage !== DesignLanguage.GLASS) { From 6289fa8a65a1a72eb8240dbbe0564ad51bf1d880 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 10:44:06 +0200 Subject: [PATCH 06/27] feat(theme): add Glass shell surfaces with per-platform material branch --- src/renderer/App.css | 39 +++++++++++++++++++++++++++++++++++++++ tailwind.config.mts | 4 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/renderer/App.css b/src/renderer/App.css index 1cd54f1d7..5ac510f4f 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -18,6 +18,9 @@ --gitify-radius-md: var(--radius-md); --gitify-radius-full: calc(infinity * 1px); --gitify-shadow-sm: var(--shadow-sm); + + --gitify-sidebar: #24292e; + --gitify-footer: var(--bgColor-neutral-muted); } @theme inline { @@ -27,6 +30,42 @@ --shadow-gitify-sm: var(--gitify-shadow-sm); } +/** + * Glass design language. Shell surfaces become translucent tints; the material + * hook decides where the blur comes from — the OS on macOS (`vibrancy`) or CSS + * `backdrop-filter` elsewhere. Glass touches the shell only; notification rows + * keep their opaque backgrounds. Tint/blur values are starting points to tune. + */ +[data-theme='glass'][data-color-mode='light'] { + --gitify-glass-tint: rgb(255 255 255 / 0.72); +} + +[data-theme='glass'][data-color-mode='dark'] { + --gitify-glass-tint: rgb(22 27 34 / 0.62); +} + +[data-theme='glass'] { + /* Sidebar stays dark in both modes so its forced-white icons stay legible. */ + --gitify-sidebar: rgb(22 27 34 / 0.55); + --gitify-footer: var(--gitify-glass-tint); +} + +[data-theme='glass'][data-glass-material='backdrop-filter'] + :where(.bg-gitify-sidebar, .bg-gitify-footer) { + -webkit-backdrop-filter: blur(16px) saturate(1.6); + backdrop-filter: blur(16px) saturate(1.6); +} + +/* Vibrancy path: clear the app's own backgrounds so the native material shows. */ +[data-theme='glass'][data-glass-material='vibrancy'] { + --gitify-background: transparent; +} + +[data-theme='glass'][data-glass-material='vibrancy'] body, +[data-theme='glass'][data-glass-material='vibrancy'] #root { + background-color: transparent; +} + html, body, #root { diff --git a/tailwind.config.mts b/tailwind.config.mts index 81cdd5b04..d8da9a5cc 100644 --- a/tailwind.config.mts +++ b/tailwind.config.mts @@ -39,8 +39,8 @@ const config: Config = { gitify: { font: 'var(--fgColor-default)', background: 'var(--gitify-background)', - sidebar: '#24292e', - footer: 'var(--bgColor-neutral-muted)', + sidebar: 'var(--gitify-sidebar)', + footer: 'var(--gitify-footer)', caution: colors.orange[600], error: 'var(--fgColor-danger)', From 98ea376f244e8baff481d6f5ebb39e74c5d7b5cb Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 10:52:32 +0200 Subject: [PATCH 07/27] feat(theme): wire macOS window vibrancy via IPC for Glass --- src/main/handlers/system.ts | 12 +++++- src/main/lifecycle/window.test.ts | 50 +++++++++++++++++++++++- src/main/lifecycle/window.ts | 17 ++++++++ src/preload/index.ts | 6 +++ src/renderer/App.css | 10 +++-- src/renderer/__helpers__/vitest.setup.ts | 1 + src/renderer/hooks/useAppearance.test.ts | 30 +++++++++++++- src/renderer/hooks/useAppearance.ts | 23 +++++++++++ src/shared/events.ts | 2 + 9 files changed, 144 insertions(+), 7 deletions(-) diff --git a/src/main/handlers/system.ts b/src/main/handlers/system.ts index 659cc1475..27e9093d6 100644 --- a/src/main/handlers/system.ts +++ b/src/main/handlers/system.ts @@ -5,7 +5,7 @@ import { EVENTS } from '../../shared/events'; import { logInfo } from '../../shared/logger'; import { handleMainEvent, onMainEvent, sendRendererEvent } from '../events'; -import { applyKeepWindowOnBlur } from '../lifecycle/window'; +import { applyKeepWindowOnBlur, applyWindowVibrancy } from '../lifecycle/window'; /** * Register IPC handlers for OS-level system operations. @@ -84,4 +84,14 @@ export function registerSystemHandlers(mb: Menubar): void { onMainEvent(EVENTS.UPDATE_KEEP_WINDOW_ON_BLUR, (_, value: boolean) => { applyKeepWindowOnBlur(mb, value); }); + + /** + * Toggle the macOS window vibrancy material for the Glass design language. + * Request/response so the renderer can await the material before clearing the + * window's own background (avoids a black frame during the switch). + */ + handleMainEvent(EVENTS.SET_WINDOW_VIBRANCY, (_, enabled: boolean) => { + applyWindowVibrancy(mb, enabled); + return undefined; + }); } diff --git a/src/main/lifecycle/window.test.ts b/src/main/lifecycle/window.test.ts index d18963eee..21abadef7 100644 --- a/src/main/lifecycle/window.test.ts +++ b/src/main/lifecycle/window.test.ts @@ -3,8 +3,9 @@ import type { Menubar } from 'electron-menubar'; import type MenuBuilder from '../menu'; import { __resetWindowLifecycleForTests, - configureWindowEvents, applyKeepWindowOnBlur, + applyWindowVibrancy, + configureWindowEvents, } from './window'; const appOnMock = vi.fn(); @@ -73,6 +74,7 @@ describe('main/lifecycle/window.ts', () => { setSize: vi.fn(), center: vi.fn(), setAlwaysOnTop: vi.fn(), + setVibrancy: vi.fn(), hide: vi.fn(), isDestroyed: vi.fn().mockReturnValue(false), on: vi.fn(), @@ -184,6 +186,52 @@ describe('main/lifecycle/window.ts', () => { }); }); + describe('applyWindowVibrancy', () => { + it('applies the material on macOS when enabled', () => { + setPlatform('darwin'); + + applyWindowVibrancy(menubar, true); + + expect(menubar.window?.setVibrancy).toHaveBeenCalledWith('under-window'); + }); + + it('removes the material on macOS when disabled', () => { + setPlatform('darwin'); + + applyWindowVibrancy(menubar, false); + + expect(menubar.window?.setVibrancy).toHaveBeenCalledWith(null); + }); + + it('is a no-op off macOS', () => { + applyWindowVibrancy(menubar, true); + + expect(menubar.window?.setVibrancy).not.toHaveBeenCalled(); + }); + + it('skips the call when the window is destroyed', () => { + setPlatform('darwin'); + // oxlint-disable-next-line no-unsafe-optional-chaining -- window is guaranteed defined in this test + (menubar.window?.isDestroyed as ReturnType).mockReturnValue(true); + + applyWindowVibrancy(menubar, true); + + expect(menubar.window?.setVibrancy).not.toHaveBeenCalled(); + }); + + it('re-applies the remembered material when the window is shown', () => { + setPlatform('darwin'); + configureWindowEvents(menubar, menuBuilder); + applyWindowVibrancy(menubar, true); + // oxlint-disable-next-line no-unsafe-optional-chaining -- window is guaranteed defined in this test + (menubar.window?.setVibrancy as ReturnType).mockClear(); + + findWindowHandler(menubar, 'show')?.({ preventDefault: vi.fn() }); + + expect(menubar.window?.setVibrancy).toHaveBeenCalledWith('under-window'); + }); + }); + describe('devtools-closed handler', () => { it('delegates re-centering to mb.recenterOnTray()', () => { configureWindowEvents(menubar, menuBuilder); diff --git a/src/main/lifecycle/window.ts b/src/main/lifecycle/window.ts index b1dcd459f..179c62401 100644 --- a/src/main/lifecycle/window.ts +++ b/src/main/lifecycle/window.ts @@ -8,6 +8,7 @@ import type MenuBuilder from '../menu'; let isQuitting = false; let keepWindowOnBlur = false; +let windowVibrancyEnabled = false; /** * Reset module-level lifecycle flags. Module-level state is unavoidable @@ -19,6 +20,20 @@ let keepWindowOnBlur = false; export function __resetWindowLifecycleForTests(): void { isQuitting = false; keepWindowOnBlur = false; + windowVibrancyEnabled = false; +} + +/** + * Enable or disable the macOS window vibrancy material (the Glass design + * language). No-op off macOS. The desired state is remembered so it can be + * re-applied if `electron-menubar` rebuilds the window (see `configureWindowEvents`). + */ +export function applyWindowVibrancy(mb: Menubar, enabled: boolean): void { + windowVibrancyEnabled = enabled; + if (!isMacOS() || !mb.window || mb.window.isDestroyed()) { + return; + } + mb.window.setVibrancy(enabled ? 'under-window' : null); } /** @@ -55,6 +70,8 @@ export function configureWindowEvents(mb: Menubar, menuBuilder: MenuBuilder): vo win.on('show', () => { menuBuilder.setWindowVisibility(true); + // Re-apply vibrancy in case the window was rebuilt since it was last set. + applyWindowVibrancy(mb, windowVibrancyEnabled); }); win.on('hide', () => { diff --git a/src/preload/index.ts b/src/preload/index.ts index e3f137a77..283792044 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -63,6 +63,12 @@ export const api = { */ setKeepWindowOnBlur: (value: boolean) => sendMainEvent(EVENTS.UPDATE_KEEP_WINDOW_ON_BLUR, value), + /** + * Enable or disable the macOS window vibrancy material for Glass. Resolves once + * the material has been applied so the renderer can order the visual switch. + */ + setWindowVibrancy: (enabled: boolean) => invokeMainEvent(EVENTS.SET_WINDOW_VIBRANCY, enabled), + /** * Apply the global keyboard shortcut for toggling the app window visibility. * diff --git a/src/renderer/App.css b/src/renderer/App.css index 5ac510f4f..78540bb6a 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -56,13 +56,15 @@ backdrop-filter: blur(16px) saturate(1.6); } -/* Vibrancy path: clear the app's own backgrounds so the native material shows. */ -[data-theme='glass'][data-glass-material='vibrancy'] { +/* Vibrancy path: `.gitify-vibrant` is set on the root only once the native + * material is applied (see useAppearance), so clearing the app's own backgrounds + * to reveal it never flashes black mid-switch. */ +.gitify-vibrant { --gitify-background: transparent; } -[data-theme='glass'][data-glass-material='vibrancy'] body, -[data-theme='glass'][data-glass-material='vibrancy'] #root { +.gitify-vibrant body, +.gitify-vibrant #root { background-color: transparent; } diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index 8587efbdf..7bb4ce078 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -84,6 +84,7 @@ function createGitifyBridgeApi(): Window['gitify'] { openExternalLink: vi.fn(), decryptValue: vi.fn().mockResolvedValue({ token: 'decrypted' }), encryptValue: vi.fn().mockResolvedValue('encrypted'), + setWindowVibrancy: vi.fn().mockResolvedValue(undefined), platform: { isLinux: vi.fn().mockReturnValue(false), isMacOS: vi.fn().mockReturnValue(true), diff --git a/src/renderer/hooks/useAppearance.test.ts b/src/renderer/hooks/useAppearance.test.ts index a0089f6da..5a2d5d03c 100644 --- a/src/renderer/hooks/useAppearance.test.ts +++ b/src/renderer/hooks/useAppearance.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import { useSettingsStore } from '../stores'; @@ -10,6 +10,7 @@ describe('renderer/hooks/useAppearance.ts', () => { afterEach(() => { document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-glass-material'); + document.documentElement.classList.remove('gitify-vibrant'); }); it('marks the root with the Classic design language by default', () => { @@ -41,4 +42,31 @@ describe('renderer/hooks/useAppearance.ts', () => { expect(document.documentElement.getAttribute('data-glass-material')).toBe('backdrop-filter'); }); + + it('applies vibrancy and marks the root vibrant on macOS Glass', async () => { + useSettingsStore.setState({ designLanguage: DesignLanguage.GLASS }); + + renderHook(() => useAppearance()); + + expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(true); + await waitFor(() => + expect(document.documentElement.classList.contains('gitify-vibrant')).toBe(true), + ); + }); + + it('disables vibrancy for Classic on macOS', () => { + renderHook(() => useAppearance()); + + expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(false); + expect(document.documentElement.classList.contains('gitify-vibrant')).toBe(false); + }); + + it('does not touch vibrancy off macOS', () => { + vi.mocked(window.gitify.platform.isMacOS).mockReturnValue(false); + useSettingsStore.setState({ designLanguage: DesignLanguage.GLASS }); + + renderHook(() => useAppearance()); + + expect(window.gitify.setWindowVibrancy).not.toHaveBeenCalled(); + }); }); diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts index 82a02200e..c24598bf4 100644 --- a/src/renderer/hooks/useAppearance.ts +++ b/src/renderer/hooks/useAppearance.ts @@ -4,6 +4,8 @@ import { useSettingsStore } from '../stores'; import { useTheme } from '../components/ui'; +import { DesignLanguage } from '../types'; + import { DEFAULT_DAY_COLOR_SCHEME, DEFAULT_DAY_HIGH_CONTRAST_COLOR_SCHEME, @@ -57,4 +59,25 @@ export function useAppearance(): void { window.gitify.platform.isMacOS() ? 'vibrancy' : 'backdrop-filter', ); }, []); + + useEffect(() => { + if (!window.gitify.platform.isMacOS()) { + return; + } + + const root = document.documentElement; + const enable = designLanguage === DesignLanguage.GLASS; + + // The `gitify-vibrant` class clears the window's own background so the native + // material shows. Add it only after vibrancy is applied, and remove it before + // vibrancy is dropped, so the window never renders black mid-switch. + if (!enable) { + root.classList.remove('gitify-vibrant'); + } + + window.gitify.setWindowVibrancy(enable).then( + () => enable && root.classList.add('gitify-vibrant'), + () => root.classList.remove('gitify-vibrant'), + ); + }, [designLanguage]); } diff --git a/src/shared/events.ts b/src/shared/events.ts index c68362eea..52a732cdd 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -17,6 +17,7 @@ export const EVENTS = { UPDATE_KEYBOARD_SHORTCUT: `${P}update-keyboard-shortcut`, UPDATE_AUTO_LAUNCH: `${P}update-auto-launch`, UPDATE_KEEP_WINDOW_ON_BLUR: `${P}update-keep-window-on-blur`, + SET_WINDOW_VIBRANCY: `${P}set-window-vibrancy`, SAFE_STORAGE_ENCRYPT: `${P}safe-storage-encrypt`, SAFE_STORAGE_DECRYPT: `${P}safe-storage-decrypt`, NOTIFICATION_SOUND_PATH: `${P}notification-sound-path`, @@ -106,6 +107,7 @@ export type EventContracts = AssertEventCoverage<{ request: boolean; response: undefined; }; + [EVENTS.SET_WINDOW_VIBRANCY]: { request: boolean; response: undefined }; [EVENTS.SAFE_STORAGE_ENCRYPT]: { request: string; response: string }; [EVENTS.SAFE_STORAGE_DECRYPT]: { request: string; From 74f6c2529989221c4b95741912cc6703d34b4dc6 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 10:59:43 +0200 Subject: [PATCH 08/27] feat(theme): degrade Glass to solid under reduced transparency / contrast --- src/renderer/App.css | 26 +++++++++++++ src/renderer/__mocks__/state-mocks.ts | 1 + .../settings/AppearanceSettings.test.tsx | 38 +++++++++++++++++++ .../settings/AppearanceSettings.tsx | 25 ++++++++++++ src/renderer/hooks/useAppearance.test.ts | 20 +++++++++- src/renderer/hooks/useAppearance.ts | 28 +++++++++----- .../usePrefersReducedTransparency.test.ts | 33 ++++++++++++++++ .../hooks/usePrefersReducedTransparency.ts | 23 +++++++++++ src/renderer/stores/defaults.ts | 1 + src/renderer/types.ts | 2 + 10 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 src/renderer/hooks/usePrefersReducedTransparency.test.ts create mode 100644 src/renderer/hooks/usePrefersReducedTransparency.ts diff --git a/src/renderer/App.css b/src/renderer/App.css index 78540bb6a..eaf33e696 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -68,6 +68,32 @@ background-color: transparent; } +/* Accessibility degradation: solid, legible surfaces under the OS Reduce + * Transparency / Increase Contrast settings, or `.gitify-solid` (increase-contrast + * or the in-app translucency toggle — see useAppearance). The vibrancy base needs + * no fallback here: useAppearance withholds `.gitify-vibrant` in these states. */ +@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { + [data-theme='glass'] { + --gitify-glass-tint: var(--bgColor-neutral-muted); + --gitify-sidebar: #24292e; + } + + [data-theme='glass'] :where(.bg-gitify-sidebar, .bg-gitify-footer) { + -webkit-backdrop-filter: none; + backdrop-filter: none; + } +} + +[data-theme='glass'].gitify-solid { + --gitify-glass-tint: var(--bgColor-neutral-muted); + --gitify-sidebar: #24292e; +} + +[data-theme='glass'].gitify-solid :where(.bg-gitify-sidebar, .bg-gitify-footer) { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + html, body, #root { diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 172305521..b792e30b6 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -28,6 +28,7 @@ const mockAppearanceSettings: AppearanceSettingsState = { designLanguage: DesignLanguage.CLASSIC, theme: Theme.SYSTEM, increaseContrast: false, + enableTranslucency: true, zoomPercentage: 100 as Percentage, showAccountHeader: false, wrapNotificationTitle: false, diff --git a/src/renderer/components/settings/AppearanceSettings.test.tsx b/src/renderer/components/settings/AppearanceSettings.test.tsx index bd3a84186..296be90d4 100644 --- a/src/renderer/components/settings/AppearanceSettings.test.tsx +++ b/src/renderer/components/settings/AppearanceSettings.test.tsx @@ -56,6 +56,44 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { expect(screen.queryByRole('option', { name: 'Dark Tritanopia' })).not.toBeInTheDocument(); }); + it('shows and toggles the translucency checkbox on Glass', async () => { + await act(async () => { + renderWithProviders(, { + settings: { designLanguage: DesignLanguage.GLASS }, + }); + }); + + await userEvent.click(screen.getByTestId('checkbox-enableTranslucency')); + + expect(toggleSettingSpy).toHaveBeenCalledWith('enableTranslucency'); + }); + + it('hides the translucency checkbox on Classic', async () => { + await act(async () => { + renderWithProviders(); + }); + + expect(screen.queryByTestId('checkbox-enableTranslucency')).not.toBeInTheDocument(); + }); + + it('disables the translucency checkbox under OS reduced transparency', async () => { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList); + + await act(async () => { + renderWithProviders(, { + settings: { designLanguage: DesignLanguage.GLASS }, + }); + }); + + expect(screen.getByTestId('checkbox-enableTranslucency')).toBeDisabled(); + + vi.restoreAllMocks(); + }); + it('should toggle increase contrast checkbox', async () => { await act(async () => { renderWithProviders(, { diff --git a/src/renderer/components/settings/AppearanceSettings.tsx b/src/renderer/components/settings/AppearanceSettings.tsx index a56a78a91..91de8cdf1 100644 --- a/src/renderer/components/settings/AppearanceSettings.tsx +++ b/src/renderer/components/settings/AppearanceSettings.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { PaintbrushIcon, SyncIcon, ZoomInIcon, ZoomOutIcon } from '@primer/octicons-react'; +import { usePrefersReducedTransparency } from '../../hooks/usePrefersReducedTransparency'; import { useAccountsStore, useSettingsStore } from '../../stores'; import { Checkbox } from '../fields/Checkbox'; @@ -32,6 +33,8 @@ export const AppearanceSettings: FC = () => { const designLanguage = useSettingsStore((s) => s.designLanguage); const theme = useSettingsStore((s) => s.theme); const increaseContrast = useSettingsStore((s) => s.increaseContrast); + const enableTranslucency = useSettingsStore((s) => s.enableTranslucency); + const prefersReducedTransparency = usePrefersReducedTransparency(); const colorModeSupported = (mode: Theme) => supportedColorModes(designLanguage).includes(mode); const showAccountHeader = useSettingsStore((s) => s.showAccountHeader); @@ -102,6 +105,28 @@ export const AppearanceSettings: FC = () => { } /> + toggleSetting('enableTranslucency')} + tooltip={ + prefersReducedTransparency ? ( + + Disabled because Reduce Transparency is enabled in your system accessibility + settings. + + ) : ( + + Use translucent Glass surfaces. Automatically turns off under Reduce Transparency or + Increase Contrast. + + ) + } + visible={designLanguage === DesignLanguage.GLASS} + /> + diff --git a/src/renderer/hooks/useAppearance.test.ts b/src/renderer/hooks/useAppearance.test.ts index 5a2d5d03c..74c5abf14 100644 --- a/src/renderer/hooks/useAppearance.test.ts +++ b/src/renderer/hooks/useAppearance.test.ts @@ -10,7 +10,7 @@ describe('renderer/hooks/useAppearance.ts', () => { afterEach(() => { document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-glass-material'); - document.documentElement.classList.remove('gitify-vibrant'); + document.documentElement.classList.remove('gitify-vibrant', 'gitify-solid'); }); it('marks the root with the Classic design language by default', () => { @@ -69,4 +69,22 @@ describe('renderer/hooks/useAppearance.ts', () => { expect(window.gitify.setWindowVibrancy).not.toHaveBeenCalled(); }); + + it('degrades Glass to solid (no vibrancy) under increased contrast', () => { + useSettingsStore.setState({ designLanguage: DesignLanguage.GLASS, increaseContrast: true }); + + renderHook(() => useAppearance()); + + expect(document.documentElement.classList.contains('gitify-solid')).toBe(true); + expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(false); + }); + + it('degrades Glass to solid when translucency is disabled', () => { + useSettingsStore.setState({ designLanguage: DesignLanguage.GLASS, enableTranslucency: false }); + + renderHook(() => useAppearance()); + + expect(document.documentElement.classList.contains('gitify-solid')).toBe(true); + expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(false); + }); }); diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts index c24598bf4..32abc5922 100644 --- a/src/renderer/hooks/useAppearance.ts +++ b/src/renderer/hooks/useAppearance.ts @@ -15,6 +15,7 @@ import { mapThemeModeToColorScheme, resolveColorMode, } from '../utils/ui/theme'; +import { usePrefersReducedTransparency } from './usePrefersReducedTransparency'; /** * Applies appearance side effects: Primer color mode/scheme plus the root @@ -26,6 +27,8 @@ export function useAppearance(): void { const designLanguage = useSettingsStore((s) => s.designLanguage); const theme = useSettingsStore((s) => s.theme); const increaseContrast = useSettingsStore((s) => s.increaseContrast); + const enableTranslucency = useSettingsStore((s) => s.enableTranslucency); + const prefersReducedTransparency = usePrefersReducedTransparency(); const { setColorMode, setDayScheme, setNightScheme } = useTheme(); @@ -61,23 +64,30 @@ export function useAppearance(): void { }, []); useEffect(() => { + const root = document.documentElement; + + // Glass degrades to solid surfaces under increased contrast or when the user + // turns translucency off; `.gitify-solid` drives that in CSS (the OS Reduce + // Transparency setting is handled by a media query in App.css). + const solid = increaseContrast || !enableTranslucency; + root.classList.toggle('gitify-solid', solid); + if (!window.gitify.platform.isMacOS()) { return; } - const root = document.documentElement; - const enable = designLanguage === DesignLanguage.GLASS; + const vibrant = + designLanguage === DesignLanguage.GLASS && !solid && !prefersReducedTransparency; - // The `gitify-vibrant` class clears the window's own background so the native - // material shows. Add it only after vibrancy is applied, and remove it before - // vibrancy is dropped, so the window never renders black mid-switch. - if (!enable) { + // Add `.gitify-vibrant` only after the material is applied, and drop it before + // vibrancy is removed, so the window never renders black mid-switch. + if (!vibrant) { root.classList.remove('gitify-vibrant'); } - window.gitify.setWindowVibrancy(enable).then( - () => enable && root.classList.add('gitify-vibrant'), + window.gitify.setWindowVibrancy(vibrant).then( + () => vibrant && root.classList.add('gitify-vibrant'), () => root.classList.remove('gitify-vibrant'), ); - }, [designLanguage]); + }, [designLanguage, increaseContrast, enableTranslucency, prefersReducedTransparency]); } diff --git a/src/renderer/hooks/usePrefersReducedTransparency.test.ts b/src/renderer/hooks/usePrefersReducedTransparency.test.ts new file mode 100644 index 000000000..387ca76d3 --- /dev/null +++ b/src/renderer/hooks/usePrefersReducedTransparency.test.ts @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react'; + +import { usePrefersReducedTransparency } from './usePrefersReducedTransparency'; + +function mockMatchMedia(matches: boolean) { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList); +} + +describe('renderer/hooks/usePrefersReducedTransparency.ts', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('is false when the OS does not request reduced transparency', () => { + mockMatchMedia(false); + + const { result } = renderHook(() => usePrefersReducedTransparency()); + + expect(result.current).toBe(false); + }); + + it('is true when the OS requests reduced transparency', () => { + mockMatchMedia(true); + + const { result } = renderHook(() => usePrefersReducedTransparency()); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/renderer/hooks/usePrefersReducedTransparency.ts b/src/renderer/hooks/usePrefersReducedTransparency.ts new file mode 100644 index 000000000..f85903995 --- /dev/null +++ b/src/renderer/hooks/usePrefersReducedTransparency.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +const QUERY = '(prefers-reduced-transparency: reduce)'; + +/** + * Tracks the OS "Reduce Transparency" accessibility setting. This is the reliable + * live signal (Electron's `nativeTheme` does not surface it); the main process + * cannot read it, so Glass degradation is driven from the renderer. + */ +export function usePrefersReducedTransparency(): boolean { + const [reduced, setReduced] = useState(() => window.matchMedia(QUERY).matches); + + useEffect(() => { + const mql = window.matchMedia(QUERY); + const onChange = () => setReduced(mql.matches); + + onChange(); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); + }, []); + + return reduced; +} diff --git a/src/renderer/stores/defaults.ts b/src/renderer/stores/defaults.ts index 407e501a1..f3621626e 100644 --- a/src/renderer/stores/defaults.ts +++ b/src/renderer/stores/defaults.ts @@ -43,6 +43,7 @@ const DEFAULT_APPEARANCE_SETTINGS: AppearanceSettingsState = { designLanguage: DesignLanguage.CLASSIC, theme: Theme.SYSTEM, increaseContrast: false, + enableTranslucency: true, zoomPercentage: 100 as Percentage, showAccountHeader: false, wrapNotificationTitle: false, diff --git a/src/renderer/types.ts b/src/renderer/types.ts index acb2266e7..61773908e 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -93,6 +93,8 @@ export interface AppearanceSettingsState { /** The color-mode axis (color scheme / accessibility palette). */ theme: Theme; increaseContrast: boolean; + /** Glass translucency, forced off by increase-contrast or OS Reduce Transparency. */ + enableTranslucency: boolean; zoomPercentage: Percentage; showAccountHeader: boolean; wrapNotificationTitle: boolean; From 06dcfde9948007d8424f167a77763a36f61d6402 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 11:51:14 +0200 Subject: [PATCH 09/27] feat(theme): desaturate Glass status palette toward a native tone --- src/renderer/App.css | 25 +++++++++++++++++++++++++ tailwind.config.mts | 10 +++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/renderer/App.css b/src/renderer/App.css index eaf33e696..426a9718b 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -21,6 +21,13 @@ --gitify-sidebar: #24292e; --gitify-footer: var(--bgColor-neutral-muted); + + /* Status/accent colours, indirected so Glass can desaturate them. */ + --gitify-icon-open: var(--fgColor-open); + --gitify-icon-closed: var(--fgColor-closed); + --gitify-icon-done: var(--fgColor-done); + --gitify-icon-attention: var(--fgColor-attention); + --gitify-link: var(--fgColor-link); } @theme inline { @@ -48,6 +55,14 @@ /* Sidebar stays dark in both modes so its forced-white icons stay legible. */ --gitify-sidebar: rgb(22 27 34 / 0.55); --gitify-footer: var(--gitify-glass-tint); + + /* Restrained, near-native palette: blend GitHub's vivid status/accent colours + * toward the muted foreground (the `45%` is the dial to tune). */ + --gitify-icon-open: color-mix(in oklab, var(--fgColor-open), var(--fgColor-muted) 45%); + --gitify-icon-closed: color-mix(in oklab, var(--fgColor-closed), var(--fgColor-muted) 45%); + --gitify-icon-done: color-mix(in oklab, var(--fgColor-done), var(--fgColor-muted) 45%); + --gitify-icon-attention: color-mix(in oklab, var(--fgColor-attention), var(--fgColor-muted) 45%); + --gitify-link: color-mix(in oklab, var(--fgColor-link), var(--fgColor-muted) 30%); } [data-theme='glass'][data-glass-material='backdrop-filter'] @@ -76,6 +91,11 @@ [data-theme='glass'] { --gitify-glass-tint: var(--bgColor-neutral-muted); --gitify-sidebar: #24292e; + --gitify-icon-open: var(--fgColor-open); + --gitify-icon-closed: var(--fgColor-closed); + --gitify-icon-done: var(--fgColor-done); + --gitify-icon-attention: var(--fgColor-attention); + --gitify-link: var(--fgColor-link); } [data-theme='glass'] :where(.bg-gitify-sidebar, .bg-gitify-footer) { @@ -87,6 +107,11 @@ [data-theme='glass'].gitify-solid { --gitify-glass-tint: var(--bgColor-neutral-muted); --gitify-sidebar: #24292e; + --gitify-icon-open: var(--fgColor-open); + --gitify-icon-closed: var(--fgColor-closed); + --gitify-icon-done: var(--fgColor-done); + --gitify-icon-attention: var(--fgColor-attention); + --gitify-link: var(--fgColor-link); } [data-theme='glass'].gitify-solid :where(.bg-gitify-sidebar, .bg-gitify-footer) { diff --git a/tailwind.config.mts b/tailwind.config.mts index d8da9a5cc..8e3de9859 100644 --- a/tailwind.config.mts +++ b/tailwind.config.mts @@ -44,18 +44,18 @@ const config: Config = { caution: colors.orange[600], error: 'var(--fgColor-danger)', - link: 'var(--fgColor-link)', + link: 'var(--gitify-link)', input: { rest: 'var(--control-bgColor-rest)', focus: 'var(--control-bgColor-active)', }, icon: { - attention: 'var(--fgColor-attention)', - closed: 'var(--fgColor-closed)', - done: 'var(--fgColor-done)', + attention: 'var(--gitify-icon-attention)', + closed: 'var(--gitify-icon-closed)', + done: 'var(--gitify-icon-done)', muted: 'var(--fgColor-muted)', - open: 'var(--fgColor-open)', + open: 'var(--gitify-icon-open)', }, counter: { From 760e4e9030578cbd2b0aa1dab30b3a40d00bfff9 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 13:14:50 +0200 Subject: [PATCH 10/27] fix(theme): construct a transparent vibrant window on macOS so Glass material shows --- src/main/config.ts | 10 ++++++++++ src/main/lifecycle/window.test.ts | 1 + src/main/lifecycle/window.ts | 3 +++ 3 files changed, 14 insertions(+) diff --git a/src/main/config.ts b/src/main/config.ts index 50a3d9a4e..65297dcf0 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url'; import type { BrowserWindowConstructorOptions } from 'electron'; import { APPLICATION } from '../shared/constants'; +import { isMacOS } from '../shared/platform'; import { isDevMode } from './utils'; @@ -39,6 +40,15 @@ export const WindowConfig: BrowserWindowConstructorOptions = { minWidth: 500, minHeight: 400, resizable: false, + /** + * macOS Glass needs a transparent, vibrant window so the native material can + * show through; `setVibrancy()` alone can't clear the opaque window background. + * `transparent` is immutable after creation, so it's on for all macOS sessions + * — Classic simply paints an opaque background over it. Not applied on + * Windows/Linux, where `transparent` needs a frameless window and the CSS + * `backdrop-filter` path handles Glass instead. + */ + ...(isMacOS() ? { transparent: true, vibrancy: 'under-window' as const } : {}), /** Hide the app from the Windows taskbar */ skipTaskbar: true, webPreferences: { diff --git a/src/main/lifecycle/window.test.ts b/src/main/lifecycle/window.test.ts index 21abadef7..a5860ab00 100644 --- a/src/main/lifecycle/window.test.ts +++ b/src/main/lifecycle/window.test.ts @@ -75,6 +75,7 @@ describe('main/lifecycle/window.ts', () => { center: vi.fn(), setAlwaysOnTop: vi.fn(), setVibrancy: vi.fn(), + setBackgroundColor: vi.fn(), hide: vi.fn(), isDestroyed: vi.fn().mockReturnValue(false), on: vi.fn(), diff --git a/src/main/lifecycle/window.ts b/src/main/lifecycle/window.ts index 179c62401..2823a98e5 100644 --- a/src/main/lifecycle/window.ts +++ b/src/main/lifecycle/window.ts @@ -34,6 +34,9 @@ export function applyWindowVibrancy(mb: Menubar, enabled: boolean): void { return; } mb.window.setVibrancy(enabled ? 'under-window' : null); + // Clear the window's own (opaque white) background so the vibrancy material + // shows; restore it when vibrancy is off so Classic keeps its solid backdrop. + mb.window.setBackgroundColor(enabled ? '#00000000' : '#ffffff'); } /** From ee83c298a6210f86c313041cf7aa7ff38867c7e1 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 14:11:10 +0200 Subject: [PATCH 11/27] feat(theme): dissolve sidebar into unified Glass and mute the primary CTA --- src/renderer/App.css | 49 +++++++++++++++++++ src/renderer/components/Sidebar.tsx | 2 +- .../__snapshots__/Sidebar.test.tsx.snap | 4 +- .../__snapshots__/AppLayout.test.tsx.snap | 2 +- src/renderer/hooks/useAppearance.test.ts | 6 ++- src/renderer/hooks/useAppearance.ts | 9 ++-- 6 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/renderer/App.css b/src/renderer/App.css index 426a9718b..76d333b57 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -45,10 +45,12 @@ */ [data-theme='glass'][data-color-mode='light'] { --gitify-glass-tint: rgb(255 255 255 / 0.72); + --gitify-glass-border: rgb(0 0 0 / 0.14); } [data-theme='glass'][data-color-mode='dark'] { --gitify-glass-tint: rgb(22 27 34 / 0.62); + --gitify-glass-border: rgb(255 255 255 / 0.14); } [data-theme='glass'] { @@ -71,6 +73,53 @@ backdrop-filter: blur(16px) saturate(1.6); } +/** + * All-in Glass: when translucency is actually on (`.gitify-translucent`, gated by + * a no-preference media query so OS Reduce Transparency still wins), the window is + * one continuous glass surface. The sidebar loses its fill and becomes icons on the + * glass, separated from the content by an inset hairline instead of a solid block. + */ +@media (prefers-reduced-transparency: no-preference) { + /* Primer sets its colour tokens on the `data-color-mode` wrapper, so Glass + * overrides of Primer tokens must target that element (root-level ones are + * shadowed by it). Mute the vivid primary CTA (success-green) toward neutral. */ + .gitify-translucent [data-color-mode] { + --button-primary-bgColor-rest: color-mix( + in oklab, + var(--bgColor-success-emphasis), + var(--fgColor-muted) 55% + ); + --button-primary-bgColor-hover: color-mix( + in oklab, + var(--bgColor-success-emphasis), + var(--fgColor-muted) 42% + ); + --button-primary-bgColor-active: color-mix( + in oklab, + var(--bgColor-success-emphasis), + var(--fgColor-muted) 34% + ); + } + + .gitify-translucent .gitify-sidebar { + background: transparent; + } + + .gitify-translucent .gitify-sidebar :where(svg) { + color: var(--fgColor-muted); + } + + .gitify-translucent .gitify-sidebar::after { + content: ''; + position: absolute; + top: 14px; + right: 0; + bottom: 14px; + width: 1px; + background: var(--gitify-glass-border); + } +} + /* Vibrancy path: `.gitify-vibrant` is set on the root only once the native * material is applied (see useAppearance), so clearing the app's own backgrounds * to reveal it never flashes black mid-switch. */ diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 0e2d4c2cd..30b603de6 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -37,7 +37,7 @@ export const Sidebar: FC = () => { return ( diff --git a/src/renderer/components/__snapshots__/Sidebar.test.tsx.snap b/src/renderer/components/__snapshots__/Sidebar.test.tsx.snap index 367c2e4ea..82cc337cc 100644 --- a/src/renderer/components/__snapshots__/Sidebar.test.tsx.snap +++ b/src/renderer/components/__snapshots__/Sidebar.test.tsx.snap @@ -102,7 +102,7 @@ exports[`renderer/components/Sidebar.tsx > notifications icon > renders correct exports[`renderer/components/Sidebar.tsx > should render itself & its children (logged in) 1`] = `
should render itself & its children ( exports[`renderer/components/Sidebar.tsx > should render itself & its children (logged out) 1`] = `
should render itself & its c type="button" />
{ afterEach(() => { document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-glass-material'); - document.documentElement.classList.remove('gitify-vibrant', 'gitify-solid'); + document.documentElement.classList.remove( + 'gitify-vibrant', + 'gitify-solid', + 'gitify-translucent', + ); }); it('marks the root with the Classic design language by default', () => { diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts index 32abc5922..8d76033b5 100644 --- a/src/renderer/hooks/useAppearance.ts +++ b/src/renderer/hooks/useAppearance.ts @@ -72,13 +72,16 @@ export function useAppearance(): void { const solid = increaseContrast || !enableTranslucency; root.classList.toggle('gitify-solid', solid); + // The full translucent Glass treatment (dissolved chrome, hairline dividers) + // is active only when nothing is forcing solid surfaces. + const vibrant = + designLanguage === DesignLanguage.GLASS && !solid && !prefersReducedTransparency; + root.classList.toggle('gitify-translucent', vibrant); + if (!window.gitify.platform.isMacOS()) { return; } - const vibrant = - designLanguage === DesignLanguage.GLASS && !solid && !prefersReducedTransparency; - // Add `.gitify-vibrant` only after the material is applied, and drop it before // vibrancy is removed, so the window never renders black mid-switch. if (!vibrant) { From 2321fc01dd76bf1ca7c38af42e6bb33ac3a0a08f Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 14:15:33 +0200 Subject: [PATCH 12/27] feat(theme): soften notification grouping bands and count pills under Glass --- src/renderer/App.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/renderer/App.css b/src/renderer/App.css index 76d333b57..1ff68081a 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -99,12 +99,21 @@ var(--bgColor-success-emphasis), var(--fgColor-muted) 34% ); + + /* Neutralise the blue notification-count pill. */ + --gitify-counter-primary: var(--gitify-counter-secondary); } .gitify-translucent .gitify-sidebar { background: transparent; } + /* Repo / account grouping bands become faint glass tints instead of solid + * fills, so the list reads as one surface while keeping the grouping. */ + .gitify-translucent :where(.bg-gitify-repository, .bg-gitify-account-rest) { + background: color-mix(in oklab, var(--gitify-glass-tint), transparent 45%); + } + .gitify-translucent .gitify-sidebar :where(svg) { color: var(--fgColor-muted); } From 3c95c11ef61c89863bc7cf1f0e83cd092fa97d40 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 15:09:32 +0200 Subject: [PATCH 13/27] feat(theme): make Glass translucency always-on and add a visible sidebar divider --- src/renderer/App.css | 4 +- src/renderer/__mocks__/state-mocks.ts | 1 - .../settings/AppearanceSettings.test.tsx | 38 ------------------- .../settings/AppearanceSettings.tsx | 25 ------------ src/renderer/hooks/useAppearance.test.ts | 9 ----- src/renderer/hooks/useAppearance.ts | 10 ++--- src/renderer/stores/defaults.ts | 1 - src/renderer/types.ts | 2 - 8 files changed, 7 insertions(+), 83 deletions(-) diff --git a/src/renderer/App.css b/src/renderer/App.css index 1ff68081a..3ca3f1cc8 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -46,11 +46,13 @@ [data-theme='glass'][data-color-mode='light'] { --gitify-glass-tint: rgb(255 255 255 / 0.72); --gitify-glass-border: rgb(0 0 0 / 0.14); + --gitify-glass-divider: rgb(0 0 0 / 0.28); } [data-theme='glass'][data-color-mode='dark'] { --gitify-glass-tint: rgb(22 27 34 / 0.62); --gitify-glass-border: rgb(255 255 255 / 0.14); + --gitify-glass-divider: rgb(255 255 255 / 0.2); } [data-theme='glass'] { @@ -125,7 +127,7 @@ right: 0; bottom: 14px; width: 1px; - background: var(--gitify-glass-border); + background: var(--gitify-glass-divider); } } diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index b792e30b6..172305521 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -28,7 +28,6 @@ const mockAppearanceSettings: AppearanceSettingsState = { designLanguage: DesignLanguage.CLASSIC, theme: Theme.SYSTEM, increaseContrast: false, - enableTranslucency: true, zoomPercentage: 100 as Percentage, showAccountHeader: false, wrapNotificationTitle: false, diff --git a/src/renderer/components/settings/AppearanceSettings.test.tsx b/src/renderer/components/settings/AppearanceSettings.test.tsx index 296be90d4..bd3a84186 100644 --- a/src/renderer/components/settings/AppearanceSettings.test.tsx +++ b/src/renderer/components/settings/AppearanceSettings.test.tsx @@ -56,44 +56,6 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { expect(screen.queryByRole('option', { name: 'Dark Tritanopia' })).not.toBeInTheDocument(); }); - it('shows and toggles the translucency checkbox on Glass', async () => { - await act(async () => { - renderWithProviders(, { - settings: { designLanguage: DesignLanguage.GLASS }, - }); - }); - - await userEvent.click(screen.getByTestId('checkbox-enableTranslucency')); - - expect(toggleSettingSpy).toHaveBeenCalledWith('enableTranslucency'); - }); - - it('hides the translucency checkbox on Classic', async () => { - await act(async () => { - renderWithProviders(); - }); - - expect(screen.queryByTestId('checkbox-enableTranslucency')).not.toBeInTheDocument(); - }); - - it('disables the translucency checkbox under OS reduced transparency', async () => { - vi.spyOn(window, 'matchMedia').mockReturnValue({ - matches: true, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } as unknown as MediaQueryList); - - await act(async () => { - renderWithProviders(, { - settings: { designLanguage: DesignLanguage.GLASS }, - }); - }); - - expect(screen.getByTestId('checkbox-enableTranslucency')).toBeDisabled(); - - vi.restoreAllMocks(); - }); - it('should toggle increase contrast checkbox', async () => { await act(async () => { renderWithProviders(, { diff --git a/src/renderer/components/settings/AppearanceSettings.tsx b/src/renderer/components/settings/AppearanceSettings.tsx index 91de8cdf1..a56a78a91 100644 --- a/src/renderer/components/settings/AppearanceSettings.tsx +++ b/src/renderer/components/settings/AppearanceSettings.tsx @@ -2,7 +2,6 @@ import type { FC } from 'react'; import { PaintbrushIcon, SyncIcon, ZoomInIcon, ZoomOutIcon } from '@primer/octicons-react'; -import { usePrefersReducedTransparency } from '../../hooks/usePrefersReducedTransparency'; import { useAccountsStore, useSettingsStore } from '../../stores'; import { Checkbox } from '../fields/Checkbox'; @@ -33,8 +32,6 @@ export const AppearanceSettings: FC = () => { const designLanguage = useSettingsStore((s) => s.designLanguage); const theme = useSettingsStore((s) => s.theme); const increaseContrast = useSettingsStore((s) => s.increaseContrast); - const enableTranslucency = useSettingsStore((s) => s.enableTranslucency); - const prefersReducedTransparency = usePrefersReducedTransparency(); const colorModeSupported = (mode: Theme) => supportedColorModes(designLanguage).includes(mode); const showAccountHeader = useSettingsStore((s) => s.showAccountHeader); @@ -105,28 +102,6 @@ export const AppearanceSettings: FC = () => { } /> - toggleSetting('enableTranslucency')} - tooltip={ - prefersReducedTransparency ? ( - - Disabled because Reduce Transparency is enabled in your system accessibility - settings. - - ) : ( - - Use translucent Glass surfaces. Automatically turns off under Reduce Transparency or - Increase Contrast. - - ) - } - visible={designLanguage === DesignLanguage.GLASS} - /> - diff --git a/src/renderer/hooks/useAppearance.test.ts b/src/renderer/hooks/useAppearance.test.ts index 44da34536..8b137b341 100644 --- a/src/renderer/hooks/useAppearance.test.ts +++ b/src/renderer/hooks/useAppearance.test.ts @@ -82,13 +82,4 @@ describe('renderer/hooks/useAppearance.ts', () => { expect(document.documentElement.classList.contains('gitify-solid')).toBe(true); expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(false); }); - - it('degrades Glass to solid when translucency is disabled', () => { - useSettingsStore.setState({ designLanguage: DesignLanguage.GLASS, enableTranslucency: false }); - - renderHook(() => useAppearance()); - - expect(document.documentElement.classList.contains('gitify-solid')).toBe(true); - expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(false); - }); }); diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts index 8d76033b5..c36915acd 100644 --- a/src/renderer/hooks/useAppearance.ts +++ b/src/renderer/hooks/useAppearance.ts @@ -27,7 +27,6 @@ export function useAppearance(): void { const designLanguage = useSettingsStore((s) => s.designLanguage); const theme = useSettingsStore((s) => s.theme); const increaseContrast = useSettingsStore((s) => s.increaseContrast); - const enableTranslucency = useSettingsStore((s) => s.enableTranslucency); const prefersReducedTransparency = usePrefersReducedTransparency(); const { setColorMode, setDayScheme, setNightScheme } = useTheme(); @@ -66,10 +65,9 @@ export function useAppearance(): void { useEffect(() => { const root = document.documentElement; - // Glass degrades to solid surfaces under increased contrast or when the user - // turns translucency off; `.gitify-solid` drives that in CSS (the OS Reduce - // Transparency setting is handled by a media query in App.css). - const solid = increaseContrast || !enableTranslucency; + // Glass is always translucent; it only degrades to solid under increased + // contrast (`.gitify-solid`) or OS Reduce Transparency (a media query in App.css). + const solid = increaseContrast; root.classList.toggle('gitify-solid', solid); // The full translucent Glass treatment (dissolved chrome, hairline dividers) @@ -92,5 +90,5 @@ export function useAppearance(): void { () => vibrant && root.classList.add('gitify-vibrant'), () => root.classList.remove('gitify-vibrant'), ); - }, [designLanguage, increaseContrast, enableTranslucency, prefersReducedTransparency]); + }, [designLanguage, increaseContrast, prefersReducedTransparency]); } diff --git a/src/renderer/stores/defaults.ts b/src/renderer/stores/defaults.ts index f3621626e..407e501a1 100644 --- a/src/renderer/stores/defaults.ts +++ b/src/renderer/stores/defaults.ts @@ -43,7 +43,6 @@ const DEFAULT_APPEARANCE_SETTINGS: AppearanceSettingsState = { designLanguage: DesignLanguage.CLASSIC, theme: Theme.SYSTEM, increaseContrast: false, - enableTranslucency: true, zoomPercentage: 100 as Percentage, showAccountHeader: false, wrapNotificationTitle: false, diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 61773908e..acb2266e7 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -93,8 +93,6 @@ export interface AppearanceSettingsState { /** The color-mode axis (color scheme / accessibility palette). */ theme: Theme; increaseContrast: boolean; - /** Glass translucency, forced off by increase-contrast or OS Reduce Transparency. */ - enableTranslucency: boolean; zoomPercentage: Percentage; showAccountHeader: boolean; wrapNotificationTitle: boolean; From bb071c48945ccd735f248165258f0abe50cbaf74 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 15:27:45 +0200 Subject: [PATCH 14/27] feat(theme): make the sidebar logo follow the icon colour under Glass --- src/renderer/App.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/renderer/App.css b/src/renderer/App.css index 3ca3f1cc8..a802c68e1 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -120,6 +120,12 @@ color: var(--fgColor-muted); } + /* The logo paints a hardcoded gradient fill, so `color` can't reach it; make it + * follow the icon colour so it stays visible on light glass. */ + .gitify-translucent .gitify-sidebar [fill^='url'] { + fill: currentColor; + } + .gitify-translucent .gitify-sidebar::after { content: ''; position: absolute; From 8b9e6ceb97d03304075946f09e01c263a6b5ce06 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 30 Jul 2026 15:32:13 +0200 Subject: [PATCH 15/27] feat(theme): lighten Glass tints, soften blur, and use a translucent nav selection --- src/renderer/App.css | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/renderer/App.css b/src/renderer/App.css index a802c68e1..02d3d0572 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -44,13 +44,14 @@ * keep their opaque backgrounds. Tint/blur values are starting points to tune. */ [data-theme='glass'][data-color-mode='light'] { - --gitify-glass-tint: rgb(255 255 255 / 0.72); + /* Lighter tints let the native material carry the look instead of flattening it. */ + --gitify-glass-tint: rgb(255 255 255 / 0.35); --gitify-glass-border: rgb(0 0 0 / 0.14); --gitify-glass-divider: rgb(0 0 0 / 0.28); } [data-theme='glass'][data-color-mode='dark'] { - --gitify-glass-tint: rgb(22 27 34 / 0.62); + --gitify-glass-tint: rgb(22 27 34 / 0.3); --gitify-glass-border: rgb(255 255 255 / 0.14); --gitify-glass-divider: rgb(255 255 255 / 0.2); } @@ -71,8 +72,9 @@ [data-theme='glass'][data-glass-material='backdrop-filter'] :where(.bg-gitify-sidebar, .bg-gitify-footer) { - -webkit-backdrop-filter: blur(16px) saturate(1.6); - backdrop-filter: blur(16px) saturate(1.6); + /* Softer, more material-like than a heavy web blur. */ + -webkit-backdrop-filter: blur(22px) saturate(1.2); + backdrop-filter: blur(22px) saturate(1.2); } /** @@ -108,6 +110,10 @@ .gitify-translucent .gitify-sidebar { background: transparent; + /* Active nav item: a translucent accent wash, not a solid GitHub-green tile. */ + --button-primary-bgColor-rest: color-mix(in oklab, var(--fgColor-accent), transparent 82%); + --button-primary-bgColor-hover: color-mix(in oklab, var(--fgColor-accent), transparent 74%); + --button-primary-bgColor-active: color-mix(in oklab, var(--fgColor-accent), transparent 68%); } /* Repo / account grouping bands become faint glass tints instead of solid From 234432244f99d14bb2d4fc1643082ccd96c852bf Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Fri, 31 Jul 2026 01:02:01 +0200 Subject: [PATCH 16/27] chore(dev): quiet codegen and Chromium dev logs, skip auto-launch in dev --- codegen.ts | 20 +++++++++++++------- src/main/handlers/system.ts | 7 +++++++ vite.config.ts | 5 ++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/codegen.ts b/codegen.ts index a2e7cd94e..80c3823a6 100644 --- a/codegen.ts +++ b/codegen.ts @@ -1,15 +1,21 @@ +import { existsSync } from 'node:fs'; + import type { CodegenConfig } from '@graphql-codegen/cli'; import dotenv from 'dotenv'; -dotenv.config(); +dotenv.config({ quiet: true }); if (!process.env.GITHUB_TOKEN) { - // oxlint-disable-next-line no-console -- CLI script output - console.warn( - '\x1b[33m⚠ GITHUB_TOKEN is not set. Skipping GraphQL codegen.\n' + - ' To generate updated types, create a .env file with a valid GitHub PAT.\n' + - ' See .env.template for details.\x1b[0m', - ); + // Only warn when a .env file exists: its absence means the contributor never + // opted into codegen, so nagging on every `pnpm dev` run would just be noise. + if (existsSync('.env')) { + // oxlint-disable-next-line no-console -- CLI script output + console.warn( + '\x1b[33m⚠ GITHUB_TOKEN is not set. Skipping GraphQL codegen.\n' + + ' To generate updated types, add a valid GitHub PAT to your .env file.\n' + + ' See .env.template for details.\x1b[0m', + ); + } process.exit(0); } diff --git a/src/main/handlers/system.ts b/src/main/handlers/system.ts index 27e9093d6..1e371f073 100644 --- a/src/main/handlers/system.ts +++ b/src/main/handlers/system.ts @@ -6,6 +6,7 @@ import { logInfo } from '../../shared/logger'; import { handleMainEvent, onMainEvent, sendRendererEvent } from '../events'; import { applyKeepWindowOnBlur, applyWindowVibrancy } from '../lifecycle/window'; +import { isDevMode } from '../utils'; /** * Register IPC handlers for OS-level system operations. @@ -73,8 +74,14 @@ export function registerSystemHandlers(mb: Menubar): void { /** * Update the application's auto-launch setting based on the provided configuration. + * + * Skipped in development: the unsigned dev Electron binary cannot register as a + * macOS login item, so calling this would only emit a Chromium error log. */ onMainEvent(EVENTS.UPDATE_AUTO_LAUNCH, (_, settings) => { + if (isDevMode()) { + return; + } app.setLoginItemSettings(settings); }); diff --git a/vite.config.ts b/vite.config.ts index a7854a51f..81ccd2c5a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -111,7 +111,10 @@ export default defineConfig({ // vite-plugin-electron v1 starts Electron with `cwd: server.config.root`. // Our Vite root is `src/renderer`, so we must override `cwd` back to the // repository root or Electron will try to boot from `src/renderer`. - await startup(undefined, { + // `--log-level=3` (FATAL only) silences Chromium-internal ERROR spam in dev + // (e.g. `SetApplicationIsDaemon` paramErr from renderer processes), which is + // upstream noise we cannot act on. App/electron-log output is unaffected. + await startup(['.', '--no-sandbox', '--log-level=3'], { cwd: fileURLToPath(new URL('.', import.meta.url)), }); }, From 2321176c055aa865e7670a487b768eb9c9cdb758 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Fri, 31 Jul 2026 01:03:38 +0200 Subject: [PATCH 17/27] fix(theme): make Glass vibrancy show the real desktop on macOS Drop `transparent: true` (it makes behind-window vibrancy render near-opaque over the desktop) and switch the material to `popover`; keep `visualEffectState: active` so the menubar popup stays translucent while unfocused. Sync `nativeTheme.themeSource` with the color mode via a new SET_NATIVE_THEME IPC so dark Glass gets a dark material instead of light text on a light frost. Clear the Primer `[data-color-mode]` wrapper's opaque background so the material shows through the whole window. Also removes the Increase contrast setting, which forced Glass to a solid surface and whose useAppearance/theme wiring is intertwined with the changes above. --- src/main/config.ts | 22 ++++--- src/main/handlers/system.test.ts | 17 +++++ src/main/handlers/system.ts | 12 +++- src/main/lifecycle/window.test.ts | 4 +- src/main/lifecycle/window.ts | 11 ++-- src/preload/index.ts | 9 ++- src/renderer/App.css | 29 +++------ src/renderer/__helpers__/vitest.setup.ts | 1 + src/renderer/__mocks__/state-mocks.ts | 1 - .../settings/AppearanceSettings.test.tsx | 13 ---- .../settings/AppearanceSettings.tsx | 14 ----- src/renderer/hooks/useAppearance.test.ts | 31 +++++++--- src/renderer/hooks/useAppearance.ts | 39 +++++------- .../__snapshots__/Settings.test.tsx.snap | 62 ++----------------- src/renderer/stores/defaults.ts | 1 - src/renderer/stores/useSettingsStore.test.ts | 3 +- src/renderer/types.ts | 1 - src/renderer/utils/ui/theme.test.ts | 39 +++--------- src/renderer/utils/ui/theme.ts | 32 +++------- src/shared/events.ts | 5 ++ 20 files changed, 134 insertions(+), 212 deletions(-) diff --git a/src/main/config.ts b/src/main/config.ts index 65297dcf0..38b4036f0 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -41,14 +41,22 @@ export const WindowConfig: BrowserWindowConstructorOptions = { minHeight: 400, resizable: false, /** - * macOS Glass needs a transparent, vibrant window so the native material can - * show through; `setVibrancy()` alone can't clear the opaque window background. - * `transparent` is immutable after creation, so it's on for all macOS sessions - * — Classic simply paints an opaque background over it. Not applied on - * Windows/Linux, where `transparent` needs a frameless window and the CSS - * `backdrop-filter` path handles Glass instead. + * macOS Glass uses a native vibrancy material as the window background, which + * blurs the real desktop behind it. Deliberately NOT `transparent: true`: a + * transparent window makes behind-window vibrancy sample almost nothing (it + * renders near-opaque over the desktop), so the material must own the + * background instead. `popover` is a bright, frosted menu-style material (a + * touch more see-through than `menu`); `under-window` looks solid over the + * desktop. `active` keeps it translucent even though the popup shows without + * activating the app. + * Not applied on Windows/Linux, where the CSS `backdrop-filter` path handles Glass. */ - ...(isMacOS() ? { transparent: true, vibrancy: 'under-window' as const } : {}), + ...(isMacOS() + ? { + vibrancy: 'popover' as const, + visualEffectState: 'active' as const, + } + : {}), /** Hide the app from the Windows taskbar */ skipTaskbar: true, webPreferences: { diff --git a/src/main/handlers/system.test.ts b/src/main/handlers/system.test.ts index 0787eb126..dbbb44306 100644 --- a/src/main/handlers/system.test.ts +++ b/src/main/handlers/system.test.ts @@ -1,3 +1,4 @@ +import { nativeTheme } from 'electron'; import type { Menubar } from 'electron-menubar'; import { EVENTS } from '../../shared/events'; @@ -30,6 +31,9 @@ vi.mock('electron', () => ({ powerMonitor: { on: vi.fn(), } satisfies Pick, + nativeTheme: { + themeSource: 'system', + } satisfies Pick, })); describe('main/handlers/system.ts', () => { @@ -130,6 +134,19 @@ describe('main/handlers/system.ts', () => { }); }); + describe('SET_NATIVE_THEME', () => { + it('syncs nativeTheme.themeSource with the requested source', () => { + registerSystemHandlers(menubar); + + const handler = handleMock.mock.calls.find( + (call: unknown[]) => call[0] === EVENTS.SET_NATIVE_THEME, + )?.[1]; + handler?.({}, 'dark'); + + expect(nativeTheme.themeSource).toBe('dark'); + }); + }); + describe('UPDATE_KEEP_WINDOW_ON_BLUR', () => { it('forwards the value to applyKeepWindowOnBlur', () => { registerSystemHandlers(menubar); diff --git a/src/main/handlers/system.ts b/src/main/handlers/system.ts index 1e371f073..c23c97f27 100644 --- a/src/main/handlers/system.ts +++ b/src/main/handlers/system.ts @@ -1,4 +1,4 @@ -import { app, powerMonitor, shell } from 'electron'; +import { app, nativeTheme, powerMonitor, shell } from 'electron'; import type { Menubar } from 'electron-menubar'; import { EVENTS } from '../../shared/events'; @@ -101,4 +101,14 @@ export function registerSystemHandlers(mb: Menubar): void { applyWindowVibrancy(mb, enabled); return undefined; }); + + /** + * Sync the native appearance with the app's color mode so the macOS vibrancy + * material renders light/dark to match; without this, dark Glass gets a light + * material and its light text becomes illegible. + */ + handleMainEvent(EVENTS.SET_NATIVE_THEME, (_, source) => { + nativeTheme.themeSource = source; + return undefined; + }); } diff --git a/src/main/lifecycle/window.test.ts b/src/main/lifecycle/window.test.ts index a5860ab00..4bc2e7be7 100644 --- a/src/main/lifecycle/window.test.ts +++ b/src/main/lifecycle/window.test.ts @@ -193,7 +193,7 @@ describe('main/lifecycle/window.ts', () => { applyWindowVibrancy(menubar, true); - expect(menubar.window?.setVibrancy).toHaveBeenCalledWith('under-window'); + expect(menubar.window?.setVibrancy).toHaveBeenCalledWith('popover'); }); it('removes the material on macOS when disabled', () => { @@ -229,7 +229,7 @@ describe('main/lifecycle/window.ts', () => { findWindowHandler(menubar, 'show')?.({ preventDefault: vi.fn() }); - expect(menubar.window?.setVibrancy).toHaveBeenCalledWith('under-window'); + expect(menubar.window?.setVibrancy).toHaveBeenCalledWith('popover'); }); }); diff --git a/src/main/lifecycle/window.ts b/src/main/lifecycle/window.ts index 2823a98e5..4ccdee2d3 100644 --- a/src/main/lifecycle/window.ts +++ b/src/main/lifecycle/window.ts @@ -33,10 +33,13 @@ export function applyWindowVibrancy(mb: Menubar, enabled: boolean): void { if (!isMacOS() || !mb.window || mb.window.isDestroyed()) { return; } - mb.window.setVibrancy(enabled ? 'under-window' : null); - // Clear the window's own (opaque white) background so the vibrancy material - // shows; restore it when vibrancy is off so Classic keeps its solid backdrop. - mb.window.setBackgroundColor(enabled ? '#00000000' : '#ffffff'); + mb.window.setVibrancy(enabled ? 'popover' : null); + // The window is not `transparent`, so the vibrancy material itself is the + // background when Glass is on — don't paint over it. Only Classic needs an + // explicit opaque backdrop once the material is removed. + if (!enabled) { + mb.window.setBackgroundColor('#ffffff'); + } } /** diff --git a/src/preload/index.ts b/src/preload/index.ts index 283792044..123527307 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,6 @@ import { contextBridge, webFrame } from 'electron'; -import type { IKeyboardShortcut } from '../shared/events'; +import type { IKeyboardShortcut, NativeThemeSource } from '../shared/events'; import { EVENTS } from '../shared/events'; import { isLinux, isMacOS, isWindows } from '../shared/platform'; @@ -69,6 +69,13 @@ export const api = { */ setWindowVibrancy: (enabled: boolean) => invokeMainEvent(EVENTS.SET_WINDOW_VIBRANCY, enabled), + /** + * Sync the native window appearance (`nativeTheme.themeSource`) with the app's + * color mode, so the macOS vibrancy material renders light/dark to match — + * otherwise dark Glass gets a light material and its light text washes out. + */ + setNativeTheme: (source: NativeThemeSource) => invokeMainEvent(EVENTS.SET_NATIVE_THEME, source), + /** * Apply the global keyboard shortcut for toggling the app window visibility. * diff --git a/src/renderer/App.css b/src/renderer/App.css index 02d3d0572..1afe9e4fa 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -150,15 +150,21 @@ --gitify-background: transparent; } +/* The `[data-color-mode]` wrapper (Primer's ThemeProvider) sits below the root and + * both paints `background: var(--bgColor-default)` (via `@primer/css`) and re-declares + * `--gitify-background` (via Tailwind), so it would shadow the root's transparency for + * itself and descendants like AppLayout. Clear both at that level so the native + * material shows through the whole window. */ .gitify-vibrant body, -.gitify-vibrant #root { +.gitify-vibrant #root, +.gitify-vibrant [data-color-mode] { + --gitify-background: transparent; background-color: transparent; } /* Accessibility degradation: solid, legible surfaces under the OS Reduce - * Transparency / Increase Contrast settings, or `.gitify-solid` (increase-contrast - * or the in-app translucency toggle — see useAppearance). The vibrancy base needs - * no fallback here: useAppearance withholds `.gitify-vibrant` in these states. */ + * Transparency / Increase Contrast settings. The vibrancy base needs no fallback + * here: useAppearance withholds `.gitify-vibrant` under reduced transparency. */ @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { [data-theme='glass'] { --gitify-glass-tint: var(--bgColor-neutral-muted); @@ -176,21 +182,6 @@ } } -[data-theme='glass'].gitify-solid { - --gitify-glass-tint: var(--bgColor-neutral-muted); - --gitify-sidebar: #24292e; - --gitify-icon-open: var(--fgColor-open); - --gitify-icon-closed: var(--fgColor-closed); - --gitify-icon-done: var(--fgColor-done); - --gitify-icon-attention: var(--fgColor-attention); - --gitify-link: var(--fgColor-link); -} - -[data-theme='glass'].gitify-solid :where(.bg-gitify-sidebar, .bg-gitify-footer) { - -webkit-backdrop-filter: none; - backdrop-filter: none; -} - html, body, #root { diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index 7bb4ce078..0b4e1bb2c 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -85,6 +85,7 @@ function createGitifyBridgeApi(): Window['gitify'] { decryptValue: vi.fn().mockResolvedValue({ token: 'decrypted' }), encryptValue: vi.fn().mockResolvedValue('encrypted'), setWindowVibrancy: vi.fn().mockResolvedValue(undefined), + setNativeTheme: vi.fn().mockResolvedValue(undefined), platform: { isLinux: vi.fn().mockReturnValue(false), isMacOS: vi.fn().mockReturnValue(true), diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 172305521..9b818ef6a 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -27,7 +27,6 @@ export const mockToken = 'token-123-456' as Token; const mockAppearanceSettings: AppearanceSettingsState = { designLanguage: DesignLanguage.CLASSIC, theme: Theme.SYSTEM, - increaseContrast: false, zoomPercentage: 100 as Percentage, showAccountHeader: false, wrapNotificationTitle: false, diff --git a/src/renderer/components/settings/AppearanceSettings.test.tsx b/src/renderer/components/settings/AppearanceSettings.test.tsx index bd3a84186..798de0f26 100644 --- a/src/renderer/components/settings/AppearanceSettings.test.tsx +++ b/src/renderer/components/settings/AppearanceSettings.test.tsx @@ -56,19 +56,6 @@ describe('renderer/components/settings/AppearanceSettings.tsx', () => { expect(screen.queryByRole('option', { name: 'Dark Tritanopia' })).not.toBeInTheDocument(); }); - it('should toggle increase contrast checkbox', async () => { - await act(async () => { - renderWithProviders(, { - accounts: [mockGitHubAppAccount], - }); - }); - - await userEvent.click(screen.getByTestId('checkbox-increaseContrast')); - - expect(toggleSettingSpy).toHaveBeenCalledTimes(1); - expect(toggleSettingSpy).toHaveBeenCalledWith('increaseContrast'); - }); - it('should update the zoom values when using the zoom buttons', async () => { const zoomOutSpy = vi.spyOn(zoom, 'decreaseZoom').mockImplementation(vi.fn()); const zoomInSpy = vi.spyOn(zoom, 'increaseZoom').mockImplementation(vi.fn()); diff --git a/src/renderer/components/settings/AppearanceSettings.tsx b/src/renderer/components/settings/AppearanceSettings.tsx index a56a78a91..658daad9a 100644 --- a/src/renderer/components/settings/AppearanceSettings.tsx +++ b/src/renderer/components/settings/AppearanceSettings.tsx @@ -31,7 +31,6 @@ export const AppearanceSettings: FC = () => { // Setting store values const designLanguage = useSettingsStore((s) => s.designLanguage); const theme = useSettingsStore((s) => s.theme); - const increaseContrast = useSettingsStore((s) => s.increaseContrast); const colorModeSupported = (mode: Theme) => supportedColorModes(designLanguage).includes(mode); const showAccountHeader = useSettingsStore((s) => s.showAccountHeader); @@ -89,19 +88,6 @@ export const AppearanceSettings: FC = () => { - toggleSetting('increaseContrast')} - tooltip={ - - Enable high contrast colors for improved legibility. This increases color contrast - across the UI and may affect some color-specific themes. - - } - /> - diff --git a/src/renderer/hooks/useAppearance.test.ts b/src/renderer/hooks/useAppearance.test.ts index 8b137b341..1a0453546 100644 --- a/src/renderer/hooks/useAppearance.test.ts +++ b/src/renderer/hooks/useAppearance.test.ts @@ -2,7 +2,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useSettingsStore } from '../stores'; -import { DesignLanguage } from '../types'; +import { DesignLanguage, Theme } from '../types'; import { useAppearance } from './useAppearance'; @@ -10,11 +10,7 @@ describe('renderer/hooks/useAppearance.ts', () => { afterEach(() => { document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-glass-material'); - document.documentElement.classList.remove( - 'gitify-vibrant', - 'gitify-solid', - 'gitify-translucent', - ); + document.documentElement.classList.remove('gitify-vibrant', 'gitify-translucent'); }); it('marks the root with the Classic design language by default', () => { @@ -74,12 +70,27 @@ describe('renderer/hooks/useAppearance.ts', () => { expect(window.gitify.setWindowVibrancy).not.toHaveBeenCalled(); }); - it('degrades Glass to solid (no vibrancy) under increased contrast', () => { - useSettingsStore.setState({ designLanguage: DesignLanguage.GLASS, increaseContrast: true }); + it('syncs the native theme to light for a light color mode', () => { + useSettingsStore.setState({ theme: Theme.LIGHT }); renderHook(() => useAppearance()); - expect(document.documentElement.classList.contains('gitify-solid')).toBe(true); - expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(false); + expect(window.gitify.setNativeTheme).toHaveBeenCalledWith('light'); + }); + + it('syncs the native theme to dark for a dark color mode', () => { + useSettingsStore.setState({ theme: Theme.DARK }); + + renderHook(() => useAppearance()); + + expect(window.gitify.setNativeTheme).toHaveBeenCalledWith('dark'); + }); + + it('syncs the native theme to system for the auto color mode', () => { + useSettingsStore.setState({ theme: Theme.SYSTEM }); + + renderHook(() => useAppearance()); + + expect(window.gitify.setNativeTheme).toHaveBeenCalledWith('system'); }); }); diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts index c36915acd..2f45af0d2 100644 --- a/src/renderer/hooks/useAppearance.ts +++ b/src/renderer/hooks/useAppearance.ts @@ -8,9 +8,7 @@ import { DesignLanguage } from '../types'; import { DEFAULT_DAY_COLOR_SCHEME, - DEFAULT_DAY_HIGH_CONTRAST_COLOR_SCHEME, DEFAULT_NIGHT_COLOR_SCHEME, - DEFAULT_NIGHT_HIGH_CONTRAST_COLOR_SCHEME, mapThemeModeToColorMode, mapThemeModeToColorScheme, resolveColorMode, @@ -26,7 +24,6 @@ import { usePrefersReducedTransparency } from './usePrefersReducedTransparency'; export function useAppearance(): void { const designLanguage = useSettingsStore((s) => s.designLanguage); const theme = useSettingsStore((s) => s.theme); - const increaseContrast = useSettingsStore((s) => s.increaseContrast); const prefersReducedTransparency = usePrefersReducedTransparency(); const { setColorMode, setDayScheme, setNightScheme } = useTheme(); @@ -34,22 +31,20 @@ export function useAppearance(): void { useEffect(() => { const effectiveTheme = resolveColorMode(designLanguage, theme); const colorMode = mapThemeModeToColorMode(effectiveTheme); - const colorScheme = mapThemeModeToColorScheme(effectiveTheme, increaseContrast); + const colorScheme = mapThemeModeToColorScheme(effectiveTheme); setColorMode(colorMode); - // System theme has no fixed scheme; fall back to a day/night pair that still - // honours high contrast. - const dayFallback = increaseContrast - ? DEFAULT_DAY_HIGH_CONTRAST_COLOR_SCHEME - : DEFAULT_DAY_COLOR_SCHEME; - const nightFallback = increaseContrast - ? DEFAULT_NIGHT_HIGH_CONTRAST_COLOR_SCHEME - : DEFAULT_NIGHT_COLOR_SCHEME; + // Keep the native window appearance in sync so the macOS vibrancy material + // renders light/dark to match (else dark Glass gets a light material). + window.gitify.setNativeTheme( + colorMode === 'day' ? 'light' : colorMode === 'night' ? 'dark' : 'system', + ); - setDayScheme(colorScheme ?? dayFallback); - setNightScheme(colorScheme ?? nightFallback); - }, [designLanguage, theme, increaseContrast, setColorMode, setDayScheme, setNightScheme]); + // System theme has no fixed scheme; fall back to a day/night pair. + setDayScheme(colorScheme ?? DEFAULT_DAY_COLOR_SCHEME); + setNightScheme(colorScheme ?? DEFAULT_NIGHT_COLOR_SCHEME); + }, [designLanguage, theme, setColorMode, setDayScheme, setNightScheme]); useEffect(() => { document.documentElement.setAttribute('data-theme', designLanguage); @@ -65,15 +60,9 @@ export function useAppearance(): void { useEffect(() => { const root = document.documentElement; - // Glass is always translucent; it only degrades to solid under increased - // contrast (`.gitify-solid`) or OS Reduce Transparency (a media query in App.css). - const solid = increaseContrast; - root.classList.toggle('gitify-solid', solid); - - // The full translucent Glass treatment (dissolved chrome, hairline dividers) - // is active only when nothing is forcing solid surfaces. - const vibrant = - designLanguage === DesignLanguage.GLASS && !solid && !prefersReducedTransparency; + // Glass is always translucent; it only degrades to solid under the OS Reduce + // Transparency / Increase Contrast settings (a media query in App.css). + const vibrant = designLanguage === DesignLanguage.GLASS && !prefersReducedTransparency; root.classList.toggle('gitify-translucent', vibrant); if (!window.gitify.platform.isMacOS()) { @@ -90,5 +79,5 @@ export function useAppearance(): void { () => vibrant && root.classList.add('gitify-vibrant'), () => root.classList.remove('gitify-vibrant'), ); - }, [designLanguage, increaseContrast, prefersReducedTransparency]); + }, [designLanguage, prefersReducedTransparency]); } diff --git a/src/renderer/routes/__snapshots__/Settings.test.tsx.snap b/src/renderer/routes/__snapshots__/Settings.test.tsx.snap index b7434d1a5..39afb7386 100644 --- a/src/renderer/routes/__snapshots__/Settings.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Settings.test.tsx.snap @@ -319,56 +319,6 @@ exports[`renderer/routes/Settings.tsx > should render itself & its children 1`]
-
- - - -
should render itself & its children 1`] data-wrap="nowrap" >