feat(desktop): glass ships on, tuned per appearance and platform

Translucency was one number serving both appearances and both platforms,
resting at zero. A lever that starts at zero is a feature nobody finds, and
one number cannot serve four situations: a tint that reads as a whisper over
a dark palette is a milky sheet over a light one, and the same numbers that
read as frost on macOS vibrancy read as a washed sheet over Windows acrylic,
which composites its own tint in DWM before the page is drawn.

So the state splits. `mode` stays global — clear versus glass is a choice
about the window, not the palette — while the values resolve through a
ladder, per key: the appearance you are looking at, then a shared base, then
the platform default. Tuning light mode stays in light mode; an untouched
dark keeps inheriting. A v1 state lands in base, so a window someone already
tuned crosses the upgrade with exactly what was on screen.

Main reads the same defaults at window creation, because a window born
opaque cannot reliably be swapped to glass afterwards.

The chat backdrop goes off by default in the same pass: it was competing
with the glass field for the same surface.
This commit is contained in:
Brooklyn Nicholson
2026-08-20 01:16:04 -05:00
committed by brooklyn!
parent 38f46504fd
commit be3166607e
7 changed files with 582 additions and 67 deletions
+7 -1
View File
@@ -273,6 +273,7 @@ import { registerTerminalIpc } from './terminal-ipc'
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
import {
backgroundMaterialFor,
defaultTranslucencyState,
glassActive,
glassSupportedOn,
normalizeState as normalizeTranslucency,
@@ -878,7 +879,12 @@ function readPersistedTranslucency() {
try {
return normalizeTranslucency(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')), GLASS_SUPPORTED)
} catch {
return normalizeTranslucency(null, GLASS_SUPPORTED)
// Nothing persisted yet — a first launch. Glass ships on, so the FIRST
// window has to be created with the glass backing already: a window born
// opaque cannot reliably be swapped to glass afterwards (see
// windowBackingOptions). nativeTheme is the only appearance signal main
// has this early; the renderer's first resolved send corrects it.
return defaultTranslucencyState(nativeTheme.shouldUseDarkColors ? 'dark' : 'light', GLASS_SUPPORTED, IS_WINDOWS)
}
}
+121
View File
@@ -15,6 +15,8 @@ import {
clampIntensity,
DEFAULT_GLASS_MATERIAL,
DEFAULT_GLASS_SCOPE,
defaultTranslucencyState,
defaultTranslucencyValues,
GLASS_MATERIALS,
GLASS_SCOPES,
glassActive,
@@ -24,10 +26,13 @@ import {
glassSupportedOn,
glassSurfaceKeep,
hudFrostFor,
normalizeBook,
normalizeMaterial,
normalizeMode,
normalizeScope,
normalizeState,
resolveTranslucency,
setTranslucencyValues,
TRANSLUCENCY_CURVE,
TRANSLUCENCY_MAX,
TRANSLUCENCY_MIN,
@@ -548,3 +553,119 @@ describe('what an update actually changes natively', () => {
expect(nativeDiff(clear(60), glass(60))).toEqual({ backing: true, material: true, opacity: true })
})
})
/**
* The shipped defaults, per platform. These are the numbers a fresh profile
* gets before anyone opens Settings, so they are the ones most people will
* ever see — and they differ by platform because the lever means different
* things behind macOS vibrancy and Windows acrylic.
*/
describe('the defaults a fresh profile lands on', () => {
const mac = (appearance: 'dark' | 'light') => defaultTranslucencyValues(appearance, false)
const win = (appearance: 'dark' | 'light') => defaultTranslucencyValues(appearance, true)
it('ships glass on, not a lever resting at zero', () => {
for (const values of [mac('light'), mac('dark'), win('light'), win('dark')]) {
expect(values.intensity).toBeGreaterThan(0)
expect(glassActive({ ...values, mode: 'glass' })).toBe(true)
}
for (const appearance of ['light', 'dark'] as const) {
expect(defaultTranslucencyState(appearance, true, false).mode).toBe('glass')
expect(defaultTranslucencyState(appearance, true, true).mode).toBe('glass')
}
})
it('falls back to clear where no native material exists', () => {
expect(defaultTranslucencyState('dark', false, false).mode).toBe('clear')
})
it('tints light more heavily than dark, on both platforms', () => {
// A dark field already separates from what is behind it; a bright one
// needs real thinning before the desktop reads as a layer underneath.
expect(mac('light').intensity).toBeGreaterThan(mac('dark').intensity)
expect(win('light').intensity).toBeGreaterThan(win('dark').intensity)
})
it('asks far less of Windows, which composites its own tint in DWM', () => {
expect(win('light').intensity).toBeLessThan(mac('light').intensity)
expect(win('dark').intensity).toBeLessThan(mac('dark').intensity)
})
it('never fades a Windows window — setOpacity dims the composited backdrop', () => {
expect(win('light').fade).toBe(0)
expect(win('dark').fade).toBe(0)
})
it('defaults each platform onto a frost that platform can actually render', () => {
for (const appearance of ['light', 'dark'] as const) {
expect(glassMaterialsFor(true)).toContain(win(appearance).material)
expect(glassMaterialsFor(false)).toContain(mac(appearance).material)
}
})
it('opens the whole window, not just the sidebar rail', () => {
for (const values of [mac('light'), mac('dark'), win('light'), win('dark')]) {
expect(values.scope).toBe('window')
}
})
})
/**
* The per-appearance ladder: appearance slot → base → platform default, per
* key. This is what makes tuning light mode stay in light mode while an
* untouched dark keeps inheriting.
*/
describe('resolving the book for the painted appearance', () => {
const empty = normalizeBook(null, true)
it('falls all the way through to the platform default', () => {
expect(resolveTranslucency(empty, 'dark', false).intensity).toBe(defaultTranslucencyValues('dark', false).intensity)
expect(resolveTranslucency(empty, 'dark', true).intensity).toBe(defaultTranslucencyValues('dark', true).intensity)
})
it('scopes an edit to the appearance it was made in', () => {
const book = setTranslucencyValues(empty, 'light', { intensity: 90 })
expect(resolveTranslucency(book, 'light', false).intensity).toBe(90)
expect(resolveTranslucency(book, 'dark', false).intensity).toBe(defaultTranslucencyValues('dark', false).intensity)
})
it('carries a v1 state into BOTH appearances via base', () => {
// Someone who tuned a window before appearances were split keeps exactly
// what was on screen, in either appearance, until they edit one of them.
const migrated = normalizeBook({ intensity: 40, mode: 'glass' }, true)
expect(migrated.base.intensity).toBe(40)
expect(resolveTranslucency(migrated, 'light', false).intensity).toBe(40)
expect(resolveTranslucency(migrated, 'dark', false).intensity).toBe(40)
})
it('lets an appearance override base without disturbing the other', () => {
const tuned = setTranslucencyValues(normalizeBook({ intensity: 40, mode: 'glass' }, true), 'dark', {
intensity: 10
})
expect(resolveTranslucency(tuned, 'dark', false).intensity).toBe(10)
expect(resolveTranslucency(tuned, 'light', false).intensity).toBe(40)
})
it('inherits per KEY, not per appearance', () => {
// Editing only the tint in dark must leave dark's material still tracking
// base — a partial edit is not a full snapshot of the appearance.
const book = setTranslucencyValues(normalizeBook({ material: 'popover', mode: 'glass' }, true), 'dark', {
intensity: 33
})
const resolved = resolveTranslucency(book, 'dark', false)
expect(resolved.intensity).toBe(33)
expect(resolved.material).toBe('popover')
})
it('keeps mode global — clear vs glass is about the window, not the palette', () => {
const book = setTranslucencyValues({ ...empty, mode: 'clear' }, 'light', { intensity: 50 })
expect(resolveTranslucency(book, 'light', false).mode).toBe('clear')
expect(resolveTranslucency(book, 'dark', false).mode).toBe('clear')
})
})
+5
View File
@@ -18,6 +18,8 @@ export {
clampIntensity,
DEFAULT_GLASS_MATERIAL,
DEFAULT_GLASS_SCOPE,
defaultTranslucencyState,
defaultTranslucencyValues,
GLASS_MATERIALS,
GLASS_SCOPES,
glassActive,
@@ -27,10 +29,13 @@ export {
glassSupportedOn,
glassSurfaceKeep,
hudFrostFor,
normalizeBook,
normalizeMaterial,
normalizeMode,
normalizeScope,
normalizeState,
resolveTranslucency,
setTranslucencyValues,
TRANSLUCENCY_CURVE,
TRANSLUCENCY_MAX,
TRANSLUCENCY_MIN,
+3 -2
View File
@@ -523,8 +523,9 @@ export const en: Translations = {
terminalFontPreview: 'Glyph preview',
terminalFontReset: 'Use default',
translucencyTitle: 'Window Translucency',
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.',
translucencyDesc: 'See your desktop through the whole window, text and all. Tuned separately for light and dark.',
translucencyGlassDesc:
'Matte glass: the desktop shows through as a smooth blur while text stays sharp. Tuned separately for light and dark.',
translucencyModeClear: 'Clear',
translucencyModeGlass: 'Glass',
translucencyTintTitle: 'Tint',
+181 -29
View File
@@ -16,13 +16,17 @@ import { onPersistenceEvent, type PersistenceEvent } from '@/lib/storage'
import {
$translucency,
$translucencyBook,
$translucencyPeek,
beginTranslucencyPeek,
defaultTranslucencyValues,
endTranslucencyPeek,
GLASS_SUPPORTED,
isChatWindow,
resetTranslucencyPeek,
setAppearance,
setTranslucency,
setTranslucencyFade,
setTranslucencyMaterial,
setTranslucencyMode,
setTranslucencyScope,
@@ -31,7 +35,15 @@ import {
TRANSLUCENCY_STEP
} from './translucency'
const KEY = 'hermes.desktop.translucency.v1'
const KEY = 'hermes.desktop.translucency.v2'
const LEGACY_KEY = 'hermes.desktop.translucency.v1'
// The book is per-appearance; the tests below drive one appearance at a time.
// Dark is the store's initial appearance, so it is also the reset target.
// This suite pins navigator.platform to a Mac before import, so the Mac table
// is the one in play — `GLASS_IS_WINDOWS` resolves false throughout.
const DARK = defaultTranslucencyValues('dark', false)
const LIGHT = defaultTranslucencyValues('light', false)
const glassAttr = () => document.documentElement.hasAttribute('data-hermes-glass')
const clearAttr = () => document.documentElement.hasAttribute('data-hermes-clear')
@@ -56,14 +68,8 @@ describe('window translucency lever', () => {
// NB: this asserts the module's INITIAL value, so it deliberately reads the
// atom before the beforeEach above can touch it — the previous version of
// this test ran after the reset and so proved nothing about the default.
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
})
it('starts on the dark appearance defaults, glass-backed on macOS', () => {
expect(initialTranslucency).toEqual({ ...DARK, mode: GLASS_SUPPORTED ? 'glass' : 'clear' })
})
it('accepts every step the slider can emit', () => {
@@ -116,18 +122,13 @@ describe('window translucency lever', () => {
vi.advanceTimersByTime(200)
// One write, carrying the value the hand landed on.
// One write, carrying the value the hand landed on — recorded against
// the appearance being painted, not against the shared base.
expect(writes).toHaveLength(1)
expect(writes.at(-1)).toEqual({
key: KEY,
op: 'write',
value: JSON.stringify({
intensity: 23,
fade: 0,
mode: 'clear',
material: DEFAULT_GLASS_MATERIAL,
scope: DEFAULT_GLASS_SCOPE
})
value: JSON.stringify({ ...$translucencyBook.get(), dark: { intensity: 23 } })
})
} finally {
stop()
@@ -150,13 +151,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
})
expect(calls.at(-1)).toEqual({ ...DARK, intensity: 40, mode: 'clear' })
} finally {
vi.useRealTimers()
}
@@ -353,10 +348,7 @@ describe('cross-window sync', () => {
// Under glass main touches nothing native on an intensity change, so a
// sibling window's storage event is the ONLY way this window hears about it.
it("adopts a sibling window's persisted state", () => {
window.localStorage.setItem(
KEY,
JSON.stringify({ intensity: 77, mode: 'clear', material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE })
)
window.localStorage.setItem(KEY, JSON.stringify({ mode: 'clear', base: {}, light: {}, dark: { intensity: 77 } }))
window.dispatchEvent(new StorageEvent('storage', { key: KEY, newValue: 'x' }))
@@ -365,7 +357,7 @@ describe('cross-window sync', () => {
it('ignores storage events for other keys', () => {
setTranslucency(12)
window.localStorage.setItem(KEY, JSON.stringify({ intensity: 99, mode: 'clear' }))
window.localStorage.setItem(KEY, JSON.stringify({ mode: 'clear', base: {}, light: {}, dark: { intensity: 99 } }))
window.dispatchEvent(new StorageEvent('storage', { key: 'hermes.desktop.zoom.v1', newValue: 'x' }))
@@ -451,3 +443,163 @@ describe('isChatWindow', () => {
}
})
})
// A tint that reads as a whisper over a dark palette is a milky sheet over a
// light one, so the same lever has to mean a different amount in each
// appearance. These are the contract for that split.
describe('per-appearance settings', () => {
beforeEach(() => {
window.localStorage.clear()
$translucencyBook.set({ mode: 'glass', base: {}, light: {}, dark: {} })
setAppearance('dark')
})
afterEach(() => setAppearance('dark'))
it('ships each appearance its own defaults', () => {
expect($translucency.get()).toEqual({ ...DARK, mode: 'glass' })
setAppearance('light')
expect($translucency.get()).toEqual({ ...LIGHT, mode: 'glass' })
})
it('ships glass ON, so the feature is visible without being found first', () => {
expect($translucency.get().mode).toBe('glass')
expect($translucency.get().intensity).toBeGreaterThan(0)
setAppearance('light')
expect($translucency.get().intensity).toBeGreaterThan(0)
})
it('scopes an edit to the appearance it was made in', () => {
setAppearance('light')
setTranslucency(90)
setTranslucencyFade(7)
expect($translucency.get().intensity).toBe(90)
expect($translucency.get().fade).toBe(7)
// Dark never heard about it and keeps its own default.
setAppearance('dark')
expect($translucency.get().intensity).toBe(DARK.intensity)
expect($translucency.get().fade).toBe(DARK.fade)
// ...and light still holds the edit on the way back.
setAppearance('light')
expect($translucency.get().intensity).toBe(90)
})
it('carries an unset key over from the shared base', () => {
$translucencyBook.set({ mode: 'glass', base: { intensity: 44, scope: 'sidebar' }, light: {}, dark: {} })
// Neither appearance has an opinion, so both inherit base.
expect($translucency.get().intensity).toBe(44)
expect($translucency.get().scope).toBe('sidebar')
setAppearance('light')
expect($translucency.get().intensity).toBe(44)
// An edit overrides only the key it touches; the rest keeps inheriting.
setTranslucency(12)
expect($translucency.get().intensity).toBe(12)
expect($translucency.get().scope).toBe('sidebar')
setAppearance('dark')
expect($translucency.get().intensity).toBe(44)
})
// Clear vs glass is a choice about the window, not about the palette —
// splitting it would mean flipping appearance silently changed the mode.
it('keeps the mode global across appearances', () => {
setTranslucencyMode('clear')
expect($translucency.get().mode).toBe('clear')
setAppearance('light')
expect($translucency.get().mode).toBe('clear')
})
// Switching appearance changes what is painted but not what is stored, so
// it must not schedule a write.
it('does not persist on an appearance switch alone', () => {
vi.useFakeTimers()
const writes: PersistenceEvent[] = []
const stop = onPersistenceEvent(event => {
if (event.op === 'write') {
writes.push(event)
}
})
try {
setAppearance('light')
setAppearance('dark')
vi.advanceTimersByTime(400)
expect(writes).toHaveLength(0)
} finally {
stop()
vi.useRealTimers()
}
})
})
// A window someone already tuned must survive the upgrade unchanged — the new
// per-appearance defaults may only fill in where nothing was ever set.
describe('v1 → v2 migration', () => {
afterEach(() => {
window.localStorage.clear()
setAppearance('dark')
})
it('lands a tuned v1 state in base, so both appearances inherit it', async () => {
window.localStorage.clear()
window.localStorage.setItem(
LEGACY_KEY,
JSON.stringify({ intensity: 31, fade: 4, mode: 'glass', material: 'popover', scope: 'sidebar' })
)
vi.resetModules()
const fresh = await import('./translucency')
expect(fresh.$translucencyBook.get()).toEqual({
mode: 'glass',
base: { intensity: 31, fade: 4, material: 'popover', scope: 'sidebar' },
light: {},
dark: {}
})
fresh.setAppearance('light')
expect(fresh.$translucency.get()).toEqual({
intensity: 31,
fade: 4,
mode: 'glass',
material: 'popover',
scope: 'sidebar'
})
})
// The legacy rule: a non-zero v1 intensity with no mode was rendering as
// clear all along and has to keep doing so.
it('keeps a legacy mode-less state on clear', async () => {
window.localStorage.clear()
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ intensity: 25 }))
vi.resetModules()
const fresh = await import('./translucency')
expect(fresh.$translucency.get().mode).toBe('clear')
expect(fresh.$translucency.get().intensity).toBe(25)
})
it('prefers a v2 book over a stale v1 state', async () => {
window.localStorage.clear()
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ intensity: 25, mode: 'clear' }))
window.localStorage.setItem(KEY, JSON.stringify({ mode: 'glass', base: {}, light: {}, dark: { intensity: 8 } }))
vi.resetModules()
const fresh = await import('./translucency')
expect(fresh.$translucency.get().mode).toBe('glass')
expect(fresh.$translucency.get().intensity).toBe(8)
})
})
+77 -35
View File
@@ -1,18 +1,28 @@
/**
* Window translucency (see-through window).
*
* One lever, 0–100. 0 = off (fully opaque, the default). Two modes decide HOW
* the desktop shows through — see `@hermes/shared/translucency`, which owns the
* mapping both this store and the main process read.
* One lever, 0–100. Two modes decide HOW the desktop shows through — see
* `@hermes/shared/translucency`, which owns the mapping both this store and the
* main process read.
*
* 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 platform material underneath the
* web contents to read (see the `[data-hermes-glass]` block in styles.css).
* Settings are kept per light/dark appearance: a tint that reads as a whisper
* over a dark palette is a milky sheet over a light one. The book of settings
* is the persisted unit (`TranslucencyBook`); `$translucency` publishes the
* RESOLVED state for the appearance currently painted, so every consumer —
* the CSS field surfaces, the main process, the HUD — keeps reading one flat
* state and never has to know appearances were split.
*
* The renderer owns the value and mirrors the resolved state 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 platform
* material underneath the web contents to read (see the `[data-hermes-glass]`
* block in styles.css).
*/
import {
type Appearance,
clampIntensity,
defaultTranslucencyValues,
GLASS_MATERIALS,
GLASS_SCOPES,
type GlassMaterial,
@@ -20,21 +30,26 @@ import {
glassMaterialsFor,
type GlassScope,
glassSurfaceKeep,
normalizeBook,
normalizeMaterial,
normalizeScope,
normalizeState,
resolveTranslucency,
setTranslucencyValues,
TRANSLUCENCY_MAX,
TRANSLUCENCY_MIN,
TRANSLUCENCY_STEP,
type TranslucencyBook,
type TranslucencyMode,
type TranslucencyState
type TranslucencyState,
type TranslucencyValues
} from '@hermes/shared/translucency'
import { atom } from 'nanostores'
import { atom, computed } from 'nanostores'
import { isMacPlatform, isWindowsPlatform } from '@/lib/platform'
import { readJson, writeJson } from '@/lib/storage'
export {
defaultTranslucencyValues,
GLASS_MATERIALS,
GLASS_SCOPES,
glassMaterialForPicker,
@@ -44,6 +59,8 @@ export {
TRANSLUCENCY_STEP
}
export type { Appearance }
/**
* Glass needs a native window material. Electron is authoritative (preload
* sets `hermesDesktop.glassSupported` from `os.release()` so Win10 cannot
@@ -69,40 +86,65 @@ export const TRANSLUCENCY_SUPPORTED =
/** Windows collapses the frost ladder — see `glassMaterialsFor`. */
export const GLASS_IS_WINDOWS = GLASS_SUPPORTED && !isMacPlatform()
const KEY = 'hermes.desktop.translucency.v1'
// v1 held a flat state (one setting for both appearances); v2 is the book.
// Reading v1 as the seed is what carries an already-tuned window across the
// upgrade — normalizeBook lands those values in `base`, which both appearances
// inherit until one of them is edited.
const KEY = 'hermes.desktop.translucency.v2'
const LEGACY_KEY = 'hermes.desktop.translucency.v1'
// The v1 key used to hold a bare intensity (`"23"`). Normalization lives in the
// shared module so this and the main process resolve the default the same way —
// including the legacy rule, where a saved NON-ZERO intensity with no mode means
// a profile that has been rendering as clear all along and must keep doing so.
const read = (): TranslucencyState => {
const stored = readJson<unknown>(KEY)
const read = (): TranslucencyBook =>
normalizeBook(readJson<unknown>(KEY) ?? readJson<unknown>(LEGACY_KEY), GLASS_SUPPORTED)
return normalizeState(stored && typeof stored === 'object' ? stored : { intensity: stored }, GLASS_SUPPORTED)
/** The persisted book. Settings edits it; everything else reads `$translucency`. */
export const $translucencyBook = atom<TranslucencyBook>(
typeof window === 'undefined' ? normalizeBook(null, false) : read()
)
/**
* Which palette is on screen. Published by the theme provider from its
* RENDERED mode (background luminance), not the light/dark preference — a
* skin that keeps a bright surface in "dark" wants light's tint.
*/
export const $appearance = atom<Appearance>('dark')
export function setAppearance(appearance: Appearance): void {
if ($appearance.get() !== appearance) {
$appearance.set(appearance)
}
}
const initial: TranslucencyState = typeof window === 'undefined' ? normalizeState(null, false) : read()
/** The resolved state for the painted appearance — the shape every consumer reads. */
export const $translucency = computed([$translucencyBook, $appearance], (book, appearance) =>
resolveTranslucency(book, appearance, GLASS_IS_WINDOWS)
)
export const $translucency = atom<TranslucencyState>(initial)
/** Write an edit against the appearance being painted. */
const edit = (patch: Partial<TranslucencyValues>): void => {
$translucencyBook.set(setTranslucencyValues($translucencyBook.get(), $appearance.get(), patch))
}
export function setTranslucency(intensity: number): void {
$translucency.set({ ...$translucency.get(), intensity: clampIntensity(intensity) })
edit({ intensity: clampIntensity(intensity) })
}
export function setTranslucencyFade(fade: number): void {
$translucency.set({ ...$translucency.get(), fade: clampIntensity(fade) })
edit({ fade: clampIntensity(fade) })
}
export function setTranslucencyMode(mode: TranslucencyMode): void {
$translucency.set({ ...$translucency.get(), mode: mode === 'glass' && GLASS_SUPPORTED ? 'glass' : 'clear' })
$translucencyBook.set({
...$translucencyBook.get(),
mode: mode === 'glass' && GLASS_SUPPORTED ? 'glass' : 'clear'
})
}
export function setTranslucencyMaterial(material: GlassMaterial): void {
$translucency.set({ ...$translucency.get(), material: normalizeMaterial(material) })
edit({ material: normalizeMaterial(material) })
}
export function setTranslucencyScope(scope: GlassScope): void {
$translucency.set({ ...$translucency.get(), scope: normalizeScope(scope) })
edit({ scope: normalizeScope(scope) })
}
// Glass thins surfaces only in real chat windows (the primary window and
@@ -313,13 +355,20 @@ if (typeof window !== 'undefined') {
const persist = () => {
storageTimer = null
writeJson(KEY, $translucency.get())
writeJson(KEY, $translucencyBook.get())
}
// The RESOLVED state drives paint and IPC — main only ever cares about the
// appearance on screen. Switching light/dark therefore re-sends, which is
// exactly right: the window's tint and native opacity change with it.
$translucency.subscribe(state => {
applyGlassSurfaces(state)
window.hermesDesktop?.setTranslucency?.(state)
})
// Persistence follows the BOOK, so an appearance switch (which changes the
// resolved state but not the settings) never schedules a pointless write.
$translucencyBook.subscribe(() => {
if (storageTimer !== null) {
window.clearTimeout(storageTimer)
}
@@ -346,16 +395,9 @@ if (typeof window !== 'undefined') {
}
const next = read()
const current = $translucency.get()
if (
next.intensity !== current.intensity ||
next.fade !== current.fade ||
next.mode !== current.mode ||
next.material !== current.material ||
next.scope !== current.scope
) {
$translucency.set(next)
if (JSON.stringify(next) !== JSON.stringify($translucencyBook.get())) {
$translucencyBook.set(next)
}
})
+188
View File
@@ -112,6 +112,86 @@ export interface TranslucencyState {
scope: GlassScope
}
/**
* The half of the state that is scoped to the light/dark appearance.
*
* A tint that reads as a whisper over a dark palette is a milky sheet over a
* light one, so one shared number cannot serve both — the same setting has to
* mean a different amount in each appearance. `mode` stays global: clear vs
* glass is a choice about the window, not about the palette.
*/
export type TranslucencyValues = Omit<TranslucencyState, 'mode'>
export type Appearance = 'light' | 'dark'
/**
* Per-appearance defaults, per platform family. Glass ships ON: it is the
* better-looking half of the feature, and a lever that starts at zero is a
* feature nobody finds.
*
* The two platforms need different numbers because the lever means different
* things behind them. `intensity` is how much of the theme tint the renderer
* REMOVES (see `glassSurfaceKeep`), and what shows through underneath is a
* native material with its own weight:
*
* - macOS vibrancy is genuinely sheer, so the tint has to come most of the way
* off before the desktop reads at all. Light leans heavy — a bright desktop
* behind a bright window needs real thinning before the field separates —
* with a single point of fade so the window edge reads as glass rather than
* as paint. Dark takes far less: a dark field already separates, and the
* tint that flatters light would smother it.
* - Windows acrylic composites its OWN tint in DWM before the page is drawn,
* so the renderer's tint stacks on top of a backdrop that is already doing
* the work. The same numbers that read as frost on a Mac read as a washed
* sheet here; these stay low and let DWM carry it. Fade stays at zero —
* `setOpacity` over a system backdrop dims the composited result rather than
* deepening it.
*
* Both sit on the frost each platform renders best: 'header' and 'titlebar'
* are macOS-only rungs (on Windows they collapse onto mica — see
* `glassMaterialsFor`), while 'under-window' is the acrylic rung, the live
* blur closest to what macOS calls under-window.
*/
const DEFAULT_VALUES: Record<'mac' | 'windows', Record<Appearance, TranslucencyValues>> = {
mac: {
light: { intensity: 66, fade: 1, material: 'header', scope: 'window' },
dark: { intensity: 22, fade: 0, material: 'titlebar', scope: 'window' }
},
windows: {
light: { intensity: 20, fade: 0, material: 'under-window', scope: 'window' },
dark: { intensity: 5, fade: 0, material: 'under-window', scope: 'window' }
}
}
/**
* The untouched values for an appearance on this platform. Linux never reaches
* here — translucency is unsupported there, so nothing resolves.
*/
export function defaultTranslucencyValues(appearance: Appearance, isWindows: boolean): TranslucencyValues {
return DEFAULT_VALUES[isWindows ? 'windows' : 'mac'][appearance]
}
/**
* The renderer's book of translucency settings.
*
* `base` is the shared rung: a value the user set before appearances were
* split (a migrated v1 state), or one they have never touched. An appearance
* slot only carries the keys edited WHILE that appearance was painted, so
* changing the tint in light mode leaves dark's alone and an untouched dark
* still inherits whatever base says. That is the ladder — appearance over base
* over default, per key, so \"unset\" keeps carrying over.
*
* The book is renderer-owned. The main process is handed the RESOLVED state
* (see `resolveTranslucency`) because a window's backing, vibrancy and opacity
* only ever concern the appearance actually on screen.
*/
export interface TranslucencyBook {
mode: TranslucencyMode
base: Partial<TranslucencyValues>
light: Partial<TranslucencyValues>
dark: Partial<TranslucencyValues>
}
export const TRANSLUCENCY_MIN = 0
export const TRANSLUCENCY_MAX = 100
@@ -213,6 +293,114 @@ export function normalizeState(payload: unknown, glassSupported: boolean): Trans
}
}
/**
* The resolved state a surface should assume before anyone has said otherwise.
*
* Main needs this at window CREATION on a first launch: the renderer has not
* reported yet, and a window created with the opaque backing cannot reliably
* be swapped to glass moments later (see `windowBackingOptions`). Guessing the
* appearance from `nativeTheme` is close enough — the renderer's first resolved
* send corrects any mismatch while the window is still young, and every launch
* after the first reads the persisted state instead.
*/
export function defaultTranslucencyState(
appearance: Appearance,
glassSupported: boolean,
isWindows: boolean
): TranslucencyState {
return {
...defaultTranslucencyValues(appearance, isWindows),
mode: normalizeMode(undefined, glassSupported)
}
}
/** Keep only the value keys actually present, each normalized. Unknown keys drop. */
function normalizeValues(payload: unknown): Partial<TranslucencyValues> {
const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
const out: Partial<TranslucencyValues> = {}
if (record.intensity !== undefined) {
out.intensity = clampIntensity(record.intensity)
}
if (record.fade !== undefined) {
out.fade = clampIntensity(record.fade)
}
if (record.material !== undefined) {
out.material = normalizeMaterial(record.material)
}
if (record.scope !== undefined) {
out.scope = normalizeScope(record.scope)
}
return out
}
/**
* Parse a persisted book, or migrate a flat v1 state into one.
*
* A v1 payload is a window someone already tuned, so its values land in `base`
* — every appearance inherits exactly what was on screen before the upgrade,
* and the new per-appearance defaults apply only where nothing was ever set.
* The legacy clear rule rides along: a non-zero v1 intensity with no mode was
* rendering as clear and keeps doing so.
*/
export function normalizeBook(payload: unknown, glassSupported: boolean): TranslucencyBook {
const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
const migrating = record.base === undefined && record.light === undefined && record.dark === undefined
const base = normalizeValues(migrating ? record : record.base)
const legacyIntensity = migrating ? clampIntensity(record.intensity) : 0
return {
mode: normalizeMode(record.mode, glassSupported, legacyIntensity),
base,
light: normalizeValues(migrating ? null : record.light),
dark: normalizeValues(migrating ? null : record.dark)
}
}
/**
* Flatten the book for one appearance: appearance slot → base → default.
*
* This is the only thing outside the renderer's settings surface that should
* ever be handed around — main, the CSS field surfaces, and every consumer of
* `$translucency` all want the resolved answer for what is painted right now.
*/
export function resolveTranslucency(
book: TranslucencyBook,
appearance: Appearance,
isWindows: boolean
): TranslucencyState {
const fallback = defaultTranslucencyValues(appearance, isWindows)
const slot = book[appearance]
return {
mode: book.mode,
intensity: slot.intensity ?? book.base.intensity ?? fallback.intensity,
fade: slot.fade ?? book.base.fade ?? fallback.fade,
material: slot.material ?? book.base.material ?? fallback.material,
scope: slot.scope ?? book.base.scope ?? fallback.scope
}
}
/**
* Record an edit against the appearance being painted.
*
* The edit is written to the appearance slot rather than to base, so tuning
* light mode is scoped to light mode. Base is left intact as the inheritance
* rung for whichever appearance has not been touched.
*/
export function setTranslucencyValues(
book: TranslucencyBook,
appearance: Appearance,
patch: Partial<TranslucencyValues>
): TranslucencyBook {
return { ...book, [appearance]: { ...book[appearance], ...normalizeValues(patch) } }
}
/** Lever percent → native window opacity, floored so it stays usable. */
function opacityRamp(lever: number): number {
const ratio = clampIntensity(lever) / TRANSLUCENCY_MAX