From ce21f273a3a83108df08d7b33c409b38c4740bfa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:52:32 +0100 Subject: [PATCH] fix: scale the reader's header and toolbar with the text size setting --- .../__tests__/reader-text-size.test.ts | 36 +++++++++++++++++++ src/components/email/MessageHeader.tsx | 12 ++++--- src/components/email/ThreadMessageCard.tsx | 13 ++++--- src/screens/EmailThreadScreen.tsx | 30 ++++++++++------ 4 files changed, 73 insertions(+), 18 deletions(-) create mode 100644 src/components/__tests__/reader-text-size.test.ts diff --git a/src/components/__tests__/reader-text-size.test.ts b/src/components/__tests__/reader-text-size.test.ts new file mode 100644 index 00000000..c546962a --- /dev/null +++ b/src/components/__tests__/reader-text-size.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// At 200% text size the reader's header and toolbar stayed at 100% while +// the list and the body grew: their styles were built from the fixed +// tokens. They now take the user's text size from useTypography, as the +// list does. No React Native renderer in the test env, so this checks the +// wiring. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + +const FILES = { + 'screens/EmailThreadScreen.tsx': 6, + 'components/email/MessageHeader.tsx': 1, + 'components/email/ThreadMessageCard.tsx': 2, +}; + +describe('reader text size', () => { + for (const [file, uses] of Object.entries(FILES)) { + it(`builds the styles of ${file} at the user's text size`, () => { + const src = read(file); + expect(src).toMatch(/function makeStyles\(c: ThemePalette, typography: ScaledTypography = tokenTypography\)/); + expect(src).not.toMatch(/makeStyles\(c\)/); + expect((src.match(/const scaledType = useTypography\(\);\s+const styles = React\.useMemo\(\(\) => makeStyles\(c, scaledType\), \[c, scaledType\]\);/g) ?? []).length) + .toBe(uses); + }); + } + + it('scales the subject line with the text size', () => { + const src = read('screens/EmailThreadScreen.tsx'); + const subject = src.slice(src.indexOf(' subjectText: {'), src.indexOf(' subjectText: {') + 300); + expect(subject).toMatch(/fontSize: typography\.h3\.fontSize,/); + expect(subject).not.toMatch(/fontSize: 18,/); + }); +}); diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index 5a07caf9..fa5101af 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -4,7 +4,10 @@ import { Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, Lock, } from '../icons'; import type { Email, EmailAddress, Identity } from '../../api/types'; -import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; +import { spacing, radius, typography, typography as tokenTypography, componentSizes, type ThemePalette } from '../../theme/tokens'; +import { useTypography } from '../../theme/dynamic'; +// The user's text size (Settings > Accessibility), as the list has it. +type ScaledTypography = ReturnType; import { useColors } from '../../theme/colors'; import SenderAvatar from '../SenderAvatar'; import { useSettingsStore } from '../../stores/settings-store'; @@ -99,7 +102,8 @@ function DetailRow({ label, value, styles, mono }: { label: string; value?: stri */ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact, jmapAccountId }: Props) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); const t = useLocaleStore((s) => s.t); const locale = useLocaleStore((s) => s.locale); const timeFormat = useSettingsStore((s) => s.timeFormat); @@ -372,7 +376,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA ); } -function makeStyles(c: ThemePalette) { +function makeStyles(c: ThemePalette, typography: ScaledTypography = tokenTypography) { return StyleSheet.create({ block: { backgroundColor: c.background, @@ -430,7 +434,7 @@ function makeStyles(c: ThemePalette) { detailRow: { flexDirection: 'row', gap: spacing.sm }, detailLabel: { ...typography.caption, color: c.textMuted, width: 92 }, detailValue: { ...typography.caption, color: c.text, flex: 1 }, - detailMono: { fontFamily: 'monospace', fontSize: 11 }, + detailMono: { fontFamily: 'monospace', fontSize: Math.round((11 * typography.caption.fontSize) / 12) }, chips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginBottom: spacing.xs }, chip: { flexDirection: 'row', diff --git a/src/components/email/ThreadMessageCard.tsx b/src/components/email/ThreadMessageCard.tsx index e0f220f1..564dd9d4 100644 --- a/src/components/email/ThreadMessageCard.tsx +++ b/src/components/email/ThreadMessageCard.tsx @@ -2,7 +2,10 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; import { Paperclip, Reply, ReplyAll, Forward, Star } from '../icons'; import type { Email } from '../../api/types'; -import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; +import { spacing, radius, typography, typography as tokenTypography, componentSizes, type ThemePalette } from '../../theme/tokens'; +import { useTypography } from '../../theme/dynamic'; +// The user's text size (Settings > Accessibility), as the list has it. +type ScaledTypography = ReturnType; import { useColors } from '../../theme/colors'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; @@ -42,7 +45,8 @@ export function ThreadMessageCard({ }: Props) { const { email, onToggleStar } = content; const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); const t = useLocaleStore((s) => s.t); const locale = useLocaleStore((s) => s.locale); const timeFormat = useSettingsStore((s) => s.timeFormat); @@ -113,7 +117,8 @@ export function ThreadMessageCard({ */ export function ThreadCardPlaceholder() { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); return ( @@ -125,7 +130,7 @@ export function ThreadCardPlaceholder() { ); } -function makeStyles(c: ThemePalette) { +function makeStyles(c: ThemePalette, typography: ScaledTypography = tokenTypography) { return StyleSheet.create({ collapsed: { flexDirection: 'row', diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index ab16ce41..e924ca91 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -20,7 +20,10 @@ import { cardReplyActions } from '../lib/thread-card-actions'; import { NewTagRow } from '../components/NewTagRow'; import { QuickRuleSheet } from '../components/filters/QuickRuleSheet'; import { isSieveSupported } from '../api/sieve'; -import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; +import { spacing, radius, typography, typography as tokenTypography, componentSizes, type ThemePalette } from '../theme/tokens'; +import { useTypography } from '../theme/dynamic'; +// The user's text size (Settings > Accessibility), as the list has it. +type ScaledTypography = ReturnType; import { useColors, useResolvedTheme } from '../theme/colors'; import { MoveSheet } from '../components/MoveSheet'; import { ThreadMessageCard, ThreadCardPlaceholder } from '../components/email/ThreadMessageCard'; @@ -97,7 +100,8 @@ export default function EmailThreadScreen(props: Props) { function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Props) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); const { t } = useLocaleStore(); // The JMAP account the message lives in, as the screen that opened it named // it (undefined = the user's own). Never the open folder's: that may be @@ -1398,7 +1402,8 @@ function MoreMenuSheet({ onForwardAsAttachment, onRules, onViewSource, onExport, }: MoreMenuSheetProps) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); const t = useLocaleStore((s) => s.t); const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(600)).current; @@ -1564,7 +1569,8 @@ interface TagMenuSheetProps { function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); const t = useLocaleStore((s) => s.t); const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(500)).current; @@ -1649,7 +1655,8 @@ function MoreMenuItem({ icon, label, onPress, trailing, }: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); return ( void }) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); // Icons only in a short (landscape) window, which leaves room for the message. const labelsSetting = useSettingsStore((s) => s.showToolbarLabels); const { shortHeight } = useLayout(); @@ -1688,7 +1696,8 @@ function BottomBarButton({ icon, label, onPress, disabled, }: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); // Icons only in a short (landscape) window, which leaves room for the message. const labelsSetting = useSettingsStore((s) => s.showToolbarLabels); const { shortHeight } = useLayout(); @@ -1710,7 +1719,7 @@ function BottomBarButton({ ); } -function makeStyles(c: ThemePalette) { +function makeStyles(c: ThemePalette, typography: ScaledTypography = tokenTypography) { return StyleSheet.create({ container: { flex: 1, backgroundColor: c.background }, @@ -1786,9 +1795,10 @@ function makeStyles(c: ThemePalette) { // Webmail viewer on a phone: text-lg font-bold tracking-tight. subjectText: { flex: 1, - fontSize: 18, + // 18 / 26 at the default text size, scaled with it. + fontSize: typography.h3.fontSize, fontWeight: '700', - lineHeight: 26, + lineHeight: Math.round((26 * typography.h3.fontSize) / 18), color: c.text, letterSpacing: -0.2, },