Desktop UI Scale setting not syncing with restored zoom
Cluster 63846. Data as of
Nothing open on GitHub. Fix merged in #63995 on 13 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #63995. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #63995. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items concern the desktop app's UI Scale control in Appearance settings showing 100% after restart even though the zoom level is restored. One is a bug report and two are pull requests proposing to sync the control after zoom restore, with one noted as superseding an earlier PR.
Open issues0
Open PRs0
Fixes on record
-
#63995 fix(desktop): sync UI Scale control after zoom restore (supersedes #63842)
files:
apps/desktop/electron/main.ts,apps/desktop/electron/zoom.test.ts,apps/desktop/electron/zoom.tsShow the diff
-
apps/desktop/electron/main.ts@@ -4705,7 +4705,7 @@ function installPreviewShortcut(window) { // process owns setZoomLevel, so we mirror each change into localStorage and // read it back on did-finish-load to re-apply after reloads or crash recovery. import { - clampZoomLevel, + applyZoomLevel, installZoomReassertOnWindowEvents, percentToZoomLevel, ZOOM_STORAGE_KEY, @@ -4718,11 +4718,9 @@ function setAndPersistZoomLevel(window, zoomLevel) { return } - const next = clampZoomLevel(zoomLevel) - window.webContents.setZoomLevel(next) - // Keep any open settings UI in sync, including changes made via the - // keyboard shortcuts or the View menu. - window.webContents.send('hermes:zoom:changed', { level: next, percent: zoomLevelToPercent(next) }) + // Apply + notify in one funnel so the settings UI stays in sync, including + // changes made via the keyboard shortcuts or the View menu. + const next = applyZoomLevel(window.webContents, zoomLevel) window.webContents .executeJavaScript( `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}` @@ -4744,8 +4742,9 @@ function restorePersistedZoomLevel(window) { return } - const level = clampZoomLevel(Number(stored)) - window.webContents.setZoomLevel(level) + // Notify the renderer too — otherwise the Appearance UI Scale control + // can stay stuck at 100% even though the window zoom was restored. + applyZoomLevel(window.webContents, Number(stored)) }) .catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`)) } -
apps/desktop/electron/zoom.test.ts@@ -9,6 +9,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' import { + applyZoomLevel, clampZoomLevel, installZoomReassertOnWindowEvents, percentToZoomLevel, @@ -118,3 +119,42 @@ test('unknown window kinds default to chat (zoom enabled)', () => { assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true }) assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true }) }) + +// The UI Scale settings control drifts out of sync after a restart when zoom +// is applied to the window but the renderer is never told: its $zoomPercent +// store (see store/zoom.ts) only updates from zoom.get() (once, on load) and +// 'hermes:zoom:changed' events. applyZoomLevel is the single funnel every zoom +// path (user set, restore-on-load, lifecycle re-assert) shares, so applying a +// level always notifies — the regression can't come back by forgetting a send. +function fakeWebContents() { + const calls: Array<[string, ...unknown[]]> = [] + + return { + calls, + setZoomLevel: (level: number) => calls.push(['setZoomLevel', level]), + send: (channel: string, payload: unknown) => calls.push(['send', channel, payload]) + } +} + +test('applyZoomLevel applies the level then notifies the renderer', () => { + const wc = fakeWebContents() + const applied = applyZoomLevel(wc, 3) + + assert.equal(applied, 3) + assert.deepEqual(wc.calls, [ + ['setZoomLevel', 3], + ['send', 'hermes:zoom:changed', { level: 3, percent: zoomLevelToPercent(3) }] + ]) +}) + +test('applyZoomLevel clamps garbage before applying and notifying', () => { + const wc = fakeWebContents() + const applied = applyZoomLevel(wc, 999) + const clamped = clampZoomLevel(999) + + assert.equal(applied, clamped) + assert.deepEqual(wc.calls, [ + ['setZoomLevel', clamped], + ['send', 'hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }] + ]) +}) -
apps/desktop/electron/zoom.ts@@ -32,6 +32,22 @@ export function percentToZoomLevel(percent) { return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) } +/** + * Apply a clamped zoom level to a webContents AND notify the renderer, in that + * order. Every path that changes zoom (user action, restore-on-load, lifecycle + * re-assert) funnels through here so the settings UI Scale control can never + * drift from the actually-applied level — the bug where restore set the level + * but forgot to emit 'hermes:zoom:changed', leaving the control stuck at 100%. + * Returns the clamped level so callers can persist it. + */ +export function applyZoomLevel(webContents, level) { + const clamped = clampZoomLevel(level) + webContents.setZoomLevel(clamped) + webContents.send('hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }) + + return clamped +} + // Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized // and restored. Re-apply the persisted level on these lifecycle transitions. export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore']
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#63842 fix(desktop): sync UI Scale setting after zoom restore on window load · done elsewhere →
#63995
Closed issues · 1
- Closed#63846[Bug]: UI Scale setting resets to 100% in Appearance settings after restart, despite zoom being correctly restored
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #63995
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 13 Jul 2026