feat(settings): fine-tune root font size beyond the presets (#4246) (#4696)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
YellowSnnowmann
2026-07-08 19:14:13 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 653e6e1436
commit 55c33b990f
22 changed files with 315 additions and 18 deletions
@@ -15,10 +15,19 @@ vi.mock('../components/SettingsHeader', () => ({
default: ({ title }: { title: string }) => <h1>{title}</h1>,
}));
function renderPanel(fontSize: 'small' | 'medium' | 'large' | 'xlarge' = 'medium') {
function renderPanel(
fontSize: 'small' | 'medium' | 'large' | 'xlarge' = 'medium',
customFontSizePx: number | null = null
) {
return renderWithProviders(<AppearancePanel />, {
preloadedState: {
theme: { mode: 'system', tabBarLabels: 'hover', fontSize, agentMessageViewMode: 'bubbles' },
theme: {
mode: 'system',
tabBarLabels: 'hover',
fontSize,
customFontSizePx,
agentMessageViewMode: 'bubbles',
},
},
});
}
@@ -48,6 +57,31 @@ describe('<AppearancePanel /> 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/ });
@@ -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<HTMLInputElement>) => {
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 (
<button
key={opt.id}
@@ -253,6 +287,43 @@ const AppearancePanel = () => {
);
})}
</div>
{/* Fine-tune the exact size beyond the presets (issue #4246). */}
<div className="bg-surface rounded-xl border border-line px-4 py-3 mt-3">
<div className="flex items-center justify-between gap-3">
<label htmlFor="font-size-custom-number" className="text-sm font-medium text-content">
{t('settings.appearance.fontSizeCustomLabel')}
</label>
<SettingsNumberField
id="font-size-custom-number"
value={pxDraft}
onChange={setPxDraft}
onCommit={commitCustomFontSize}
unit={t('settings.appearance.fontSizeUnit')}
min={MIN_FONT_SIZE_PX}
max={MAX_FONT_SIZE_PX}
aria-label={t('settings.appearance.fontSizeCustomAria')}
data-testid="font-size-custom-number"
/>
</div>
<input
id="font-size-slider"
type="range"
min={MIN_FONT_SIZE_PX}
max={MAX_FONT_SIZE_PX}
step={1}
value={effectiveFontSizePx}
onChange={handleFontSizeSlider}
aria-label={t('settings.appearance.fontSizeCustomSliderAria')}
aria-valuetext={`${effectiveFontSizePx}${t('settings.appearance.fontSizeUnit')}`}
className="w-full mt-3 accent-primary-500 cursor-pointer"
data-testid="font-size-slider"
/>
<div className="flex items-center justify-between mt-1 text-[11px] text-content-faint">
<span>{`${MIN_FONT_SIZE_PX}${t('settings.appearance.fontSizeUnit')}`}</span>
<span>{`${MAX_FONT_SIZE_PX}${t('settings.appearance.fontSizeUnit')}`}</span>
</div>
</div>
<p className="text-xs text-content-muted leading-relaxed px-1 mt-2">
{t('settings.appearance.fontSizeHelperText')}
</p>
+4
View File
@@ -5304,6 +5304,10 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'أكبر حجم للنص لأقصى قدر من الوضوح.',
'settings.appearance.fontSizeHelperText':
'يضبط حجم النص في التطبيق بأكمله — المحادثة والإعدادات واللوحات — بشكل مستقل عن إعداد خط نظامك.',
'settings.appearance.fontSizeCustomLabel': 'حجم مخصص',
'settings.appearance.fontSizeCustomAria': 'حجم الخط المخصص بالبكسل',
'settings.appearance.fontSizeCustomSliderAria': 'شريط تمرير حجم الخط المخصص، بالبكسل',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': 'شريط علامات التبويب السفلي',
'settings.appearance.tabBarAlwaysShowLabels': 'إظهار التسميات دائمًا',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+4
View File
@@ -5425,6 +5425,10 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'সর্বাধিক পঠনযোগ্যতার জন্য সবচেয়ে বড় লেখা।',
'settings.appearance.fontSizeHelperText':
'সিস্টেম ফন্ট সেটিং নির্বিশেষে পুরো অ্যাপ জুড়ে — চ্যাট, সেটিংস ও প্যানেল — লেখার আকার পরিবর্তন করে।',
'settings.appearance.fontSizeCustomLabel': 'কাস্টম আকার',
'settings.appearance.fontSizeCustomAria': 'পিক্সেলে কাস্টম ফন্ট আকার',
'settings.appearance.fontSizeCustomSliderAria': 'কাস্টম ফন্ট আকারের স্লাইডার, পিক্সেলে',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': 'নীচের ট্যাব বার',
'settings.appearance.tabBarAlwaysShowLabels': 'সর্বদা লেবেলগুলি দেখান',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+5
View File
@@ -5572,6 +5572,11 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'Der größte Text für maximale Lesbarkeit.',
'settings.appearance.fontSizeHelperText':
'Skaliert den Text in der gesamten App — Chat, Einstellungen und Bereiche — unabhängig von der Schrifteinstellung deines Systems.',
'settings.appearance.fontSizeCustomLabel': 'Benutzerdefinierte Größe',
'settings.appearance.fontSizeCustomAria': 'Benutzerdefinierte Schriftgröße in Pixeln',
'settings.appearance.fontSizeCustomSliderAria':
'Schieberegler für benutzerdefinierte Schriftgröße, in Pixeln',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': 'Untere Tab-Leiste',
'settings.appearance.tabBarAlwaysShowLabels': 'Beschriftungen immer anzeigen',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+4
View File
@@ -6117,6 +6117,10 @@ const en: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'The largest text, for maximum readability.',
'settings.appearance.fontSizeHelperText':
'Scales text across the whole app — chat, settings and panels — independently of your system font setting.',
'settings.appearance.fontSizeCustomLabel': 'Custom size',
'settings.appearance.fontSizeCustomAria': 'Custom font size in pixels',
'settings.appearance.fontSizeCustomSliderAria': 'Custom font size slider, in pixels',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': 'Bottom tab bar',
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+5
View File
@@ -5526,6 +5526,11 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'El texto más grande, para la máxima legibilidad.',
'settings.appearance.fontSizeHelperText':
'Escala el texto en toda la app —chat, ajustes y paneles— independientemente de la configuración de fuente de tu sistema.',
'settings.appearance.fontSizeCustomLabel': 'Tamaño personalizado',
'settings.appearance.fontSizeCustomAria': 'Tamaño de fuente personalizado en píxeles',
'settings.appearance.fontSizeCustomSliderAria':
'Control deslizante de tamaño de fuente personalizado, en píxeles',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': 'Barra de pestañas inferior',
'settings.appearance.tabBarAlwaysShowLabels': 'Mostrar siempre etiquetas',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+5
View File
@@ -5546,6 +5546,11 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'Le texte le plus grand, pour une lisibilité maximale.',
'settings.appearance.fontSizeHelperText':
'Ajuste la taille du texte dans toute lapplication — chat, paramètres et panneaux — indépendamment du réglage de police de votre système.',
'settings.appearance.fontSizeCustomLabel': 'Taille personnalisée',
'settings.appearance.fontSizeCustomAria': 'Taille de police personnalisée en pixels',
'settings.appearance.fontSizeCustomSliderAria':
'Curseur de taille de police personnalisée, en pixels',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': "Barre d'onglets inférieure",
'settings.appearance.tabBarAlwaysShowLabels': 'Toujours afficher les étiquettes',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+4
View File
@@ -5424,6 +5424,10 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'अधिकतम पठनीयता के लिए सबसे बड़ा टेक्स्ट।',
'settings.appearance.fontSizeHelperText':
'आपके सिस्टम फ़ॉन्ट सेटिंग से स्वतंत्र रूप से पूरे ऐप — चैट, सेटिंग्स और पैनल — में टेक्स्ट का आकार बदलता है।',
'settings.appearance.fontSizeCustomLabel': 'कस्टम आकार',
'settings.appearance.fontSizeCustomAria': 'पिक्सेल में कस्टम फ़ॉन्ट आकार',
'settings.appearance.fontSizeCustomSliderAria': 'कस्टम फ़ॉन्ट आकार स्लाइडर, पिक्सेल में',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': 'निचला टैब बार',
'settings.appearance.tabBarAlwaysShowLabels': 'हमेशा लेबल दिखाएं',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+4
View File
@@ -5443,6 +5443,10 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'Teks terbesar untuk keterbacaan maksimal.',
'settings.appearance.fontSizeHelperText':
'Menskalakan teks di seluruh aplikasi — obrolan, pengaturan, dan panel — terlepas dari pengaturan font sistem Anda.',
'settings.appearance.fontSizeCustomLabel': 'Ukuran khusus',
'settings.appearance.fontSizeCustomAria': 'Ukuran font khusus dalam piksel',
'settings.appearance.fontSizeCustomSliderAria': 'Penggeser ukuran font khusus, dalam piksel',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': 'Bilah tab bawah',
'settings.appearance.tabBarAlwaysShowLabels': 'Selalu tampilkan label',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+5
View File
@@ -5515,6 +5515,11 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'Il testo più grande, per la massima leggibilità.',
'settings.appearance.fontSizeHelperText':
'Ridimensiona il testo in tutta lapp — chat, impostazioni e pannelli — indipendentemente dallimpostazione del carattere del sistema.',
'settings.appearance.fontSizeCustomLabel': 'Dimensione personalizzata',
'settings.appearance.fontSizeCustomAria': 'Dimensione del carattere personalizzata in pixel',
'settings.appearance.fontSizeCustomSliderAria':
'Cursore dimensione carattere personalizzata, in pixel',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': 'Barra delle schede inferiore',
'settings.appearance.tabBarAlwaysShowLabels': 'Mostra sempre le etichette',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+4
View File
@@ -5361,6 +5361,10 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': '최대 가독성을 위한 가장 큰 텍스트.',
'settings.appearance.fontSizeHelperText':
'시스템 글꼴 설정과 관계없이 앱 전체 — 채팅, 설정, 패널 — 의 텍스트 크기를 조정합니다.',
'settings.appearance.fontSizeCustomLabel': '사용자 지정 크기',
'settings.appearance.fontSizeCustomAria': '픽셀 단위 사용자 지정 글꼴 크기',
'settings.appearance.fontSizeCustomSliderAria': '사용자 지정 글꼴 크기 슬라이더, 픽셀 단위',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': '하단 탭 표시줄',
'settings.appearance.tabBarAlwaysShowLabels': '항상 레이블 표시',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+5
View File
@@ -5506,6 +5506,11 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'Największy tekst dla maksymalnej czytelności.',
'settings.appearance.fontSizeHelperText':
'Skaluje tekst w całej aplikacji — czat, ustawienia i panele — niezależnie od ustawienia czcionki w systemie.',
'settings.appearance.fontSizeCustomLabel': 'Rozmiar niestandardowy',
'settings.appearance.fontSizeCustomAria': 'Niestandardowy rozmiar czcionki w pikselach',
'settings.appearance.fontSizeCustomSliderAria':
'Suwak niestandardowego rozmiaru czcionki, w pikselach',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': 'Dolny pasek zakładek',
'settings.appearance.tabBarAlwaysShowLabels': 'Zawsze pokazuj etykiety',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+5
View File
@@ -5515,6 +5515,11 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'O maior texto, para máxima legibilidade.',
'settings.appearance.fontSizeHelperText':
'Dimensiona o texto em todo o app — chat, configurações e painéis — independentemente da configuração de fonte do seu sistema.',
'settings.appearance.fontSizeCustomLabel': 'Tamanho personalizado',
'settings.appearance.fontSizeCustomAria': 'Tamanho da fonte personalizado em pixels',
'settings.appearance.fontSizeCustomSliderAria':
'Controle deslizante de tamanho de fonte personalizado, em pixels',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': 'Barra inferior da guia',
'settings.appearance.tabBarAlwaysShowLabels': 'Sempre mostrar rótulos',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+5
View File
@@ -5480,6 +5480,11 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': 'Самый крупный текст для максимальной читаемости.',
'settings.appearance.fontSizeHelperText':
'Масштабирует текст во всём приложении — чат, настройки и панели — независимо от системных настроек шрифта.',
'settings.appearance.fontSizeCustomLabel': 'Пользовательский размер',
'settings.appearance.fontSizeCustomAria': 'Пользовательский размер шрифта в пикселях',
'settings.appearance.fontSizeCustomSliderAria':
'Ползунок пользовательского размера шрифта, в пикселях',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': 'Нижняя панель вкладок',
'settings.appearance.tabBarAlwaysShowLabels': 'Всегда показывать метки',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+4
View File
@@ -5140,6 +5140,10 @@ const messages: TranslationMap = {
'settings.appearance.fontSizeXLargeDesc': '最大的文字,可读性最佳。',
'settings.appearance.fontSizeHelperText':
'在整个应用中缩放文字——聊天、设置和面板——与系统字体设置无关。',
'settings.appearance.fontSizeCustomLabel': '自定义大小',
'settings.appearance.fontSizeCustomAria': '自定义字体大小(像素)',
'settings.appearance.fontSizeCustomSliderAria': '自定义字体大小滑块(像素)',
'settings.appearance.fontSizeUnit': 'px',
'settings.appearance.tabBarHeading': '底部标签栏',
'settings.appearance.tabBarAlwaysShowLabels': '始终显示标签',
'settings.appearance.tabBarAlwaysShowLabelsDesc': '关闭时,标签仅出现在悬停时或活动选项卡上。',
+15
View File
@@ -19,6 +19,21 @@ describe('<ThemeProvider />', () => {
}
);
it('applies a fine-tuned custom px size over the preset (issue #4246)', () => {
renderWithProviders(
<ThemeProvider>
<span>child</span>
</ThemeProvider>,
{
preloadedState: {
theme: { mode: 'light', tabBarLabels: 'hover', fontSize: 'medium', customFontSizePx: 23 },
},
}
);
expect(document.documentElement.style.fontSize).toBe('23px');
});
it('renders its children', () => {
const { getByText } = renderWithProviders(
<ThemeProvider>
+10 -9
View File
@@ -4,9 +4,9 @@ import { findFamily, resolveFamilyVariant } from '../lib/theme/presets';
import type { Theme } from '../lib/theme/types';
import { useAppSelector } from '../store/hooks';
import {
FONT_SIZE_PX,
selectActiveFamilyId,
selectEffectiveTheme,
selectRootFontSizePx,
selectThemeVariant,
} from '../store/themeSlice';
@@ -22,11 +22,12 @@ import {
* and any remaining `dark:` utilities activate together.
* - `theme.mode === system` (active id `system`) also subscribes to
* `prefers-color-scheme` so OS-level flips re-apply live without a reload.
* - `theme.fontSize` → the `<html>` inline `font-size` (rem-based Tailwind text
* utilities scale off this), unchanged from before.
* - The root `<html>` inline `font-size` (rem-based Tailwind text utilities
* scale off this) is driven by `selectRootFontSizePx`: a fine-tuned custom px
* (issue #4246) overrides the `theme.fontSize` preset when set.
*/
const ThemeProvider = ({ children }: { children: ReactNode }) => {
const fontSize = useAppSelector(state => state.theme.fontSize);
const rootFontSizePx = useAppSelector(selectRootFontSizePx);
const themeVariant = useAppSelector(selectThemeVariant);
const activeFamilyId = useAppSelector(selectActiveFamilyId);
const effectiveTheme = useAppSelector(selectEffectiveTheme);
@@ -75,13 +76,13 @@ const ThemeProvider = ({ children }: { children: ReactNode }) => {
});
}, []);
// Apply the global font size to <html>.
// Apply the global font size to <html> — a fine-tuned custom px overrides the
// preset (issue #4246), resolved in selectRootFontSizePx.
useEffect(() => {
if (typeof document === 'undefined') return;
const px = FONT_SIZE_PX[fontSize] ?? FONT_SIZE_PX.medium;
console.debug('[theme] applying root font-size', { fontSize, px });
document.documentElement.style.fontSize = px;
}, [fontSize]);
console.debug('[theme] applying root font-size', { px: rootFontSizePx });
document.documentElement.style.fontSize = rootFontSizePx;
}, [rootFontSizePx]);
// Apply the active theme whenever it changes.
useEffect(() => {
+1
View File
@@ -102,6 +102,7 @@ const themePersistConfig = {
'mode',
'tabBarLabels',
'fontSize',
'customFontSizePx',
'agentMessageViewMode',
'developerMode',
'hideAgentInsights',
+53
View File
@@ -3,14 +3,20 @@ import { describe, expect, it } from 'vitest';
import type { Theme } from '../lib/theme/types';
import themeReducer, {
clampFontSizePx,
deleteCustomTheme,
FONT_SIZE_PX,
type FontSize,
MAX_FONT_SIZE_PX,
MIN_FONT_SIZE_PX,
resetActiveTheme,
selectEffectiveFontSizePx,
selectEffectiveTheme,
selectHideAgentInsights,
selectRootFontSizePx,
setActiveTheme,
setAgentMessageViewMode,
setCustomFontSizePx,
setFontRole,
setFontSize,
setHideAgentInsights,
@@ -58,6 +64,7 @@ describe('themeSlice', () => {
mode: 'dark',
tabBarLabels: 'always',
fontSize: 'xlarge',
customFontSizePx: null,
agentMessageViewMode: 'text',
developerMode: false,
hideAgentInsights: false,
@@ -97,6 +104,52 @@ describe('themeSlice', () => {
expect(FONT_SIZE_PX.medium).toBe('16px');
});
describe('custom (fine-tuned) font size — issue #4246', () => {
it('defaults customFontSizePx to null (presets drive the root size)', () => {
const state = themeReducer(undefined, { type: '@@INIT' });
expect(state.customFontSizePx).toBeNull();
});
it('clampFontSizePx bounds, rounds, and guards against non-finite input', () => {
expect(clampFontSizePx(MIN_FONT_SIZE_PX - 5)).toBe(MIN_FONT_SIZE_PX);
expect(clampFontSizePx(MAX_FONT_SIZE_PX + 5)).toBe(MAX_FONT_SIZE_PX);
expect(clampFontSizePx(17.6)).toBe(18);
expect(clampFontSizePx(Number.NaN)).toBe(16);
});
it('setCustomFontSizePx stores a clamped override and null clears it', () => {
let state = themeReducer(undefined, { type: '@@INIT' });
state = themeReducer(state, setCustomFontSizePx(24));
expect(state.customFontSizePx).toBe(24);
state = themeReducer(state, setCustomFontSizePx(999));
expect(state.customFontSizePx).toBe(MAX_FONT_SIZE_PX);
state = themeReducer(state, setCustomFontSizePx(null));
expect(state.customFontSizePx).toBeNull();
});
it('picking a preset clears any fine-tuned override', () => {
let state = themeReducer(undefined, { type: '@@INIT' });
state = themeReducer(state, setCustomFontSizePx(26));
state = themeReducer(state, setFontSize('large'));
expect(state.fontSize).toBe('large');
expect(state.customFontSizePx).toBeNull();
});
it('selectRootFontSizePx: custom px wins, else the active preset', () => {
let state = themeReducer(undefined, { type: '@@INIT' });
state = themeReducer(state, setFontSize('large'));
expect(selectRootFontSizePx({ theme: state })).toBe('18px');
state = themeReducer(state, setCustomFontSizePx(23));
expect(selectRootFontSizePx({ theme: state })).toBe('23px');
expect(selectEffectiveFontSizePx({ theme: state })).toBe(23);
});
it('selectRootFontSizePx falls back to medium when the slice is absent', () => {
expect(selectRootFontSizePx({})).toBe('16px');
expect(selectEffectiveFontSizePx({})).toBe(16);
});
});
describe('runtime themes', () => {
it('defaults to the classic family with the system variant', () => {
const state = themeReducer(undefined, { type: '@@INIT' });
+61 -3
View File
@@ -30,10 +30,10 @@ export type AgentMessageViewMode = 'bubbles' | 'text';
export type FontSize = 'small' | 'medium' | 'large' | 'xlarge';
/**
* Single source of truth mapping each {@link FontSize} to the concrete root
* `font-size` applied to `<html>`. `medium` (16px) matches the historical
* Single source of truth mapping each {@link FontSize} preset to the concrete
* root `font-size` applied to `<html>`. `medium` (16px) matches the historical
* `:root` size, so existing users see no change after the field defaults in.
* Consumed by `ThemeProvider`; keep this the only place the px values live.
* Consumed by `ThemeProvider`; keep this the only place the preset px live.
*/
export const FONT_SIZE_PX: Record<FontSize, string> = {
small: '14px',
@@ -42,10 +42,36 @@ export const FONT_SIZE_PX: Record<FontSize, string> = {
xlarge: '20px',
};
/**
* Fine-tuning bounds for {@link ThemeState.customFontSizePx} (issue #4246), in
* px. The range brackets the presets (1420px) on both sides so users who find
* even "Extra large" too small can push further, and users who prefer denser
* text can go below "Small". Applied via {@link selectRootFontSizePx}.
*/
export const MIN_FONT_SIZE_PX = 12;
export const MAX_FONT_SIZE_PX = 28;
/** Root size used when no preset/custom value resolves (matches `medium`). */
export const DEFAULT_FONT_SIZE_PX = 16;
/** Clamp an arbitrary px value into the supported range, rounded to whole px. */
export function clampFontSizePx(px: number): number {
if (!Number.isFinite(px)) return DEFAULT_FONT_SIZE_PX;
return Math.min(MAX_FONT_SIZE_PX, Math.max(MIN_FONT_SIZE_PX, Math.round(px)));
}
interface ThemeState {
mode: ThemeMode;
tabBarLabels: TabBarLabels;
fontSize: FontSize;
/**
* Fine-tuned root font size in px (issue #4246). When set, it overrides the
* {@link ThemeState.fontSize} preset so users can pick any size in the
* {@link MIN_FONT_SIZE_PX}{@link MAX_FONT_SIZE_PX} range. `null` (the default,
* and the value for all pre-existing persisted state) means "follow the
* preset", keeping historical behaviour byte-identical. Selecting a preset
* tile clears this back to `null`.
*/
customFontSizePx: number | null;
agentMessageViewMode: AgentMessageViewMode;
/**
* Runtime Developer Mode (default OFF).
@@ -86,6 +112,7 @@ const initialState: ThemeState = {
mode: 'system',
tabBarLabels: 'hover',
fontSize: 'medium',
customFontSizePx: null,
agentMessageViewMode: 'text',
developerMode: false,
hideAgentInsights: false,
@@ -172,8 +199,17 @@ const themeSlice = createSlice({
setTabBarLabels(state, action: PayloadAction<TabBarLabels>) {
state.tabBarLabels = action.payload;
},
/** Pick a preset size. Clears any fine-tuned override so the preset wins. */
setFontSize(state, action: PayloadAction<FontSize>) {
state.fontSize = action.payload;
state.customFontSizePx = null;
},
/**
* Fine-tune the root font size in px (issue #4246). A number is clamped to
* the supported range; `null` drops the override back to the active preset.
*/
setCustomFontSizePx(state, action: PayloadAction<number | null>) {
state.customFontSizePx = action.payload === null ? null : clampFontSizePx(action.payload);
},
setAgentMessageViewMode(state, action: PayloadAction<AgentMessageViewMode>) {
state.agentMessageViewMode = action.payload;
@@ -212,6 +248,7 @@ export const {
setActiveFamily,
setTabBarLabels,
setFontSize,
setCustomFontSizePx,
setAgentMessageViewMode,
setDeveloperMode,
setHideAgentInsights,
@@ -355,6 +392,27 @@ export const selectHideAgentInsights = (state: { theme: ThemeState }): boolean =
export const selectDeveloperMode = (state: { theme: ThemeState }): boolean =>
state.theme.developerMode;
/**
* The effective root font size in px (numeric). A fine-tuned
* {@link ThemeState.customFontSizePx} (issue #4246) wins; otherwise the active
* {@link ThemeState.fontSize} preset drives it. Falls back to `medium` (16) when
* the slice is absent (SSR / early boot). This is the canonical resolver;
* {@link selectRootFontSizePx} formats it for the DOM.
*/
export function selectEffectiveFontSizePx(state: { theme?: ThemeState }): number {
const theme = state.theme;
const custom = theme?.customFontSizePx;
if (typeof custom === 'number' && Number.isFinite(custom)) {
return clampFontSizePx(custom);
}
return parseInt(FONT_SIZE_PX[theme?.fontSize ?? 'medium'] ?? FONT_SIZE_PX.medium, 10);
}
/** The effective root `font-size` string to apply to `<html>` (px suffix). */
export function selectRootFontSizePx(state: { theme?: ThemeState }): string {
return `${selectEffectiveFontSizePx(state)}px`;
}
/**
* Resolves a `ThemeMode` to the concrete `light` or `dark` value that should
* be applied to `<html>`. `system` consults `prefers-color-scheme`; in non-DOM
+1
View File
@@ -78,6 +78,7 @@ const INTENTIONAL_ENGLISH = new Set([
"settings.ai.localOllama",
"settings.ai.minutesShort",
"settings.ai.openAiUrlLabel",
"settings.appearance.fontSizeUnit", // "px" — CSS unit, identical in every locale
"settings.billing.inferenceBudget.dailySpendPoint",
"settings.localModel.download.embeddingModel",
"settings.localModel.download.ttsOutput",