From 11fae79346a29e46da4a0fe7e51b88fc3f82cf4e Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:45:10 +0530 Subject: [PATCH] feat(appearance): add global font size setting (#3120) (#3396) --- .../settings/panels/AppearancePanel.test.tsx | 48 ++++++++ .../settings/panels/AppearancePanel.tsx | 104 ++++++++++++++++++ app/src/lib/i18n/ar.ts | 12 ++ app/src/lib/i18n/bn.ts | 12 ++ app/src/lib/i18n/de.ts | 12 ++ app/src/lib/i18n/en.ts | 12 ++ app/src/lib/i18n/es.ts | 12 ++ app/src/lib/i18n/fr.ts | 12 ++ app/src/lib/i18n/hi.ts | 12 ++ app/src/lib/i18n/id.ts | 12 ++ app/src/lib/i18n/it.ts | 12 ++ app/src/lib/i18n/ko.ts | 12 ++ app/src/lib/i18n/pl.ts | 12 ++ app/src/lib/i18n/pt.ts | 12 ++ app/src/lib/i18n/ru.ts | 12 ++ app/src/lib/i18n/zh-CN.ts | 12 ++ app/src/providers/ThemeProvider.test.tsx | 32 ++++++ app/src/providers/ThemeProvider.tsx | 26 ++++- app/src/store/index.ts | 2 +- app/src/store/themeSlice.test.ts | 41 +++++++ app/src/store/themeSlice.ts | 27 ++++- app/src/test/test-utils.tsx | 5 +- 22 files changed, 443 insertions(+), 10 deletions(-) create mode 100644 app/src/components/settings/panels/AppearancePanel.test.tsx create mode 100644 app/src/providers/ThemeProvider.test.tsx create mode 100644 app/src/store/themeSlice.test.ts diff --git a/app/src/components/settings/panels/AppearancePanel.test.tsx b/app/src/components/settings/panels/AppearancePanel.test.tsx new file mode 100644 index 000000000..16de804c5 --- /dev/null +++ b/app/src/components/settings/panels/AppearancePanel.test.tsx @@ -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 }) =>

{title}

, +})); + +function renderPanel(fontSize: 'small' | 'medium' | 'large' | 'xlarge' = 'medium') { + return renderWithProviders(, { + preloadedState: { theme: { mode: 'system', tabBarLabels: 'hover', fontSize } }, + }); +} + +describe(' 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'); + }); +}); diff --git a/app/src/components/settings/panels/AppearancePanel.tsx b/app/src/components/settings/panels/AppearancePanel.tsx index 6c4832ba1..ba01337ea 100644 --- a/app/src/components/settings/panels/AppearancePanel.tsx +++ b/app/src/components/settings/panels/AppearancePanel.tsx @@ -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 = ( { 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 (
{

+
+

+ {t('settings.appearance.fontSizeHeading')} +

+
+ {FONT_SIZE_OPTIONS.map((opt, idx) => { + const selected = opt.id === fontSize; + return ( + + ); + })} +
+

+ {t('settings.appearance.fontSizeHelperText')} +

+
+

{t('settings.appearance.tabBarHeading')} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 0074c12a5..9879f800b 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 8feb8c607..bf550d9ad 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 0fddf74b4..4e79a99be 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index f5e80b0c0..d13b210c2 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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': diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 4f11eb404..152e12f5f 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index a1132594a..a4cd5b8ca 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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 l’application — 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': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 0c0c59654..afa649e18 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 876749006..1a938ce62 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 5d32c4ec7..4e2323f00 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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 l’app — chat, impostazioni e pannelli — indipendentemente dall’impostazione del carattere del sistema.', 'settings.appearance.tabBarHeading': 'Barra delle schede inferiore', 'settings.appearance.tabBarAlwaysShowLabels': 'Mostra sempre le etichette', 'settings.appearance.tabBarAlwaysShowLabelsDesc': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 996be46b5..c0a66bc54 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index ca1e4035f..118127121 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index fb580a19a..d723ade7e 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index b4b856d64..52a3e158a 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 99e49c709..9c2f4c4f0 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '关闭时,标签仅出现在悬停时或活动选项卡上。', diff --git a/app/src/providers/ThemeProvider.test.tsx b/app/src/providers/ThemeProvider.test.tsx new file mode 100644 index 000000000..3d2536834 --- /dev/null +++ b/app/src/providers/ThemeProvider.test.tsx @@ -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('', () => { + it.each(['small', 'medium', 'large', 'xlarge'])( + 'applies the %s font size to the root element', + fontSize => { + renderWithProviders( + + child + , + { preloadedState: { theme: { mode: 'light', tabBarLabels: 'hover', fontSize } } } + ); + + expect(document.documentElement.style.fontSize).toBe(FONT_SIZE_PX[fontSize]); + } + ); + + it('renders its children', () => { + const { getByText } = renderWithProviders( + + hello + , + { preloadedState: { theme: { mode: 'light', tabBarLabels: 'hover', fontSize: 'medium' } } } + ); + + expect(getByText('hello')).toBeInTheDocument(); + }); +}); diff --git a/app/src/providers/ThemeProvider.tsx b/app/src/providers/ThemeProvider.tsx index 2f94e1dab..8ba804663 100644 --- a/app/src/providers/ThemeProvider.tsx +++ b/app/src/providers/ThemeProvider.tsx @@ -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 `` 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 `` element: * - * Mode = `system` also subscribes to `prefers-color-scheme` so OS-level theme - * flips propagate live without a reload. + * - `theme.mode` → the `` 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 `` 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 . 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 = () => { diff --git a/app/src/store/index.ts b/app/src/store/index.ts index 8f435c021..98a01a409 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -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); diff --git a/app/src/store/themeSlice.test.ts b/app/src/store/themeSlice.test.ts new file mode 100644 index 000000000..1f2a3af6c --- /dev/null +++ b/app/src/store/themeSlice.test.ts @@ -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'); + }); +}); diff --git a/app/src/store/themeSlice.ts b/app/src/store/themeSlice.ts index c9b41e516..2d843684f 100644 --- a/app/src/store/themeSlice.ts +++ b/app/src/store/themeSlice.ts @@ -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 `` 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 ``. `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 = { + 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) { state.tabBarLabels = action.payload; }, + setFontSize(state, action: PayloadAction) { + state.fontSize = action.payload; + }, }, }); -export const { setThemeMode, setTabBarLabels } = themeSlice.actions; +export const { setThemeMode, setTabBarLabels, setFontSize } = themeSlice.actions; export default themeSlice.reducer; /** diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx index 9f16fe751..ea1c22258 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -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) {