diff --git a/app/src/components/settings/panels/AppearancePanel.test.tsx b/app/src/components/settings/panels/AppearancePanel.test.tsx
index c67739090..e5b952a7d 100644
--- a/app/src/components/settings/panels/AppearancePanel.test.tsx
+++ b/app/src/components/settings/panels/AppearancePanel.test.tsx
@@ -15,10 +15,19 @@ vi.mock('../components/SettingsHeader', () => ({
default: ({ title }: { title: string }) =>
{title}
,
}));
-function renderPanel(fontSize: 'small' | 'medium' | 'large' | 'xlarge' = 'medium') {
+function renderPanel(
+ fontSize: 'small' | 'medium' | 'large' | 'xlarge' = 'medium',
+ customFontSizePx: number | null = null
+) {
return renderWithProviders(, {
preloadedState: {
- theme: { mode: 'system', tabBarLabels: 'hover', fontSize, agentMessageViewMode: 'bubbles' },
+ theme: {
+ mode: 'system',
+ tabBarLabels: 'hover',
+ fontSize,
+ customFontSizePx,
+ agentMessageViewMode: 'bubbles',
+ },
},
});
}
@@ -48,6 +57,31 @@ describe(' font size', () => {
expect(store.getState().theme.fontSize).toBe('xlarge');
});
+ it('reflects the effective size on the slider and highlights a matching preset', () => {
+ // 18px == the Large preset, so the slider reads 18 and Large stays checked.
+ const { getByTestId, getByRole } = renderPanel('medium', 18);
+ expect((getByTestId('font-size-slider') as HTMLInputElement).value).toBe('18');
+ const group = getByRole('radiogroup', { name: 'settings.appearance.fontSizeAria' });
+ expect(within(group).getByRole('radio', { name: /fontSizeLarge/ })).toHaveAttribute(
+ 'aria-checked',
+ 'true'
+ );
+ });
+
+ it('dispatches a clamped custom px as the slider moves', () => {
+ const { getByTestId, store } = renderPanel('medium');
+ fireEvent.change(getByTestId('font-size-slider'), { target: { value: '26' } });
+ expect(store.getState().theme.customFontSizePx).toBe(26);
+ });
+
+ it('commits the numeric field on blur, clamped to the supported range', () => {
+ const { getByTestId, store } = renderPanel('medium');
+ const field = within(getByTestId('font-size-custom-number')).getByRole('spinbutton');
+ fireEvent.change(field, { target: { value: '99' } });
+ fireEvent.blur(field);
+ expect(store.getState().theme.customFontSizePx).toBe(28);
+ });
+
it('toggles assistant text mode for chat output', () => {
const { getByRole, store } = renderPanel('medium');
const toggle = getByRole('switch', { name: /settings\.appearance\.assistantTextMode/ });
diff --git a/app/src/components/settings/panels/AppearancePanel.tsx b/app/src/components/settings/panels/AppearancePanel.tsx
index 6f35fb8ec..9ead547ef 100644
--- a/app/src/components/settings/panels/AppearancePanel.tsx
+++ b/app/src/components/settings/panels/AppearancePanel.tsx
@@ -1,11 +1,16 @@
-import type { ReactElement } from 'react';
+import { type ChangeEvent, type ReactElement, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import {
type AgentMessageViewMode,
+ FONT_SIZE_PX,
type FontSize,
+ MAX_FONT_SIZE_PX,
+ MIN_FONT_SIZE_PX,
+ selectEffectiveFontSizePx,
setAgentMessageViewMode,
+ setCustomFontSizePx,
setFontSize,
setHideAgentInsights,
setTabBarLabels,
@@ -14,7 +19,7 @@ import {
type ThemeMode,
} from '../../../store/themeSlice';
import LanguageSelect from '../../LanguageSelect';
-import { SettingsRow, SettingsSection, SettingsSwitch } from '../controls';
+import { SettingsNumberField, SettingsRow, SettingsSection, SettingsSwitch } from '../controls';
import SettingsPanel from '../layout/SettingsPanel';
interface ModeOption {
@@ -69,7 +74,7 @@ const AppearancePanel = () => {
const { t } = useT();
const dispatch = useAppDispatch();
const mode = useAppSelector(state => state.theme.mode);
- const fontSize = useAppSelector(state => state.theme.fontSize);
+ const effectiveFontSizePx = useAppSelector(selectEffectiveFontSizePx);
const tabBarLabels = useAppSelector(state => state.theme.tabBarLabels);
const agentMessageViewMode = useAppSelector(
state => state.theme.agentMessageViewMode ?? 'bubbles'
@@ -89,6 +94,33 @@ const AppearancePanel = () => {
dispatch(setHideAgentInsights(!hideAgentInsights));
};
+ // Local draft for the numeric px field so partial typing doesn't thrash the
+ // store; commits (blur / Enter) clamp and dispatch, while the slider dispatches
+ // live. Re-sync the draft to the effective size when it changes externally
+ // (slider drag, preset click) via React's render-phase pattern — no effect,
+ // so there's no cascading-render round-trip.
+ const [pxDraft, setPxDraft] = useState(String(effectiveFontSizePx));
+ const [syncedPx, setSyncedPx] = useState(effectiveFontSizePx);
+ if (effectiveFontSizePx !== syncedPx) {
+ setSyncedPx(effectiveFontSizePx);
+ setPxDraft(String(effectiveFontSizePx));
+ }
+ const commitCustomFontSize = () => {
+ const parsed = Number.parseInt(pxDraft, 10);
+ if (Number.isFinite(parsed)) {
+ console.debug('[appearance] commit custom font-size', { pxDraft, parsed });
+ dispatch(setCustomFontSizePx(parsed));
+ } else {
+ console.debug('[appearance] custom font-size rejected, reverting draft', { pxDraft });
+ setPxDraft(String(effectiveFontSizePx));
+ }
+ };
+ const handleFontSizeSlider = (event: ChangeEvent) => {
+ const px = Number(event.target.value);
+ console.debug('[appearance] custom font-size slider', { px });
+ dispatch(setCustomFontSizePx(px));
+ };
+
// Build at render time so the labels follow the active locale; `t()` itself
// memoises on locale change, so this stays stable across re-renders within a
// locale.
@@ -209,7 +241,9 @@ const AppearancePanel = () => {
role="radiogroup"
aria-label={t('settings.appearance.fontSizeAria')}>
{FONT_SIZE_OPTIONS.map((opt, idx) => {
- const selected = opt.id === fontSize;
+ // Highlight the preset whose px matches the effective size, so a
+ // fine-tuned value landing exactly on a preset still lights it up.
+ const selected = Number.parseInt(FONT_SIZE_PX[opt.id], 10) === effectiveFontSizePx;
return (