diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx
index dc9ef69ba1..0b250bf543 100644
--- a/src/web-ui/src/app/App.tsx
+++ b/src/web-ui/src/app/App.tsx
@@ -32,7 +32,8 @@ import { ToolbarModeProvider } from '../flow_chat/components/toolbar-mode/Toolba
import { RealtimeVoiceCallProvider } from '../flow_chat/components/voice/RealtimeVoiceCallContext';
import type { AgentCompanionPetCommand } from './services/agentCompanionPetCommands';
import AskUserAnnouncer from './components/NavPanel/AskUserAnnouncer';
-import { shouldBlockBrowserShortcut } from './browserShortcutPolicy';
+import { handleBrowserShortcut } from './browserShortcutPolicy';
+import { fontPreferenceService } from '@/infrastructure/font-preference/core/FontPreferenceService';
import { activateCreationRuntime } from '@/infrastructure/creation/creationRuntime';
import { attachCreationRuntime, recordCreationActivationError } from '@/infrastructure/creation/creationBridge';
import { createCreationUiApi } from './creation/creationUiApi';
@@ -852,17 +853,14 @@ function App() {
};
}, []);
- // Always block browser-native find. Page reload remains available while the
+ // Control typography and block browser-native find/print. Reload remains available while the
// frontend runs in dev mode and is blocked in release builds. The desktop
// host independently applies the matching Rust build-profile policy.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- const primary = e.ctrlKey || e.metaKey;
- if (!primary) return;
- if (shouldBlockBrowserShortcut(e.key, import.meta.env.DEV)) {
- e.preventDefault();
- e.stopPropagation();
- }
+ handleBrowserShortcut(e, import.meta.env.DEV, delta => {
+ void fontPreferenceService.adjustUiSize(delta);
+ });
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
diff --git a/src/web-ui/src/app/browserShortcutPolicy.test.ts b/src/web-ui/src/app/browserShortcutPolicy.test.ts
index e5a2baa71d..e856c0294e 100644
--- a/src/web-ui/src/app/browserShortcutPolicy.test.ts
+++ b/src/web-ui/src/app/browserShortcutPolicy.test.ts
@@ -1,6 +1,8 @@
-import { describe, expect, it } from 'vitest';
+// @vitest-environment jsdom
-import { shouldBlockBrowserShortcut } from './browserShortcutPolicy';
+import { describe, expect, it, vi } from 'vitest';
+
+import { handleBrowserShortcut, shouldBlockBrowserShortcut } from './browserShortcutPolicy';
describe('browser shortcut policy', () => {
it('allows page reload shortcuts in development', () => {
@@ -16,6 +18,69 @@ describe('browser shortcut policy', () => {
it('continues to block browser find and ignores unrelated shortcuts', () => {
expect(shouldBlockBrowserShortcut('f', true)).toBe(true);
expect(shouldBlockBrowserShortcut('F', false)).toBe(true);
- expect(shouldBlockBrowserShortcut('p', false)).toBe(false);
+ expect(shouldBlockBrowserShortcut('s', false)).toBe(false);
+ });
+
+ it('blocks printing in development and release builds', () => {
+ expect(shouldBlockBrowserShortcut('p', true)).toBe(true);
+ expect(shouldBlockBrowserShortcut('P', false)).toBe(true);
+ });
+});
+
+describe.each([
+ ['Win32', { ctrlKey: true }],
+ ['MacIntel', { metaKey: true }],
+] as const)('browser shortcuts on %s', (platform, modifier) => {
+ it.each([
+ [{ key: '=', code: 'Equal' }, 1],
+ [{ key: '+', code: 'Equal', shiftKey: true }, 1],
+ [{ key: '+', code: 'NumpadAdd' }, 1],
+ [{ key: '-', code: 'Minus' }, -1],
+ [{ key: '_', code: 'Minus', shiftKey: true }, -1],
+ [{ key: '-', code: 'NumpadSubtract' }, -1],
+ ] as const)('changes typography for %j before focused controls receive it', (keys, delta) => {
+ const adjust = vi.fn();
+ const input = document.createElement('textarea');
+ document.body.append(input);
+ input.focus();
+ const editorHandler = vi.fn();
+ input.addEventListener('keydown', editorHandler);
+ const capture = (event: KeyboardEvent) => handleBrowserShortcut(event, false, adjust, platform);
+ window.addEventListener('keydown', capture, true);
+ try {
+ const event = new KeyboardEvent('keydown', { ...keys, ...modifier, bubbles: true, cancelable: true });
+ input.dispatchEvent(event);
+ expect(adjust).toHaveBeenCalledExactlyOnceWith(delta);
+ expect(event.defaultPrevented).toBe(true);
+ expect(editorHandler).not.toHaveBeenCalled();
+ } finally {
+ window.removeEventListener('keydown', capture, true);
+ input.remove();
+ }
+ });
+
+ it.each(['p', 'P'])('prevents printing for %s', key => {
+ const adjust = vi.fn();
+ const event = new KeyboardEvent('keydown', { key, ...modifier, cancelable: true });
+ handleBrowserShortcut(event, true, adjust, platform);
+ expect(event.defaultPrevented).toBe(true);
+ expect(adjust).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ { key: '+' },
+ { key: 'p' },
+ { key: '+', ...modifier, altKey: true },
+ { key: '+', ctrlKey: true, metaKey: true },
+ { key: '+', ...modifier, isComposing: true },
+ { key: '+', ...modifier, keyCode: 229 },
+ { key: 's', ...modifier },
+ { key: '+', ...(platform === 'MacIntel' ? { ctrlKey: true } : { metaKey: true }) },
+ ])('leaves unrelated keys and IME input untouched: %j', keys => {
+ const adjust = vi.fn();
+ const event = new KeyboardEvent('keydown', { ...keys, cancelable: true });
+ handleBrowserShortcut(event, false, adjust, platform);
+ expect(event.defaultPrevented).toBe(false);
+ expect(adjust).not.toHaveBeenCalled();
});
});
diff --git a/src/web-ui/src/app/browserShortcutPolicy.ts b/src/web-ui/src/app/browserShortcutPolicy.ts
index 4296d88f6d..7c784f0aaa 100644
--- a/src/web-ui/src/app/browserShortcutPolicy.ts
+++ b/src/web-ui/src/app/browserShortcutPolicy.ts
@@ -1,4 +1,40 @@
+import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime';
+
export function shouldBlockBrowserShortcut(key: string, allowPageReload: boolean): boolean {
const normalizedKey = key.toLowerCase();
- return normalizedKey === 'f' || (normalizedKey === 'r' && !allowPageReload);
+ return normalizedKey === 'f' || normalizedKey === 'p' || (normalizedKey === 'r' && !allowPageReload);
+}
+
+/** Capture browser shortcuts before focused editors and terminals consume them. */
+export function handleBrowserShortcut(
+ event: KeyboardEvent,
+ allowPageReload: boolean,
+ adjustFontSize: (delta: -1 | 1) => void,
+ platform = navigator.platform,
+): void {
+ if (!(event.ctrlKey || event.metaKey)) return;
+
+ if (shouldBlockBrowserShortcut(event.key, allowPageReload)) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ const isMac = platform.toUpperCase().includes('MAC');
+ const primary = isMac
+ ? event.metaKey && !event.ctrlKey
+ : event.ctrlKey && !event.metaKey;
+ if (!primary || event.altKey || isImeOwnedKeyboardEvent(event)) return;
+
+ // '+' may require Shift; '=' is the conventional unshifted zoom-in binding.
+ const delta = event.key === '+' || event.key === '=' || event.code === 'NumpadAdd'
+ ? 1
+ : event.key === '-' || event.key === '_' || event.code === 'NumpadSubtract'
+ ? -1
+ : 0;
+ if (!delta) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+ adjustFontSize(delta);
}
diff --git a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.test.tsx b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.test.tsx
index fad7cc0a14..f3f20b8b55 100644
--- a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.test.tsx
+++ b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.test.tsx
@@ -84,6 +84,27 @@ describe('FontPreferencePanel', () => {
expect(numberInput?.value).toBe('18');
expect(previewInput?.style.fontSize).toBe('18px');
});
+ it('updates the custom stepper and preview when a shortcut changes the preference', async () => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ fontPreferenceState.level = 'custom';
+ fontPreferenceState.customPx = 16;
+ const container = document.createElement('div');
+ const root = createRoot(container);
+ try {
+ await act(async () => root.render());
+ fontPreferenceState.customPx = 17;
+ await act(async () => root.render());
+ expect(container.querySelector(
+ '[data-testid="appearance-ui-font-custom-controls"] input',
+ )?.value).toBe('17');
+ expect(container.querySelector(
+ '[data-testid="appearance-ui-font-preview-input"]',
+ )?.style.fontSize).toBe('17px');
+ } finally {
+ await act(async () => root.unmount());
+ }
+ });
+
it('applies presets and initializes custom sizing from the current preset', async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement('div');
diff --git a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx
index 956d81bb50..053a03acf6 100644
--- a/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx
+++ b/src/web-ui/src/infrastructure/font-preference/components/FontPreferencePanel.tsx
@@ -4,7 +4,7 @@ import {
Select,
type SelectOption,
} from '@openbitfun/ui';
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ConfigPageRow, ConfigPageSection } from '@/infrastructure/config/components/common';
import { useFontPreference } from '../hooks/useFontPreference';
@@ -21,6 +21,10 @@ export function FontPreferencePanel() {
const [customInput, setCustomInput] = useState(String(customPx ?? 14));
const [previewText, setPreviewText] = useState('');
+ useEffect(() => {
+ setCustomInput(String(customPx ?? PRESET_UI_BASE_PX.default));
+ }, [customPx]);
+
/** Baseline px currently applied in the UI (preset level or custom). */
const getEffectiveUiBasePx = useCallback((): number => {
if (level === 'custom') {
diff --git a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts
index c4f1821529..2452649de8 100644
--- a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts
+++ b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.test.ts
@@ -48,4 +48,33 @@ describe('FontPreferenceService', () => {
expect(document.documentElement.style.getPropertyValue('--openbitfun-appearance-token-flowchat-font-size-base')).toBe('');
// typography-audit: negative-test-end
});
+
+ it('adjusts a saved preset, applies typography, and persists the existing config shape', async () => {
+ configMocks.getConfig.mockResolvedValue({ uiSize: { level: 'large' } });
+ const service = new FontPreferenceService();
+ await service.initialize();
+ await service.adjustUiSize(1);
+ expect(document.documentElement.style.getPropertyValue('--openbitfun-font-size-base')).toBe('17px');
+ expect(configMocks.setConfig).toHaveBeenLastCalledWith('font', {
+ uiSize: { level: 'custom', customPx: 17 },
+ });
+ await service.adjustUiSize(-1);
+ expect(service.getPreference().uiSize.customPx).toBe(16);
+ });
+
+ it.each([[12, -1], [20, 1]] as const)('keeps the %ipx boundary without redundant writes', async (customPx, delta) => {
+ const service = new FontPreferenceService();
+ await service.setUiSize('custom', customPx);
+ configMocks.setConfig.mockClear();
+ await service.adjustUiSize(delta);
+ expect(service.getPreference().uiSize.customPx).toBe(customPx);
+ expect(configMocks.setConfig).not.toHaveBeenCalled();
+ });
+
+ it('uses the latest size for repeated key presses before persistence completes', async () => {
+ configMocks.setConfig.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 0)));
+ const service = new FontPreferenceService();
+ await Promise.all([service.adjustUiSize(1), service.adjustUiSize(1), service.adjustUiSize(-1)]);
+ expect(service.getPreference().uiSize.customPx).toBe(15);
+ });
});
diff --git a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts
index c52c33a227..425b95eed6 100644
--- a/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts
+++ b/src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts
@@ -79,6 +79,14 @@ export class FontPreferenceService {
await this.setPreference(DEFAULT_FONT_PREFERENCE);
}
+ async adjustUiSize(delta: -1 | 1): Promise {
+ const currentPx = parseFloat(resolveFontSizeTokens(this.preference.uiSize).base);
+ const nextPx = Math.max(12, Math.min(20, currentPx + delta));
+ if (nextPx !== currentPx) {
+ await this.setUiSize('custom', nextPx);
+ }
+ }
+
// ---- CSS Application ----
applyPreference(pref: FontPreference): void {