feat(appearance): add global font size setting (#3120) (#3396)

This commit is contained in:
Cyrus Gray
2026-06-05 17:45:10 +05:30
committed by GitHub
parent c23d6ff20c
commit 11fae79346
22 changed files with 443 additions and 10 deletions
@@ -0,0 +1,48 @@
import { fireEvent, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import AppearancePanel from './AppearancePanel';
// Pass-through translator so assertions can target the i18n keys directly.
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
vi.mock('../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ breadcrumbs: [], navigateBack: vi.fn() }),
}));
vi.mock('../components/SettingsHeader', () => ({
default: ({ title }: { title: string }) => <h1>{title}</h1>,
}));
function renderPanel(fontSize: 'small' | 'medium' | 'large' | 'xlarge' = 'medium') {
return renderWithProviders(<AppearancePanel />, {
preloadedState: { theme: { mode: 'system', tabBarLabels: 'hover', fontSize } },
});
}
describe('<AppearancePanel /> font size', () => {
it('renders the four font-size options as a radio group', () => {
const { getByRole } = renderPanel();
const group = getByRole('radiogroup', { name: 'settings.appearance.fontSizeAria' });
const radios = within(group).getAllByRole('radio');
expect(radios).toHaveLength(4);
});
it('marks the active font size as checked', () => {
const { getByRole } = renderPanel('large');
const group = getByRole('radiogroup', { name: 'settings.appearance.fontSizeAria' });
const large = within(group).getByRole('radio', { name: /fontSizeLarge/ });
expect(large).toHaveAttribute('aria-checked', 'true');
});
it('dispatches setFontSize when an option is clicked', () => {
const { getByRole, store } = renderPanel('medium');
const group = getByRole('radiogroup', { name: 'settings.appearance.fontSizeAria' });
const xlarge = within(group).getByRole('radio', { name: /fontSizeXLarge/ });
fireEvent.click(xlarge);
expect(store.getState().theme.fontSize).toBe('xlarge');
});
});
@@ -3,6 +3,8 @@ import type { ReactElement } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import {
type FontSize,
setFontSize,
setTabBarLabels,
setThemeMode,
type TabBarLabels,
@@ -18,6 +20,14 @@ interface ModeOption {
icon: ReactElement;
}
interface FontSizeOption {
id: FontSize;
label: string;
description: string;
/** Sample "A" glyph sized to preview the option inline. */
glyphClass: string;
}
const SunIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
<path
@@ -56,6 +66,7 @@ const AppearancePanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const dispatch = useAppDispatch();
const mode = useAppSelector(state => state.theme.mode);
const fontSize = useAppSelector(state => state.theme.fontSize);
const tabBarLabels = useAppSelector(state => state.theme.tabBarLabels);
const labelsAlwaysVisible = tabBarLabels === 'always';
const toggleTabBarLabels = () => {
@@ -87,6 +98,33 @@ const AppearancePanel = () => {
},
];
const FONT_SIZE_OPTIONS: FontSizeOption[] = [
{
id: 'small',
label: t('settings.appearance.fontSizeSmall'),
description: t('settings.appearance.fontSizeSmallDesc'),
glyphClass: 'text-xs',
},
{
id: 'medium',
label: t('settings.appearance.fontSizeMedium'),
description: t('settings.appearance.fontSizeMediumDesc'),
glyphClass: 'text-sm',
},
{
id: 'large',
label: t('settings.appearance.fontSizeLarge'),
description: t('settings.appearance.fontSizeLargeDesc'),
glyphClass: 'text-base',
},
{
id: 'xlarge',
label: t('settings.appearance.fontSizeXLarge'),
description: t('settings.appearance.fontSizeXLargeDesc'),
glyphClass: 'text-lg',
},
];
return (
<div>
<SettingsHeader
@@ -161,6 +199,72 @@ const AppearancePanel = () => {
</p>
</div>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.appearance.fontSizeHeading')}
</h3>
<div
className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden"
role="radiogroup"
aria-label={t('settings.appearance.fontSizeAria')}>
{FONT_SIZE_OPTIONS.map((opt, idx) => {
const selected = opt.id === fontSize;
return (
<button
key={opt.id}
type="button"
role="radio"
aria-checked={selected}
onClick={() => dispatch(setFontSize(opt.id))}
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors focus:outline-none focus-visible:bg-primary-50 dark:bg-primary-500/10 dark:focus-visible:bg-primary-900/30 ${
idx !== 0 ? 'border-t border-neutral-100 dark:border-neutral-800' : ''
} ${
selected
? 'bg-primary-50 dark:bg-primary-500/10'
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/60'
}`}>
<span
className={`flex items-center justify-center w-9 h-9 rounded-lg ${
selected
? 'bg-primary-500 text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300'
}`}>
<span className={`font-semibold leading-none ${opt.glyphClass}`} aria-hidden>
A
</span>
</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
{opt.label}
</span>
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
{opt.description}
</span>
</span>
{selected && (
<svg
className="w-5 h-5 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
);
})}
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.appearance.fontSizeHelperText')}
</p>
</div>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.appearance.tabBarHeading')}
+12
View File
@@ -3765,6 +3765,18 @@ const messages: TranslationMap = {
'settings.appearance.modeSystemDesc': 'اتبع إعداد مظهر نظام التشغيل لديك.',
'settings.appearance.helperText':
'يعمل الوضع الداكن على تحويل التطبيق بأكمله - الدردشة والإعدادات واللوحات - إلى لوحة معتمة. "نظام المطابقة" يتتبع مظهر نظام التشغيل الخاص بك وتحديثاته مباشرة.',
'settings.appearance.fontSizeHeading': 'حجم الخط',
'settings.appearance.fontSizeAria': 'حجم الخط',
'settings.appearance.fontSizeSmall': 'صغير',
'settings.appearance.fontSizeSmallDesc': 'نص مضغوط — لعرض المزيد على الشاشة.',
'settings.appearance.fontSizeMedium': 'متوسط',
'settings.appearance.fontSizeMediumDesc': 'الحجم الافتراضي المتوازن.',
'settings.appearance.fontSizeLarge': 'كبير',
'settings.appearance.fontSizeLargeDesc': 'نص أكبر لقراءة أسهل.',
'settings.appearance.fontSizeXLarge': 'كبير جدًا',
'settings.appearance.fontSizeXLargeDesc': 'أكبر حجم للنص لأقصى قدر من الوضوح.',
'settings.appearance.fontSizeHelperText':
'يضبط حجم النص في التطبيق بأكمله — المحادثة والإعدادات واللوحات — بشكل مستقل عن إعداد خط نظامك.',
'settings.appearance.tabBarHeading': 'شريط علامات التبويب السفلي',
'settings.appearance.tabBarAlwaysShowLabels': 'إظهار التسميات دائمًا',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3838,6 +3838,18 @@ const messages: TranslationMap = {
'settings.appearance.modeSystemDesc': 'আপনার OS চেহারা সেটিং অনুসরণ করুন.',
'settings.appearance.helperText':
'ডার্ক মোড পুরো অ্যাপকে স্যুইচ করে — চ্যাট, সেটিংস, প্যানেল — একটি আবছা প্যালেটে। "ম্যাচ সিস্টেম" আপনার OS চেহারা অনুসরণ করে এবং লাইভ আপডেট করে।',
'settings.appearance.fontSizeHeading': 'ফন্টের আকার',
'settings.appearance.fontSizeAria': 'ফন্টের আকার',
'settings.appearance.fontSizeSmall': 'ছোট',
'settings.appearance.fontSizeSmallDesc': 'কমপ্যাক্ট লেখা — স্ক্রিনে আরও বেশি দেখান।',
'settings.appearance.fontSizeMedium': 'মাঝারি',
'settings.appearance.fontSizeMediumDesc': 'ডিফল্ট, সুষম আকার।',
'settings.appearance.fontSizeLarge': 'বড়',
'settings.appearance.fontSizeLargeDesc': 'সহজে পড়ার জন্য বড় লেখা।',
'settings.appearance.fontSizeXLarge': 'অতিরিক্ত বড়',
'settings.appearance.fontSizeXLargeDesc': 'সর্বাধিক পঠনযোগ্যতার জন্য সবচেয়ে বড় লেখা।',
'settings.appearance.fontSizeHelperText':
'সিস্টেম ফন্ট সেটিং নির্বিশেষে পুরো অ্যাপ জুড়ে — চ্যাট, সেটিংস ও প্যানেল — লেখার আকার পরিবর্তন করে।',
'settings.appearance.tabBarHeading': 'নীচের ট্যাব বার',
'settings.appearance.tabBarAlwaysShowLabels': 'সর্বদা লেবেলগুলি দেখান',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3940,6 +3940,18 @@ const messages: TranslationMap = {
'Folgt den Einstellungen für das Erscheinungsbild deines Betriebssystems.',
'settings.appearance.helperText':
'Der Dunkelmodus schaltet die gesamte App Chat, Einstellungen, Bedienfelder auf eine dunkle Palette um. „Match System“ verfolgt das Erscheinungsbild deines Betriebssystems und aktualisiert es live.',
'settings.appearance.fontSizeHeading': 'Schriftgröße',
'settings.appearance.fontSizeAria': 'Schriftgröße',
'settings.appearance.fontSizeSmall': 'Klein',
'settings.appearance.fontSizeSmallDesc': 'Kompakter Text — mehr auf dem Bildschirm.',
'settings.appearance.fontSizeMedium': 'Mittel',
'settings.appearance.fontSizeMediumDesc': 'Die ausgewogene Standardgröße.',
'settings.appearance.fontSizeLarge': 'Groß',
'settings.appearance.fontSizeLargeDesc': 'Größerer Text für leichteres Lesen.',
'settings.appearance.fontSizeXLarge': 'Sehr groß',
'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.tabBarHeading': 'Untere Tab-Leiste',
'settings.appearance.tabBarAlwaysShowLabels': 'Beschriftungen immer anzeigen',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -4256,6 +4256,18 @@ const en: TranslationMap = {
'settings.appearance.modeSystemDesc': 'Follow your OS appearance setting.',
'settings.appearance.helperText':
'Dark mode switches the entire app — chat, settings, panels — to a dim palette. "Match system" follows your OS appearance and updates live.',
'settings.appearance.fontSizeHeading': 'Font size',
'settings.appearance.fontSizeAria': 'Font size',
'settings.appearance.fontSizeSmall': 'Small',
'settings.appearance.fontSizeSmallDesc': 'Compact text — fit more on screen.',
'settings.appearance.fontSizeMedium': 'Medium',
'settings.appearance.fontSizeMediumDesc': 'The default, balanced size.',
'settings.appearance.fontSizeLarge': 'Large',
'settings.appearance.fontSizeLargeDesc': 'Bigger text for easier reading.',
'settings.appearance.fontSizeXLarge': 'Extra large',
'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.tabBarHeading': 'Bottom tab bar',
'settings.appearance.tabBarAlwaysShowLabels': 'Always show labels',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3916,6 +3916,18 @@ const messages: TranslationMap = {
'Siga la configuración de apariencia de su sistema operativo.',
'settings.appearance.helperText':
'El modo oscuro cambia toda la aplicación (chat, configuración, paneles) a una paleta tenue. El "sistema de coincidencias" sigue la apariencia de su sistema operativo y se actualiza en vivo.',
'settings.appearance.fontSizeHeading': 'Tamaño de fuente',
'settings.appearance.fontSizeAria': 'Tamaño de fuente',
'settings.appearance.fontSizeSmall': 'Pequeño',
'settings.appearance.fontSizeSmallDesc': 'Texto compacto: más contenido en pantalla.',
'settings.appearance.fontSizeMedium': 'Mediano',
'settings.appearance.fontSizeMediumDesc': 'El tamaño predeterminado y equilibrado.',
'settings.appearance.fontSizeLarge': 'Grande',
'settings.appearance.fontSizeLargeDesc': 'Texto más grande para leer con más facilidad.',
'settings.appearance.fontSizeXLarge': 'Extra grande',
'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.tabBarHeading': 'Barra de pestañas inferior',
'settings.appearance.tabBarAlwaysShowLabels': 'Mostrar siempre etiquetas',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3928,6 +3928,18 @@ const messages: TranslationMap = {
"Suivez les paramètres d'apparence de votre système d'exploitation.",
'settings.appearance.helperText':
"Le mode sombre fait basculer l'ensemble de l'application (chat, paramètres, panneaux) vers une palette sombre. \"Match system\" suit l'apparence de votre système d'exploitation et se met à jour en direct.",
'settings.appearance.fontSizeHeading': 'Taille de police',
'settings.appearance.fontSizeAria': 'Taille de police',
'settings.appearance.fontSizeSmall': 'Petite',
'settings.appearance.fontSizeSmallDesc': 'Texte compact — plus de contenu à l’écran.',
'settings.appearance.fontSizeMedium': 'Moyenne',
'settings.appearance.fontSizeMediumDesc': 'La taille par défaut, équilibrée.',
'settings.appearance.fontSizeLarge': 'Grande',
'settings.appearance.fontSizeLargeDesc': 'Texte plus grand pour une lecture plus facile.',
'settings.appearance.fontSizeXLarge': 'Très grande',
'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.tabBarHeading': "Barre d'onglets inférieure",
'settings.appearance.tabBarAlwaysShowLabels': 'Toujours afficher les étiquettes',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3847,6 +3847,18 @@ const messages: TranslationMap = {
'settings.appearance.modeSystemDesc': 'अपने OS उपस्थिति सेटिंग का पालन करें.',
'settings.appearance.helperText':
'डार्क मोड पूरे ऐप - चैट, सेटिंग्स, पैनल - को एक मंद पैलेट में बदल देता है। "मैच सिस्टम" आपके ओएस की उपस्थिति और अपडेट का लाइव अनुसरण करता है।',
'settings.appearance.fontSizeHeading': 'फ़ॉन्ट आकार',
'settings.appearance.fontSizeAria': 'फ़ॉन्ट आकार',
'settings.appearance.fontSizeSmall': 'छोटा',
'settings.appearance.fontSizeSmallDesc': 'संक्षिप्त टेक्स्ट — स्क्रीन पर अधिक दिखाएँ।',
'settings.appearance.fontSizeMedium': 'मध्यम',
'settings.appearance.fontSizeMediumDesc': 'डिफ़ॉल्ट, संतुलित आकार।',
'settings.appearance.fontSizeLarge': 'बड़ा',
'settings.appearance.fontSizeLargeDesc': 'आसान पढ़ने के लिए बड़ा टेक्स्ट।',
'settings.appearance.fontSizeXLarge': 'बहुत बड़ा',
'settings.appearance.fontSizeXLargeDesc': 'अधिकतम पठनीयता के लिए सबसे बड़ा टेक्स्ट।',
'settings.appearance.fontSizeHelperText':
'आपके सिस्टम फ़ॉन्ट सेटिंग से स्वतंत्र रूप से पूरे ऐप — चैट, सेटिंग्स और पैनल — में टेक्स्ट का आकार बदलता है।',
'settings.appearance.tabBarHeading': 'निचला टैब बार',
'settings.appearance.tabBarAlwaysShowLabels': 'हमेशा लेबल दिखाएं',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3854,6 +3854,18 @@ const messages: TranslationMap = {
'settings.appearance.modeSystemDesc': 'Ikuti pengaturan tampilan OS Anda.',
'settings.appearance.helperText':
'Mode gelap mengubah seluruh aplikasi - obrolan, pengaturan, dan panel - ke palet redup. "Ikuti sistem" mengikuti tampilan OS Anda dan diperbarui otomatis.',
'settings.appearance.fontSizeHeading': 'Ukuran font',
'settings.appearance.fontSizeAria': 'Ukuran font',
'settings.appearance.fontSizeSmall': 'Kecil',
'settings.appearance.fontSizeSmallDesc': 'Teks ringkas — tampilkan lebih banyak di layar.',
'settings.appearance.fontSizeMedium': 'Sedang',
'settings.appearance.fontSizeMediumDesc': 'Ukuran default yang seimbang.',
'settings.appearance.fontSizeLarge': 'Besar',
'settings.appearance.fontSizeLargeDesc': 'Teks lebih besar agar lebih mudah dibaca.',
'settings.appearance.fontSizeXLarge': 'Sangat besar',
'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.tabBarHeading': 'Bilah tab bawah',
'settings.appearance.tabBarAlwaysShowLabels': 'Selalu tampilkan label',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3905,6 +3905,18 @@ const messages: TranslationMap = {
"Segui l'impostazione dell'aspetto del tuo sistema operativo.",
'settings.appearance.helperText':
'La modalità oscura trasforma l\'intera app (chat, impostazioni, pannelli) in una tavolozza scura. Il "sistema di corrispondenza" segue l\'aspetto del tuo sistema operativo e si aggiorna in tempo reale.',
'settings.appearance.fontSizeHeading': 'Dimensione del carattere',
'settings.appearance.fontSizeAria': 'Dimensione del carattere',
'settings.appearance.fontSizeSmall': 'Piccolo',
'settings.appearance.fontSizeSmallDesc': 'Testo compatto — più contenuti sullo schermo.',
'settings.appearance.fontSizeMedium': 'Medio',
'settings.appearance.fontSizeMediumDesc': 'La dimensione predefinita ed equilibrata.',
'settings.appearance.fontSizeLarge': 'Grande',
'settings.appearance.fontSizeLargeDesc': 'Testo più grande per una lettura più facile.',
'settings.appearance.fontSizeXLarge': 'Molto grande',
'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.tabBarHeading': 'Barra delle schede inferiore',
'settings.appearance.tabBarAlwaysShowLabels': 'Mostra sempre le etichette',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3799,6 +3799,18 @@ const messages: TranslationMap = {
'settings.appearance.modeSystemDesc': 'OS 외관 설정을 따릅니다.',
'settings.appearance.helperText':
'다크 모드는 전체 앱 — 채팅, 설정, 패널 — 을 어두운 팔레트로 전환합니다. "시스템과 일치"는 OS 외관을 따르며 실시간으로 업데이트됩니다.',
'settings.appearance.fontSizeHeading': '글꼴 크기',
'settings.appearance.fontSizeAria': '글꼴 크기',
'settings.appearance.fontSizeSmall': '작게',
'settings.appearance.fontSizeSmallDesc': '간결한 텍스트 — 화면에 더 많이 표시.',
'settings.appearance.fontSizeMedium': '보통',
'settings.appearance.fontSizeMediumDesc': '기본의 균형 잡힌 크기.',
'settings.appearance.fontSizeLarge': '크게',
'settings.appearance.fontSizeLargeDesc': '읽기 쉬운 더 큰 텍스트.',
'settings.appearance.fontSizeXLarge': '아주 크게',
'settings.appearance.fontSizeXLargeDesc': '최대 가독성을 위한 가장 큰 텍스트.',
'settings.appearance.fontSizeHelperText':
'시스템 글꼴 설정과 관계없이 앱 전체 — 채팅, 설정, 패널 — 의 텍스트 크기를 조정합니다.',
'settings.appearance.tabBarHeading': '하단 탭 표시줄',
'settings.appearance.tabBarAlwaysShowLabels': '항상 레이블 표시',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3907,6 +3907,18 @@ const messages: TranslationMap = {
'Postępuj zgodnie z ustawieniem wyglądu Twojego systemu operacyjnego.',
'settings.appearance.helperText':
'Tryb ciemny przełącza całą aplikację — czat, ustawienia, panele — na przyciemnioną paletę. „Dopasuj do systemu” podąża za wyglądem systemu i aktualizuje się na żywo.',
'settings.appearance.fontSizeHeading': 'Rozmiar czcionki',
'settings.appearance.fontSizeAria': 'Rozmiar czcionki',
'settings.appearance.fontSizeSmall': 'Mały',
'settings.appearance.fontSizeSmallDesc': 'Zwarty tekst — więcej na ekranie.',
'settings.appearance.fontSizeMedium': 'Średni',
'settings.appearance.fontSizeMediumDesc': 'Domyślny, zrównoważony rozmiar.',
'settings.appearance.fontSizeLarge': 'Duży',
'settings.appearance.fontSizeLargeDesc': 'Większy tekst dla łatwiejszego czytania.',
'settings.appearance.fontSizeXLarge': 'Bardzo duży',
'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.tabBarHeading': 'Dolny pasek zakładek',
'settings.appearance.tabBarAlwaysShowLabels': 'Zawsze pokazuj etykiety',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3908,6 +3908,18 @@ const messages: TranslationMap = {
'Siga a configuração de aparência do seu sistema operacional.',
'settings.appearance.helperText':
'O modo escuro muda todo o aplicativo bate-papo, configurações, painéis para uma paleta escura. "Match system" segue a aparência do seu sistema operacional e atualiza ao vivo.',
'settings.appearance.fontSizeHeading': 'Tamanho da fonte',
'settings.appearance.fontSizeAria': 'Tamanho da fonte',
'settings.appearance.fontSizeSmall': 'Pequeno',
'settings.appearance.fontSizeSmallDesc': 'Texto compacto — mais conteúdo na tela.',
'settings.appearance.fontSizeMedium': 'Médio',
'settings.appearance.fontSizeMediumDesc': 'O tamanho padrão e equilibrado.',
'settings.appearance.fontSizeLarge': 'Grande',
'settings.appearance.fontSizeLargeDesc': 'Texto maior para leitura mais fácil.',
'settings.appearance.fontSizeXLarge': 'Extra grande',
'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.tabBarHeading': 'Barra inferior da guia',
'settings.appearance.tabBarAlwaysShowLabels': 'Sempre mostrar rótulos',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3873,6 +3873,18 @@ const messages: TranslationMap = {
'settings.appearance.modeSystemDesc': 'Следуйте настройкам внешнего вида вашей ОС.',
'settings.appearance.helperText':
'Темный режим переключает все приложение — чат, настройки, панели — на тусклую палитру. «Система сопоставления» следит за внешним видом вашей ОС и обновляет ее в реальном времени.',
'settings.appearance.fontSizeHeading': 'Размер шрифта',
'settings.appearance.fontSizeAria': 'Размер шрифта',
'settings.appearance.fontSizeSmall': 'Маленький',
'settings.appearance.fontSizeSmallDesc': 'Компактный текст — больше на экране.',
'settings.appearance.fontSizeMedium': 'Средний',
'settings.appearance.fontSizeMediumDesc': 'Стандартный сбалансированный размер.',
'settings.appearance.fontSizeLarge': 'Большой',
'settings.appearance.fontSizeLargeDesc': 'Более крупный текст для удобного чтения.',
'settings.appearance.fontSizeXLarge': 'Очень большой',
'settings.appearance.fontSizeXLargeDesc': 'Самый крупный текст для максимальной читаемости.',
'settings.appearance.fontSizeHelperText':
'Масштабирует текст во всём приложении — чат, настройки и панели — независимо от системных настроек шрифта.',
'settings.appearance.tabBarHeading': 'Нижняя панель вкладок',
'settings.appearance.tabBarAlwaysShowLabels': 'Всегда показывать метки',
'settings.appearance.tabBarAlwaysShowLabelsDesc':
+12
View File
@@ -3656,6 +3656,18 @@ const messages: TranslationMap = {
'settings.appearance.modeSystemDesc': '跟随操作系统外观设置。',
'settings.appearance.helperText':
'深色模式会将整个应用——聊天、设置、面板——切换为暗色调。"跟随系统"会同步你的操作系统外观并实时更新。',
'settings.appearance.fontSizeHeading': '字体大小',
'settings.appearance.fontSizeAria': '字体大小',
'settings.appearance.fontSizeSmall': '小',
'settings.appearance.fontSizeSmallDesc': '紧凑文字——屏幕显示更多内容。',
'settings.appearance.fontSizeMedium': '中',
'settings.appearance.fontSizeMediumDesc': '默认的均衡大小。',
'settings.appearance.fontSizeLarge': '大',
'settings.appearance.fontSizeLargeDesc': '更大的文字,阅读更轻松。',
'settings.appearance.fontSizeXLarge': '超大',
'settings.appearance.fontSizeXLargeDesc': '最大的文字,可读性最佳。',
'settings.appearance.fontSizeHelperText':
'在整个应用中缩放文字——聊天、设置和面板——与系统字体设置无关。',
'settings.appearance.tabBarHeading': '底部标签栏',
'settings.appearance.tabBarAlwaysShowLabels': '始终显示标签',
'settings.appearance.tabBarAlwaysShowLabelsDesc': '关闭时,标签仅出现在悬停时或活动选项卡上。',
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { FONT_SIZE_PX, type FontSize } from '../store/themeSlice';
import { renderWithProviders } from '../test/test-utils';
import ThemeProvider from './ThemeProvider';
describe('<ThemeProvider />', () => {
it.each<FontSize>(['small', 'medium', 'large', 'xlarge'])(
'applies the %s font size to the root <html> element',
fontSize => {
renderWithProviders(
<ThemeProvider>
<span>child</span>
</ThemeProvider>,
{ preloadedState: { theme: { mode: 'light', tabBarLabels: 'hover', fontSize } } }
);
expect(document.documentElement.style.fontSize).toBe(FONT_SIZE_PX[fontSize]);
}
);
it('renders its children', () => {
const { getByText } = renderWithProviders(
<ThemeProvider>
<span>hello</span>
</ThemeProvider>,
{ preloadedState: { theme: { mode: 'light', tabBarLabels: 'hover', fontSize: 'medium' } } }
);
expect(getByText('hello')).toBeInTheDocument();
});
});
+20 -6
View File
@@ -1,18 +1,32 @@
import { ReactNode, useEffect } from 'react';
import { useAppSelector } from '../store/hooks';
import { resolveTheme, type ThemeMode } from '../store/themeSlice';
import { FONT_SIZE_PX, resolveTheme, type ThemeMode } from '../store/themeSlice';
/**
* Syncs the Redux `theme.mode` slice to the `<html>` element's class list so
* Tailwind's `darkMode: 'class'` and the `:root.dark` CSS variable block in
* theme.css activate together.
* Syncs the Redux `theme` slice to the root `<html>` element:
*
* Mode = `system` also subscribes to `prefers-color-scheme` so OS-level theme
* flips propagate live without a reload.
* - `theme.mode` → the `<html>` class list so Tailwind's `darkMode: 'class'`
* and the `:root.dark` CSS variable block in theme.css activate together.
* Mode = `system` also subscribes to `prefers-color-scheme` so OS-level theme
* flips propagate live without a reload.
* - `theme.fontSize` (issue #3120) → the `<html>` inline `font-size`. Because
* Tailwind's text scale is rem-based and `:root` is 16px, scaling the root
* font-size scales every text utility app-wide (chat messages, composer, UI
* chrome) independently of the OS / system font setting.
*/
const ThemeProvider = ({ children }: { children: ReactNode }) => {
const mode = useAppSelector(state => state.theme.mode) as ThemeMode;
const fontSize = useAppSelector(state => state.theme.fontSize);
// Apply the global font size to <html>. rem-based Tailwind utilities scale
// off this, so a single inline style flows through the whole tree.
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]);
useEffect(() => {
const apply = () => {
+1 -1
View File
@@ -94,7 +94,7 @@ const persistedLocaleReducer = persistReducer(localePersistConfig, localeReducer
const themePersistConfig = {
key: 'theme',
storage: localStorageAdapter,
whitelist: ['mode', 'tabBarLabels'],
whitelist: ['mode', 'tabBarLabels', 'fontSize'],
};
const persistedThemeReducer = persistReducer(themePersistConfig, themeReducer);
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import themeReducer, {
FONT_SIZE_PX,
type FontSize,
setFontSize,
setTabBarLabels,
setThemeMode,
} from './themeSlice';
describe('themeSlice', () => {
it('defaults fontSize to medium', () => {
const state = themeReducer(undefined, { type: '@@INIT' });
expect(state.fontSize).toBe('medium');
});
it('updates fontSize via setFontSize', () => {
let state = themeReducer(undefined, { type: '@@INIT' });
state = themeReducer(state, setFontSize('large'));
expect(state.fontSize).toBe('large');
state = themeReducer(state, setFontSize('small'));
expect(state.fontSize).toBe('small');
});
it('leaves mode and tabBarLabels untouched when only fontSize changes', () => {
let state = themeReducer(undefined, { type: '@@INIT' });
state = themeReducer(state, setThemeMode('dark'));
state = themeReducer(state, setTabBarLabels('always'));
state = themeReducer(state, setFontSize('xlarge'));
expect(state).toEqual({ mode: 'dark', tabBarLabels: 'always', fontSize: 'xlarge' });
});
it('maps every font size to a concrete px value', () => {
const sizes: FontSize[] = ['small', 'medium', 'large', 'xlarge'];
expect(sizes.map(size => FONT_SIZE_PX[size])).toEqual(['14px', '16px', '18px', '20px']);
});
it('keeps medium aligned with the historical 16px root size', () => {
expect(FONT_SIZE_PX.medium).toBe('16px');
});
});
+25 -2
View File
@@ -2,13 +2,33 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type ThemeMode = 'light' | 'dark' | 'system';
export type TabBarLabels = 'hover' | 'always';
/**
* Global app font size (issue #3120). Drives the root `<html>` font-size, which
* scales every rem-based Tailwind text utility — including chat messages and the
* composer — independently of the OS / system font setting.
*/
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
* `: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.
*/
export const FONT_SIZE_PX: Record<FontSize, string> = {
small: '14px',
medium: '16px',
large: '18px',
xlarge: '20px',
};
interface ThemeState {
mode: ThemeMode;
tabBarLabels: TabBarLabels;
fontSize: FontSize;
}
const initialState: ThemeState = { mode: 'system', tabBarLabels: 'hover' };
const initialState: ThemeState = { mode: 'system', tabBarLabels: 'hover', fontSize: 'medium' };
const themeSlice = createSlice({
name: 'theme',
@@ -20,10 +40,13 @@ const themeSlice = createSlice({
setTabBarLabels(state, action: PayloadAction<TabBarLabels>) {
state.tabBarLabels = action.payload;
},
setFontSize(state, action: PayloadAction<FontSize>) {
state.fontSize = action.payload;
},
},
});
export const { setThemeMode, setTabBarLabels } = themeSlice.actions;
export const { setThemeMode, setTabBarLabels, setFontSize } = themeSlice.actions;
export default themeSlice.reducer;
/**
+4 -1
View File
@@ -18,6 +18,7 @@ import localeReducer from '../store/localeSlice';
import mascotReducer from '../store/mascotSlice';
import personaReducer from '../store/personaSlice';
import socketReducer from '../store/socketSlice';
import themeReducer from '../store/themeSlice';
/**
* Creates a fresh Redux store for testing.
@@ -27,7 +28,8 @@ import socketReducer from '../store/socketSlice';
* VoicePanel reads + dispatches against this slice, and useSelector
* would throw on a missing reducer without a stub here. `persona` is wired
* in for the same reason (issue #2345): PersonaPanel reads + dispatches
* against it.
* against it. `theme` is wired in for the appearance controls (issue #3120):
* AppearancePanel reads + dispatches theme mode, font size and tab-bar labels.
*/
const testRootReducer = combineReducers({
channelConnections: channelConnectionsReducer,
@@ -38,6 +40,7 @@ const testRootReducer = combineReducers({
mascot: mascotReducer,
persona: personaReducer,
socket: socketReducer,
theme: themeReducer,
});
export function createTestStore(preloadedState?: Record<string, unknown>) {