diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 4eb6d039c7..40c97fa9a1 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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) } } diff --git a/apps/desktop/electron/translucency.test.ts b/apps/desktop/electron/translucency.test.ts index 351fc72576..276c5c4b00 100644 --- a/apps/desktop/electron/translucency.test.ts +++ b/apps/desktop/electron/translucency.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/electron/translucency.ts b/apps/desktop/electron/translucency.ts index 72f936e9cc..eecaf57f1b 100644 --- a/apps/desktop/electron/translucency.ts +++ b/apps/desktop/electron/translucency.ts @@ -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, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index f5f0664aa4..1e9c8a8bd1 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/store/translucency.test.ts b/apps/desktop/src/store/translucency.test.ts index 1e9e0cadc9..0aa334ae1d 100644 --- a/apps/desktop/src/store/translucency.test.ts +++ b/apps/desktop/src/store/translucency.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/store/translucency.ts b/apps/desktop/src/store/translucency.ts index 4bb0c89944..1c5e62bfdb 100644 --- a/apps/desktop/src/store/translucency.ts +++ b/apps/desktop/src/store/translucency.ts @@ -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(KEY) +const read = (): TranslucencyBook => + normalizeBook(readJson(KEY) ?? readJson(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( + 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('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(initial) +/** Write an edit against the appearance being painted. */ +const edit = (patch: Partial): 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) } }) diff --git a/apps/shared/src/translucency.ts b/apps/shared/src/translucency.ts index fe8ea43b5f..4469d54906 100644 --- a/apps/shared/src/translucency.ts +++ b/apps/shared/src/translucency.ts @@ -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 + +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> = { + 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 + light: Partial + dark: Partial +} + 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 { + const record = payload && typeof payload === 'object' ? (payload as Record) : {} + const out: Partial = {} + + 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) : {} + 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 +): 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