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:
committed by
brooklyn!
parent
38f46504fd
commit
be3166607e
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user