fix: scale the reader's header and toolbar with the text size setting

This commit is contained in:
Linus Rath
2026-10-31 13:52:38 +01:00
parent f2513de025
commit ce21f273a3
4 changed files with 73 additions and 18 deletions
@@ -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,/);
});
});
+8 -4
View File
@@ -4,7 +4,10 @@ import {
Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, Lock, Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, Lock,
} from '../icons'; } from '../icons';
import type { Email, EmailAddress, Identity } from '../../api/types'; 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<typeof useTypography>;
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import SenderAvatar from '../SenderAvatar'; import SenderAvatar from '../SenderAvatar';
import { useSettingsStore } from '../../stores/settings-store'; 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) { export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact, jmapAccountId }: Props) {
const c = useColors(); 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 t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale); const locale = useLocaleStore((s) => s.locale);
const timeFormat = useSettingsStore((s) => s.timeFormat); 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({ return StyleSheet.create({
block: { block: {
backgroundColor: c.background, backgroundColor: c.background,
@@ -430,7 +434,7 @@ function makeStyles(c: ThemePalette) {
detailRow: { flexDirection: 'row', gap: spacing.sm }, detailRow: { flexDirection: 'row', gap: spacing.sm },
detailLabel: { ...typography.caption, color: c.textMuted, width: 92 }, detailLabel: { ...typography.caption, color: c.textMuted, width: 92 },
detailValue: { ...typography.caption, color: c.text, flex: 1 }, 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 }, chips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginBottom: spacing.xs },
chip: { chip: {
flexDirection: 'row', flexDirection: 'row',
+9 -4
View File
@@ -2,7 +2,10 @@ import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native'; import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Paperclip, Reply, ReplyAll, Forward, Star } from '../icons'; import { Paperclip, Reply, ReplyAll, Forward, Star } from '../icons';
import type { Email } from '../../api/types'; 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<typeof useTypography>;
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store'; import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
@@ -42,7 +45,8 @@ export function ThreadMessageCard({
}: Props) { }: Props) {
const { email, onToggleStar } = content; const { email, onToggleStar } = content;
const c = useColors(); 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 t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale); const locale = useLocaleStore((s) => s.locale);
const timeFormat = useSettingsStore((s) => s.timeFormat); const timeFormat = useSettingsStore((s) => s.timeFormat);
@@ -113,7 +117,8 @@ export function ThreadMessageCard({
*/ */
export function ThreadCardPlaceholder() { export function ThreadCardPlaceholder() {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const scaledType = useTypography();
const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]);
return ( return (
<View style={styles.collapsed}> <View style={styles.collapsed}>
<View style={styles.placeholderAvatar} /> <View style={styles.placeholderAvatar} />
@@ -125,7 +130,7 @@ export function ThreadCardPlaceholder() {
); );
} }
function makeStyles(c: ThemePalette) { function makeStyles(c: ThemePalette, typography: ScaledTypography = tokenTypography) {
return StyleSheet.create({ return StyleSheet.create({
collapsed: { collapsed: {
flexDirection: 'row', flexDirection: 'row',
+20 -10
View File
@@ -20,7 +20,10 @@ import { cardReplyActions } from '../lib/thread-card-actions';
import { NewTagRow } from '../components/NewTagRow'; import { NewTagRow } from '../components/NewTagRow';
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet'; import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
import { isSieveSupported } from '../api/sieve'; 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<typeof useTypography>;
import { useColors, useResolvedTheme } from '../theme/colors'; import { useColors, useResolvedTheme } from '../theme/colors';
import { MoveSheet } from '../components/MoveSheet'; import { MoveSheet } from '../components/MoveSheet';
import { ThreadMessageCard, ThreadCardPlaceholder } from '../components/email/ThreadMessageCard'; 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) { function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Props) {
const c = useColors(); 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(); const { t } = useLocaleStore();
// The JMAP account the message lives in, as the screen that opened it named // 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 // it (undefined = the user's own). Never the open folder's: that may be
@@ -1398,7 +1402,8 @@ function MoreMenuSheet({
onForwardAsAttachment, onRules, onViewSource, onExport, onForwardAsAttachment, onRules, onViewSource, onExport,
}: MoreMenuSheetProps) { }: MoreMenuSheetProps) {
const c = useColors(); 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 t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(600)).current; const slideY = React.useRef(new Animated.Value(600)).current;
@@ -1564,7 +1569,8 @@ interface TagMenuSheetProps {
function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) { function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) {
const c = useColors(); 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 t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current; const slideY = React.useRef(new Animated.Value(500)).current;
@@ -1649,7 +1655,8 @@ function MoreMenuItem({
icon, label, onPress, trailing, icon, label, onPress, trailing,
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) { }: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const scaledType = useTypography();
const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]);
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
@@ -1666,7 +1673,8 @@ function ToolbarButton({
icon, label, onPress, icon, label, onPress,
}: { icon: React.ReactNode; label: string; onPress?: () => void }) { }: { icon: React.ReactNode; label: string; onPress?: () => void }) {
const c = useColors(); 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. // Icons only in a short (landscape) window, which leaves room for the message.
const labelsSetting = useSettingsStore((s) => s.showToolbarLabels); const labelsSetting = useSettingsStore((s) => s.showToolbarLabels);
const { shortHeight } = useLayout(); const { shortHeight } = useLayout();
@@ -1688,7 +1696,8 @@ function BottomBarButton({
icon, label, onPress, disabled, icon, label, onPress, disabled,
}: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) { }: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) {
const c = useColors(); 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. // Icons only in a short (landscape) window, which leaves room for the message.
const labelsSetting = useSettingsStore((s) => s.showToolbarLabels); const labelsSetting = useSettingsStore((s) => s.showToolbarLabels);
const { shortHeight } = useLayout(); const { shortHeight } = useLayout();
@@ -1710,7 +1719,7 @@ function BottomBarButton({
); );
} }
function makeStyles(c: ThemePalette) { function makeStyles(c: ThemePalette, typography: ScaledTypography = tokenTypography) {
return StyleSheet.create({ return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background }, 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. // Webmail viewer on a phone: text-lg font-bold tracking-tight.
subjectText: { subjectText: {
flex: 1, flex: 1,
fontSize: 18, // 18 / 26 at the default text size, scaled with it.
fontSize: typography.h3.fontSize,
fontWeight: '700', fontWeight: '700',
lineHeight: 26, lineHeight: Math.round((26 * typography.h3.fontSize) / 18),
color: c.text, color: c.text,
letterSpacing: -0.2, letterSpacing: -0.2,
}, },