mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
653e6e1436
commit
55c33b990f
@@ -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>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 l’application — 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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 l’app — chat, impostazioni e pannelli — indipendentemente dall’impostazione 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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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': '关闭时,标签仅出现在悬停时或活动选项卡上。',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -102,6 +102,7 @@ const themePersistConfig = {
|
||||
'mode',
|
||||
'tabBarLabels',
|
||||
'fontSize',
|
||||
'customFontSizePx',
|
||||
'agentMessageViewMode',
|
||||
'developerMode',
|
||||
'hideAgentInsights',
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 (14–20px) 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user