From 467098871b79d57456c61efc037c58fa8301566c Mon Sep 17 00:00:00 2001 From: Rolf Heij Date: Mon, 14 Sep 2026 13:23:23 +0200 Subject: [PATCH 001/109] fix(zoom): release Ctrl chords in main; step app zoom with adjustZoomFactor Delete the app-wide Ctrl+=/-/0 zoom chord branches from main.ts's before-input-event handler and the now-unused resetZoomFactor, freeing the chords for per-pane content zoom. Rewire zoomIn/zoomOut to step through adjustZoomFactor (clamp + one-decimal rounding) instead of raw float addition, fixing accumulated drift that eventually made the settings validator reject a legitimate step. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Gkwyu1GZbkdGdPXaygcerV --- src/main/main.ts | 49 ++++------------------ src/shared/utils/content-zoom.util.test.ts | 12 ++++++ 2 files changed, 19 insertions(+), 42 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 5a3e2a3aaf8..ac8435f4c5b 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -207,8 +207,6 @@ import { DEV_MODE_QUERY_PARAMETER, IS_MAIN_WINDOW_QUERY_PARAMETER, LOG_LEVEL_QUERY_PARAMETER, - MAX_ZOOM_FACTOR, - MIN_ZOOM_FACTOR, SCROLL_GROUP_STATE_QUERY_PARAMETER, STARTUP_MARK_PROCESS_START, STARTUP_MARKS_QUERY_PARAMETER, @@ -228,6 +226,7 @@ import * as networkService from '@shared/services/network.service'; import { get } from '@shared/services/project-data-provider.service'; import { settingsService } from '@shared/services/settings.service'; import { initialize as initializeSharedStoreService } from '@shared/services/shared-store.service'; +import { adjustZoomFactor } from '@shared/utils/content-zoom.util'; import { markStartup, markStartupOnce } from '@shared/utils/startup-timing.util'; import { SerializedRequestType } from '@shared/utils/util'; import { CommandNames, SettingTypes } from 'papi-shared-types'; @@ -272,32 +271,18 @@ const setZoomFactor = async (factor: number): Promise => { } }; -/** Reset the zoom factor of the app to 1.0 (100%) */ -const resetZoomFactor = async () => { - try { - return await settingsService.reset('platform.zoomFactor'); - } catch (e) { - logger.warn(`Failed to reset zoom factor from settings: ${getErrorMessage(e)}`); - return DEFAULT_ZOOM_FACTOR; - } -}; - -/** Increase the zoom factor of all application windows by 0.1, up to a maximum of 3.0 */ +/** Increase the zoom factor of all application windows by one step (0.1), up to 3.0 */ const zoomIn = async () => { const currentZoom = await getZoomFactor(); - if (currentZoom < MAX_ZOOM_FACTOR) { - const newZoom = currentZoom + 0.1; - await setZoomFactor(newZoom); - } + const newZoom = adjustZoomFactor(currentZoom, 1); + if (newZoom !== currentZoom) await setZoomFactor(newZoom); }; -/** Decrease the zoom factor of all application windows by 0.1, down to a minimum of 0.5 */ +/** Decrease the zoom factor of all application windows by one step (0.1), down to 0.5 */ const zoomOut = async () => { const currentZoom = await getZoomFactor(); - if (currentZoom > MIN_ZOOM_FACTOR) { - const newZoom = currentZoom - 0.1; - await setZoomFactor(newZoom); - } + const newZoom = adjustZoomFactor(currentZoom, -1); + if (newZoom !== currentZoom) await setZoomFactor(newZoom); }; // #endregion @@ -1876,26 +1861,6 @@ async function main() { if (process.platform !== 'darwin') { // Non-Mac shortcuts - // Zoom shortcuts - Mac's zoom shortcuts already work because of the menu items - // Zoom in: Ctrl++ or Ctrl+= - if (input.control && (input.key === '=' || input.key === '+')) { - event.preventDefault(); - zoomIn(); - return; - } - // Zoom out: Ctrl+- - if (input.control && input.key === '-') { - event.preventDefault(); - zoomOut(); - return; - } - // Reset zoom: Ctrl+0 - if (input.control && input.key === '0') { - event.preventDefault(); - resetZoomFactor(); - return; - } - // keyboard tab group navigation - Ctrl+PgUp and Ctrl+PgDown if (input.control && (input.key === 'PageUp' || input.key === 'PageDown')) { event.preventDefault(); diff --git a/src/shared/utils/content-zoom.util.test.ts b/src/shared/utils/content-zoom.util.test.ts index d3a67640385..5c66b9cce1b 100644 --- a/src/shared/utils/content-zoom.util.test.ts +++ b/src/shared/utils/content-zoom.util.test.ts @@ -34,6 +34,18 @@ describe('content-zoom.util', () => { expect(adjustZoomFactor(0.5, -1)).toBe(0.5); }); + it('avoids accumulated float drift across many repeated steps', () => { + let factor = 1; + for (let i = 0; i < 20; i += 1) factor = adjustZoomFactor(factor, 1); + expect(factor).toBe(3); + + let factorDown = 1; + for (let i = 0; i < 5; i += 1) factorDown = adjustZoomFactor(factorDown, -1); + expect(factorDown).toBe(0.5); + + expect(adjustZoomFactor(2.9000000000000004, 1)).toBe(3); + }); + it('rounds to one decimal', () => { expect(roundZoom(1.2000000000000002)).toBe(1.2); expect(roundZoom(0.75)).toBe(0.8); From 19bafc23c81197223d311d912ce79065ff27ff43 Mon Sep 17 00:00:00 2001 From: Rolf Heij Date: Mon, 14 Sep 2026 13:34:39 +0200 Subject: [PATCH 002/109] feat(zoom): explicit macOS View menu zoom items bound to content zoom Replace the View menu's bare role: 'viewMenu' entry with an explicit submenu so the native zoomIn/zoomOut/resetZoom roles no longer swallow Cmd+=/-/0 before the per-pane content-zoom commands see them. Adds hidden numpad-accelerator duplicates (Electron allows only one accelerator per item) and the three new View-menu labels to en/es localization. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Gkwyu1GZbkdGdPXaygcerV --- assets/localization/en.json | 3 + assets/localization/es.json | 3 + src/main/platform-macos-menubar.data.test.ts | 109 +++++++++++++++++++ src/main/platform-macos-menubar.data.ts | 76 ++++++++++++- 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/main/platform-macos-menubar.data.test.ts diff --git a/assets/localization/en.json b/assets/localization/en.json index 0826eebe679..60d01ad24b0 100644 --- a/assets/localization/en.json +++ b/assets/localization/en.json @@ -139,6 +139,9 @@ "%mainMenu_tab%": "Tab", "%mainMenu_text%": "Text", "%mainMenu_view%": "View", + "%mainMenu_view_resetZoom%": "Reset zoom to default", + "%mainMenu_view_zoomIn%": "Zoom in", + "%mainMenu_view_zoomOut%": "Zoom out", "%mainMenu_visitSupportBible%": "Visit Support.Bible", "%mainMenu_window%": "Window", "%markerMenu_deprecated_label%": "Deprecated", diff --git a/assets/localization/es.json b/assets/localization/es.json index ea9296e4199..77d0f59f3fc 100644 --- a/assets/localization/es.json +++ b/assets/localization/es.json @@ -255,6 +255,9 @@ "%mainMenu_tab%": "Pestaña", "%mainMenu_text%": "Texto", "%mainMenu_view%": "Ver", + "%mainMenu_view_resetZoom%": "Restablecer zoom al valor predeterminado", + "%mainMenu_view_zoomIn%": "Acercar", + "%mainMenu_view_zoomOut%": "Alejar", "%mainMenu_visitSupportBible%": "Visitar Support.Bible", "%mainMenu_window%": "Ventana", "%markerMenu_deprecated_label%": "Obsoleto", diff --git a/src/main/platform-macos-menubar.data.test.ts b/src/main/platform-macos-menubar.data.test.ts new file mode 100644 index 00000000000..dad4f506f64 --- /dev/null +++ b/src/main/platform-macos-menubar.data.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CONTENT_ZOOM_COMMANDS } from '@shared/models/content-zoom.model'; +import * as commandService from '@shared/services/command.service'; +import { logger } from '@shared/services/logger.service'; +import { macosMenubarObject } from './platform-macos-menubar.data'; + +vi.mock('@shared/services/command.service', () => ({ + sendCommand: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@shared/services/logger.service', () => ({ + logger: { warn: vi.fn() }, +})); + +/** + * `MenuItemConstructorOptionsWithOrder.submenu` is typed as an intersection of an object type with + * an array type rather than an array of the intersection, so TypeScript resolves `.find`/`.map` on + * a submenu array down to a near-empty element type. Reading a submenu item's `id`, `role`, + * `accelerator`, `click` etc. therefore goes through `unknown` and a local type guard instead of + * trusting the (pre-existing) declared type. + */ +function isRecord(candidate: unknown): candidate is Record { + return !!candidate && typeof candidate === 'object'; +} + +function isZeroArgClickHandler(candidate: unknown): candidate is () => void { + return typeof candidate === 'function'; +} + +const viewMenu = macosMenubarObject.find((menu) => menu.id === 'macosMenubar.viewMenu'); +const rawSubmenu: unknown = viewMenu?.submenu; +if (!Array.isArray(rawSubmenu)) + throw new Error('macosMenubarObject has no View menu with a submenu array'); +const submenu = rawSubmenu.filter(isRecord); + +function getItem(id: string): Record | undefined { + return submenu.find((item) => item.id === id); +} + +describe('macosMenubarObject View menu', () => { + it('labels the explicit zoom items', () => { + const labels = submenu.map((item) => item.label); + expect(labels).toContain('%mainMenu_view_zoomIn%'); + expect(labels).toContain('%mainMenu_view_zoomOut%'); + expect(labels).toContain('%mainMenu_view_resetZoom%'); + }); + + it('does not rely on the native zoom roles', () => { + const roles = submenu.map((item) => item.role); + expect(roles).not.toContain('zoomIn'); + expect(roles).not.toContain('zoomOut'); + expect(roles).not.toContain('resetZoom'); + }); + + it('keeps the reload, dev-tools and full-screen roles', () => { + const roles = submenu.map((item) => item.role); + expect(roles).toContain('reload'); + expect(roles).toContain('toggleDevTools'); + expect(roles).toContain('togglefullscreen'); + }); + + it('binds the zoom-in item to CommandOrControl+= with a click handler', () => { + const zoomIn = getItem('contentZoomIn'); + expect(zoomIn?.accelerator).toBe('CommandOrControl+='); + expect(typeof zoomIn?.click).toBe('function'); + }); + + it.each([ + ['contentZoomIn', CONTENT_ZOOM_COMMANDS.in], + ['contentZoomOut', CONTENT_ZOOM_COMMANDS.out], + ['contentZoomReset', CONTENT_ZOOM_COMMANDS.reset], + ])('clicking %s sends %s with no extra arguments', (id, command) => { + const item = getItem(id); + if (!isZeroArgClickHandler(item?.click)) throw new Error(`${id} has no click handler`); + + item.click(); + + expect(commandService.sendCommand).toHaveBeenCalledWith(command); + }); + + it.each([ + ['contentZoomInNumpad', 'CommandOrControl+numadd', CONTENT_ZOOM_COMMANDS.in], + ['contentZoomOutNumpad', 'CommandOrControl+numsub', CONTENT_ZOOM_COMMANDS.out], + ['contentZoomResetNumpad', 'CommandOrControl+num0', CONTENT_ZOOM_COMMANDS.reset], + ])( + '%s is a hidden duplicate with accelerator %s invoking the same command', + (id, accelerator, command) => { + const item = getItem(id); + expect(item?.visible).toBe(false); + expect(item?.accelerator).toBe(accelerator); + if (!isZeroArgClickHandler(item?.click)) throw new Error(`${id} has no click handler`); + + item.click(); + + expect(commandService.sendCommand).toHaveBeenCalledWith(command); + }, + ); + + it('catches a rejected sendCommand and logs it instead of throwing', async () => { + vi.mocked(commandService.sendCommand).mockRejectedValueOnce(new Error('network unavailable')); + const zoomIn = getItem('contentZoomIn'); + if (!isZeroArgClickHandler(zoomIn?.click)) + throw new Error('contentZoomIn has no click handler'); + + zoomIn.click(); + + await vi.waitFor(() => expect(logger.warn).toHaveBeenCalled()); + }); +}); diff --git a/src/main/platform-macos-menubar.data.ts b/src/main/platform-macos-menubar.data.ts index 238b2c7669e..432e0566b67 100644 --- a/src/main/platform-macos-menubar.data.ts +++ b/src/main/platform-macos-menubar.data.ts @@ -1,5 +1,8 @@ +import { CONTENT_ZOOM_COMMANDS } from '@shared/models/content-zoom.model'; +import * as commandService from '@shared/services/command.service'; +import { logger } from '@shared/services/logger.service'; import { MenuItemConstructorOptions } from 'electron'; -import { Localized, LocalizeKey } from 'platform-bible-utils'; +import { getErrorMessage, Localized, LocalizeKey } from 'platform-bible-utils'; /** * A group of ReferencedItems specific to the predefined menus in the MacOS menu bar. If they set @@ -37,6 +40,18 @@ export type MenuItemConstructorOptionsWithOrder = MenuItemConstructorOptions & { export type LocalizedMacosMenubar = Localized[]; +/** + * Sends one content-zoom command from a macOS View menu click, logging rather than throwing on + * failure. + */ +function sendContentZoomCommand( + command: (typeof CONTENT_ZOOM_COMMANDS)[keyof typeof CONTENT_ZOOM_COMMANDS], +): void { + commandService + .sendCommand(command) + .catch((e) => logger.warn(`macOS View menu: ${command} failed: ${getErrorMessage(e)}`)); +} + // Cannot contribute this as is in main.ts, need to convert labels and tooltips to localized strings and remove order property export const macosMenubarObject: MenuItemConstructorOptionsWithOrder[] = [ { @@ -75,6 +90,65 @@ export const macosMenubarObject: MenuItemConstructorOptionsWithOrder[] = [ label: '%mainMenu_view%', role: 'viewMenu', id: 'macosMenubar.viewMenu', + // Explicit submenu, not `role: 'viewMenu'`'s default items: on macOS the app menu owns ⌘ + // chords, and Electron's built-in zoomIn/zoomOut/resetZoom roles would claim ⌘+/⌘-/⌘0 before + // the content-zoom commands ever saw them. + submenu: [ + { role: 'reload', id: 'reload', order: 1 }, + { role: 'forceReload', id: 'forceReload', order: 2 }, + { role: 'toggleDevTools', id: 'toggleDevTools', order: 3 }, + { type: 'separator', id: 'viewSeparatorAfterDevTools', order: 4 }, + { + label: '%mainMenu_view_zoomIn%', + id: 'contentZoomIn', + order: 5, + accelerator: 'CommandOrControl+=', + click: () => sendContentZoomCommand(CONTENT_ZOOM_COMMANDS.in), + }, + { + label: '%mainMenu_view_zoomOut%', + id: 'contentZoomOut', + order: 6, + accelerator: 'CommandOrControl+-', + click: () => sendContentZoomCommand(CONTENT_ZOOM_COMMANDS.out), + }, + { + label: '%mainMenu_view_resetZoom%', + id: 'contentZoomReset', + order: 7, + accelerator: 'CommandOrControl+0', + click: () => sendContentZoomCommand(CONTENT_ZOOM_COMMANDS.reset), + }, + // Hidden duplicates carrying the numpad accelerators: Electron allows only one accelerator + // per menu item, so the numpad chords need their own (invisible) items rather than a second + // accelerator on the items above. + { + label: '%mainMenu_view_zoomIn%', + id: 'contentZoomInNumpad', + order: 8, + accelerator: 'CommandOrControl+numadd', + visible: false, + click: () => sendContentZoomCommand(CONTENT_ZOOM_COMMANDS.in), + }, + { + label: '%mainMenu_view_zoomOut%', + id: 'contentZoomOutNumpad', + order: 9, + accelerator: 'CommandOrControl+numsub', + visible: false, + click: () => sendContentZoomCommand(CONTENT_ZOOM_COMMANDS.out), + }, + { + label: '%mainMenu_view_resetZoom%', + id: 'contentZoomResetNumpad', + order: 10, + accelerator: 'CommandOrControl+num0', + visible: false, + click: () => sendContentZoomCommand(CONTENT_ZOOM_COMMANDS.reset), + }, + { type: 'separator', id: 'viewSeparatorBeforeFullScreen', order: 11 }, + { role: 'togglefullscreen', id: 'togglefullscreen', order: 12 }, + ], }, { label: '%mainMenu_tab%', From 6f2ff8c2408ccc6ade57c63f57b9cc7ce55925e2 Mon Sep 17 00:00:00 2001 From: Rolf Heij Date: Mon, 14 Sep 2026 13:39:56 +0200 Subject: [PATCH 003/109] feat(dialogs): isAnyDialogOpen query over docked dialogs and modal overlays Adds hasAnyDialogRequest() to the dialog service shard and hasOverlayOfType() to the overlay store, composed into a single isAnyDialogOpen() query in a new dialog-open.util.ts. A "dialog open" is either a live docked PAPI dialog request or an active modal overlay. The renderer window-chrome key listener (a later task) uses this to no-op content-zoom chords while a dialog has focus. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Gkwyu1GZbkdGdPXaygcerV --- .../services/dialog-open.util.test.ts | 37 +++++++++++++++++ src/renderer/services/dialog-open.util.ts | 19 +++++++++ .../services/dialog.service-shard.test.ts | 26 ++++++++++++ src/renderer/services/dialog.service-shard.ts | 10 +++++ .../services/overlays/overlay-store.test.ts | 41 +++++++++++++++++++ .../services/overlays/overlay-store.ts | 10 +++++ 6 files changed, 143 insertions(+) create mode 100644 src/renderer/services/dialog-open.util.test.ts create mode 100644 src/renderer/services/dialog-open.util.ts diff --git a/src/renderer/services/dialog-open.util.test.ts b/src/renderer/services/dialog-open.util.test.ts new file mode 100644 index 00000000000..d0bcbab5f09 --- /dev/null +++ b/src/renderer/services/dialog-open.util.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const mockHasAnyDialogRequest = vi.fn(); +vi.mock('@renderer/services/dialog.service-shard', () => ({ + hasAnyDialogRequest: mockHasAnyDialogRequest, +})); + +const mockHasOverlayOfType = vi.fn(); +vi.mock('@renderer/services/overlays/overlay-store', () => ({ + hasOverlayOfType: mockHasOverlayOfType, +})); + +describe('dialog-open.util', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHasAnyDialogRequest.mockReturnValue(false); + mockHasOverlayOfType.mockReturnValue(false); + }); + + it('returns false when neither source reports a dialog', async () => { + const { isAnyDialogOpen } = await import('./dialog-open.util'); + expect(isAnyDialogOpen()).toBe(false); + }); + + it('returns true when the dialog shard reports a live request', async () => { + mockHasAnyDialogRequest.mockReturnValue(true); + const { isAnyDialogOpen } = await import('./dialog-open.util'); + expect(isAnyDialogOpen()).toBe(true); + }); + + it('returns true when a modal overlay exists', async () => { + mockHasOverlayOfType.mockReturnValue(true); + const { isAnyDialogOpen } = await import('./dialog-open.util'); + expect(isAnyDialogOpen()).toBe(true); + expect(mockHasOverlayOfType).toHaveBeenCalledWith('modalDialog'); + }); +}); diff --git a/src/renderer/services/dialog-open.util.ts b/src/renderer/services/dialog-open.util.ts new file mode 100644 index 00000000000..1ac9a767cec --- /dev/null +++ b/src/renderer/services/dialog-open.util.ts @@ -0,0 +1,19 @@ +/** + * Whether a dialog is currently open in this window, composed from the two independent places a + * dialog can live: a live docked PAPI dialog request (`dialog.service-shard.ts`) or a modal overlay + * (`overlay-store.ts`). Callers that must not act while a dialog has focus (e.g. a window-chrome + * key listener) check this rather than either source alone. + */ + +import { hasAnyDialogRequest } from '@renderer/services/dialog.service-shard'; +import { hasOverlayOfType } from '@renderer/services/overlays/overlay-store'; + +/** + * Determine whether any dialog is open in this window + * + * @returns True if there is a live docked dialog request or an active modal overlay; false + * otherwise + */ +export function isAnyDialogOpen(): boolean { + return hasAnyDialogRequest() || hasOverlayOfType('modalDialog'); +} diff --git a/src/renderer/services/dialog.service-shard.test.ts b/src/renderer/services/dialog.service-shard.test.ts index 76ae98c348c..fd8039a8a6b 100644 --- a/src/renderer/services/dialog.service-shard.test.ts +++ b/src/renderer/services/dialog.service-shard.test.ts @@ -227,6 +227,32 @@ describe('dialog.service-shard', () => { }); }); + describe('hasAnyDialogRequest', () => { + it('returns false when no dialog request exists', async () => { + const { hasAnyDialogRequest } = await import('./dialog.service-shard'); + expect(hasAnyDialogRequest()).toBe(false); + }); + + it('returns true while a request is live, false once it resolves', async () => { + const { hasAnyDialogRequest, resolveDialogRequest } = await import('./dialog.service-shard'); + + const { addTab } = await import('@renderer/services/web-view.service-shard'); + vi.mocked(addTab).mockResolvedValue(undefined); + + // Start a non-overlay dialog (selectProject goes through tab-based path). + const dialogPromise = capturedShowDialog('platform.selectProject', {}); + + await vi.waitFor(() => { + expect(hasAnyDialogRequest()).toBe(true); + }); + + resolveDialogRequest('mock-guid', 'selected-project-id'); + expect(hasAnyDialogRequest()).toBe(false); + + await dialogPromise; + }); + }); + describe('resolveDialogRequest', () => { it('throws when resolving a non-existent dialog request', async () => { const { resolveDialogRequest } = await import('./dialog.service-shard'); diff --git a/src/renderer/services/dialog.service-shard.ts b/src/renderer/services/dialog.service-shard.ts index ec2d2d3f9e7..8b18fdee4d3 100644 --- a/src/renderer/services/dialog.service-shard.ts +++ b/src/renderer/services/dialog.service-shard.ts @@ -93,6 +93,16 @@ export function hasDialogRequest(id: string) { return dialogRequests.has(id); } +/** + * Determine whether any dialog request is currently unresolved, regardless of id + * + * @returns True if at least one dialog request is unresolved; false otherwise + * @internal function; not exposed on papi + */ +export function hasAnyDialogRequest() { + return dialogRequests.size > 0; +} + /** * Resolve a dialog request. Synchronously resolves, then asynchronously closes the dialog * diff --git a/src/renderer/services/overlays/overlay-store.test.ts b/src/renderer/services/overlays/overlay-store.test.ts index c4e71ce1676..84050ec2871 100644 --- a/src/renderer/services/overlays/overlay-store.test.ts +++ b/src/renderer/services/overlays/overlay-store.test.ts @@ -14,6 +14,7 @@ import { rejectAndRemoveOverlay, updateOverlayContent, updateCommandPaletteState, + hasOverlayOfType, } from './overlay-store'; function createContextMenuEntry( @@ -442,6 +443,46 @@ describe('overlay-store', () => { }); }); + describe('hasOverlayOfType', () => { + it('returns false when the store is empty', () => { + expect(hasOverlayOfType('modalDialog')).toBe(false); + }); + + it('returns true once a matching overlay is added', () => { + const modalEntry: OverlayEntry = { + type: 'modalDialog', + id: 'modal-1', + webViewId: 'webview-1', + Component: vi.fn(), + props: {}, + resolve: vi.fn(), + reject: vi.fn(), + }; + addOverlay(modalEntry); + expect(hasOverlayOfType('modalDialog')).toBe(true); + }); + + it('returns false again after the matching overlay is removed', () => { + const modalEntry: OverlayEntry = { + type: 'modalDialog', + id: 'modal-1', + webViewId: 'webview-1', + Component: vi.fn(), + props: {}, + resolve: vi.fn(), + reject: vi.fn(), + }; + addOverlay(modalEntry); + resolveAndRemoveOverlay('modal-1', 'modalDialog', undefined); + expect(hasOverlayOfType('modalDialog')).toBe(false); + }); + + it('returns false when only an overlay of a different type exists', () => { + addOverlay(createContextMenuEntry('overlay-1', 'webview-1')); + expect(hasOverlayOfType('modalDialog')).toBe(false); + }); + }); + describe('rejectAndRemoveOverlay listener notification', () => { it('should notify listeners when an overlay is rejected and removed', () => { const entry = createContextMenuEntry('overlay-1', 'webview-1'); diff --git a/src/renderer/services/overlays/overlay-store.ts b/src/renderer/services/overlays/overlay-store.ts index 089b9747935..e3e015ed6ba 100644 --- a/src/renderer/services/overlays/overlay-store.ts +++ b/src/renderer/services/overlays/overlay-store.ts @@ -106,6 +106,16 @@ export function getOverlayById(id: string): OverlayEntry | undefined { return overlays.get(id); } +/** + * Determine whether at least one active overlay has the given type + * + * @param type The overlay type to check for (e.g. 'modalDialog') + * @returns True if an overlay of that type is currently active; false otherwise + */ +export function hasOverlayOfType(type: OverlayEntry['type']): boolean { + return Array.from(overlays.values()).some((entry) => entry.type === type); +} + /** * Get the most recently created overlay matching `predicate` — the topmost of the overlays it * accepts, since a newer overlay always renders over an older one. From 09d86d78065f016eebc09b45584d7648292583f8 Mon Sep 17 00:00:00 2001 From: Rolf Heij Date: Mon, 14 Sep 2026 13:45:31 +0200 Subject: [PATCH 004/109] feat(zoom): window-chrome Ctrl chord listener with dialog no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers a per-window keydown listener that turns Ctrl/⌘+`+`/`-`/`0` into content-zoom actions for the window's active tab and area, covering the case where keyboard focus is on the window's own chrome (tab headers, reference box, toolbar buttons) rather than inside a web view's iframe, which the in-view bootstrap script already handles. No-ops while a dialog is open and skips events targeting or inside an iframe, leaving those to the bootstrap. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Gkwyu1GZbkdGdPXaygcerV --- src/renderer/index.tsx | 10 +- .../web-view-content-zoom.chrome-keys.test.ts | 156 ++++++++++++++++++ .../web-view-content-zoom.chrome-keys.ts | 82 +++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 src/renderer/services/web-view-content-zoom.chrome-keys.test.ts create mode 100644 src/renderer/services/web-view-content-zoom.chrome-keys.ts diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 5a0233a5254..b00c0fa8b17 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -26,7 +26,13 @@ import { import { initializeUsersnapApi } from '@renderer/services/usersnap.service'; import { startUsersnapServiceShard } from '@renderer/services/usersnap.service-shard'; import { startOnboardingTourServiceShard } from '@renderer/services/onboarding-tour.service-shard'; -import { initializeContentZoomService } from '@renderer/services/web-view-content-zoom.service'; +import { isAnyDialogOpen } from '@renderer/services/dialog-open.util'; +import { registerContentZoomChromeKeys } from '@renderer/services/web-view-content-zoom.chrome-keys'; +import { + adjustContentZoom, + initializeContentZoomService, + resetContentZoom, +} from '@renderer/services/web-view-content-zoom.service'; import { cleanupOldWebViewState } from '@renderer/services/web-view-state.service'; import { getAllOpenWebViewDefinitionsSync, @@ -146,6 +152,8 @@ initConnectionLostService(); }).catch((e) => logger.warn(`Content zoom service failed to initialize: ${getErrorMessage(e)}`), ); + // The returned unsubscriber is discarded: this listener runs for the window's lifetime. + registerContentZoomChromeKeys({ adjustContentZoom, resetContentZoom, isAnyDialogOpen }); await runPromisesAndThrowIfRejected( webViewProviderService.initialize(), diff --git a/src/renderer/services/web-view-content-zoom.chrome-keys.test.ts b/src/renderer/services/web-view-content-zoom.chrome-keys.test.ts new file mode 100644 index 00000000000..0188db16a42 --- /dev/null +++ b/src/renderer/services/web-view-content-zoom.chrome-keys.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@shared/services/logger.service', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +// The mocked logger, so a test can assert on a warning it produced. +// eslint-disable-next-line import/first +import { logger } from '@shared/services/logger.service'; +// The module under test, imported after the mock above is established. +// eslint-disable-next-line import/first +import { registerContentZoomChromeKeys } from './web-view-content-zoom.chrome-keys'; + +function dispatchKeyDown(target: EventTarget, init: KeyboardEventInit): KeyboardEvent { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }); + target.dispatchEvent(event); + return event; +} + +describe('registerContentZoomChromeKeys', () => { + let adjustContentZoom: ReturnType; + let resetContentZoom: ReturnType; + let isAnyDialogOpen: ReturnType; + let unsubscribe: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + adjustContentZoom = vi.fn().mockResolvedValue(undefined); + resetContentZoom = vi.fn().mockResolvedValue(undefined); + isAnyDialogOpen = vi.fn().mockReturnValue(false); + unsubscribe = registerContentZoomChromeKeys({ + adjustContentZoom, + resetContentZoom, + isAnyDialogOpen, + }); + }); + + afterEach(() => { + unsubscribe(); + }); + + it('zooms in on Ctrl+=', () => { + const event = dispatchKeyDown(document.body, { key: '=', ctrlKey: true }); + expect(adjustContentZoom).toHaveBeenCalledTimes(1); + expect(adjustContentZoom).toHaveBeenCalledWith(undefined, 1); + expect(event.defaultPrevented).toBe(true); + }); + + it('zooms in on Ctrl++', () => { + const event = dispatchKeyDown(document.body, { key: '+', ctrlKey: true }); + expect(adjustContentZoom).toHaveBeenCalledWith(undefined, 1); + expect(event.defaultPrevented).toBe(true); + }); + + it('zooms in on Ctrl+NumpadAdd', () => { + const event = dispatchKeyDown(document.body, { code: 'NumpadAdd', ctrlKey: true }); + expect(adjustContentZoom).toHaveBeenCalledWith(undefined, 1); + expect(event.defaultPrevented).toBe(true); + }); + + it('zooms out on Ctrl+-', () => { + const event = dispatchKeyDown(document.body, { key: '-', ctrlKey: true }); + expect(adjustContentZoom).toHaveBeenCalledWith(undefined, -1); + expect(event.defaultPrevented).toBe(true); + }); + + it('zooms out on Ctrl+NumpadSubtract', () => { + const event = dispatchKeyDown(document.body, { code: 'NumpadSubtract', ctrlKey: true }); + expect(adjustContentZoom).toHaveBeenCalledWith(undefined, -1); + expect(event.defaultPrevented).toBe(true); + }); + + it('resets on Ctrl+0', () => { + const event = dispatchKeyDown(document.body, { key: '0', ctrlKey: true }); + expect(resetContentZoom).toHaveBeenCalledWith(undefined); + expect(event.defaultPrevented).toBe(true); + }); + + it('resets on Ctrl+Numpad0', () => { + const event = dispatchKeyDown(document.body, { code: 'Numpad0', ctrlKey: true }); + expect(resetContentZoom).toHaveBeenCalledWith(undefined); + expect(event.defaultPrevented).toBe(true); + }); + + it('acts on Meta instead of Ctrl', () => { + const event = dispatchKeyDown(document.body, { key: '=', metaKey: true }); + expect(adjustContentZoom).toHaveBeenCalledWith(undefined, 1); + expect(event.defaultPrevented).toBe(true); + }); + + it('accepts Shift, since Ctrl+Shift+= is how many keyboards type Ctrl++', () => { + const event = dispatchKeyDown(document.body, { key: '=', ctrlKey: true, shiftKey: true }); + expect(adjustContentZoom).toHaveBeenCalledWith(undefined, 1); + expect(event.defaultPrevented).toBe(true); + }); + + it('does not act when Alt is held, since Ctrl+Alt chords have their own meanings', () => { + const event = dispatchKeyDown(document.body, { key: '=', ctrlKey: true, altKey: true }); + expect(adjustContentZoom).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('does not act with no modifier held', () => { + const event = dispatchKeyDown(document.body, { key: '=' }); + expect(adjustContentZoom).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('does not act while a dialog is open', () => { + isAnyDialogOpen.mockReturnValue(true); + const event = dispatchKeyDown(document.body, { key: '=', ctrlKey: true }); + expect(adjustContentZoom).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('does not act when the event target is a web view iframe', () => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const event = dispatchKeyDown(iframe, { key: '=', ctrlKey: true }); + expect(adjustContentZoom).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + iframe.remove(); + }); + + it('does not act when the event target is inside an iframe', () => { + const iframe = document.createElement('iframe'); + const inner = document.createElement('div'); + iframe.appendChild(inner); + document.body.appendChild(iframe); + const event = dispatchKeyDown(inner, { key: '=', ctrlKey: true }); + expect(adjustContentZoom).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + iframe.remove(); + }); + + it('does not act on an unrelated key with Ctrl held', () => { + const event = dispatchKeyDown(document.body, { key: 'k', ctrlKey: true }); + expect(adjustContentZoom).not.toHaveBeenCalled(); + expect(resetContentZoom).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('stops listening once the returned unsubscriber is called', () => { + unsubscribe(); + const event = dispatchKeyDown(document.body, { key: '=', ctrlKey: true }); + expect(adjustContentZoom).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('logs a rejected adjustContentZoom promise instead of letting it escape unhandled', async () => { + adjustContentZoom.mockRejectedValueOnce(new Error('adjust failed')); + dispatchKeyDown(document.body, { key: '=', ctrlKey: true }); + await vi.waitFor(() => expect(logger.warn).toHaveBeenCalled()); + expect(vi.mocked(logger.warn).mock.calls[0][0]).toContain('adjust failed'); + }); +}); diff --git a/src/renderer/services/web-view-content-zoom.chrome-keys.ts b/src/renderer/services/web-view-content-zoom.chrome-keys.ts new file mode 100644 index 00000000000..5656d7f1c0a --- /dev/null +++ b/src/renderer/services/web-view-content-zoom.chrome-keys.ts @@ -0,0 +1,82 @@ +import { ContentZoomAreaId, WebViewId } from '@shared/models/web-view.model'; +import { logger } from '@shared/services/logger.service'; +import { getErrorMessage } from 'platform-bible-utils'; + +/** + * The zoom actions and dialog-open query {@link registerContentZoomChromeKeys} calls, injected so + * this module does not import the content-zoom service or the dialog-open query directly. + */ +export type ContentZoomChromeKeysDeps = { + adjustContentZoom: ( + webViewId: WebViewId | undefined, + deltaSteps: number, + areaId?: ContentZoomAreaId, + ) => Promise; + resetContentZoom: (webViewId: WebViewId | undefined, areaId?: ContentZoomAreaId) => Promise; + isAnyDialogOpen: () => boolean; +}; + +/** + * Same modifier rule as the in-view bootstrap chords, except Shift is accepted here: `Ctrl+Shift+=` + * is how many keyboards type `Ctrl++`. Alt is still excluded, because Ctrl+Alt chords carry their + * own meanings. + */ +function isChordModifier(e: KeyboardEvent): boolean { + return (e.ctrlKey || e.metaKey) && !e.altKey; +} + +/** + * Whether the event target is a web view's iframe, or lives inside one. The in-view bootstrap + * script already turns these chords into zoom actions for a target inside a web view, so this + * listener must not act on the same keystroke a second time. + */ +function isInsideIframe(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + if (target instanceof HTMLIFrameElement) return true; + return !!target.closest('iframe'); +} + +type ChordAction = 'in' | 'out' | 'reset'; + +/** Keys exactly as the in-view bootstrap recognizes them. */ +function actionFor(e: KeyboardEvent): ChordAction | undefined { + if (e.key === '=' || e.key === '+' || e.code === 'NumpadAdd') return 'in'; + if (e.key === '-' || e.code === 'NumpadSubtract') return 'out'; + if (e.key === '0' || e.code === 'Numpad0') return 'reset'; + return undefined; +} + +/** + * Registers the window-chrome Ctrl/⌘+`+`/`-`/`0` content-zoom chords. The in-view bootstrap script + * (`web-view-content-zoom.bootstrap-script.ts`) only sees these keys while focus is inside a web + * view's iframe, so with keyboard focus on the window's own UI instead — a tab header just clicked, + * the reference box, a renderer toolbar button — nothing would otherwise zoom the active pane. + * Calls the injected actions with no web view id and no area id, so the content-zoom service + * resolves the window's active tab and that tab's active area on its own. + * + * No-ops while a dialog is open ({@link ContentZoomChromeKeysDeps.isAnyDialogOpen}): dialogs are out + * of scope for content zoom, and a dialog's own use of these keys, if any, must not be shadowed by + * this listener. + * + * @param deps The zoom actions and dialog-open query to call. + * @returns A function that removes the listener. + */ +export function registerContentZoomChromeKeys(deps: ContentZoomChromeKeysDeps): () => void { + const onKeyDown = (e: KeyboardEvent): void => { + if (!isChordModifier(e)) return; + if (deps.isAnyDialogOpen()) return; + if (isInsideIframe(e.target)) return; + const action = actionFor(e); + if (!action) return; + e.preventDefault(); + const promise = + action === 'reset' + ? deps.resetContentZoom(undefined) + : deps.adjustContentZoom(undefined, action === 'in' ? 1 : -1); + promise.catch((err) => + logger.warn(`Content zoom: window-chrome chord failed. ${getErrorMessage(err)}`), + ); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); +} From c78b5c190979e8f635a81e2e50e80b8a836d8f6a Mon Sep 17 00:00:00 2001 From: Rolf Heij Date: Mon, 14 Sep 2026 13:50:56 +0200 Subject: [PATCH 005/109] docs(shortcuts): catalog the content-zoom chords' new handlers; drop the app-zoom entries The Zoom category still documented the removed main-process before-input-event chords (zoom-in/zoom-out/reset-zoom) and carried TODO(PT-4577) notes on the content-zoom entries about those chords shadowing them on Windows/Linux. Delete the stale app-zoom entries (platform.zoomIn/zoomOut no longer have a default chord), and rewrite content-zoom-in/out/reset to describe the current handling: the in-view bootstrap for focus inside a web view, the new window-chrome listener for focus on the tab bar/reference box (no-op while a dialog is open), and the macOS View menu's explicit accelerator - citing all four files that implement it. content-zoom-wheel and the unrelated Enhanced Resources zoom entries (a self-contained per-web-view listener, unaffected by the chord move) are left as they were. Adds a small regression test pinning the Zoom category's shape and checking every one of its location paths resolves to a real file on disk. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Gkwyu1GZbkdGdPXaygcerV --- src/shared/data/keyboard-shortcuts.data.ts | 52 ++++++--------------- src/stories/keyboard-shortcuts.data.test.ts | 48 +++++++++++++++++++ 2 files changed, 62 insertions(+), 38 deletions(-) create mode 100644 src/stories/keyboard-shortcuts.data.test.ts diff --git a/src/shared/data/keyboard-shortcuts.data.ts b/src/shared/data/keyboard-shortcuts.data.ts index 80502b09589..669a616c508 100644 --- a/src/shared/data/keyboard-shortcuts.data.ts +++ b/src/shared/data/keyboard-shortcuts.data.ts @@ -224,73 +224,49 @@ export const rootKeyboardShortcuts: KeyboardShortcutEntry[] = [ keys: { macOS: '⎋', windows: 'Esc', linux: 'Esc' }, locations: ['src/renderer/components/overlays/overlay-connection-lost.component.tsx'], }, - { - id: 'zoom-in', - purpose: 'Zoom in', - category: 'Zoom', - context: 'Main process (global)', - keys: { macOS: '⌘+', windows: 'Ctrl++', linux: 'Ctrl++' }, - locations: ['src/main/main.ts', 'src/main/platform-macos-menubar.data.ts'], - }, - { - id: 'zoom-out', - purpose: 'Zoom out', - category: 'Zoom', - context: 'Main process (global)', - keys: { macOS: '⌘-', windows: 'Ctrl+-', linux: 'Ctrl+-' }, - locations: ['src/main/main.ts', 'src/main/platform-macos-menubar.data.ts'], - }, - { - id: 'reset-zoom', - purpose: 'Reset zoom to default', - category: 'Zoom', - context: 'Main process (global)', - keys: { macOS: '⌘0', windows: 'Ctrl+0', linux: 'Ctrl+0' }, - locations: ['src/main/main.ts', 'src/main/platform-macos-menubar.data.ts'], - }, { id: 'content-zoom-in', - purpose: 'Zoom the content of the pane in by one step (10 %)', + purpose: + 'Zoom the focused zoom area of the pane in by 10 % (the area containing keyboard focus, else the area last used)', category: 'Zoom', context: - 'Inside a web view — content zoom of one zoom area (the area with keyboard focus, else the pane’s active area)', + 'Inside any web view that marks at least one zoom area, the platform bootstrap handles the key and targets the area containing focus, else the area last clicked or focused. With keyboard focus on the window chrome (tab bar, reference box) the window-level listener targets the active tab’s active area and does nothing while a dialog is open. On macOS the View menu item carries the ⌘ accelerator.', // The handler also accepts `=` (the unshifted key sharing the `+` cap), the numpad `+` key, and // Ctrl+Shift+`=` — the `+` key itself on US/UK layouts — so the published `Ctrl++` is literally the working chord. - // TODO(PT-4577): unreachable on Windows/Linux until the main-process before-input-event zoom - // branches that claim this chord are removed. keys: { macOS: '⌘+', windows: 'Ctrl++', linux: 'Ctrl++' }, locations: [ 'src/renderer/services/web-view-content-zoom.bootstrap-script.ts', + 'src/renderer/services/web-view-content-zoom.chrome-keys.ts', + 'src/main/platform-macos-menubar.data.ts', 'src/main/services/web-view.service-router.ts', ], }, { id: 'content-zoom-out', - purpose: 'Zoom the content of the pane out by one step (10 %)', + purpose: 'Zoom the focused zoom area of the pane out by 10 %', category: 'Zoom', - context: - 'Inside a web view — content zoom of one zoom area (the area with keyboard focus, else the pane’s active area)', + context: 'Same as content-zoom-in', // The handler also accepts the numpad `-` key. - // TODO(PT-4577): unreachable on Windows/Linux until the main-process before-input-event zoom - // branches that claim this chord are removed. keys: { macOS: '⌘-', windows: 'Ctrl+-', linux: 'Ctrl+-' }, locations: [ 'src/renderer/services/web-view-content-zoom.bootstrap-script.ts', + 'src/renderer/services/web-view-content-zoom.chrome-keys.ts', + 'src/main/platform-macos-menubar.data.ts', 'src/main/services/web-view.service-router.ts', ], }, { id: 'content-zoom-reset', - purpose: 'Return the content of the pane to the default zoom from Settings', + purpose: + 'Return the focused zoom area of the pane to the default zoom set in Settings (not to 100 %)', category: 'Zoom', - context: - 'Inside a web view — content zoom of one zoom area (the area with keyboard focus, else the pane’s active area)', + context: 'Same as content-zoom-in', // The handler also accepts the numpad `0` key. - // TODO(PT-4577): unreachable on Windows/Linux until the main-process before-input-event zoom - // branches that claim this chord are removed. keys: { macOS: '⌘0', windows: 'Ctrl+0', linux: 'Ctrl+0' }, locations: [ 'src/renderer/services/web-view-content-zoom.bootstrap-script.ts', + 'src/renderer/services/web-view-content-zoom.chrome-keys.ts', + 'src/main/platform-macos-menubar.data.ts', 'src/main/services/web-view.service-router.ts', ], }, diff --git a/src/stories/keyboard-shortcuts.data.test.ts b/src/stories/keyboard-shortcuts.data.test.ts new file mode 100644 index 00000000000..56ff5e1c3a1 --- /dev/null +++ b/src/stories/keyboard-shortcuts.data.test.ts @@ -0,0 +1,48 @@ +import { existsSync } from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { rootKeyboardShortcuts } from './keyboard-shortcuts.data'; + +const REPO_ROOT = path.resolve(__dirname, '../..'); + +/** + * The content-zoom chord handlers this file's Zoom category must cite once the chords move from the + * main-process app-wide zoom to per-pane content zoom: the in-view bootstrap, the window-chrome + * listener, the macOS View menu items, and the command router they all funnel through. + */ +const CONTENT_ZOOM_CHORD_LOCATIONS = [ + 'src/renderer/services/web-view-content-zoom.bootstrap-script.ts', + 'src/renderer/services/web-view-content-zoom.chrome-keys.ts', + 'src/main/platform-macos-menubar.data.ts', + 'src/main/services/web-view.service-router.ts', +]; + +function zoomEntries() { + return rootKeyboardShortcuts.filter((entry) => entry.category === 'Zoom'); +} + +/** All (entry id, location) pairs across the Zoom category, for a per-pair existence check. */ +function zoomEntryLocationPairs(): [entryId: string, location: string][] { + return zoomEntries().flatMap((entry) => entry.locations.map((location) => [entry.id, location])); +} + +describe('keyboard-shortcuts.data Zoom category', () => { + it('has no leftover app-wide zoom entries for the removed main-process chords', () => { + const ids = zoomEntries().map((entry) => entry.id); + expect(ids).not.toContain('zoom-in'); + expect(ids).not.toContain('zoom-out'); + expect(ids).not.toContain('reset-zoom'); + }); + + it.each(['content-zoom-in', 'content-zoom-out', 'content-zoom-reset'])( + 'documents all four content-zoom chord handlers on %s', + (id) => { + const entry = zoomEntries().find((zoomEntry) => zoomEntry.id === id); + expect(entry?.locations).toEqual(CONTENT_ZOOM_CHORD_LOCATIONS); + }, + ); + + it.each(zoomEntryLocationPairs())('%s location "%s" resolves to a real file', (_id, location) => { + expect(existsSync(path.join(REPO_ROOT, location))).toBe(true); + }); +}); From 9c466d051abebb0e9e35cc965cc3e9a322bab2a5 Mon Sep 17 00:00:00 2001 From: Rolf Heij Date: Mon, 14 Sep 2026 14:07:23 +0200 Subject: [PATCH 006/109] =?UTF-8?q?chore(zoom):=20final-review=20polish=20?= =?UTF-8?q?=E2=80=94=20catalog=20context,=20test=20typing,=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Gkwyu1GZbkdGdPXaygcerV --- src/main/platform-macos-menubar.data.test.ts | 2 +- src/renderer/services/dialog-open.util.ts | 12 +++------ .../web-view-content-zoom.chrome-keys.test.ts | 12 +++++++++ .../web-view-content-zoom.chrome-keys.ts | 26 ++++++++++++------- src/shared/data/keyboard-shortcuts.data.ts | 8 +++--- src/stories/keyboard-shortcuts.data.test.ts | 11 ++++---- 6 files changed, 44 insertions(+), 27 deletions(-) diff --git a/src/main/platform-macos-menubar.data.test.ts b/src/main/platform-macos-menubar.data.test.ts index dad4f506f64..d6563299ed1 100644 --- a/src/main/platform-macos-menubar.data.test.ts +++ b/src/main/platform-macos-menubar.data.test.ts @@ -20,7 +20,7 @@ vi.mock('@shared/services/logger.service', () => ({ * trusting the (pre-existing) declared type. */ function isRecord(candidate: unknown): candidate is Record { - return !!candidate && typeof candidate === 'object'; + return !!candidate && typeof candidate === 'object' && !Array.isArray(candidate); } function isZeroArgClickHandler(candidate: unknown): candidate is () => void { diff --git a/src/renderer/services/dialog-open.util.ts b/src/renderer/services/dialog-open.util.ts index 1ac9a767cec..a0decb30fd7 100644 --- a/src/renderer/services/dialog-open.util.ts +++ b/src/renderer/services/dialog-open.util.ts @@ -1,15 +1,11 @@ -/** - * Whether a dialog is currently open in this window, composed from the two independent places a - * dialog can live: a live docked PAPI dialog request (`dialog.service-shard.ts`) or a modal overlay - * (`overlay-store.ts`). Callers that must not act while a dialog has focus (e.g. a window-chrome - * key listener) check this rather than either source alone. - */ - import { hasAnyDialogRequest } from '@renderer/services/dialog.service-shard'; import { hasOverlayOfType } from '@renderer/services/overlays/overlay-store'; /** - * Determine whether any dialog is open in this window + * Determine whether any dialog is open in this window, composed from the two independent places a + * dialog can live: a live docked PAPI dialog request (`dialog.service-shard.ts`) or a modal overlay + * (`overlay-store.ts`). Callers that must not act while a dialog has focus (e.g. a window-chrome + * key listener) should check this rather than either source alone. * * @returns True if there is a live docked dialog request or an active modal overlay; false * otherwise diff --git a/src/renderer/services/web-view-content-zoom.chrome-keys.test.ts b/src/renderer/services/web-view-content-zoom.chrome-keys.test.ts index 0188db16a42..60d667f8af6 100644 --- a/src/renderer/services/web-view-content-zoom.chrome-keys.test.ts +++ b/src/renderer/services/web-view-content-zoom.chrome-keys.test.ts @@ -94,6 +94,18 @@ describe('registerContentZoomChromeKeys', () => { expect(event.defaultPrevented).toBe(true); }); + it('does nothing on Ctrl+Shift+-, leaving it free for other uses', () => { + const event = dispatchKeyDown(document.body, { key: '-', ctrlKey: true, shiftKey: true }); + expect(adjustContentZoom).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('does nothing on Ctrl+Shift+0, leaving it free for other uses', () => { + const event = dispatchKeyDown(document.body, { key: '0', ctrlKey: true, shiftKey: true }); + expect(resetContentZoom).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + it('does not act when Alt is held, since Ctrl+Alt chords have their own meanings', () => { const event = dispatchKeyDown(document.body, { key: '=', ctrlKey: true, altKey: true }); expect(adjustContentZoom).not.toHaveBeenCalled(); diff --git a/src/renderer/services/web-view-content-zoom.chrome-keys.ts b/src/renderer/services/web-view-content-zoom.chrome-keys.ts index 5656d7f1c0a..2fb85f08e79 100644 --- a/src/renderer/services/web-view-content-zoom.chrome-keys.ts +++ b/src/renderer/services/web-view-content-zoom.chrome-keys.ts @@ -16,19 +16,26 @@ export type ContentZoomChromeKeysDeps = { isAnyDialogOpen: () => boolean; }; -/** - * Same modifier rule as the in-view bootstrap chords, except Shift is accepted here: `Ctrl+Shift+=` - * is how many keyboards type `Ctrl++`. Alt is still excluded, because Ctrl+Alt chords carry their - * own meanings. - */ +/** Ctrl (or ⌘) is required. Alt is excluded, because Ctrl+Alt chords carry their own meanings. */ function isChordModifier(e: KeyboardEvent): boolean { return (e.ctrlKey || e.metaKey) && !e.altKey; } +type ChordAction = 'in' | 'out' | 'reset'; + +/** + * Whether Shift may accompany the given action. Shift is accepted only for zoom-in, since + * `Ctrl+Shift+=` is how many keyboards type `Ctrl++`; a held Shift on the zoom-out or reset keys is + * rejected so `Ctrl+Shift+-` and `Ctrl+Shift+0` stay free for other handlers. + */ +function isAllowedShiftState(e: KeyboardEvent, action: ChordAction): boolean { + return !e.shiftKey || action === 'in'; +} + /** - * Whether the event target is a web view's iframe, or lives inside one. The in-view bootstrap - * script already turns these chords into zoom actions for a target inside a web view, so this - * listener must not act on the same keystroke a second time. + * Keystrokes inside a web view's iframe belong to the in-view bootstrap. They do not bubble into + * this document, so this is insurance against an iframe element itself (or a same-document node + * under one) being the target. */ function isInsideIframe(target: EventTarget | null): boolean { if (!(target instanceof Element)) return false; @@ -36,8 +43,6 @@ function isInsideIframe(target: EventTarget | null): boolean { return !!target.closest('iframe'); } -type ChordAction = 'in' | 'out' | 'reset'; - /** Keys exactly as the in-view bootstrap recognizes them. */ function actionFor(e: KeyboardEvent): ChordAction | undefined { if (e.key === '=' || e.key === '+' || e.code === 'NumpadAdd') return 'in'; @@ -68,6 +73,7 @@ export function registerContentZoomChromeKeys(deps: ContentZoomChromeKeysDeps): if (isInsideIframe(e.target)) return; const action = actionFor(e); if (!action) return; + if (!isAllowedShiftState(e, action)) return; e.preventDefault(); const promise = action === 'reset' diff --git a/src/shared/data/keyboard-shortcuts.data.ts b/src/shared/data/keyboard-shortcuts.data.ts index 669a616c508..a83697a24da 100644 --- a/src/shared/data/keyboard-shortcuts.data.ts +++ b/src/shared/data/keyboard-shortcuts.data.ts @@ -230,7 +230,7 @@ export const rootKeyboardShortcuts: KeyboardShortcutEntry[] = [ 'Zoom the focused zoom area of the pane in by 10 % (the area containing keyboard focus, else the area last used)', category: 'Zoom', context: - 'Inside any web view that marks at least one zoom area, the platform bootstrap handles the key and targets the area containing focus, else the area last clicked or focused. With keyboard focus on the window chrome (tab bar, reference box) the window-level listener targets the active tab’s active area and does nothing while a dialog is open. On macOS the View menu item carries the ⌘ accelerator.', + 'Inside a web view (the bootstrap targets the zoom area with focus, else the pane’s active area); on the window chrome (renderer listener, no-op while a dialog is open); or the macOS View menu', // The handler also accepts `=` (the unshifted key sharing the `+` cap), the numpad `+` key, and // Ctrl+Shift+`=` — the `+` key itself on US/UK layouts — so the published `Ctrl++` is literally the working chord. keys: { macOS: '⌘+', windows: 'Ctrl++', linux: 'Ctrl++' }, @@ -245,7 +245,8 @@ export const rootKeyboardShortcuts: KeyboardShortcutEntry[] = [ id: 'content-zoom-out', purpose: 'Zoom the focused zoom area of the pane out by 10 %', category: 'Zoom', - context: 'Same as content-zoom-in', + context: + 'Inside a web view (the bootstrap targets the zoom area with focus, else the pane’s active area); on the window chrome (renderer listener, no-op while a dialog is open); or the macOS View menu', // The handler also accepts the numpad `-` key. keys: { macOS: '⌘-', windows: 'Ctrl+-', linux: 'Ctrl+-' }, locations: [ @@ -260,7 +261,8 @@ export const rootKeyboardShortcuts: KeyboardShortcutEntry[] = [ purpose: 'Return the focused zoom area of the pane to the default zoom set in Settings (not to 100 %)', category: 'Zoom', - context: 'Same as content-zoom-in', + context: + 'Inside a web view (the bootstrap targets the zoom area with focus, else the pane’s active area); on the window chrome (renderer listener, no-op while a dialog is open); or the macOS View menu', // The handler also accepts the numpad `0` key. keys: { macOS: '⌘0', windows: 'Ctrl+0', linux: 'Ctrl+0' }, locations: [ diff --git a/src/stories/keyboard-shortcuts.data.test.ts b/src/stories/keyboard-shortcuts.data.test.ts index 56ff5e1c3a1..100156b5b4b 100644 --- a/src/stories/keyboard-shortcuts.data.test.ts +++ b/src/stories/keyboard-shortcuts.data.test.ts @@ -6,9 +6,8 @@ import { rootKeyboardShortcuts } from './keyboard-shortcuts.data'; const REPO_ROOT = path.resolve(__dirname, '../..'); /** - * The content-zoom chord handlers this file's Zoom category must cite once the chords move from the - * main-process app-wide zoom to per-pane content zoom: the in-view bootstrap, the window-chrome - * listener, the macOS View menu items, and the command router they all funnel through. + * The four handlers that implement the content-zoom chords; every Zoom-category chord entry must + * cite all of them. */ const CONTENT_ZOOM_CHORD_LOCATIONS = [ 'src/renderer/services/web-view-content-zoom.bootstrap-script.ts', @@ -23,7 +22,9 @@ function zoomEntries() { /** All (entry id, location) pairs across the Zoom category, for a per-pair existence check. */ function zoomEntryLocationPairs(): [entryId: string, location: string][] { - return zoomEntries().flatMap((entry) => entry.locations.map((location) => [entry.id, location])); + return zoomEntries().flatMap((entry) => + entry.locations.map((location): [string, string] => [entry.id, location]), + ); } describe('keyboard-shortcuts.data Zoom category', () => { @@ -38,7 +39,7 @@ describe('keyboard-shortcuts.data Zoom category', () => { 'documents all four content-zoom chord handlers on %s', (id) => { const entry = zoomEntries().find((zoomEntry) => zoomEntry.id === id); - expect(entry?.locations).toEqual(CONTENT_ZOOM_CHORD_LOCATIONS); + expect(entry?.locations).toEqual(expect.arrayContaining(CONTENT_ZOOM_CHORD_LOCATIONS)); }, ); From 6e281a8f34362fc294abaa44188b66481f456aec Mon Sep 17 00:00:00 2001 From: Rolf Heij Date: Mon, 14 Sep 2026 14:15:51 +0200 Subject: [PATCH 007/109] chore(types): regenerate papi.d.ts for the overlay-store dialog query Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Gkwyu1GZbkdGdPXaygcerV --- lib/papi-dts/papi.d.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index ca97fa6e6d4..c17a556a8e6 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -11110,6 +11110,13 @@ declare module 'renderer/services/overlays/overlay-store' { export function subscribe(listener: () => void): () => void; /** Get a specific overlay by id, or undefined if not found */ export function getOverlayById(id: string): OverlayEntry | undefined; + /** + * Determine whether at least one active overlay has the given type + * + * @param type The overlay type to check for (e.g. 'modalDialog') + * @returns True if an overlay of that type is currently active; false otherwise + */ + export function hasOverlayOfType(type: OverlayEntry['type']): boolean; /** * Get the most recently created overlay matching `predicate` — the topmost of the overlays it * accepts, since a newer overlay always renders over an older one. From 5a043bb4d6f7ed6b6a83aee98b747cd081dea6eb Mon Sep 17 00:00:00 2001 From: Rolf Heij Date: Mon, 14 Sep 2026 15:16:16 +0200 Subject: [PATCH 008/109] feat(zoom): gate the content-zoom target resolver while a dialog is open Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Gkwyu1GZbkdGdPXaygcerV --- src/renderer/index.tsx | 1 + .../web-view-content-zoom.service.test.ts | 17 +++++++++ .../services/web-view-content-zoom.service.ts | 38 +++++++++++++------ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index b00c0fa8b17..f99dc7a5f4e 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -149,6 +149,7 @@ initConnectionLostService(); getAllOpenDefinitions: getAllOpenWebViewDefinitionsSync, onDidUpdateWebView, getLastFocusedTabId, + isAnyDialogOpen, }).catch((e) => logger.warn(`Content zoom service failed to initialize: ${getErrorMessage(e)}`), ); diff --git a/src/renderer/services/web-view-content-zoom.service.test.ts b/src/renderer/services/web-view-content-zoom.service.test.ts index 660c4eaacba..edc1fdc6d30 100644 --- a/src/renderer/services/web-view-content-zoom.service.test.ts +++ b/src/renderer/services/web-view-content-zoom.service.test.ts @@ -83,6 +83,7 @@ describe('web-view-content-zoom.service', () => { let iframe: HTMLIFrameElement; const showIndicator = vi.fn(); let lastFocused: string | undefined; + let dialogOpen = false; /** One iframe per pane, since the production `getIframe` is keyed by web view id. */ const iframes = new Map(); function iframeFor(webViewId: string): HTMLIFrameElement { @@ -107,6 +108,7 @@ describe('web-view-content-zoom.service', () => { showIndicator.mockClear(); vi.mocked(logger.warn).mockClear(); lastFocused = undefined; + dialogOpen = false; document.body.innerHTML = ''; iframes.clear(); iframe = iframeFor('editor-1'); @@ -126,6 +128,7 @@ describe('web-view-content-zoom.service', () => { return () => false; }, getLastFocusedTabId: () => lastFocused, + isAnyDialogOpen: () => dialogOpen, settings: { get: async (key: string) => settings[key], set: settingsSet, @@ -154,6 +157,13 @@ describe('web-view-content-zoom.service', () => { expect(resolveContentZoomTarget(undefined)).toBeUndefined(); }); + it('resolves nothing while a dialog is open, whether or not an explicit id or a last-focused tab exists', () => { + lastFocused = 'editor-1'; + dialogOpen = true; + expect(resolveContentZoomTarget('editor-1')).toBeUndefined(); + expect(resolveContentZoomTarget(undefined)).toBeUndefined(); + }); + it('resolves the area: explicit and known → itself; unknown → nothing; none given → active, else first', () => { expect(resolveContentZoomArea('editor-1', 'footnotes')).toBe('footnotes'); expect(resolveContentZoomArea('editor-1', 'sidebar')).toBeUndefined(); @@ -827,6 +837,13 @@ describe('web-view-content-zoom.service', () => { expect(showIndicator).not.toHaveBeenCalled(); }); + it('does nothing while a dialog is open', async () => { + dialogOpen = true; + await adjustContentZoom('editor-1', 1); + expect(updateDefinition).not.toHaveBeenCalled(); + expect(showIndicator).not.toHaveBeenCalled(); + }); + it('does nothing and does not throw when the definition has vanished for a pane with reported areas', async () => { setContentZoomAreas('ghost', ['main']); await expect(adjustContentZoom('ghost', 1, 'main')).resolves.toBeUndefined(); diff --git a/src/renderer/services/web-view-content-zoom.service.ts b/src/renderer/services/web-view-content-zoom.service.ts index 26d4b092a4a..5408bd9c0ce 100644 --- a/src/renderer/services/web-view-content-zoom.service.ts +++ b/src/renderer/services/web-view-content-zoom.service.ts @@ -52,6 +52,7 @@ type ContentZoomDeps = { callback: (event: { webView: SavedWebViewDefinition }) => void, ) => Unsubscriber; getLastFocusedTabId: () => string | undefined; + isAnyDialogOpen: () => boolean; settings: { get: (key: SettingKey) => Promise; set: (key: SettingKey, value: unknown) => Promise; @@ -64,10 +65,11 @@ type ContentZoomDeps = { }; /** - * Whether {@link warnShardDepsNotConfigured} has already logged. These five functions come from the - * renderer's two window-scoped shards and only exist once the composition root - * (`src/renderer/index.tsx`) calls {@link initializeContentZoomService} with them; a call routed - * through one of the stubs below before that happens is worth one warning, not one per call. + * Whether {@link warnShardDepsNotConfigured} has already logged. These six functions — five from the + * renderer's two window-scoped shards, plus `isAnyDialogOpen` from the dialog-open util — only + * exist once the composition root (`src/renderer/index.tsx`) calls + * {@link initializeContentZoomService} with them; a call routed through one of the stubs below + * before that happens is worth one warning, not one per call. */ let hasWarnedShardDepsNotConfigured = false; @@ -81,10 +83,12 @@ function warnShardDepsNotConfigured(): void { const productionDeps: ContentZoomDeps = { getIframe: getWebViewIframe, - // The five functions below come from the renderer's web-view and window shards. Importing them - // here directly would create an import cycle (both shards import from this module), so the - // renderer's composition root injects its own functions through `initializeContentZoomService` - // instead; these stubs cover the window between module load and that call. + // The six functions below come from the renderer's web-view and window shards, plus the + // dialog-open util. Importing any of them here directly would create an import cycle (the + // dialog-open util's own dependency, `dialog.service-shard`, imports the web-view shard, which + // imports this module), so the renderer's composition root injects its own functions through + // `initializeContentZoomService` instead; these stubs cover the window between module load and + // that call. getDefinition: () => { warnShardDepsNotConfigured(); return undefined; @@ -105,6 +109,10 @@ const productionDeps: ContentZoomDeps = { warnShardDepsNotConfigured(); return undefined; }, + isAnyDialogOpen: () => { + warnShardDepsNotConfigured(); + return false; + }, settings: { get: (key) => settingsService.get(key), set: (key, value) => @@ -310,10 +318,14 @@ function effectiveOwnLevels( return pendingOwnLevels.get(definition.id) ?? getOwnLevels(definition); } -/** Explicit id → the window's last focused tab → nothing. Exported for tests. */ +/** + * Explicit id → the window's last focused tab → nothing, or nothing while a dialog is open. + * Exported for tests. + */ export function resolveContentZoomTarget( explicitWebViewId: string | undefined, ): WebViewId | undefined { + if (deps.isAnyDialogOpen()) return undefined; if (explicitWebViewId) return explicitWebViewId; return deps.getLastFocusedTabId(); } @@ -1141,9 +1153,10 @@ async function subscribeToMemory(): Promise { * * @param shardDeps The renderer's composition root supplies the web-view and window shards' * `getDefinition`, `updateDefinition`, `getAllOpenDefinitions`, `onDidUpdateWebView` and - * `getLastFocusedTabId` here rather than this module importing them directly — both shards import - * from this module, so a direct import back would create a cycle. Merged into the deps in use - * whenever provided, even on a later call after the first initialization already ran. + * `getLastFocusedTabId` here, plus `isAnyDialogOpen` from the dialog-open util, rather than this + * module importing any of them directly — each would create an import cycle back through the + * shards. Merged into the deps in use whenever provided, even on a later call after the first + * initialization already ran. */ export function initializeContentZoomService( shardDeps?: Pick< @@ -1153,6 +1166,7 @@ export function initializeContentZoomService( | 'getAllOpenDefinitions' | 'onDidUpdateWebView' | 'getLastFocusedTabId' + | 'isAnyDialogOpen' >, ): Promise { if (shardDeps) deps = { ...deps, ...shardDeps }; From 685305e78a3c0ba8bab237d078e45212df0af265 Mon Sep 17 00:00:00 2001 From: Rolf Heij Date: Mon, 14 Sep 2026 17:36:38 +0200 Subject: [PATCH 009/109] docs(zoom): app-zoom command docs describe the chord move; chord-rule parity test Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Gkwyu1GZbkdGdPXaygcerV --- lib/papi-dts/papi.d.ts | 12 +- src/declarations/papi-shared-types.ts | 12 +- src/main/main.ts | 8 +- ...ontent-zoom.bootstrap-script.test-utils.ts | 62 +++++++ ...view-content-zoom.bootstrap-script.test.ts | 54 +----- .../web-view-content-zoom.bootstrap-script.ts | 5 + ...web-view-content-zoom.chord-parity.test.ts | 158 ++++++++++++++++++ .../web-view-content-zoom.chrome-keys.ts | 8 +- 8 files changed, 250 insertions(+), 69 deletions(-) create mode 100644 src/renderer/services/web-view-content-zoom.bootstrap-script.test-utils.ts create mode 100644 src/renderer/services/web-view-content-zoom.chord-parity.test.ts diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index c17a556a8e6..c8c443b2729 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -5691,15 +5691,15 @@ declare module 'papi-shared-types' { */ 'platform.getWindows': () => Promise; /** - * Increase the zoom level of the entire UI, including menus and toolbars, by 10 %. On Windows - * and Linux, Ctrl+`=` / Ctrl+`+` invoke this until PT-4577 hands those chords to per-pane - * content zoom (`platform.webViewContentZoomIn`). + * Increase the zoom level of the entire UI, including menus and toolbars, by 10 %. Has no + * default keyboard shortcut: the Ctrl/⌘ `+`/`-`/`0` chords belong to per-pane content zoom + * (`platform.webViewContentZoomIn` / `…Out` / `…Reset`). */ 'platform.zoomIn': () => Promise; /** - * Decrease the zoom level of the entire UI, including menus and toolbars, by 10 %. On Windows - * and Linux, Ctrl+`-` invokes this until PT-4577 hands that chord to per-pane content zoom - * (`platform.webViewContentZoomOut`). + * Decrease the zoom level of the entire UI, including menus and toolbars, by 10 %. Has no + * default keyboard shortcut: the Ctrl/⌘ `+`/`-`/`0` chords belong to per-pane content zoom + * (`platform.webViewContentZoomIn` / `…Out` / `…Reset`). */ 'platform.zoomOut': () => Promise; /** diff --git a/src/declarations/papi-shared-types.ts b/src/declarations/papi-shared-types.ts index c9723777854..7b2ea7f6562 100644 --- a/src/declarations/papi-shared-types.ts +++ b/src/declarations/papi-shared-types.ts @@ -113,15 +113,15 @@ declare module 'papi-shared-types' { */ 'platform.getWindows': () => Promise; /** - * Increase the zoom level of the entire UI, including menus and toolbars, by 10 %. On Windows - * and Linux, Ctrl+`=` / Ctrl+`+` invoke this until PT-4577 hands those chords to per-pane - * content zoom (`platform.webViewContentZoomIn`). + * Increase the zoom level of the entire UI, including menus and toolbars, by 10 %. Has no + * default keyboard shortcut: the Ctrl/⌘ `+`/`-`/`0` chords belong to per-pane content zoom + * (`platform.webViewContentZoomIn` / `…Out` / `…Reset`). */ 'platform.zoomIn': () => Promise; /** - * Decrease the zoom level of the entire UI, including menus and toolbars, by 10 %. On Windows - * and Linux, Ctrl+`-` invokes this until PT-4577 hands that chord to per-pane content zoom - * (`platform.webViewContentZoomOut`). + * Decrease the zoom level of the entire UI, including menus and toolbars, by 10 %. Has no + * default keyboard shortcut: the Ctrl/⌘ `+`/`-`/`0` chords belong to per-pane content zoom + * (`platform.webViewContentZoomIn` / `…Out` / `…Reset`). */ 'platform.zoomOut': () => Promise; /** diff --git a/src/main/main.ts b/src/main/main.ts index ac8435f4c5b..15c850e4194 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -2789,8 +2789,8 @@ async function main() { method: { summary: 'Increase the zoom level of the entire UI, including menus and toolbars, by 10 %. ' + - 'On Windows and Linux, Ctrl+= / Ctrl++ invoke this until PT-4577 hands those chords ' + - 'to per-pane content zoom (platform.webViewContentZoomIn).', + 'Has no default keyboard shortcut: the Ctrl/⌘ +/-/0 chords belong to per-pane content ' + + 'zoom (platform.webViewContentZoomIn / …Out / …Reset).', params: [], result: { name: 'return value', @@ -2809,8 +2809,8 @@ async function main() { method: { summary: 'Decrease the zoom level of the entire UI, including menus and toolbars, by 10 %. ' + - 'On Windows and Linux, Ctrl+- invokes this until PT-4577 hands that chord to per-pane ' + - 'content zoom (platform.webViewContentZoomOut).', + 'Has no default keyboard shortcut: the Ctrl/⌘ +/-/0 chords belong to per-pane content ' + + 'zoom (platform.webViewContentZoomIn / …Out / …Reset).', params: [], result: { name: 'return value', diff --git a/src/renderer/services/web-view-content-zoom.bootstrap-script.test-utils.ts b/src/renderer/services/web-view-content-zoom.bootstrap-script.test-utils.ts new file mode 100644 index 00000000000..b5a04bd1dbe --- /dev/null +++ b/src/renderer/services/web-view-content-zoom.bootstrap-script.test-utils.ts @@ -0,0 +1,62 @@ +import { vi } from 'vitest'; +import { + getContentZoomBootstrapScript, + getContentZoomStyleElement, +} from './web-view-content-zoom.bootstrap-script'; + +export type PapiLike = { + commands: { sendCommand: ReturnType }; + logger: { warn: ReturnType }; +}; + +export type Bound = { + adjustContentZoomById: ReturnType; + resetContentZoomById: ReturnType; + reportContentZoomAreasById: ReturnType; + reportContentZoomActiveAreaById: ReturnType; +}; + +/** + * Installs the bootstrap script into the current jsdom document exactly as it runs inside a web + * view: injected as source text and evaluated, not imported as a module. Shared by the bootstrap + * script's own behavior tests (`web-view-content-zoom.bootstrap-script.test.ts`) and the + * chrome-keys/bootstrap chord-rule parity test (`web-view-content-zoom.chord-parity.test.ts`), + * which drives this installation alongside `registerContentZoomChromeKeys` and compares the action + * each one takes for the same keystroke. + */ +export function install( + webViewId: string, + html: string, + bound?: Partial, + levels: { [areaId: string]: number } = {}, +): { papi: PapiLike; bound: Bound } { + document.head.innerHTML = getContentZoomStyleElement('n', 1, levels); + document.body.innerHTML = html; + const papi: PapiLike = { + commands: { sendCommand: vi.fn(async () => undefined) }, + logger: { warn: vi.fn() }, + }; + const allBound: Bound = { + adjustContentZoomById: vi.fn(), + resetContentZoomById: vi.fn(), + reportContentZoomAreasById: vi.fn(), + reportContentZoomActiveAreaById: vi.fn(), + ...bound, + }; + Object.assign(window, { papi, webViewId, __platformContentZoom: undefined, ...allBound }); + // Exercises the bootstrap exactly as it runs inside a web view: injected as source text and + // evaluated, not imported as a module. + // eslint-disable-next-line no-new-func + new Function(getContentZoomBootstrapScript(webViewId))(); + return { papi, bound: allBound }; +} + +declare global { + interface Window { + __platformContentZoom?: { + showIndicator: (areaId: string, text: string) => void; + destroy: () => void; + activeArea?: string; + }; + } +} diff --git a/src/renderer/services/web-view-content-zoom.bootstrap-script.test.ts b/src/renderer/services/web-view-content-zoom.bootstrap-script.test.ts index d4cb05f279b..14202024926 100644 --- a/src/renderer/services/web-view-content-zoom.bootstrap-script.test.ts +++ b/src/renderer/services/web-view-content-zoom.bootstrap-script.test.ts @@ -1,54 +1,14 @@ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - getContentZoomBootstrapScript, - getContentZoomStyleElement, -} from './web-view-content-zoom.bootstrap-script'; - -type PapiLike = { - commands: { sendCommand: ReturnType }; - logger: { warn: ReturnType }; -}; -type Bound = { - adjustContentZoomById: ReturnType; - resetContentZoomById: ReturnType; - reportContentZoomAreasById: ReturnType; - reportContentZoomActiveAreaById: ReturnType; -}; +import { getContentZoomStyleElement } from './web-view-content-zoom.bootstrap-script'; +import { install } from './web-view-content-zoom.bootstrap-script.test-utils'; const TWO_AREAS = '
bar
' + '

text

' + ''; -function install( - webViewId: string, - html: string, - bound?: Partial, - levels: { [areaId: string]: number } = {}, -): { papi: PapiLike; bound: Bound } { - document.head.innerHTML = getContentZoomStyleElement('n', 1, levels); - document.body.innerHTML = html; - const papi: PapiLike = { - commands: { sendCommand: vi.fn(async () => undefined) }, - logger: { warn: vi.fn() }, - }; - const allBound: Bound = { - adjustContentZoomById: vi.fn(), - resetContentZoomById: vi.fn(), - reportContentZoomAreasById: vi.fn(), - reportContentZoomActiveAreaById: vi.fn(), - ...bound, - }; - Object.assign(window, { papi, webViewId, __platformContentZoom: undefined, ...allBound }); - // Exercises the bootstrap exactly as it runs inside a web view: injected as source text and - // evaluated, not imported as a module. - // eslint-disable-next-line no-new-func - new Function(getContentZoomBootstrapScript(webViewId))(); - return { papi, bound: allBound }; -} - function key(init: KeyboardEventInit, target: EventTarget = window): KeyboardEvent { const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }); target.dispatchEvent(event); @@ -1279,13 +1239,3 @@ describe('content-zoom bootstrap script', () => { expect(selectors.some((selector) => byId('nestedMain').matches(selector))).toBe(false); }); }); - -declare global { - interface Window { - __platformContentZoom?: { - showIndicator: (areaId: string, text: string) => void; - destroy: () => void; - activeArea?: string; - }; - } -} diff --git a/src/renderer/services/web-view-content-zoom.bootstrap-script.ts b/src/renderer/services/web-view-content-zoom.bootstrap-script.ts index f9c12ce1398..a03daa1a5fa 100644 --- a/src/renderer/services/web-view-content-zoom.bootstrap-script.ts +++ b/src/renderer/services/web-view-content-zoom.bootstrap-script.ts @@ -307,6 +307,11 @@ export function getContentZoomBootstrapScript(webViewId: string): string { warnPapi('Content zoom command ' + command + ' threw: ' + (e && e.message ? e.message : e)); } }; + // The keydown chord rule below mirrors web-view-content-zoom.chrome-keys.ts's + // isChordModifier / isAllowedShiftState / actionFor. This script is serialized to a string + // and cannot import that module, so the two copies are independently maintained - change + // both together. web-view-content-zoom.chord-parity.test.ts is the guard that keeps them + // in sync. const hasModifier = (e) => (e.ctrlKey || e.metaKey) && !e.altKey; const onKeyDown = (e) => { diff --git a/src/renderer/services/web-view-content-zoom.chord-parity.test.ts b/src/renderer/services/web-view-content-zoom.chord-parity.test.ts new file mode 100644 index 00000000000..e41443f7d2e --- /dev/null +++ b/src/renderer/services/web-view-content-zoom.chord-parity.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CONTENT_ZOOM_COMMANDS } from '@shared/models/content-zoom.model'; + +vi.mock('@shared/services/logger.service', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +// One of the two modules under test, imported after the mock above is established. +// eslint-disable-next-line import/first +import { registerContentZoomChromeKeys } from './web-view-content-zoom.chrome-keys'; +// The other module under test; it does not itself touch the logger, but stays below the mock so +// both imports read as one group. +// eslint-disable-next-line import/first +import { install } from './web-view-content-zoom.bootstrap-script.test-utils'; + +type ChordAction = 'in' | 'out' | 'reset'; + +type ModifierCase = { + label: string; + ctrlKey?: boolean; + metaKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; +}; + +/** + * Every modifier combination the chord rule branches on: no modifier (rejected), Ctrl and Meta (the + * two accepted chord modifiers), each with Shift added, and each with Alt added (which rejects the + * chord regardless of Ctrl/Meta or Shift). + */ +const MODIFIER_CASES: ModifierCase[] = [ + { label: 'none' }, + { label: 'ctrl', ctrlKey: true }, + { label: 'meta', metaKey: true }, + { label: 'ctrl+shift', ctrlKey: true, shiftKey: true }, + { label: 'ctrl+alt', ctrlKey: true, altKey: true }, + { label: 'meta+shift', metaKey: true, shiftKey: true }, + { label: 'ctrl+alt+shift', ctrlKey: true, altKey: true, shiftKey: true }, +]; + +type KeyCase = { + label: string; + init: KeyboardEventInit; + /** The action this key maps to before the modifier rule (Ctrl/Meta, Alt, Shift) is applied. */ + baseAction: ChordAction | undefined; +}; + +const KEY_CASES: KeyCase[] = [ + { label: '=', init: { key: '=' }, baseAction: 'in' }, + { label: '+', init: { key: '+' }, baseAction: 'in' }, + { label: 'NumpadAdd (code)', init: { code: 'NumpadAdd' }, baseAction: 'in' }, + { label: '-', init: { key: '-' }, baseAction: 'out' }, + { label: 'NumpadSubtract (code)', init: { code: 'NumpadSubtract' }, baseAction: 'out' }, + { label: '0', init: { key: '0' }, baseAction: 'reset' }, + { label: 'Numpad0 (code)', init: { code: 'Numpad0' }, baseAction: 'reset' }, + { label: 'k (unrelated)', init: { key: 'k' }, baseAction: undefined }, +]; + +/** + * The chord rule stated independently of both `isChordModifier`/`isAllowedShiftState`/`actionFor` + * (chrome-keys.ts) and the bootstrap script's own `hasModifier` + keydown handler: Ctrl or ⌘ is + * required and Alt rejects the chord outright; Shift is accepted only when the key's own action is + * zoom-in (`Ctrl+Shift+=` is how many keyboards type `Ctrl++`). + */ +function expectedAction(keyCase: KeyCase, modifiers: ModifierCase): ChordAction | undefined { + const hasChordModifier = (modifiers.ctrlKey || modifiers.metaKey) && !modifiers.altKey; + if (!hasChordModifier) return undefined; + if (modifiers.shiftKey && keyCase.baseAction !== 'in') return undefined; + return keyCase.baseAction; +} + +const TABLE = KEY_CASES.flatMap((keyCase) => + MODIFIER_CASES.map((modifiers) => ({ + keyCase, + modifiers, + expected: expectedAction(keyCase, modifiers), + })), +); + +function eventInit(keyCase: KeyCase, modifiers: ModifierCase): KeyboardEventInit { + return { + bubbles: true, + cancelable: true, + ...keyCase.init, + ctrlKey: modifiers.ctrlKey ?? false, + metaKey: modifiers.metaKey ?? false, + shiftKey: modifiers.shiftKey ?? false, + altKey: modifiers.altKey ?? false, + }; +} + +/** Reads the action `registerContentZoomChromeKeys` took from which injected dep it called. */ +function chromeKeysAction( + adjustContentZoom: ReturnType, + resetContentZoom: ReturnType, +): ChordAction | undefined { + if (resetContentZoom.mock.calls.length > 0) return 'reset'; + const [call] = adjustContentZoom.mock.calls; + if (!call) return undefined; + const [, deltaSteps] = call; + return deltaSteps === 1 ? 'in' : 'out'; +} + +/** + * Reads the action the bootstrap script took from the command it sent through + * `papi.commands.sendCommand` — `install` below leaves both bound helpers unset so the bootstrap + * always falls back to the command, the same wire-level name a real web view without an in-process + * shard would send. + */ +function bootstrapAction(sendCommand: ReturnType): ChordAction | undefined { + const [call] = sendCommand.mock.calls; + if (!call) return undefined; + const [command] = call; + if (command === CONTENT_ZOOM_COMMANDS.in) return 'in'; + if (command === CONTENT_ZOOM_COMMANDS.out) return 'out'; + if (command === CONTENT_ZOOM_COMMANDS.reset) return 'reset'; + throw new Error(`unexpected content-zoom command: ${command}`); +} + +const SINGLE_AREA = + '

t

'; + +describe('content-zoom chord rule parity: window-chrome keys vs. in-view bootstrap', () => { + afterEach(() => { + // Unwinds the bootstrap instance a row installed, matching the teardown + // web-view-content-zoom.bootstrap-script.test.ts uses for the same reason: without it the + // mutation observer it started outlives the test and the next row's `install` leaks a second + // window keydown listener alongside it. + // eslint-disable-next-line no-underscore-dangle + window.__platformContentZoom?.destroy(); + }); + + it.each(TABLE)( + 'key=$keyCase.label modifiers=$modifiers.label -> $expected', + ({ keyCase, modifiers, expected }) => { + const adjustContentZoom = vi.fn().mockResolvedValue(undefined); + const resetContentZoom = vi.fn().mockResolvedValue(undefined); + const unsubscribe = registerContentZoomChromeKeys({ + adjustContentZoom, + resetContentZoom, + isAnyDialogOpen: () => false, + }); + try { + document.body.dispatchEvent(new KeyboardEvent('keydown', eventInit(keyCase, modifiers))); + expect(chromeKeysAction(adjustContentZoom, resetContentZoom)).toBe(expected); + } finally { + unsubscribe(); + } + + const { papi } = install('wv-chord-parity', SINGLE_AREA, { + adjustContentZoomById: undefined, + resetContentZoomById: undefined, + }); + window.dispatchEvent(new KeyboardEvent('keydown', eventInit(keyCase, modifiers))); + expect(bootstrapAction(papi.commands.sendCommand)).toBe(expected); + }, + ); +}); diff --git a/src/renderer/services/web-view-content-zoom.chrome-keys.ts b/src/renderer/services/web-view-content-zoom.chrome-keys.ts index 2fb85f08e79..0854662d539 100644 --- a/src/renderer/services/web-view-content-zoom.chrome-keys.ts +++ b/src/renderer/services/web-view-content-zoom.chrome-keys.ts @@ -43,7 +43,13 @@ function isInsideIframe(target: EventTarget | null): boolean { return !!target.closest('iframe'); } -/** Keys exactly as the in-view bootstrap recognizes them. */ +/** + * Keys exactly as the in-view bootstrap script's own keydown handler recognizes them (`onKeyDown` + * inside `getContentZoomBootstrapScript` in `web-view-content-zoom.bootstrap-script.ts`). That + * script is serialized to a string and cannot import this module, so the two copies are + * independently maintained — change both together. `web-view-content-zoom.chord-parity.test.ts` is + * the guard that keeps them in sync. + */ function actionFor(e: KeyboardEvent): ChordAction | undefined { if (e.key === '=' || e.key === '+' || e.code === 'NumpadAdd') return 'in'; if (e.key === '-' || e.code === 'NumpadSubtract') return 'out'; From 4d99fc8a35e2c9fc5d081f110a43d60fd05e4d2b Mon Sep 17 00:00:00 2001 From: Rolf Heij Date: Tue, 15 Sep 2026 10:09:24 +0200 Subject: [PATCH 010/109] docs(zoom): document the ContentZoomRoot opt-in for extension authors Co-Authored-By: Claude Fable 5.1 --- .context/standards/Architecture-Decisions.md | 133 ++++++++++++++++++ .../standards/Component-Builder-Patterns.md | 43 +++++- .../standards/Extension-Development-Guide.md | 36 ++++- .context/standards/Paranext-Core-Patterns.md | 21 +++ 4 files changed, 229 insertions(+), 4 deletions(-) diff --git a/.context/standards/Architecture-Decisions.md b/.context/standards/Architecture-Decisions.md index b1cc2cc533a..7d7d984fa97 100644 --- a/.context/standards/Architecture-Decisions.md +++ b/.context/standards/Architecture-Decisions.md @@ -4052,6 +4052,44 @@ and the rename lands with the `ProjectSelector` migration (PT-4549). Both names - **Source:** PT-4341 "Open Find from any scripture tab type" (PR #2677) — review finding that the branch diverged from `adr-app-global-shortcuts-in-main` without recording why. +## adr-per-web-view-state-lives-in-the-definition: Per-web-view state lives in the web view definition; user defaults and cross-instance memory live in settings + +- **Date:** 2026-09-15 +- **Status:** Accepted +- **Context:** Per-pane content zoom (epic PT-4575) needs three different lifetimes for one number: + the level the pane on screen is at now, the level to hand the next pane of the same project and + kind, and the user's default for panes that have never been zoomed. The platform already has a + home for the first — a web view's own `state` inside its `SavedWebViewDefinition`, written through + `updateWebViewDefinition` and read back in a view with `useWebViewState`. That store is the one the + dock layout serializes, so it survives a restart and travels with the tab when the tab moves to + another window (`adr-web-view-ids-are-unique-from-birth`, `adr-durable-window-ids`). An April + prototype of this feature (PR #2211) instead kept a `Record` inside a renderer + zoom service and mirrored it into a hidden setting, making the pane's own level a second copy that + had to be reconciled with the definition on every open, move, reload and close. +- **Decision:** Per-web-view state of any kind lives in that web view's `SavedWebViewDefinition.state`, + written with `updateWebViewDefinition`. State that must outlive a single pane — a user-level + default, or "what this project's editor was last set to" — lives in **user settings**: a visible + setting for the default the user controls, a hidden one for the memory. Services keep no parallel + store keyed by web view id. Content zoom is the first feature written to the rule: the pane's own + levels are `platform.contentZoomLevels` in the definition state, the default and the per-project + memory are `platform.webViewContentZoom` and `platform.webViewContentZoomMemory` + (`src/renderer/services/web-view-content-zoom.service.ts`). +- **Alternatives:** (a) **A per-view service with its own hidden `Record` store** — the April + content-zoom prototype, PR #2211 — rejected: two stores for one value, and the one that is not the + definition has to be taught by hand about every lifecycle event the definition gets for free. + (b) **Everything in settings, keyed by web view id** — rejected: ids are minted per pane and never + reused, so such a setting grows without bound and needs a pruning story; that cost is accepted only + for the memory setting, which is keyed by project and kind rather than by pane, and even there + pruning is its own work item (PT-4585). (c) **Everything in the definition, nothing in settings** — + rejected: a level that dies with its pane cannot answer "the same zoom next time I open this + project", which is what Paratext 9 does (`ParatextBase/DefaultZoomMemento.cs`). +- **Consequences:** Per-pane state travels through restart, window move and layout share for free, and + a feature that wants it writes no storage code. In exchange, definition state is semi-public — it + is visible in a shared layout and readable by the view itself — so nothing sensitive belongs there, + and every key needs a name that cannot collide, hence the `platform.`-prefixed key. **Revisit** if a + per-pane value ever grows larger than a layout file should reasonably carry. +- **Source:** PT-4576 (PR #2803) and PT-4580, epic PT-4575; the rejected alternative is PR #2211. + ## adr-per-window-focus-ring-keys-off-broadcast-window-id: A per-window focus ring keys off a main-broadcast window id, not local DOM focus - **Date:** 2026-09-03 @@ -6697,6 +6735,58 @@ and the rename lands with the `ProjectSelector` migration (PT-4549). Both names - **Source:** the multi-agent review of #2654 and the follow-up decision on its finding about `policyRemedy`. +## adr-web-view-content-zoom-in-iframe-shortcuts: Content-zoom chords are handled by a platform-injected bootstrap inside each web view + +- **Date:** 2026-09-15 +- **Status:** Accepted (narrows `adr-app-global-shortcuts-in-main`; refines `adr-per-web-view-ctrl-f-for-find`) +- **Context:** Content zoom (epic PT-4575) is scoped to one **pane**, and a pane may hold several + independently zoomable areas — the Scripture editor's text and its footnotes list. Acting on the + right one needs the id of the web view the user is in and the area holding the caret or the + pointer. The main process, which claimed Ctrl+`+`/`-`/`0` in its `before-input-event` handler for + app-wide zoom, has neither: it can name the focused window, not the focused tab, and it cannot see + which element inside an `about:srcdoc` iframe has focus. `adr-per-web-view-ctrl-f-for-find` already + settled this shape of problem for Ctrl+F by moving the listener into the view and holding it in one + shared hook — but that hook is extension code every view has to mount, and content zoom has to work + for views that know nothing about it, including plain-HTML ones. +- **Decision:** The chords are handled inside each web view by the **platform's own injected + bootstrap** (`getContentZoomBootstrapScript` in + `src/renderer/services/web-view-content-zoom.bootstrap-script.ts`), appended to the script the + renderer already injects into every non-URL web view. It targets the area holding keyboard focus, + else the area under the pointer for the wheel, else the pane's last active area, and acts with the + pane's own id. This is `adr-per-web-view-ctrl-f-for-find`'s shared hook taken one step further: the + shared handler is platform-injected, so a view opts in by marking **content**, not by mounting + code. The bootstrap prefers in-process functions the window's web-view shard binds on the iframe's + `window` and falls back to the three PAPI commands `platform.webViewContentZoomIn` / `…Out` / + `…Reset`, which keeps `adr-renderer-registers-no-names` intact — the names are registered by the + router, not by the renderer — and keeps a wheel burst off the network. Main gives up the chords in + PT-4577 (PR #2809, not yet merged at the time of writing — until it lands, Windows and Linux still + swallow them in `before-input-event`): the three zoom branches are deleted, `platform.zoomIn` and + `platform.zoomOut` stay as commands with no default chord, macOS binds explicit View-menu items + carrying ⌘=/⌘-/⌘0 in place of the native `zoomIn`/`zoomOut`/`resetZoom` roles, and a renderer + top-document `keydown` listener covers the case where keyboard focus is on window chrome — the tab + bar, the reference box, a toolbar button — where no iframe sees the key at all. +- **Alternatives:** (a) **Keep it in main and route to the focused window** — rejected: main knows the + window, not the pane and not the area; the same objection `adr-per-web-view-ctrl-f-for-find` + records. (b) **A renderer window-level listener that forwards keys into the right iframe** — + rejected as the general mechanism: it is focus-blind, since the renderer cannot see which element + inside a sandboxed iframe holds the caret, and it adds a hop to keep in sync. It survives only as + the narrow window-chrome case above, where there is no iframe to ask. (c) **A shared React hook + every view mounts**, as Find does — rejected here: it would make zoom an opt-in for *code* rather + than for *content*, it would repeat the coverage gap that decision already names, and plain-HTML + views could not opt in at all. +- **Consequences:** A view that marks no zoom area ignores the chords entirely — the bootstrap does + not even register its wheel listener while a pane has no areas — and the platform scales such a view + whole at the Settings default instead. The bootstrap listens in the **bubble** phase on purpose, so + a view that owns Ctrl+wheel for a sub-region keeps precedence by stopping propagation in the capture + phase; the Text Collection grid's per-resource zoom does exactly that + (`extensions/src/platform-scripture-editor/src/scripture-text-grid/use-resource-zoom-input.hook.ts`). + On macOS the chord arrives through the menu accelerator rather than through the iframe, so it + resolves the pane's *active* area rather than the area holding the caret; the bootstrap's `focusin` + tracking keeps the two equal in practice. **Revisit** if a second platform-injected shortcut appears + — two bootstraps competing for one key would want a shared dispatcher rather than two listeners. +- **Source:** PT-4576 (PR #2803, the bootstrap and the injected stylesheet) and PT-4577 (PR #2809, + chord ownership), epic PT-4575. + ## adr-web-view-error-boundary-placement: Web views get one error boundary at the shared mount point, not one per extension - **Date:** 2026-08-27 @@ -7092,3 +7182,46 @@ and the rename lands with the `ProjectSelector` migration (PT-4549). Both names drift left by PR #2365 (silently raised `Z_INDEX_ABOVE_DOCK` 250 → 600, burying every tooltip) and PR #2229 (placed a menu at `Z_INDEX_OVERLAY` underneath its own `Z_INDEX_ABOVE_DOCK` host) by adding the ordering tests in `z-index.test.tsx` and this decision record. + +## adr-zoom-composition: A pane shows Electron zoom × project font size × content zoom, and content zoom is CSS `zoom` on marked areas + +- **Date:** 2026-09-15 +- **Status:** Accepted +- **Context:** Platform.Bible had one scaling control — the app-wide Electron zoom behind the + `platform.zoomFactor` setting, which scales the chrome along with everything else — plus a + project-level font size the editor's Standard view honours, plus two private per-view zooms that + grew where the platform had nothing to offer (the Text Collection grid's per-resource factor, and + the Enhanced Resources viewer's own handler). Adding a per-pane zoom on top of that raises two + questions at once: what the layers multiply out to on screen, and what the new layer is implemented + with. Spikes S1 and S2 of the epic's investigation measured the candidates on the real Scripture + editor. +- **Decision:** On screen a pane shows **Electron zoom × project font size × content zoom** — the + content zoom of the area, its own level if it has one, otherwise the Settings default — with the + Text Collection's per-resource factor multiplying inside the grid's area. Content zoom composes + with the project font size and never replaces or resets it: it scales whatever base size the area + already has, and reset returns to the Settings default, not to the project font size. It is + implemented as CSS `zoom` on each element carrying `data-platform-content-zoom-root`, driven by a + custom property per area (`--platform-content-zoom-`, falling back to + `--platform-content-zoom-default`). **Zoom areas are a platform capability**: a view marks one or + more non-nested areas and the platform owns the targeting (focus, then pointer, then last active + area), the per-area state, the memory and the indicator. Views do not build their own zoom stacks. +- **Alternatives:** (a) **A font-size cascade on the content root** — rejected: it does not reach the + editor's rendered scripture, which sets its own sizes (PT-4167), so the one view the feature exists + for would not scale. (b) **`transform: scale`** — rejected: it breaks hit-testing, so clicks and + caret placement land off-target, which S1 reproduced. (c) **Scaling the whole `