From 5b32f9dc3650793454870f56a0138933921af102 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 01:35:31 -0500 Subject: [PATCH 1/3] feat(desktop): default UI zoom to five steps out (~91%) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship Ctrl/Cmd+- ×5 from Chromium 0 as DEFAULT_ZOOM_LEVEL so fresh installs open tighter. Actual Size / Ctrl+0 and mandatory garbage fallbacks all land on the same level; existing zoom-state.json is left alone. --- apps/desktop/electron/main.ts | 37 +++++++++++-------- apps/desktop/electron/zoom.test.ts | 28 +++++++++----- apps/desktop/electron/zoom.ts | 17 +++++++-- .../src/app/settings/appearance-settings.tsx | 9 +++-- apps/desktop/src/store/zoom.ts | 4 +- 5 files changed, 60 insertions(+), 35 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7ac24c57f8ed..1565501bb2e7 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -5163,7 +5163,7 @@ function buildApplicationMenu() { label: 'Actual Size', accelerator: 'CommandOrControl+0', click: () => { - setAndPersistZoomLevel(mainWindow, 0) + setAndPersistZoomLevel(mainWindow, DEFAULT_ZOOM_LEVEL) } }, { @@ -5171,7 +5171,7 @@ function buildApplicationMenu() { accelerator: 'CommandOrControl+Plus', click: () => { if (mainWindow && !mainWindow.isDestroyed()) { - setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() + 0.1) + setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() + ZOOM_STEP) } } }, @@ -5180,7 +5180,7 @@ function buildApplicationMenu() { accelerator: 'CommandOrControl+-', click: () => { if (mainWindow && !mainWindow.isDestroyed()) { - setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() - 0.1) + setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() - ZOOM_STEP) } } }, @@ -5259,8 +5259,10 @@ function installPreviewShortcut(window) { // read it back on did-finish-load to re-apply after reloads or crash recovery. import { applyZoomLevel, + DEFAULT_ZOOM_LEVEL, installZoomReassertOnWindowEvents, percentToZoomLevel, + ZOOM_STEP, ZOOM_STORAGE_KEY, zoomLevelToPercent, zoomWiringForWindowKind @@ -5304,31 +5306,33 @@ function restorePersistedZoomLevel(window) { return } - // Fall back to localStorage for installs that predate zoom-state.json, - // migrating the value into the JSON store on first read. + // No JSON yet: paint the shipped default immediately so a fresh install + // doesn't flash Chromium 100%, then try localStorage for pre-JSON installs + // and overwrite if a legacy value is there. + applyZoomLevel(window.webContents, DEFAULT_ZOOM_LEVEL) + window.webContents .executeJavaScript( `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()` ) .then(stored => { - if (stored == null || !window || window.isDestroyed()) { + if (!window || window.isDestroyed()) { return } - // Notify the renderer too — otherwise the Appearance UI Scale control - // can stay stuck at 100% even though the window zoom was restored. - const applied = applyZoomLevel(window.webContents, Number(stored)) + const level = stored == null ? DEFAULT_ZOOM_LEVEL : Number(stored) + const applied = applyZoomLevel(window.webContents, level) writeZoomState(applied) }) .catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`)) } function installZoomShortcuts(window) { - // Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2). - // The menu items handle this on macOS (where the menu is always present), - // but on Linux/Windows the menu is null and Chromium's default handler - // would use the full 0.2 step, so we intercept here for consistency. - const ZOOM_STEP = 0.1 + // Override Ctrl/Cmd + +/-/0 with half Chromium's default zoom step (ZOOM_STEP + // is 0.1 vs Chromium's 0.2). The menu items handle this on macOS (where the + // menu is always present), but on Linux/Windows the menu is null and + // Chromium's default handler would use the full 0.2 step, so we intercept + // here for consistency. Ctrl/Cmd+0 resets to DEFAULT_ZOOM_LEVEL, not Chromium 0. window.webContents.on('before-input-event', (event, input) => { const mod = IS_MAC ? input.meta : input.control @@ -5344,7 +5348,7 @@ function installZoomShortcuts(window) { } event.preventDefault() - setAndPersistZoomLevel(window, 0) + setAndPersistZoomLevel(window, DEFAULT_ZOOM_LEVEL) } else if (key === '=' || key === '+') { // Zoom-in must accept the shift modifier: on US layouts Plus is // physically Shift+=, so Cmd+Plus arrives as Cmd+Shift+'+' (or '=' @@ -9242,7 +9246,8 @@ ipcMain.handle('hermes:window:openInstance', async () => { // shortcuts and the View menu. Reads and writes target the asking window. ipcMain.handle('hermes:zoom:get', event => { const window = BrowserWindow.fromWebContents(event.sender) - const level = window && !window.isDestroyed() ? window.webContents.getZoomLevel() : 0 + const level = + window && !window.isDestroyed() ? window.webContents.getZoomLevel() : DEFAULT_ZOOM_LEVEL return { level, percent: zoomLevelToPercent(level) } }) diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts index d32456e92546..c310db45f240 100644 --- a/apps/desktop/electron/zoom.test.ts +++ b/apps/desktop/electron/zoom.test.ts @@ -11,9 +11,11 @@ import { test, vi } from 'vitest' import { applyZoomLevel, clampZoomLevel, + DEFAULT_ZOOM_LEVEL, installZoomReassertOnWindowEvents, percentToZoomLevel, ZOOM_RESIZE_REASSERT_DELAY_MS, + ZOOM_STEP, ZOOM_STORAGE_KEY, zoomLevelToPercent, zoomReassertWindowEvents, @@ -24,26 +26,32 @@ test('storage key stays stable so persisted zoom survives upgrades', () => { assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') }) +test('default zoom is five Ctrl/Cmd+- steps out from Chromium 0', () => { + assert.equal(ZOOM_STEP, 0.1) + assert.equal(DEFAULT_ZOOM_LEVEL, -0.5) + assert.equal(zoomLevelToPercent(DEFAULT_ZOOM_LEVEL), 91) +}) + test('clampZoomLevel rejects garbage and enforces bounds', () => { - assert.equal(clampZoomLevel(NaN), 0) - assert.equal(clampZoomLevel(Infinity), 0) - assert.equal(clampZoomLevel(undefined), 0) - assert.equal(clampZoomLevel('2'), 0) + assert.equal(clampZoomLevel(NaN), DEFAULT_ZOOM_LEVEL) + assert.equal(clampZoomLevel(Infinity), DEFAULT_ZOOM_LEVEL) + assert.equal(clampZoomLevel(undefined), DEFAULT_ZOOM_LEVEL) + assert.equal(clampZoomLevel('2'), DEFAULT_ZOOM_LEVEL) assert.equal(clampZoomLevel(0.3), 0.3) assert.equal(clampZoomLevel(-42), -9) assert.equal(clampZoomLevel(42), 9) }) -test('level 0 is exactly 100 percent', () => { +test('level 0 is exactly 100 percent (Chromium actual-size baseline)', () => { assert.equal(zoomLevelToPercent(0), 100) assert.equal(percentToZoomLevel(100), 0) }) -test('percentToZoomLevel rejects garbage', () => { - assert.equal(percentToZoomLevel(NaN), 0) - assert.equal(percentToZoomLevel(0), 0) - assert.equal(percentToZoomLevel(-50), 0) - assert.equal(percentToZoomLevel(undefined), 0) +test('percentToZoomLevel rejects garbage by falling back to the shipped default', () => { + assert.equal(percentToZoomLevel(NaN), DEFAULT_ZOOM_LEVEL) + assert.equal(percentToZoomLevel(0), DEFAULT_ZOOM_LEVEL) + assert.equal(percentToZoomLevel(-50), DEFAULT_ZOOM_LEVEL) + assert.equal(percentToZoomLevel(undefined), DEFAULT_ZOOM_LEVEL) }) test('preset percentages roundtrip within rounding', () => { diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index e81ffeeb07f7..0e2131b2a639 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -2,8 +2,11 @@ * Pure helpers for window zoom. The main process owns webContents.setZoomLevel, * so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel * through this one clamped scale. Percent is the user-facing unit (100 = the - * default size); Chromium's internal unit is the zoom level, where - * factor = 1.2 ^ level. + * Chromium actual-size baseline); Chromium's internal unit is the zoom level, + * where factor = 1.2 ^ level. + * + * Our shipped default is five Ctrl/Cmd+- steps out from Chromium 0 — a slightly + * tighter UI that still lands near the 90% Appearance preset. */ export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' @@ -12,9 +15,15 @@ const ZOOM_FACTOR_BASE = 1.2 const MIN_ZOOM_LEVEL = -9 const MAX_ZOOM_LEVEL = 9 +/** Half Chromium's default step; matching the shortcuts and View menu. */ +export const ZOOM_STEP = 0.1 + +/** Five Ctrl/Cmd+- presses from Chromium 0 (~91%). Fresh installs + Actual Size. */ +export const DEFAULT_ZOOM_LEVEL = -5 * ZOOM_STEP + export function clampZoomLevel(value) { if (!Number.isFinite(value)) { - return 0 + return DEFAULT_ZOOM_LEVEL } return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) @@ -26,7 +35,7 @@ export function zoomLevelToPercent(level) { export function percentToZoomLevel(percent) { if (!Number.isFinite(percent) || percent <= 0) { - return 0 + return DEFAULT_ZOOM_LEVEL } return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 32deb34d2d47..617646061cd1 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -64,10 +64,11 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) ) } -// UI scale presets, as zoom percentages. 100 is the browser-default size; -// the ids double as the percent values sent to the main process. A Cmd/Ctrl -// +/- step landing between presets highlights nothing, and the row -// description keeps showing the exact current percent. +// UI scale presets, as zoom percentages. 100 is Chromium's actual-size +// baseline; the shipped default is five Ctrl/Cmd+- steps out (~91%). Ids +// double as the percent values sent to the main process. A Cmd/Ctrl +/- +// step landing between presets highlights nothing, and the row description +// keeps showing the exact current percent. const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] diff --git a/apps/desktop/src/store/zoom.ts b/apps/desktop/src/store/zoom.ts index 6fc867c3dddf..d2258e05097d 100644 --- a/apps/desktop/src/store/zoom.ts +++ b/apps/desktop/src/store/zoom.ts @@ -10,7 +10,9 @@ import { atom } from 'nanostores' -export const $zoomPercent = atom(100) +// Mirror DEFAULT_ZOOM_LEVEL (-0.5 ≈ 91%) so Appearance doesn't flash 100% before +// the main-process zoom.get() resolves. Keep in sync with electron/zoom.ts. +export const $zoomPercent = atom(91) export function setZoomPercent(percent: number): void { window.hermesDesktop?.zoom?.setPercent(percent) From 9ad400580cc464b421a667cf8c4fa83f51f92554 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:33:10 -0500 Subject: [PATCH 2/3] fix(desktop): pin E2E sandboxes to Chromium zoom 0 Fresh installs now default to ~91%, but Playwright hit-testing and visual baselines still assume 100%. Seed zoom-state.json so isolated E2E profiles don't inherit the product default. --- apps/desktop/e2e/fixtures.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 3427d42c9bf3..75d51f6ea2ef 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -120,6 +120,17 @@ export function createSandbox(prefix: string): Sandbox { 'utf8', ) + // Pin Chromium actual-size zoom (level 0) for the suite. Fresh installs + // ship DEFAULT_ZOOM_LEVEL ≈ -0.5 (~91%), but Playwright click hit-testing + // and the committed visual baselines were calibrated at 100%. Without this + // file every sandbox would inherit the product default and fail pointer + // interception + snapshot diffs. + fs.writeFileSync( + path.join(userDataDir, 'zoom-state.json'), + JSON.stringify({ zoomLevel: 0 }, null, 2), + 'utf8', + ) + return { root, hermesHome, From 4a9754b3c3d04a155ddbfcb45642d3afe975c735 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 04:00:55 -0500 Subject: [PATCH 3/3] feat(desktop): default UI zoom to Appearance 90% preset Land on the exact 90% scale so the settings control shows a selection on fresh installs, instead of five Ctrl+- steps (~91%) between presets. --- apps/desktop/e2e/fixtures.ts | 8 ++++---- apps/desktop/electron/zoom.test.ts | 6 +++--- apps/desktop/electron/zoom.ts | 8 ++++---- apps/desktop/src/app/settings/appearance-settings.tsx | 8 ++++---- apps/desktop/src/store/zoom.ts | 4 ++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 75d51f6ea2ef..fee8e4197032 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -121,10 +121,10 @@ export function createSandbox(prefix: string): Sandbox { ) // Pin Chromium actual-size zoom (level 0) for the suite. Fresh installs - // ship DEFAULT_ZOOM_LEVEL ≈ -0.5 (~91%), but Playwright click hit-testing - // and the committed visual baselines were calibrated at 100%. Without this - // file every sandbox would inherit the product default and fail pointer - // interception + snapshot diffs. + // ship DEFAULT_ZOOM_LEVEL at the Appearance 90% preset, but Playwright + // click hit-testing and the committed visual baselines were calibrated at + // 100%. Without this file every sandbox would inherit the product default + // and fail pointer interception + snapshot diffs. fs.writeFileSync( path.join(userDataDir, 'zoom-state.json'), JSON.stringify({ zoomLevel: 0 }, null, 2), diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts index c310db45f240..7fbb291a24bd 100644 --- a/apps/desktop/electron/zoom.test.ts +++ b/apps/desktop/electron/zoom.test.ts @@ -26,10 +26,10 @@ test('storage key stays stable so persisted zoom survives upgrades', () => { assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') }) -test('default zoom is five Ctrl/Cmd+- steps out from Chromium 0', () => { +test('default zoom matches the Appearance 90% preset', () => { assert.equal(ZOOM_STEP, 0.1) - assert.equal(DEFAULT_ZOOM_LEVEL, -0.5) - assert.equal(zoomLevelToPercent(DEFAULT_ZOOM_LEVEL), 91) + assert.equal(zoomLevelToPercent(DEFAULT_ZOOM_LEVEL), 90) + assert.equal(DEFAULT_ZOOM_LEVEL, percentToZoomLevel(90)) }) test('clampZoomLevel rejects garbage and enforces bounds', () => { diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index 0e2131b2a639..ee3d57648896 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -5,8 +5,8 @@ * Chromium actual-size baseline); Chromium's internal unit is the zoom level, * where factor = 1.2 ^ level. * - * Our shipped default is five Ctrl/Cmd+- steps out from Chromium 0 — a slightly - * tighter UI that still lands near the 90% Appearance preset. + * Our shipped default is the Appearance 90% preset — tight enough to feel + * denser than Chromium 100%, and selected in the UI Scale control on first run. */ export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' @@ -18,8 +18,8 @@ const MAX_ZOOM_LEVEL = 9 /** Half Chromium's default step; matching the shortcuts and View menu. */ export const ZOOM_STEP = 0.1 -/** Five Ctrl/Cmd+- presses from Chromium 0 (~91%). Fresh installs + Actual Size. */ -export const DEFAULT_ZOOM_LEVEL = -5 * ZOOM_STEP +/** Appearance 90% preset. Fresh installs + Actual Size / Ctrl+0. */ +export const DEFAULT_ZOOM_LEVEL = Math.log(0.9) / Math.log(ZOOM_FACTOR_BASE) export function clampZoomLevel(value) { if (!Number.isFinite(value)) { diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 617646061cd1..530870d13b25 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -65,10 +65,10 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) } // UI scale presets, as zoom percentages. 100 is Chromium's actual-size -// baseline; the shipped default is five Ctrl/Cmd+- steps out (~91%). Ids -// double as the percent values sent to the main process. A Cmd/Ctrl +/- -// step landing between presets highlights nothing, and the row description -// keeps showing the exact current percent. +// baseline; the shipped default is the 90% preset. Ids double as the percent +// values sent to the main process. A Cmd/Ctrl +/- step landing between +// presets highlights nothing, and the row description keeps showing the +// exact current percent. const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] diff --git a/apps/desktop/src/store/zoom.ts b/apps/desktop/src/store/zoom.ts index d2258e05097d..821563acc4b0 100644 --- a/apps/desktop/src/store/zoom.ts +++ b/apps/desktop/src/store/zoom.ts @@ -10,9 +10,9 @@ import { atom } from 'nanostores' -// Mirror DEFAULT_ZOOM_LEVEL (-0.5 ≈ 91%) so Appearance doesn't flash 100% before +// Mirror DEFAULT_ZOOM_LEVEL (90%) so Appearance doesn't flash 100% before // the main-process zoom.get() resolves. Keep in sync with electron/zoom.ts. -export const $zoomPercent = atom(91) +export const $zoomPercent = atom(90) export function setZoomPercent(percent: number): void { window.hermesDesktop?.zoom?.setPercent(percent)