diff --git a/src/main/config.ts b/src/main/config.ts index 50a3d9a4e..38b4036f0 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,23 @@ export const WindowConfig: BrowserWindowConstructorOptions = { minWidth: 500, minHeight: 400, resizable: false, + /** + * 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() + ? { + 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 659cc1475..c23c97f27 100644 --- a/src/main/handlers/system.ts +++ b/src/main/handlers/system.ts @@ -1,11 +1,12 @@ -import { app, powerMonitor, shell } from 'electron'; +import { app, nativeTheme, powerMonitor, shell } from 'electron'; import type { Menubar } from 'electron-menubar'; 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'; +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); }); @@ -84,4 +91,24 @@ 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; + }); + + /** + * 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 d18963eee..12d42ad28 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,8 @@ describe('main/lifecycle/window.ts', () => { setSize: vi.fn(), center: vi.fn(), setAlwaysOnTop: vi.fn(), + setVibrancy: vi.fn(), + setBackgroundColor: vi.fn(), hide: vi.fn(), isDestroyed: vi.fn().mockReturnValue(false), on: vi.fn(), @@ -184,6 +187,70 @@ 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('popover'); + }); + + 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('popover'); + }); + + it('clears the window background when enabled so the material can show', () => { + setPlatform('darwin'); + + applyWindowVibrancy(menubar, true); + + // Regression: a runtime Classic -> Glass switch must clear the opaque + // backdrop left by Classic, otherwise vibrancy cannot sample the desktop. + expect(menubar.window?.setBackgroundColor).toHaveBeenCalledWith('#00000000'); + }); + + it('restores an opaque backdrop when disabled', () => { + setPlatform('darwin'); + + applyWindowVibrancy(menubar, false); + + expect(menubar.window?.setBackgroundColor).toHaveBeenCalledWith('#ffffff'); + }); + }); + 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..2694722b4 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,26 @@ 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 ? 'popover' : null); + // Clear the window's own background so the vibrancy material can sample the + // desktop; a prior Classic disable leaves it opaque, which would otherwise + // block Glass on a runtime Classic → Glass switch. `#00000000` works without a + // `transparent` window because the vibrancy view provides the translucency. + // Restore an opaque backdrop for Classic once the material is removed. + mb.window.setBackgroundColor(enabled ? '#00000000' : '#ffffff'); } /** @@ -55,6 +76,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..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'; @@ -63,6 +63,19 @@ 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), + + /** + * 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 6e486a595..fd8fb9f34 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -7,6 +7,468 @@ /** Tailwind CSS Configuration */ @config "../../tailwind.config.mts"; +/** + * 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); + --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); + + /* 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 { + --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); +} + +/** + * Glass design language. The window is one continuous material: the shell + * (sidebar/footer) and every functional control (buttons, inputs, pills, + * checkboxes) become translucent tints with hairline borders, while the + * content layer (notification rows) stays clear so the material shows through. + * The blur comes from the OS on macOS (`vibrancy`) or CSS `backdrop-filter` + * elsewhere. Follows Apple's materials guidance: glass for functional chrome, + * vibrant foregrounds on top of tints, hairlines for separation. + */ +/* `data-theme` lives on the root while Primer sets `data-color-mode` on its + * ThemeProvider descendant, so these must use a descendant combinator (not a + * same-element compound) to define the mode-specific tints where the content + * can inherit them. */ +[data-theme='glass'] [data-color-mode='light'] { + /* Lighter tints let the native material carry the look instead of flattening it. */ + --gitify-glass-tint: rgb(255 255 255 / 0.22); + --gitify-glass-border: rgb(0 0 0 / 0.14); + + /* Stepped control fills: rest < hover < active reads as gentle depth on glass. */ + --gitify-glass-control-rest: rgb(255 255 255 / 0.35); + --gitify-glass-control-hover: rgb(255 255 255 / 0.5); + --gitify-glass-control-active: rgb(255 255 255 / 0.62); + --gitify-glass-control-disabled: rgb(255 255 255 / 0.18); +} + +[data-theme='glass'] [data-color-mode='dark'] { + --gitify-glass-tint: rgb(22 27 34 / 0.2); + --gitify-glass-border: rgb(255 255 255 / 0.14); + + /* On dark glass, controls lighten the material instead of darkening it. */ + --gitify-glass-control-rest: rgb(255 255 255 / 0.09); + --gitify-glass-control-hover: rgb(255 255 255 / 0.15); + --gitify-glass-control-active: rgb(255 255 255 / 0.22); + --gitify-glass-control-disabled: rgb(255 255 255 / 0.05); +} + +[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); + + /* 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'] + :where(.bg-gitify-sidebar, .bg-gitify-footer) { + /* Softer, more material-like than a heavy web blur. */ + -webkit-backdrop-filter: blur(22px) saturate(1.2); + backdrop-filter: blur(22px) saturate(1.2); +} + +/** + * 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 + * floating directly on the glass. + */ +@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). This re-skins Primer's button family as glass. */ + .gitify-translucent [data-color-mode] { + /* Neutralise the vivid primary CTA (success-green) toward neutral. */ + --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% + ); + + /* Default buttons (footer pills, steppers, secondary actions) become glass + * tiles: stepped translucent fills with a hairline border, no drop shadow. */ + --button-default-bgColor-rest: var(--gitify-glass-control-rest); + --button-default-bgColor-hover: var(--gitify-glass-control-hover); + --button-default-bgColor-active: var(--gitify-glass-control-active); + --button-default-bgColor-selected: var(--gitify-glass-control-active); + --button-default-bgColor-disabled: var(--gitify-glass-control-disabled); + --button-default-borderColor-rest: var(--gitify-glass-border); + --button-default-borderColor-hover: var(--gitify-glass-border); + --button-default-borderColor-active: var(--gitify-glass-border); + --button-default-shadow-resting: none; + --button-default-shadow-inset: none; + + /* Danger buttons behave like normal glass buttons: the same neutral tile + * and wash steps as the default variant, with the red foreground/icon as + * the only destructive signal, and it never changes between states + * (Primer's danger hover would otherwise flip the text to white). */ + --button-danger-bgColor-rest: var(--gitify-glass-control-rest); + --button-danger-bgColor-hover: var(--gitify-glass-control-hover); + --button-danger-bgColor-active: var(--gitify-glass-control-active); + --button-danger-bgColor-disabled: var(--gitify-glass-control-disabled); + --button-danger-fgColor-hover: var(--button-danger-fgColor-rest); + --button-danger-fgColor-active: var(--button-danger-fgColor-rest); + --button-danger-iconColor-hover: var(--button-danger-iconColor-rest); + --button-danger-iconColor-active: var(--button-danger-iconColor-rest); + --button-danger-borderColor-hover: var(--gitify-glass-border); + --button-danger-borderColor-active: var(--gitify-glass-border); + + /* Bare-icon buttons (band actions, header icons) lift with a glass wash on + * hover instead of Primer's solid gray square. Row hover-actions stay fully + * bare (see the `.gitify-hover-actions` rule below). */ + --button-invisible-bgColor-hover: var(--gitify-glass-control-hover); + --button-invisible-bgColor-active: var(--gitify-glass-control-active); + } + + .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%); + /* Sidebar icon hovers stay whisper-quiet against the glass. */ + --button-invisible-bgColor-hover: var(--gitify-glass-control-rest); + --button-invisible-bgColor-active: var(--gitify-glass-control-hover); + } + + /* Repo grouping bands become faint glass tints, so the list reads as one + * surface while keeping the grouping. The account header floats directly on + * the glass with no tint. Bands and notification rows float as rounded cards + * on the glass: a small inline inset makes the rounding visible (macOS + * list-row geometry), and the band lifts with the same glass wash as row + * hover. */ + .gitify-translucent .bg-gitify-repository { + background: color-mix(in oklab, var(--gitify-glass-tint), transparent 62%); + } + + .gitify-translucent .bg-gitify-account-rest { + background: transparent; + } + + .gitify-translucent :where(.bg-gitify-repository, .bg-gitify-account-rest):hover { + background: var(--gitify-glass-control-rest); + } + + /* The repo-name button inside a band is `variant="invisible"`; suppress its + * own hover wash so it doesn't double-paint over the band's wash with a hard + * edge where the button ends. The band hover already lifts the whole row. */ + .gitify-translucent :where(.bg-gitify-repository, .bg-gitify-account-rest) > button { + --button-invisible-bgColor-hover: transparent; + --button-invisible-bgColor-active: transparent; + } + + .gitify-translucent :where(.bg-gitify-repository, .bg-gitify-account-rest), + .gitify-translucent .group.relative.border-b { + margin-inline: 4px; + border-radius: var(--gitify-radius-md); + } + + /* Account scope rows (Accounts route) match the control tint. */ + .gitify-translucent :where(.bg-gitify-accounts) { + background: var(--gitify-glass-control-rest); + } + + /* The account-profile button (avatar + username) sits bare inside the card: + * no tile at rest, just the hover wash. `data-testid` is the stable hook. */ + .gitify-translucent [data-testid='account-profile'] { + --button-default-bgColor-rest: transparent; + --button-default-borderColor-rest: transparent; + } + + /* Row hover is a light glass wash instead of the solid Classic fill. */ + .gitify-translucent .hover\:bg-gitify-notification-hover:hover { + background-color: var(--gitify-glass-control-rest); + } + + /* Inline-code spans inside notification titles share the same token as a + * plain (non-hover) class; give them the frosted treatment too. */ + .gitify-translucent .bg-gitify-notification-hover { + background-color: var(--gitify-glass-control-rest); + } + + /* Count pills (notification counts, filter counts) become borderless frosted + * chips: at this size a hairline reads as noise, and macOS count badges rely + * on fill contrast alone. Covers both CustomCounter and the Primer Labels + * used by the metric pills (whose muted border is dropped). */ + .gitify-translucent :where(.bg-gitify-counter-primary, .bg-gitify-counter-secondary) { + background-color: var(--gitify-glass-control-rest); + } + + .gitify-translucent [data-color-mode] [data-component='Label'][data-variant='secondary'] { + background-color: var(--gitify-glass-control-rest); + border-color: transparent; + } + + /* Metric-pill hover lifts to the next glass step instead of the solid fill. */ + .gitify-translucent .hover\:bg-gitify-notification-pill-hover:hover { + background-color: var(--gitify-glass-control-hover); + } + + /* Avatars get a hairline ring so white-backed org logos don't read as solid + * tiles floating on the glass. */ + .gitify-translucent [data-component='Avatar'] { + box-shadow: 0 0 0 1px var(--gitify-glass-border); + } + + /* Issue-label tokens carry their GitHub colour as inline `--label-r/g/b` + * vars; re-render them as tinted glass (same hue, mostly translucent, hue + * hairline) instead of solid chips. Text keeps Primer's contrast-chosen + * colour, which still reads on the softened fill. */ + .gitify-translucent [data-color-mode] [style*='--label-r'] { + background-color: rgb(var(--label-r) var(--label-g) var(--label-b) / 0.78); + border-color: rgb(var(--label-r) var(--label-g) var(--label-b) / 0.4); + } + + /* Row action buttons (mark-as-read / done / chevron) float as bare icons under + * Glass: drop the hover band behind the group and the invisible-button fills. + * The `:hover` variant guards against the `group-hover:` utility winning on + * specificity when the row is hovered. */ + .gitify-translucent .gitify-hover-actions, + .gitify-translucent .group:hover .gitify-hover-actions { + background: transparent; + } + + /* Hover tooltips float as frosted glass with a hairline (the macOS tooltip + * material) instead of Primer's solid slab: light frosted with dark text in + * light mode, dark frosted with white text in dark mode. Targets + * `[data-component='Tooltip']` rather than `[role='tooltip']` because Primer + * label-type tooltips (from `aria-label`, e.g. the sidebar issue/PR links) + * carry no role and would stay black otherwise. The `::after` caret is only + * a hit-area, so restyling the body is safe. */ + .gitify-translucent [data-color-mode] [data-component='Tooltip'] { + -webkit-backdrop-filter: blur(14px) saturate(1.5); + backdrop-filter: blur(14px) saturate(1.5); + border: 1px solid var(--gitify-glass-border); + } + + .gitify-translucent [data-color-mode='light'] [data-component='Tooltip'] { + background-color: color-mix(in oklab, var(--bgColor-default), transparent 8%); + color: var(--fgColor-default); + } + + .gitify-translucent [data-color-mode='dark'] [data-component='Tooltip'] { + background-color: color-mix(in oklab, var(--bgColor-neutral-emphasis), transparent 30%); + } + + /* Primer's AnchoredOverlay wrapper (what the `?` popouts render inside) + * paints a solid background beneath the translucent popout, stacking it to + * near-opaque. Clear it so only the popout's own frosted fill shows. */ + .gitify-translucent [data-color-mode] [data-component='AnchoredOverlay'] { + background: transparent; + } + + /* The `?` info popouts match the hover-tooltips: the same frosted fill, + * blur and hairline. `bgColor-default` keeps them mode-aware without a + * per-mode split. (Selector weights past the tooltip rules above.) */ + .gitify-translucent [role='tooltip'].bg-gitify-tooltip-popout { + background-color: color-mix(in oklab, var(--bgColor-default), transparent 8%); + -webkit-backdrop-filter: blur(14px) saturate(1.5); + backdrop-filter: blur(14px) saturate(1.5); + border-color: var(--gitify-glass-border); + } + + .gitify-translucent .gitify-hover-actions :where(button) { + --button-invisible-bgColor-rest: transparent; + --button-invisible-bgColor-hover: transparent; + --button-invisible-bgColor-active: transparent; + + /* Bare icons still need affordance: dimmed at rest, full colour on hover, + * a press dip on active. */ + opacity: 0.65; + transition: opacity 120ms ease-in-out; + } + + .gitify-translucent .gitify-hover-actions :where(button):hover { + opacity: 1; + } + + .gitify-translucent .gitify-hover-actions :where(button):active { + opacity: 0.85; + } + + /* Let the glass show through the scrollbar: drop the opaque track fill so only + * a slim, floating translucent thumb remains. */ + .gitify-translucent *::-webkit-scrollbar-track { + background-color: transparent; + } + + .gitify-translucent *::-webkit-scrollbar-thumb { + background-color: color-mix(in oklab, var(--fgColor-muted), transparent 60%); + border: 3px solid transparent; + background-clip: padding-box; + } + + .gitify-translucent *::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in oklab, var(--fgColor-muted), transparent 35%); + } + + /* With bare (background-less) action buttons there's no band to hide the row + * text behind them, so on hover fade the row content out under the buttons + * instead — the title/number dissolve into the glass rather than colliding. + * `--gitify-actions` (rendered button count, set on the row) sizes the fade so + * rows with fewer buttons don't over-fade: each small IconButton is ~1.75rem + * plus the group's right padding, with a 2rem soft leading edge. */ + .gitify-translucent .group:hover .gitify-row-content { + --gitify-actions-width: calc(var(--gitify-actions, 3) * 1.75rem + 0.25rem); + -webkit-mask-image: linear-gradient( + to right, + #000 calc(100% - var(--gitify-actions-width) - 2rem), + transparent calc(100% - var(--gitify-actions-width)) + ); + mask-image: linear-gradient( + to right, + #000 calc(100% - var(--gitify-actions-width) - 2rem), + transparent calc(100% - var(--gitify-actions-width)) + ); + } + + /* Form controls (Select / TextInput) read as glass instead of Primer's solid + * field: a faint translucent fill, borderless at rest (macOS controls carry + * no outline), with an accent ring only while focused. Targets the stable + * `[data-component]` wrapper, not Primer's hashed class. */ + .gitify-translucent [data-color-mode] [data-component='TextInput'] { + background-color: var(--gitify-glass-control-rest); + border: none; + box-shadow: none; + } + + .gitify-translucent [data-color-mode] [data-component='TextInput']:focus-within { + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--fgColor-accent), transparent 40%); + } + + /* Primer's ` updateSetting('designLanguage', evt.target.value as DesignLanguage)} + value={designLanguage} + > + Classic + Glass + + + @@ -70,10 +96,11 @@ export const AppearanceSettings: FC = () => { onChange={() => toggleSetting('increaseContrast')} tooltip={ - Enable high contrast colors for improved legibility. This increases color contrast - across the UI and may affect some color-specific themes. + Use GitHub's high-contrast color schemes for improved legibility. Also applied + automatically when your system's increase-contrast setting is enabled. } + visible={designLanguage === DesignLanguage.CLASSIC} /> @@ -90,7 +117,12 @@ export const AppearanceSettings: FC = () => { unsafeDisableTooltip={true} /> - diff --git a/src/renderer/components/settings/NotificationSettings.tsx b/src/renderer/components/settings/NotificationSettings.tsx index 7c3f0ebea..56c3380e4 100644 --- a/src/renderer/components/settings/NotificationSettings.tsx +++ b/src/renderer/components/settings/NotificationSettings.tsx @@ -111,7 +111,12 @@ export const NotificationSettings: FC = () => { unsafeDisableTooltip={true} /> - diff --git a/src/renderer/hooks/useAppearance.test.ts b/src/renderer/hooks/useAppearance.test.ts new file mode 100644 index 000000000..f2a84ca71 --- /dev/null +++ b/src/renderer/hooks/useAppearance.test.ts @@ -0,0 +1,180 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { useSettingsStore } from '../stores'; + +import { DesignLanguage, Theme } from '../types'; + +import { useAppearance } from './useAppearance'; + +// Capture the Primer color-scheme setters so high-contrast schemes can be asserted. +const primerTheme = vi.hoisted(() => ({ + setColorMode: vi.fn(), + setDayScheme: vi.fn(), + setNightScheme: vi.fn(), +})); + +vi.mock('@primer/react', async (importOriginal) => ({ + ...(await importOriginal()), + useTheme: () => primerTheme, +})); + +function mockPrefersContrast(matches: boolean) { + return vi.spyOn(window, 'matchMedia').mockImplementation( + (query: string) => + ({ + matches: query.includes('prefers-contrast') ? matches : false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as MediaQueryList, + ); +} + +describe('renderer/hooks/useAppearance.ts', () => { + afterEach(() => { + document.documentElement.removeAttribute('data-theme'); + document.documentElement.removeAttribute('data-glass-material'); + document.documentElement.classList.remove('gitify-vibrant', 'gitify-translucent'); + }); + + 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); + + 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'); + }); + + 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(); + }); + + it('syncs the native theme to light for a light color mode', () => { + useSettingsStore.setState({ theme: Theme.LIGHT }); + + renderHook(() => useAppearance()); + + 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'); + }); + + it('syncs the native theme from the Glass-clamped color mode', () => { + // Glass clamps DARK_DIMMED down to its base dark; the native theme must + // follow the clamped mode, not the raw stored theme. + useSettingsStore.setState({ + designLanguage: DesignLanguage.GLASS, + theme: Theme.DARK_DIMMED, + }); + + renderHook(() => useAppearance()); + + expect(window.gitify.setNativeTheme).toHaveBeenCalledWith('dark'); + }); + + it('applies the high-contrast schemes for Classic under the increase contrast setting', () => { + useSettingsStore.setState({ + designLanguage: DesignLanguage.CLASSIC, + theme: Theme.LIGHT, + increaseContrast: true, + }); + + renderHook(() => useAppearance()); + + expect(primerTheme.setDayScheme).toHaveBeenCalledWith('light_high_contrast'); + }); + + it('does not apply high-contrast schemes for Glass', () => { + useSettingsStore.setState({ + designLanguage: DesignLanguage.GLASS, + theme: Theme.LIGHT, + increaseContrast: true, + }); + + renderHook(() => useAppearance()); + + expect(primerTheme.setDayScheme).toHaveBeenCalledWith('light'); + }); + + it('applies high contrast for Classic from the OS increase-contrast setting', () => { + const spy = mockPrefersContrast(true); + useSettingsStore.setState({ + designLanguage: DesignLanguage.CLASSIC, + theme: Theme.LIGHT, + increaseContrast: false, + }); + + renderHook(() => useAppearance()); + + expect(primerTheme.setDayScheme).toHaveBeenCalledWith('light_high_contrast'); + spy.mockRestore(); + }); + + it('degrades Glass to solid under the OS increase-contrast setting', () => { + const spy = mockPrefersContrast(true); + useSettingsStore.setState({ designLanguage: DesignLanguage.GLASS }); + + renderHook(() => useAppearance()); + + expect(window.gitify.setWindowVibrancy).toHaveBeenCalledWith(false); + spy.mockRestore(); + }); +}); diff --git a/src/renderer/hooks/useAppearance.ts b/src/renderer/hooks/useAppearance.ts new file mode 100644 index 000000000..1dcbb6810 --- /dev/null +++ b/src/renderer/hooks/useAppearance.ts @@ -0,0 +1,115 @@ +import { useEffect } from 'react'; + +import { useTheme } from '@primer/react'; + +import { useSettingsStore } from '../stores'; + +import { DesignLanguage } from '../types'; + +import { rendererLogError, toError } from '../utils/core/logger'; +import { + DEFAULT_DAY_COLOR_SCHEME, + DEFAULT_DAY_HIGH_CONTRAST_COLOR_SCHEME, + DEFAULT_NIGHT_COLOR_SCHEME, + DEFAULT_NIGHT_HIGH_CONTRAST_COLOR_SCHEME, + mapThemeModeToColorMode, + mapThemeModeToColorScheme, + resolveColorMode, +} from '../utils/ui/theme'; +import { usePrefersContrast } from './usePrefersContrast'; +import { usePrefersReducedTransparency } from './usePrefersReducedTransparency'; + +/** + * Applies appearance side effects: Primer color mode/scheme plus the root + * `data-theme` (design language) and `data-glass-material` attributes. + * + * Must be called from within the Primer `ThemeProvider` (it consumes `useTheme`). + */ +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 prefersContrast = usePrefersContrast(); + + const { setColorMode, setDayScheme, setNightScheme } = useTheme(); + + useEffect(() => { + const effectiveTheme = resolveColorMode(designLanguage, theme); + const colorMode = mapThemeModeToColorMode(effectiveTheme); + + // High contrast swaps in Primer's `*_high_contrast` schemes for Classic, driven by + // the in-app setting or the OS "Increase Contrast" preference. Glass never takes + // them; it degrades to a solid surface instead (see the vibrancy effect below). + const highContrast = + designLanguage === DesignLanguage.CLASSIC && (increaseContrast || prefersContrast); + const colorScheme = mapThemeModeToColorScheme(effectiveTheme, highContrast); + + setColorMode(colorMode); + + // Keep the native window appearance in sync so the macOS vibrancy material + // renders light/dark to match (else dark Glass gets a light material). Not + // gated to macOS: on other platforms this simply aligns native chrome with + // the chosen theme, which is harmless (SYSTEM sends 'system', i.e. no override). + window.gitify + .setNativeTheme(colorMode === 'day' ? 'light' : colorMode === 'night' ? 'dark' : 'system') + .catch((err) => + rendererLogError('useAppearance', 'Failed to sync native theme source', toError(err)), + ); + + // System theme has no fixed scheme; fall back to a day/night pair that honours + // high contrast. + setDayScheme( + colorScheme ?? + (highContrast ? DEFAULT_DAY_HIGH_CONTRAST_COLOR_SCHEME : DEFAULT_DAY_COLOR_SCHEME), + ); + setNightScheme( + colorScheme ?? + (highContrast ? DEFAULT_NIGHT_HIGH_CONTRAST_COLOR_SCHEME : DEFAULT_NIGHT_COLOR_SCHEME), + ); + }, [ + designLanguage, + theme, + increaseContrast, + prefersContrast, + setColorMode, + setDayScheme, + setNightScheme, + ]); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', designLanguage); + }, [designLanguage]); + + useEffect(() => { + document.documentElement.setAttribute( + 'data-glass-material', + window.gitify.platform.isMacOS() ? 'vibrancy' : 'backdrop-filter', + ); + }, []); + + useEffect(() => { + const root = document.documentElement; + + // Glass is always translucent; it degrades to solid under the OS Reduce + // Transparency or Increase Contrast settings (matched by a media query in App.css). + const vibrant = + designLanguage === DesignLanguage.GLASS && !prefersReducedTransparency && !prefersContrast; + root.classList.toggle('gitify-translucent', vibrant); + + if (!window.gitify.platform.isMacOS()) { + return; + } + + // 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(vibrant).then( + () => vibrant && root.classList.add('gitify-vibrant'), + () => root.classList.remove('gitify-vibrant'), + ); + }, [designLanguage, prefersReducedTransparency, prefersContrast]); +} diff --git a/src/renderer/hooks/usePrefersContrast.test.ts b/src/renderer/hooks/usePrefersContrast.test.ts new file mode 100644 index 000000000..7dd0e7e57 --- /dev/null +++ b/src/renderer/hooks/usePrefersContrast.test.ts @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react'; + +import { usePrefersContrast } from './usePrefersContrast'; + +function mockMatchMedia(matches: boolean) { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList); +} + +describe('renderer/hooks/usePrefersContrast.ts', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('is false when the OS does not request increased contrast', () => { + mockMatchMedia(false); + + const { result } = renderHook(() => usePrefersContrast()); + + expect(result.current).toBe(false); + }); + + it('is true when the OS requests increased contrast', () => { + mockMatchMedia(true); + + const { result } = renderHook(() => usePrefersContrast()); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/renderer/hooks/usePrefersContrast.ts b/src/renderer/hooks/usePrefersContrast.ts new file mode 100644 index 000000000..37ab9ed0a --- /dev/null +++ b/src/renderer/hooks/usePrefersContrast.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +const QUERY = '(prefers-contrast: more)'; + +/** + * Tracks the OS "Increase Contrast" accessibility setting (live). Drives the + * high-contrast Primer schemes for Classic and the solid fallback for Glass, so + * raising contrast in the system settings takes effect without an app restart. + */ +export function usePrefersContrast(): boolean { + const [more, setMore] = useState(() => window.matchMedia(QUERY).matches); + + useEffect(() => { + const mql = window.matchMedia(QUERY); + const onChange = () => setMore(mql.matches); + + onChange(); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); + }, []); + + return more; +} 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/routes/__snapshots__/Settings.test.tsx.snap b/src/renderer/routes/__snapshots__/Settings.test.tsx.snap index d1bbc7900..3751dc40d 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" > +
+ + + + + +
should render itself & its children 1`] >