feat(desktop): back window glass with Windows 11 system materials

Glass was macOS-only because it rode setVibrancy. Windows 11 22H2 has a
first-party equivalent in setBackgroundMaterial, so the mode now resolves
its backing per platform instead of per-OS-check: macOS keeps vibrancy,
Windows 11 gets DWM acrylic / tabbed / mica, and everything older stays on
Clear. No third-party native addon.

Two Windows-specific details the mapping has to respect. DWM only paints
the client area of a transparent window (electron#49443), so glass-capable
Windows chat windows are born transparent with the opaque themed
backgroundColor covering them while glass is off — a live Clear/Glass
toggle then needs no window recreate. And Windows exposes three backdrops
for four frost rungs, so the two heaviest both resolve to mica; the mapping
stays total so a frost saved on a Mac still renders.

Glass support is computed once from os.release() and shared: main uses it
for the persisted default and every window, preload publishes it to the
renderer so the UI can't offer a mode the window can't back.
This commit is contained in:
Brooklyn Nicholson
2026-08-19 02:09:58 -05:00
parent b5455fdd16
commit eb52328857
17 changed files with 476 additions and 93 deletions
+41 -18
View File
@@ -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
+5
View File
@@ -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),
+166 -11
View File
@@ -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
+8
View File
@@ -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'
+4
View File
@@ -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
+4 -2
View File
@@ -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': 'عميق',
+4 -3
View File
@@ -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',
+4 -2
View File
@@ -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': '深い',
+2
View File
@@ -424,6 +424,8 @@ export interface Translations {
translucencyGlassDesc: string
translucencyModeClear: string
translucencyModeGlass: string
translucencyTintTitle: string
translucencyFadeTitle: string
translucencyFrostTitle: string
translucencyFrost: {
'under-window': string
+4 -2
View File
@@ -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': '深邃',
+4 -2
View File
@@ -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': '深邃',
+11 -2
View File
@@ -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 || '')
+4 -1
View File
@@ -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')
+42 -5
View File
@@ -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
+17 -15
View File
@@ -551,23 +551,25 @@
}
/* ── Window glass (Settings → Appearance → Window Translucency: Glass) ──
Set on <html> 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 <html> 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: <body> 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 <html>
before first paint; it has to go transparent here or it sits behind the
+8
View File
@@ -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 {
+148 -30
View File
@@ -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<GlassMaterial, Exclude<WindowsBackgroundMaterial, 'none'>> = {
'under-window': 'acrylic',
popover: 'tabbed',
titlebar: 'mica',
header: 'mica'
}
/**
* The frost rungs Windows can render as DISTINCT looks: the first rung for each
* backdrop. Shipping two options that composite identically is the mistake the
* macOS census already corrected once (sidebar/hud); deriving the list from the
* mapping means a change there can never reintroduce a duplicate.
*/
const WINDOWS_GLASS_MATERIALS: readonly GlassMaterial[] = GLASS_MATERIALS.filter(
(material, index) =>
GLASS_MATERIALS.findIndex(rung => WINDOWS_MATERIAL_BY_FROST[rung] === WINDOWS_MATERIAL_BY_FROST[material]) === index
)
/**
* Windows 11 22H2 (build 22621) is the floor Electron documents for
* `setBackgroundMaterial`. Windows 11 still reports kernel 10.0; the build
* number is the discriminator. Fail closed on a missing/unparseable release.
*
* @see https://www.electronjs.org/docs/latest/api/browser-window#winsetbackgroundmaterialmaterial-windows
*/
export const WINDOWS_GLASS_MIN_BUILD = 22621
export interface TranslucencyState {
intensity: number
/**
* Glass only: native window opacity, on the same ramp Clear's lever uses.
* Defaults to 0 (no fade) because fading a glass window fades its text too —
* the very thing Glass exists to avoid. It is offered as a deliberate second
* lever, never as part of the tint.
*/
fade: number
mode: TranslucencyMode
material: GlassMaterial
scope: GlassScope
@@ -84,20 +136,49 @@ export function clampIntensity(value: unknown): number {
}
/**
* Glass rides on the macOS vibrancy material, so it is macOS-only and 'clear'
* is the fallback everywhere else.
*
* With no mode recorded, macOS gets glass — it is the better-looking half of
* the feature and the one worth finding, and pre-selecting it costs a fresh
* profile nothing because the intensity still starts at 0 (the whole feature
* is off until the user raises the lever). `legacyIntensity` is the escape
* hatch: a profile that already carries a NON-ZERO intensity but no mode
* predates this setting and has been rendering as clear all along, so it keeps
* rendering as clear. Flipping a window someone already tuned is the one thing
* a default must not do.
* Whether this OS can do ANY translucency. Clear rides `setOpacity`, which
* Electron documents as doing nothing on Linux, and glass needs a native
* material Linux does not have — so the whole setting is dead there and the
* row should not be shown at all.
*/
export function normalizeMode(value: unknown, isMac: boolean, legacyIntensity = 0): TranslucencyMode {
if (!isMac) {
export function translucencySupportedOn(platform: string): boolean {
return platform === 'darwin' || platform === 'win32'
}
/**
* Whether this OS can back glass with a first-party Electron material.
* macOS: `setVibrancy`. Windows 11 22H2+: `setBackgroundMaterial`. Linux and
* older Windows: no.
*/
export function glassSupportedOn(platform: string, release = ''): boolean {
if (platform === 'darwin') {
return true
}
if (platform !== 'win32') {
return false
}
const build = Number.parseInt(release.split('.')[2] ?? '', 10)
return Number.isFinite(build) && build >= WINDOWS_GLASS_MIN_BUILD
}
/**
* Glass needs a native window material, so unsupported platforms stay on
* 'clear'.
*
* With no mode recorded, a glass-capable OS gets glass — it is the
* better-looking half of the feature and the one worth finding, and
* pre-selecting it costs a fresh profile nothing because the intensity still
* starts at 0 (the whole feature is off until the user raises the lever).
* `legacyIntensity` is the escape hatch: a profile that already carries a
* NON-ZERO intensity but no mode predates this setting and has been rendering
* as clear all along, so it keeps rendering as clear. Flipping a window
* someone already tuned is the one thing a default must not do.
*/
export function normalizeMode(value: unknown, glassSupported: boolean, legacyIntensity = 0): TranslucencyMode {
if (!glassSupported) {
return 'clear'
}
@@ -119,32 +200,38 @@ export function normalizeScope(value: unknown): GlassScope {
}
/** Parse a persisted translucency.json / IPC payload into a safe state. */
export function normalizeState(payload: unknown, isMac: boolean): TranslucencyState {
export function normalizeState(payload: unknown, glassSupported: boolean): TranslucencyState {
const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
const intensity = clampIntensity(record.intensity)
return {
intensity,
mode: normalizeMode(record.mode, isMac, intensity),
fade: clampIntensity(record.fade),
mode: normalizeMode(record.mode, glassSupported, intensity),
material: normalizeMaterial(record.material),
scope: normalizeScope(record.scope)
}
}
/**
* Native window opacity for a state. Glass never fades the native window — its
* see-through effect is painted by the renderer over the vibrancy material.
*/
export function windowOpacityFor({ intensity, mode }: TranslucencyState): number {
if (mode === 'glass') {
return 1
}
const ratio = clampIntensity(intensity) / TRANSLUCENCY_MAX
/** Lever percent → native window opacity, floored so it stays usable. */
function opacityRamp(lever: number): number {
const ratio = clampIntensity(lever) / TRANSLUCENCY_MAX
return 1 - (1 - TRANSLUCENCY_OPACITY_FLOOR) * Math.pow(ratio, TRANSLUCENCY_CURVE)
}
/**
* Native window opacity for a state.
*
* Under Clear the lever IS the opacity. Under Glass the lever paints the tint
* and only the separate `fade` reaches the window, so a glass window stays at
* 1 until the user opts into fading it — which is what keeps a tint drag from
* touching anything native.
*/
export function windowOpacityFor({ intensity, fade, mode }: TranslucencyState): number {
return opacityRamp(mode === 'glass' ? fade : intensity)
}
/**
* Whether glass is visually active. Both processes branch on this: main to
* decide a window's backing, the renderer to decide whether to thin surfaces.
@@ -155,7 +242,7 @@ export function glassActive({ intensity, mode }: TranslucencyState): boolean {
/**
* Percent of the surface tint the renderer KEEPS at a given intensity. Linear
* to zero: at 100 the tint is fully gone — bare vibrancy glass — so the slider
* to zero: at 100 the tint is fully gone — bare platform glass — so the slider
* spans the whole range from opaque theme to untinted blur. Text and cards
* keep their own opaque tokens for contrast; only the field surfaces thin.
*/
@@ -172,3 +259,34 @@ export function glassSurfaceKeep(intensity: number): number {
export function vibrancyFor(state: TranslucencyState): GlassMaterial | 'sidebar' {
return glassActive(state) ? state.material : 'sidebar'
}
/**
* The Windows 11 system backdrop a chat window should carry. 'none' while
* glass is off so DWM does not keep drawing mica/acrylic under the opaque
* themed backing.
*/
export function backgroundMaterialFor(state: TranslucencyState): WindowsBackgroundMaterial {
return glassActive(state) ? WINDOWS_MATERIAL_BY_FROST[state.material] : 'none'
}
/** The frost rungs to offer on this platform. */
export function glassMaterialsFor(isWindows: boolean): readonly GlassMaterial[] {
return isWindows ? WINDOWS_GLASS_MATERIALS : GLASS_MATERIALS
}
/**
* The rung the picker highlights. A frost with no rung of its own here — a
* Mac's 'header' read on Windows — folds onto the rung that renders the same
* backdrop, so the picker shows a truthful selection without rewriting the
* value the user saved on their other machine.
*/
export function glassMaterialForPicker(material: GlassMaterial, isWindows: boolean): GlassMaterial {
if (!isWindows) {
return material
}
return (
WINDOWS_GLASS_MATERIALS.find(rung => WINDOWS_MATERIAL_BY_FROST[rung] === WINDOWS_MATERIAL_BY_FROST[material]) ??
DEFAULT_GLASS_MATERIAL
)
}