Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions src/web-ui/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down
71 changes: 68 additions & 3 deletions src/web-ui/src/app/browserShortcutPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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();
});
});
38 changes: 37 additions & 1 deletion src/web-ui/src/app/browserShortcutPolicy.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FontPreferencePanel />));
fontPreferenceState.customPx = 17;
await act(async () => root.render(<FontPreferencePanel />));
expect(container.querySelector<HTMLInputElement>(
'[data-testid="appearance-ui-font-custom-controls"] input',
)?.value).toBe('17');
expect(container.querySelector<HTMLInputElement>(
'[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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +21,10 @@ export function FontPreferencePanel() {
const [customInput, setCustomInput] = useState<string>(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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ export class FontPreferenceService {
await this.setPreference(DEFAULT_FONT_PREFERENCE);
}

async adjustUiSize(delta: -1 | 1): Promise<void> {
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 {
Expand Down
Loading