From 55c33b990f6d8c18dad43c44bc34fb35c27bd669 Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:44:13 +0530 Subject: [PATCH] feat(settings): fine-tune root font size beyond the presets (#4246) (#4696) Co-authored-by: Steven Enamakel --- .../settings/panels/AppearancePanel.test.tsx | 38 ++++++++- .../settings/panels/AppearancePanel.tsx | 79 ++++++++++++++++++- app/src/lib/i18n/ar.ts | 4 + app/src/lib/i18n/bn.ts | 4 + app/src/lib/i18n/de.ts | 5 ++ app/src/lib/i18n/en.ts | 4 + app/src/lib/i18n/es.ts | 5 ++ app/src/lib/i18n/fr.ts | 5 ++ app/src/lib/i18n/hi.ts | 4 + app/src/lib/i18n/id.ts | 4 + app/src/lib/i18n/it.ts | 5 ++ app/src/lib/i18n/ko.ts | 4 + app/src/lib/i18n/pl.ts | 5 ++ app/src/lib/i18n/pt.ts | 5 ++ app/src/lib/i18n/ru.ts | 5 ++ app/src/lib/i18n/zh-CN.ts | 4 + app/src/providers/ThemeProvider.test.tsx | 15 ++++ app/src/providers/ThemeProvider.tsx | 19 ++--- app/src/store/index.ts | 1 + app/src/store/themeSlice.test.ts | 53 +++++++++++++ app/src/store/themeSlice.ts | 64 ++++++++++++++- scripts/i18n-find-english.ts | 1 + 22 files changed, 315 insertions(+), 18 deletions(-) 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 (