diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 8e35bbefd9..c4746fc313 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -302,7 +302,9 @@ import { import { createStreamThrottle } from './stream-throttle' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { + backgroundMaterialFor, glassActive, + glassSupportedOn, normalizeState as normalizeTranslucency, vibrancyFor as vibrancyForTranslucency, windowBackingOptions, @@ -397,6 +399,9 @@ const IS_WSL = isWslEnvironment() // Truthful macOS kernel major (Tahoe = 25). Product version lies (16 vs 26) per // build SDK, so gate Tahoe workarounds on Darwin instead. const DARWIN_MAJOR = IS_MAC ? Number.parseInt(os.release(), 10) || 0 : 0 +// Glass: macOS vibrancy, or Windows 11 22H2+ system backdrop. Computed once +// so the renderer, the persisted default, and every chat window agree. +const GLASS_SUPPORTED = glassSupportedOn(process.platform, os.release()) const APP_ROOT = app.getAppPath() // Device-local preference: block F12 from opening DevTools. @@ -893,18 +898,19 @@ nativeTheme.themeSource = readPersistedThemeSource() // Window translucency (see-through window). One lever, 0–100; 0 = off (the // default). Two modes share the lever (see electron/translucency.ts and // store/translucency): 'clear' maps it to the native window opacity so the -// desktop shows through the whole window; 'glass' (macOS) keeps the window -// opaque and lets the renderer thin its surfaces over the vibrancy material -// instead — a matte blur with full-contrast text. Persisted so a cold launch -// applies it at window creation, before the renderer reports its value. +// desktop shows through the whole window; 'glass' keeps the window opaque +// and lets the renderer thin its surfaces over a platform material instead +// — a matte blur with full-contrast text. macOS uses vibrancy; Windows 11 +// uses DWM acrylic/mica/tabbed. Persisted so a cold launch applies it at +// window creation, before the renderer reports its value. // macOS + Windows only; `setOpacity` is a no-op on Linux. const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json') function readPersistedTranslucency() { try { - return normalizeTranslucency(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')), IS_MAC) + return normalizeTranslucency(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')), GLASS_SUPPORTED) } catch { - return normalizeTranslucency(null, IS_MAC) + return normalizeTranslucency(null, GLASS_SUPPORTED) } } @@ -932,17 +938,20 @@ function windowOpacity() { // Re-apply translucency to a live window (runtime toggle, no recreation). // `setOpacity` is a no-op on Linux, which is fine — it just stays opaque there. // The backing swap is the glass half: Chromium composites the page against -// the window backing BEFORE macOS composites the window, so glass needs the -// backing dropped for the vibrancy material to reach a transparent page, and +// the window backing BEFORE the OS composites the window, so glass needs the +// backing dropped for the platform material to reach a transparent page, and // every other state needs the opaque themed backing (anti-flash, and it is // what makes clear mode fade to the desktop instead of to black). // // `changed` says which native properties actually need touching. Dragging the // intensity slider emits ~100 updates, and in glass mode NONE of them change -// anything native — the effect is painted by the renderer and windowOpacityFor -// returns 1 throughout. Re-issuing setVibrancy on every tick restarts its -// 150ms animation before macOS can settle the material, which reads as jank -// and flattens the frost levels into each other. +// anything native — the tint is painted by the renderer and windowOpacityFor +// answers off `fade`, not `intensity`, there. Re-issuing setVibrancy on every +// tick restarts its 150ms animation before macOS can settle the material, +// which reads as jank and flattens the frost levels into each other. Windows +// setBackgroundMaterial is instantaneous but still skipped on tint-only ticks. +// The glass Fade lever is the one glass drag that does reach main, and it +// costs exactly what a Clear drag costs: one setOpacity. // // CAUTION (measured, macOS 26 / Electron 40): a runtime // setBackgroundColor('#00000000') is silently LOST on a window whose @@ -964,11 +973,18 @@ function applyWindowTranslucency(win, changed = { backing: true, material: true, win.setBackgroundColor(glassActive(translucencyState) ? '#00000000' : getWindowBackgroundColor()) } - // Glass frost level = the vibrancy material (macOS has no blur-radius - // knob). Animate the hop so a deliberate frost switch feels continuous — - // which only works if we don't re-issue it on unrelated updates. - if (changed.material && IS_MAC && typeof win.setVibrancy === 'function') { - win.setVibrancy(vibrancyForTranslucency(translucencyState), { animationDuration: 150 }) + if (changed.material) { + // Glass frost level = the platform material. Animate the macOS hop so + // a deliberate frost switch feels continuous — which only works if we + // don't re-issue it on unrelated updates. Windows has no equivalent + // animation option; setBackgroundMaterial is instantaneous. + if (IS_MAC && typeof win.setVibrancy === 'function') { + win.setVibrancy(vibrancyForTranslucency(translucencyState), { animationDuration: 150 }) + } + + if (IS_WINDOWS && GLASS_SUPPORTED && typeof win.setBackgroundMaterial === 'function') { + win.setBackgroundMaterial(backgroundMaterialFor(translucencyState)) + } } } @@ -1001,6 +1017,12 @@ function chatWindowSurfaceOptions() { // user's frost choice whenever they click elsewhere. Only observable // under glass — everywhere else the page buries the material. visualEffectState: IS_MAC ? ('active' as const) : undefined, + // Win11 DWM materials only reach the client area on a transparent window + // (electron#49443). Chat windows on glass-capable Windows are born + // transparent so a live Clear→Glass toggle doesn't need a recreate; the + // opaque themed backgroundColor covers it while glass is off. + ...(IS_WINDOWS && GLASS_SUPPORTED ? { transparent: true } : {}), + backgroundMaterial: IS_WINDOWS && GLASS_SUPPORTED ? backgroundMaterialFor(translucencyState) : undefined, opacity: windowOpacity(), ...windowBackingOptions(translucencyState, getWindowBackgroundColor()) } @@ -14085,11 +14107,12 @@ app.on('before-quit', () => { }) ipcMain.on('hermes:translucency', (_event, payload) => { - const next = normalizeTranslucency(payload, IS_MAC) + const next = normalizeTranslucency(payload, GLASS_SUPPORTED) const previous = translucencyState if ( next.intensity === previous.intensity && + next.fade === previous.fade && next.mode === previous.mode && next.material === previous.material && next.scope === previous.scope diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7e2e52a319..1296d08ff9 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -1,6 +1,11 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' +import os from 'node:os' + +import { glassSupportedOn, translucencySupportedOn } from '../../shared/src/translucency' contextBridge.exposeInMainWorld('hermesDesktop', { + glassSupported: glassSupportedOn(process.platform, os.release()), + translucencySupported: translucencySupportedOn(process.platform), getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), // Registry-scoped backend resolution: { connectionId, profile } → descriptor. getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload), diff --git a/apps/desktop/electron/translucency.test.ts b/apps/desktop/electron/translucency.test.ts index 3db509f14b..44f90a2d12 100644 --- a/apps/desktop/electron/translucency.test.ts +++ b/apps/desktop/electron/translucency.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest' import { + backgroundMaterialFor, clampIntensity, DEFAULT_GLASS_MATERIAL, DEFAULT_GLASS_SCOPE, @@ -18,6 +19,9 @@ import { GLASS_SCOPES, glassActive, type GlassMaterial, + glassMaterialForPicker, + glassMaterialsFor, + glassSupportedOn, glassSurfaceKeep, normalizeMaterial, normalizeMode, @@ -28,7 +32,10 @@ import { TRANSLUCENCY_MIN, TRANSLUCENCY_OPACITY_FLOOR, type TranslucencyState, + translucencySupportedOn, vibrancyFor, + WINDOWS_BACKGROUND_MATERIALS, + WINDOWS_GLASS_MIN_BUILD, windowBackingOptions, windowOpacityFor } from './translucency' @@ -38,13 +45,15 @@ const legacyOpacity = (intensity: number) => 1 - (intensity / 100) * 0.7 const clear = (intensity: number): TranslucencyState => ({ intensity, + fade: 0, mode: 'clear', material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE }) -const glass = (intensity: number, material: GlassMaterial = DEFAULT_GLASS_MATERIAL): TranslucencyState => ({ +const glass = (intensity: number, material: GlassMaterial = DEFAULT_GLASS_MATERIAL, fade = 0): TranslucencyState => ({ intensity, + fade, mode: 'glass', material, scope: DEFAULT_GLASS_SCOPE @@ -78,7 +87,7 @@ describe('clampIntensity', () => { }) describe('normalizeMode', () => { - it('accepts glass on macOS only — there is no vibrancy to ride elsewhere', () => { + it('accepts glass only on a platform that has a native material', () => { expect(normalizeMode('glass', true)).toBe('glass') expect(normalizeMode('glass', false)).toBe('clear') }) @@ -90,7 +99,7 @@ describe('normalizeMode', () => { // Glass is pre-selected so the better half of the feature is the one you // find, which is free because the intensity still starts at 0. - it('pre-selects glass on macOS when nothing is recorded', () => { + it('pre-selects glass when the platform supports it and nothing is recorded', () => { expect(normalizeMode(undefined, true)).toBe('glass') expect(normalizeMode('acrylic', true)).toBe('glass') expect(normalizeMode(42, true)).toBe('glass') @@ -163,11 +172,23 @@ describe('windowOpacityFor', () => { expect(windowOpacityFor(clear(240))).toBe(windowOpacityFor(clear(TRANSLUCENCY_MAX))) }) - it('never fades the native window in glass mode — the renderer paints that effect', () => { + // The tint is painted by the renderer, so the intensity lever must never + // reach setOpacity under glass — that separation is what keeps text sharp. + it('ignores the intensity lever entirely in glass mode', () => { expect(windowOpacityFor(glass(0))).toBe(1) expect(windowOpacityFor(glass(60))).toBe(1) expect(windowOpacityFor(glass(100))).toBe(1) }) + + it('fades a glass window only through its own lever, on the ramp clear uses', () => { + expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 0))).toBe(1) + expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 40))).toBe(windowOpacityFor(clear(40))) + expect(windowOpacityFor(glass(0, DEFAULT_GLASS_MATERIAL, 100))).toBe(windowOpacityFor(clear(100))) + }) + + it('leaves fade inert under clear, where the intensity lever already is the opacity', () => { + expect(windowOpacityFor({ ...clear(40), fade: 100 })).toBe(windowOpacityFor(clear(40))) + }) }) describe('glassSurfaceKeep', () => { @@ -224,10 +245,125 @@ describe('vibrancyFor', () => { }) }) +describe('glassSupportedOn', () => { + it('is on for macOS regardless of kernel version', () => { + expect(glassSupportedOn('darwin')).toBe(true) + expect(glassSupportedOn('darwin', '24.6.0')).toBe(true) + }) + + it('is on for Windows 11 22H2 and newer, off for everything older', () => { + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}`)).toBe(true) + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}.1`)).toBe(true) + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}`)).toBe(false) + expect(glassSupportedOn('win32', '10.0.19045')).toBe(false) + expect(glassSupportedOn('win32', '10.0')).toBe(false) + expect(glassSupportedOn('win32', '')).toBe(false) + }) + + it('is off on Linux — Electron has no first-party desktop material there', () => { + expect(glassSupportedOn('linux', '6.8.0')).toBe(false) + }) +}) + +describe('backgroundMaterialFor', () => { + it('is none while glass is off so DWM does not keep drawing under the backing', () => { + expect(backgroundMaterialFor(glass(0, 'header'))).toBe('none') + expect(backgroundMaterialFor(clear(60))).toBe('none') + }) + + it('maps the sheer → heavy frost ladder onto acrylic / tabbed / mica', () => { + expect(backgroundMaterialFor(glass(60, 'under-window'))).toBe('acrylic') + expect(backgroundMaterialFor(glass(60, 'popover'))).toBe('tabbed') + expect(backgroundMaterialFor(glass(60, 'titlebar'))).toBe('mica') + }) + + // Windows 11 has three system materials for four rungs, so the two heaviest + // land on mica. The mapping stays total — a saved 'header' still resolves — + // and the picker drops the duplicate instead (see glassMaterialsFor). + it('collapses Glare onto mica with Bright', () => { + expect(backgroundMaterialFor(glass(60, 'header'))).toBe('mica') + expect(backgroundMaterialFor(glass(60, 'header'))).toBe(backgroundMaterialFor(glass(60, 'titlebar'))) + }) + + it('resolves every shipped rung to a real system material', () => { + for (const material of GLASS_MATERIALS) { + expect(WINDOWS_BACKGROUND_MATERIALS, material).toContain(backgroundMaterialFor(glass(60, material))) + } + }) +}) + +describe('translucencySupportedOn', () => { + it('covers the two platforms where setOpacity or a native material exists', () => { + expect(translucencySupportedOn('darwin')).toBe(true) + expect(translucencySupportedOn('win32')).toBe(true) + }) + + // Electron documents setOpacity as doing nothing on Linux, and there is no + // material either — so the setting has no working half to offer there. + it('is off on Linux, where neither mode does anything', () => { + expect(translucencySupportedOn('linux')).toBe(false) + expect(translucencySupportedOn('freebsd')).toBe(false) + }) + + // Win10 loses glass but keeps clear, so the row must survive there. + it('stays on for a Windows build too old for glass', () => { + const oldWindows = `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}` + + expect(glassSupportedOn('win32', oldWindows)).toBe(false) + expect(translucencySupportedOn('win32')).toBe(true) + }) +}) + +describe('the frost rungs a platform offers', () => { + it('offers the whole ladder on macOS', () => { + expect(glassMaterialsFor(false)).toEqual(GLASS_MATERIALS) + }) + + // The census rule, now enforced on Windows too: no two options in the picker + // may composite to the same thing. Bright and Glare are both mica. + it('never offers two rungs that render the same Windows backdrop', () => { + const backdrops = glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material))) + + expect(new Set(backdrops).size).toBe(backdrops.length) + expect(glassMaterialsFor(true).length).toBeLessThan(GLASS_MATERIALS.length) + }) + + it('keeps every distinct Windows backdrop reachable from the picker', () => { + const backdrops = new Set(glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material)))) + const reachable = new Set(GLASS_MATERIALS.map(material => backgroundMaterialFor(glass(60, material)))) + + expect(backdrops).toEqual(reachable) + }) + + // Settings synced from a Mac carry a rung Windows has no button for. The + // picker highlights the button that renders the same backdrop rather than + // showing nothing selected — and does NOT rewrite what the Mac saved. + it('folds a dropped rung onto the button that looks the same', () => { + expect(glassMaterialForPicker('header', true)).toBe('titlebar') + expect(glassMaterialsFor(true)).toContain(glassMaterialForPicker('header', true)) + expect(backgroundMaterialFor(glass(60, glassMaterialForPicker('header', true)))).toBe( + backgroundMaterialFor(glass(60, 'header')) + ) + }) + + it('leaves every rung alone on macOS and every offered rung alone on Windows', () => { + for (const material of GLASS_MATERIALS) { + expect(glassMaterialForPicker(material, false)).toBe(material) + } + + for (const material of glassMaterialsFor(true)) { + expect(glassMaterialForPicker(material, true)).toBe(material) + } + }) +}) + describe('normalizeState', () => { it('parses a modern payload', () => { - expect(normalizeState({ intensity: 40, mode: 'glass', material: 'header', scope: 'sidebar' }, true)).toEqual({ + expect( + normalizeState({ intensity: 40, fade: 15, mode: 'glass', material: 'header', scope: 'sidebar' }, true) + ).toEqual({ intensity: 40, + fade: 15, mode: 'glass', material: 'header', scope: 'sidebar' @@ -239,19 +375,28 @@ describe('normalizeState', () => { it('keeps a legacy intensity-only payload on clear', () => { expect(normalizeState({ intensity: 70 }, true)).toEqual({ intensity: 70, + fade: 0, mode: 'clear', material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE }) }) - it('survives junk payloads', () => { - const base = { intensity: 0, material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE } + // Fade arrived after glass shipped, so a profile written by the older build + // has no key for it and must come back unfaded rather than undefined. + it('defaults a payload written before fade existed to no fade', () => { + expect(normalizeState({ intensity: 60, mode: 'glass' }, true).fade).toBe(0) + }) - // A fresh macOS profile lands on glass at zero intensity: selected, but off. + it('survives junk payloads', () => { + const base = { intensity: 0, fade: 0, material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE } + + // A fresh glass-capable profile lands on glass at zero intensity: selected, but off. expect(normalizeState(null, true)).toEqual({ ...base, mode: 'glass' }) expect(normalizeState('nope', true)).toEqual({ ...base, mode: 'glass' }) - expect(normalizeState({ intensity: 'x', material: 'nope', mode: 'glass', scope: 'nope' }, false)).toEqual({ + expect( + normalizeState({ intensity: 'x', fade: 'x', material: 'nope', mode: 'glass', scope: 'nope' }, false) + ).toEqual({ ...base, mode: 'clear' }) @@ -266,8 +411,8 @@ describe('glassActive', () => { }) }) -// The default must be selected-but-off: a fresh macOS profile shows Glass in -// the picker while the window itself is untouched until the lever moves. +// The default must be selected-but-off: a fresh glass-capable profile shows +// Glass in the picker while the window itself is untouched until the lever moves. describe('a fresh profile', () => { const fresh = normalizeState(null, true) @@ -326,12 +471,22 @@ describe('what an update actually changes natively', () => { expect(nativeDiff(clear(40), clear(41))).toEqual({ backing: false, material: false, opacity: true }) }) + // The one glass drag that reaches main, and it costs what a clear drag costs. + it('is only the opacity while dragging fade under glass', () => { + expect(nativeDiff(glass(60, DEFAULT_GLASS_MATERIAL, 40), glass(60, DEFAULT_GLASS_MATERIAL, 41))).toEqual({ + backing: false, + material: false, + opacity: true + }) + }) + it('is the material alone when the frost level changes', () => { expect(nativeDiff(glass(60, 'under-window'), glass(60, 'header'))).toEqual({ backing: false, material: true, opacity: false }) + expect(backgroundMaterialFor(glass(60, 'under-window'))).not.toBe(backgroundMaterialFor(glass(60, 'header'))) }) // Crossing zero flips glass on/off, which is exactly when the backing has to diff --git a/apps/desktop/electron/translucency.ts b/apps/desktop/electron/translucency.ts index cb9b2f28bf..aefedc21f0 100644 --- a/apps/desktop/electron/translucency.ts +++ b/apps/desktop/electron/translucency.ts @@ -14,6 +14,7 @@ import { glassActive, type TranslucencyState } from '../../shared/src/translucency' export { + backgroundMaterialFor, clampIntensity, DEFAULT_GLASS_MATERIAL, DEFAULT_GLASS_SCOPE, @@ -21,6 +22,9 @@ export { GLASS_SCOPES, glassActive, type GlassMaterial, + glassMaterialForPicker, + glassMaterialsFor, + glassSupportedOn, glassSurfaceKeep, normalizeMaterial, normalizeMode, @@ -31,7 +35,11 @@ export { TRANSLUCENCY_MIN, TRANSLUCENCY_OPACITY_FLOOR, type TranslucencyState, + translucencySupportedOn, vibrancyFor, + WINDOWS_BACKGROUND_MATERIALS, + WINDOWS_GLASS_MIN_BUILD, + type WindowsBackgroundMaterial, windowOpacityFor } from '../../shared/src/translucency' diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index db4eb8d15f..1bd046c80d 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -247,6 +247,10 @@ declare global { setActiveWork?: (payload: HermesActiveWork) => void setTitleBarTheme?: (payload: HermesTitleBarTheme) => void setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void + /** Main-process fact: this OS can back glass with a native material. */ + glassSupported?: boolean + /** Main-process fact: this OS can do any translucency at all (not Linux). */ + translucencySupported?: boolean setTranslucency?: (payload: TranslucencyState) => void setKeepAwake?: (on: boolean) => void setDisableF12?: (blocked: boolean) => void diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 2c0f27e653..43821a35d5 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -413,10 +413,12 @@ export const ar = defineLocale({ reasoningCollapsedTitle: 'طي التفكير افتراضيًا', reasoningCollapsedDesc: 'أبقِ التفكير المتدفق متاحًا دون توسيعه حتى تفتحه.', translucencyTitle: 'شفافية النافذة', - translucencyDesc: 'إظهار سطح المكتب من خلال النافذة بالكامل. متاح على macOS وWindows فقط.', - translucencyGlassDesc: 'زجاج غير لامع: يظهر سطح المكتب كضبابية ناعمة بينما يبقى النص واضحًا. متاح على macOS فقط.', + translucencyDesc: 'إظهار سطح المكتب من خلال النافذة بالكامل، بما في ذلك النص.', + translucencyGlassDesc: 'زجاج غير لامع: يظهر سطح المكتب كضبابية ناعمة بينما يبقى النص واضحًا.', translucencyModeClear: 'شفاف', translucencyModeGlass: 'زجاج', + translucencyTintTitle: 'التلوين', + translucencyFadeTitle: 'التلاشي', translucencyFrostTitle: 'نوع الضبابية', translucencyFrost: { 'under-window': 'عميق', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 091ae1b828..deda09136d 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -522,11 +522,12 @@ export const en: Translations = { terminalFontPreview: 'Glyph preview', terminalFontReset: 'Use default', translucencyTitle: 'Window Translucency', - translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', - translucencyGlassDesc: - 'Matte glass: the desktop shows through as a smooth blur while text stays sharp. macOS only.', + translucencyDesc: 'See your desktop through the whole window, text and all.', + translucencyGlassDesc: 'Matte glass: the desktop shows through as a smooth blur while text stays sharp.', translucencyModeClear: 'Clear', translucencyModeGlass: 'Glass', + translucencyTintTitle: 'Tint', + translucencyFadeTitle: 'Fade', translucencyFrostTitle: 'Frost', translucencyFrost: { 'under-window': 'Deep', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 5110bf27aa..b924693600 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -346,10 +346,12 @@ export const ja = defineLocale({ terminalFontPreview: 'グリフのプレビュー', terminalFontReset: '既定値を使用', translucencyTitle: 'ウィンドウの透過', - translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', - translucencyGlassDesc: 'マットガラス: デスクトップが滑らかなぼかしとして透け、テキストは鮮明なまま。macOS のみ。', + translucencyDesc: 'テキストも含めウィンドウ全体を透過させてデスクトップを表示します。', + translucencyGlassDesc: 'マットガラス: デスクトップが滑らかなぼかしとして透け、テキストは鮮明なまま。', translucencyModeClear: 'クリア', translucencyModeGlass: 'ガラス', + translucencyTintTitle: '色味', + translucencyFadeTitle: 'フェード', translucencyFrostTitle: 'くもりの質感', translucencyFrost: { 'under-window': '深い', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 25ba1df58b..da2b71ce8b 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -424,6 +424,8 @@ export interface Translations { translucencyGlassDesc: string translucencyModeClear: string translucencyModeGlass: string + translucencyTintTitle: string + translucencyFadeTitle: string translucencyFrostTitle: string translucencyFrost: { 'under-window': string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 54bb68ecc7..4a4716cb84 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -338,10 +338,12 @@ export const zhHant = defineLocale({ terminalFontPreview: '字形預覽', terminalFontReset: '使用預設字型', translucencyTitle: '視窗透明', - translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', - translucencyGlassDesc: '霧面玻璃:桌面以柔和模糊透出,文字保持清晰。僅支援 macOS。', + translucencyDesc: '讓整個視窗(包括文字)透出桌面。', + translucencyGlassDesc: '霧面玻璃:桌面以柔和模糊透出,文字保持清晰。', translucencyModeClear: '透明', translucencyModeGlass: '玻璃', + translucencyTintTitle: '色調', + translucencyFadeTitle: '淡出', translucencyFrostTitle: '磨砂質感', translucencyFrost: { 'under-window': '深邃', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index c78354f41e..a18da9779d 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -511,10 +511,12 @@ export const zh: Translations = { terminalFontPreview: '字形预览', terminalFontReset: '使用默认字体', translucencyTitle: '窗口透明', - translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', - translucencyGlassDesc: '磨砂玻璃:桌面以柔和模糊透出,文字保持清晰。仅支持 macOS。', + translucencyDesc: '让整个窗口(包括文字)透出桌面。', + translucencyGlassDesc: '磨砂玻璃:桌面以柔和模糊透出,文字保持清晰。', translucencyModeClear: '透明', translucencyModeGlass: '玻璃', + translucencyTintTitle: '色调', + translucencyFadeTitle: '淡出', translucencyFrostTitle: '磨砂质感', translucencyFrost: { 'under-window': '深邃', diff --git a/apps/desktop/src/lib/platform.ts b/apps/desktop/src/lib/platform.ts index 011d3afc4e..6bcb7358ed 100644 --- a/apps/desktop/src/lib/platform.ts +++ b/apps/desktop/src/lib/platform.ts @@ -2,9 +2,18 @@ * Platform detection for the renderer. * * The renderer has no `process.platform`, and several surfaces need to know - * whether they're on a Mac — keybind glyphs, terminal shortcuts, and the - * macOS-only vibrancy features. One definition so they can't disagree. + * which OS they're on — keybind glyphs, terminal shortcuts, and glass. One + * definition so they can't disagree. + * + * Win10 vs Win11 is not visible here (both report NT 10.0). The real glass + * gate is `hermesDesktop.glassSupported`, which main/preload compute from + * `os.release()`. */ export const isMacPlatform = (): boolean => typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '') + +// Not `/win/i` — that matches the substring inside `darwin`, which is jsdom's +// default userAgent. Win32 / Windows NT are the real tokens. +export const isWindowsPlatform = (): boolean => + typeof navigator !== 'undefined' && /win32|windows/i.test(navigator.platform || navigator.userAgent || '') diff --git a/apps/desktop/src/store/translucency.test.ts b/apps/desktop/src/store/translucency.test.ts index 933e738d7c..538db4faab 100644 --- a/apps/desktop/src/store/translucency.test.ts +++ b/apps/desktop/src/store/translucency.test.ts @@ -59,6 +59,7 @@ describe('window translucency lever', () => { it('starts off, with glass pre-selected on macOS', () => { expect(initialTranslucency).toEqual({ intensity: TRANSLUCENCY_MIN, + fade: TRANSLUCENCY_MIN, mode: GLASS_SUPPORTED ? 'glass' : 'clear', material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE @@ -122,6 +123,7 @@ describe('window translucency lever', () => { op: 'write', value: JSON.stringify({ intensity: 23, + fade: 0, mode: 'clear', material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE @@ -150,6 +152,7 @@ describe('window translucency lever', () => { expect(calls).toHaveLength(5) expect(calls.at(-1)).toEqual({ intensity: 40, + fade: 0, mode: 'clear', material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE @@ -191,7 +194,7 @@ describe('glass mode', () => { setTranslucency(TRANSLUCENCY_MIN) }) - it('rejects glass off macOS and applies it on macOS', () => { + it('rejects glass when the platform cannot back it', () => { setTranslucency(50) setTranslucencyMode('glass') diff --git a/apps/desktop/src/store/translucency.ts b/apps/desktop/src/store/translucency.ts index 337c358321..b37bc25989 100644 --- a/apps/desktop/src/store/translucency.ts +++ b/apps/desktop/src/store/translucency.ts @@ -7,7 +7,7 @@ * * The renderer owns the value and mirrors it to the main process over IPC. * Glass additionally needs page-level work, which lives here: the field - * surfaces have to get out of the way for the vibrancy material underneath the + * surfaces have to get out of the way for the platform material underneath the * web contents to read (see the `[data-hermes-glass]` block in styles.css). */ @@ -16,6 +16,8 @@ import { GLASS_MATERIALS, GLASS_SCOPES, type GlassMaterial, + glassMaterialForPicker, + glassMaterialsFor, type GlassScope, glassSurfaceKeep, normalizeMaterial, @@ -29,13 +31,43 @@ import { } from '@hermes/shared/translucency' import { atom } from 'nanostores' -import { isMacPlatform } from '@/lib/platform' +import { isMacPlatform, isWindowsPlatform } from '@/lib/platform' import { readJson, writeJson } from '@/lib/storage' -export { GLASS_MATERIALS, GLASS_SCOPES, TRANSLUCENCY_MAX, TRANSLUCENCY_MIN, TRANSLUCENCY_STEP } +export { + GLASS_MATERIALS, + GLASS_SCOPES, + glassMaterialForPicker, + glassMaterialsFor, + TRANSLUCENCY_MAX, + TRANSLUCENCY_MIN, + TRANSLUCENCY_STEP +} -/** Glass rides on the macOS vibrancy material; other platforms only have Clear. */ -export const GLASS_SUPPORTED = isMacPlatform() +/** + * Glass needs a native window material. Electron is authoritative (preload + * sets `hermesDesktop.glassSupported` from `os.release()` so Win10 cannot + * sneak through). Tests and non-Electron shells fall back to a UA sniff — + * Mac or Windows — which is why this file pins `navigator.platform` before + * import. + */ +export const GLASS_SUPPORTED = + typeof window !== 'undefined' && typeof window.hermesDesktop?.glassSupported === 'boolean' + ? window.hermesDesktop.glassSupported + : isMacPlatform() || isWindowsPlatform() + +/** + * Whether the setting is worth showing at all. Linux has neither half — + * `setOpacity` is a documented no-op and there is no native material — so + * Settings hides the row rather than offering a lever that does nothing. + */ +export const TRANSLUCENCY_SUPPORTED = + typeof window !== 'undefined' && typeof window.hermesDesktop?.translucencySupported === 'boolean' + ? window.hermesDesktop.translucencySupported + : isMacPlatform() || isWindowsPlatform() + +/** Windows collapses the frost ladder — see `glassMaterialsFor`. */ +export const GLASS_IS_WINDOWS = GLASS_SUPPORTED && !isMacPlatform() const KEY = 'hermes.desktop.translucency.v1' @@ -57,6 +89,10 @@ export function setTranslucency(intensity: number): void { $translucency.set({ ...$translucency.get(), intensity: clampIntensity(intensity) }) } +export function setTranslucencyFade(fade: number): void { + $translucency.set({ ...$translucency.get(), fade: clampIntensity(fade) }) +} + export function setTranslucencyMode(mode: TranslucencyMode): void { $translucency.set({ ...$translucency.get(), mode: mode === 'glass' && GLASS_SUPPORTED ? 'glass' : 'clear' }) } @@ -300,6 +336,7 @@ if (typeof window !== 'undefined') { if ( next.intensity !== current.intensity || + next.fade !== current.fade || next.mode !== current.mode || next.material !== current.material || next.scope !== current.scope diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 24364e00e2..3816915bf8 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -551,23 +551,25 @@ } /* ── Window glass (Settings → Appearance → Window Translucency: Glass) ── - Set on by store/translucency.ts; macOS chat windows only. Every - chat window already carries an NSVisualEffectView (`vibrancy: 'sidebar'` - in electron/main.ts) that WindowServer composites BELOW the web contents; - the page normally buries it under fully opaque surfaces. + Set on by store/translucency.ts; chat windows on a glass-capable + OS (macOS vibrancy, Windows 11 DWM acrylic/mica). The native material + composites BELOW the web contents; the page normally buries it under + fully opaque surfaces. ONE PAINTER:
paints the glass tint exactly once, at - `--translucency-glass-keep` (set inline from the intensity slider, floor - 30%), and the field tokens (chat / sidebar / editor surface) go fully - transparent. The field surfaces NEST — body > pane container > chat - section > transcript wrapper all wear these tokens — so thinning the - tokens themselves stacks the tint once per layer and a session pane ends - up near-opaque (~0.93 at 60%) while the landing page, with fewer layers, - reads far clearer. With a single painter the field alpha is the same - number on every route. Raised content (cards, popovers, bubbles, the - composer) keeps its own opaque fills over full-contrast text; regions - still read distinct through their borders and the material itself. - Unlike Clear, nothing on the page fades. + `--translucency-glass-keep` (set inline from the Tint slider, linear to + zero — the top of the lever is bare material), and the field tokens + (chat / sidebar / editor surface) go fully transparent. The field + surfaces NEST — body > pane container > chat section > transcript + wrapper all wear these tokens — so thinning the tokens themselves stacks + the tint once per layer and a session pane ends up near-opaque (~0.93 at + 60%) while the landing page, with fewer layers, reads far clearer. With a + single painter the field alpha is the same number on every route. Raised + content (cards, popovers, bubbles, the composer) keeps its own opaque + fills over full-contrast text; regions still read distinct through their + borders and the material itself. Unlike Clear, nothing on the page fades + — the separate Fade lever does that natively, at the window level, and + defaults to off. The boot script in index.html pins an opaque inline background on before first paint; it has to go transparent here or it sits behind the diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index b4f6299ca2..7f96081096 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -63,6 +63,7 @@ export { type SkinColorToken } from './skin' export { + backgroundMaterialFor, clampIntensity, DEFAULT_GLASS_MATERIAL, DEFAULT_GLASS_SCOPE, @@ -71,6 +72,9 @@ export { glassActive, type GlassMaterial, type GlassScope, + glassMaterialForPicker, + glassMaterialsFor, + glassSupportedOn, glassSurfaceKeep, normalizeMaterial, normalizeMode, @@ -83,7 +87,11 @@ export { TRANSLUCENCY_STEP, type TranslucencyMode, type TranslucencyState, + translucencySupportedOn, vibrancyFor, + WINDOWS_BACKGROUND_MATERIALS, + WINDOWS_GLASS_MIN_BUILD, + type WindowsBackgroundMaterial, windowOpacityFor } from './translucency' export { diff --git a/apps/shared/src/translucency.ts b/apps/shared/src/translucency.ts index c30306a837..da32fd102f 100644 --- a/apps/shared/src/translucency.ts +++ b/apps/shared/src/translucency.ts @@ -8,10 +8,11 @@ * - 'clear' — the main process maps the lever to native window opacity * (`setOpacity`), so the whole window fades, text included. macOS + Windows; * `setOpacity` is a no-op on Linux. - * - 'glass' — macOS only. The window stays fully opaque at the native level and - * the renderer thins its page surfaces instead, letting the vibrancy material - * every chat window already carries read as a matte blur while text keeps - * full contrast. + * - 'glass' — the window stays fully opaque at the native level and the + * renderer thins its page surfaces instead, letting a platform material + * read as a matte blur while text keeps full contrast. macOS rides + * `setVibrancy`; Windows 11 (22H2+) rides `setBackgroundMaterial`. Linux + * has no first-party desktop material, so glass is not offered there. * * The renderer owns the value and mirrors it to main over IPC; main persists it * so a cold launch can apply it at window creation, before the renderer reports @@ -53,8 +54,59 @@ export type GlassScope = (typeof GLASS_SCOPES)[number] export const DEFAULT_GLASS_SCOPE: GlassScope = 'window' +/** + * Electron `setBackgroundMaterial` values. `'auto'` is deliberately absent — + * it lets DWM pick, which would silently erase the frost choice. + */ +export const WINDOWS_BACKGROUND_MATERIALS = ['acrylic', 'tabbed', 'mica', 'none'] as const + +export type WindowsBackgroundMaterial = (typeof WINDOWS_BACKGROUND_MATERIALS)[number] + +/** + * Frost (sheer → heavy) → Windows 11 system backdrop. Acrylic is the live-blur + * transient material, closest to macOS under-window vibrancy; tabbed and mica + * sample the wallpaper and read more opaque. + * + * Three backdrops for four rungs, so the two heaviest both land on mica. The + * mapping stays total — a frost saved on a Mac still resolves — and the PICKER + * drops the duplicate instead (see `glassMaterialsFor`). + */ +const WINDOWS_MATERIAL_BY_FROST: Record