From c07ba1a370d702ae4524aefbc22c2e4921dc9e69 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:33:42 +0100 Subject: [PATCH] feat: show message bodies at the chosen text size and enlarge text below the minimum --- .../__tests__/conversation-reading.test.tsx | 9 ++ .../email/__tests__/viewer-reading.test.tsx | 23 ++++ components/email/email-viewer.tsx | 20 ++- components/email/thread-conversation-view.tsx | 15 ++- hooks/use-mail-reading.ts | 42 ++++++ lib/__tests__/mail-reading.test.ts | 78 +++++++++++ lib/mail-reading.ts | 126 ++++++++++++++++++ 7 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 hooks/use-mail-reading.ts create mode 100644 lib/__tests__/mail-reading.test.ts create mode 100644 lib/mail-reading.ts diff --git a/components/email/__tests__/conversation-reading.test.tsx b/components/email/__tests__/conversation-reading.test.tsx index df175663b..04b6ce18e 100644 --- a/components/email/__tests__/conversation-reading.test.tsx +++ b/components/email/__tests__/conversation-reading.test.tsx @@ -70,3 +70,12 @@ describe('conversation controls name their target', () => { expect(screen.getByRole('button', { name: /^email_viewer\.(download|preview)_named \{"name":"report\.pdf"\}$/ })).toBeInTheDocument(); }); }); + +describe('conversation message text size', () => { + it('plain text follows the mail text size instead of a fixed 13px', () => { + useSettingsStore.setState({ mailTextScale: 2 } as never); + show([mail('a', { htmlBody: [], textBody: [{ partId: '1', type: 'text/plain' }], bodyValues: { '1': { value: 'just text' } } })]); + const body = screen.getByText('just text').closest('[data-plugin-source="email-body"]') as HTMLElement; + expect(body.style.fontSize).toMatch(/0\.8125rem \* 2|1\.625rem/); + }); +}); diff --git a/components/email/__tests__/viewer-reading.test.tsx b/components/email/__tests__/viewer-reading.test.tsx index 434c20a93..b65ff5e21 100644 --- a/components/email/__tests__/viewer-reading.test.tsx +++ b/components/email/__tests__/viewer-reading.test.tsx @@ -105,3 +105,26 @@ describe('attachment actions', () => { expect(screen.getAllByRole('button', { name: 'download_named {"name":"report.pdf"}' }).length).toBeGreaterThan(0); }); }); + +describe('message text size', () => { + it('plain text follows the mail text size and its minimum instead of a fixed 14px', async () => { + useSettingsStore.setState({ mailTextScale: 1.5, mailMinFontSize: 16 } as never); + await show(message({ text: 'Plain words here' })); + const body = screen.getByText('Plain words here').closest('.email-content-text') as HTMLElement; + // jsdom folds the calc(). + expect(body.style.fontSize).toMatch(/^max\(.*(0\.875rem \* 1\.5|1\.3125rem).*, 16px\)$/); + }); + + it('plain text follows the app text size by default', async () => { + useSettingsStore.setState({ fontSize: 'xx-large' } as never); + await show(message({ text: 'Plain words here' })); + const body = screen.getByText('Plain words here').closest('.email-content-text') as HTMLElement; + expect(body.style.fontSize).toBe('0.875rem'); + }); + + it('rebuilds the HTML frame for a new size', async () => { + useSettingsStore.setState({ fontSize: 'xx-large' } as never); + const { container } = await show(message({ html: '

Some formatted text

' })); + expect(container.querySelector('iframe')!.getAttribute('srcdoc')).toContain('data-reading="1.5/0"'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 711440a5e..cdf182581 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -7,6 +7,8 @@ import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { mailLanguage } from "@/lib/mail-language"; +import { applyMailTextSize } from "@/lib/mail-reading"; +import { useMailReading } from "@/hooks/use-mail-reading"; import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode"; @@ -3149,6 +3151,12 @@ export function EmailViewer({ }, [effectiveEmailContent.html, effectiveEmailContent.isHtml]); const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode); + // Text size, minimum size and the other reading settings for the body. A + // ref for the iframe setup, which runs from observers wired once per + // document; the srcDoc changes with frameKey, so a new setting rebuilds it. + const reading = useMailReading(); + const readingRef = useRef(reading); + readingRef.current = reading; const [emailViewDarkOverride, setEmailViewDarkOverride] = useState(null); const isDark = emailViewDarkOverride !== null ? emailViewDarkOverride @@ -3204,7 +3212,7 @@ export function EmailViewer({ const iframeCsp = emailIframeCsp(effectiveEmailContent.externalBlocked); return ` - + @@ -3231,7 +3239,7 @@ export function EmailViewer({ verification email rendered as a logo-only 150px strip). Neutralise the full-height trick on any element so the body grows to its content. */ [style*="height:100%"], [style*="height: 100%"] { height: auto !important; } - body { margin: 0; padding: ${bodyPadding}; overflow-x: auto; overflow-y: hidden; height: auto !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; } + body { margin: 0; padding: ${bodyPadding}; overflow-x: auto; overflow-y: hidden; height: auto !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 0.875rem; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; } @media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } } /* Only force the cap on images that set no width of their own: an !important 100% also overrides a sender's inline max-width, and an @@ -3256,7 +3264,7 @@ export function EmailViewer({ ${wordHtmlCSS} ${darkModeCSS} ${resolveColorSchemeMedia(effectiveEmailContent.html, isDark)}`; - }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage]); + }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage, reading.frameKey]); // Unblocking external content is handled by rebuilding the iframe srcDoc: // toggling allowExternalContent (both "Load images" and "Trust sender" set @@ -3389,6 +3397,10 @@ export function EmailViewer({ hide: t('hide_quoted_text'), }); + // Text size and minimum size (Settings > Accessibility), before the + // height wiring so the first measurement is already at the new size. + applyMailTextSize(doc, { factor: readingRef.current.textFactor, minPx: readingRef.current.minFontSize }); + // Before the height wiring and after the quote toggle exists, so the // toggle is converted too. Reads the mail's computed colours and // un-hides the body when done. @@ -6189,7 +6201,7 @@ export function EmailViewer({ dangerouslySetInnerHTML={plainBodyHtml} style={{ ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), - fontSize: '14px', + fontSize: reading.plainFontSize(14), lineHeight: '1.6', wordBreak: 'break-word', whiteSpace: 'pre-wrap', diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 686b8ce14..6c023b9f2 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -16,6 +16,8 @@ import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidRefere import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; import { mailLanguage } from "@/lib/mail-language"; +import { applyMailTextSize } from "@/lib/mail-reading"; +import { useMailReading } from "@/hooks/use-mail-reading"; import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode"; import { useThemeStore } from "@/stores/theme-store"; import { useShallow } from "zustand/react/shallow"; @@ -566,6 +568,7 @@ export function ConversationEmailBody({ const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode); const isDark = resolvedTheme === 'dark' && !emailAlwaysLightMode; const plainTextFont = useSettingsStore((state) => state.plainTextFont); + const reading = useMailReading(); const showVerificationCodes = useSettingsStore((state) => state.showVerificationCodes); const verificationCode = useMemo( () => (showVerificationCodes ? findVerificationCode(email.subject, verificationCodeBodyText(email)) : null), @@ -771,7 +774,7 @@ export function ConversationEmailBody({ // for whatever the DOM walk above cannot see. const csp = emailIframeCsp(!allowExternal); // Colours are rewritten after load (applyEmailDarkMode in handleIframeLoad). - return ` + return ` @@ -782,7 +785,7 @@ export function ConversationEmailBody({ on a phone fitEmailBodyWidth() shrinks it to the screen first, so the pan is only left for content too wide to stay legible when scaled. */ html { overflow: hidden; } - body { overflow-x: auto; overflow-y: hidden; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; } + body { overflow-x: auto; overflow-y: hidden; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 0.875rem; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; } /* Only force the cap on images that set no width of their own: an !important 100% also overrides a sender's inline max-width, and an image sized by max-width + max-height + width:100% then grows to @@ -803,7 +806,7 @@ export function ConversationEmailBody({ pre { white-space: pre-wrap; word-wrap: break-word; } ${isDark ? emailDarkModeCss() : ''} ${emailContent.html}`; - }, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage]); + }, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage, reading.frameKey]); useLayoutEffect(() => { setHtmlBodyEl(null); }, [emailIframeSrcDoc]); // One object per body: React writes innerHTML again whenever this object // changes, dropping whatever was mounted in the body. @@ -824,6 +827,8 @@ export function ConversationEmailBody({ show: t('email_viewer.show_quoted_text'), hide: t('email_viewer.hide_quoted_text'), }); + // Text size and minimum size, before the first resize. + applyMailTextSize(doc, { factor: reading.textFactor, minPx: reading.minFontSize }); if (isDark) applyEmailDarkMode(doc, { remoteContentAllowed: allowExternal }); const resize = () => { // Fit desktop-width mail to the screen before measuring: the transform @@ -868,7 +873,7 @@ export function ConversationEmailBody({ } catch { // contentDocument may be inaccessible under stricter sandboxes; ignore. } - }, [t, isDark, allowExternal, email.id, selectionMenu.openRef]); + }, [t, isDark, allowExternal, email.id, selectionMenu.openRef, reading.textFactor, reading.minFontSize]); return (
@@ -941,7 +946,7 @@ export function ConversationEmailBody({ "[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2", "[&_img]:max-w-full [&_img]:h-auto" )} - style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: '13px' }} + style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: reading.plainFontSize(13) }} data-plugin-source="email-body" data-email-id={email.id} onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email.id)} diff --git a/hooks/use-mail-reading.ts b/hooks/use-mail-reading.ts new file mode 100644 index 000000000..3eed78fb6 --- /dev/null +++ b/hooks/use-mail-reading.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useSettingsStore } from "@/stores/settings-store"; +import { mailTextFactor, plainBodyFontSize } from "@/lib/mail-reading"; + +/** + * The reading settings as they apply to the message open right now: "follow + * the system" values resolved against the OS, and the values the body frame + * and the plain-text body need. Shared by the reader and the conversation + * view so a message reads the same in both. + */ +export interface MailReading { + /** Multiplier on the mail's text sizes. */ + textFactor: number; + /** Smallest text size in the body, CSS px; 0 = none. */ + minFontSize: number; + /** Font size for a plain-text body of `basePx` at 100 %. */ + plainFontSize: (basePx: number) => string; + /** Changes whenever the body frame has to be built again. */ + frameKey: string; +} + +export function useMailReading(): MailReading { + const s = useSettingsStore(useShallow((state) => ({ + fontSize: state.fontSize, + mailTextScale: state.mailTextScale, + mailMinFontSize: state.mailMinFontSize, + }))); + + return useMemo(() => { + const textFactor = mailTextFactor(s.mailTextScale, s.fontSize); + const minFontSize = s.mailMinFontSize || 0; + return { + textFactor, + minFontSize, + plainFontSize: (basePx: number) => plainBodyFontSize(basePx, s.mailTextScale, s.fontSize, minFontSize), + frameKey: `${textFactor}/${minFontSize}`, + }; + }, [s.mailTextScale, s.fontSize, s.mailMinFontSize]); +} diff --git a/lib/__tests__/mail-reading.test.ts b/lib/__tests__/mail-reading.test.ts new file mode 100644 index 000000000..87a4466f5 --- /dev/null +++ b/lib/__tests__/mail-reading.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { applyMailTextSize, mailTextFactor, plainBodyFontSize, scaledTextSize } from '@/lib/mail-reading'; + +describe('mailTextFactor', () => { + it('follows the app text size when the mail size is 0', () => { + expect(mailTextFactor(0, 'medium')).toBe(1); + expect(mailTextFactor(0, 'xx-large')).toBe(1.5); + }); + it('uses the mail size when one is set', () => { + expect(mailTextFactor(1.25, 'huge')).toBe(1.25); + }); +}); + +describe('plainBodyFontSize', () => { + it('stays at the old size by default', () => { + expect(plainBodyFontSize(14, 0, 'medium', 0)).toBe('0.875rem'); + }); + it('relies on rem for the app size and multiplies only what the mail size adds', () => { + // rem already carries 1.25, so 1.5 needs another 1.2. + expect(plainBodyFontSize(14, 1.5, 'x-large', 0)).toBe('calc(0.875rem * 1.2)'); + expect(plainBodyFontSize(14, 0, 'x-large', 0)).toBe('0.875rem'); + }); + it('puts a floor under it', () => { + expect(plainBodyFontSize(13, 0, 'medium', 16)).toBe('max(0.8125rem, 16px)'); + }); +}); + +describe('scaledTextSize', () => { + it('scales font and px line height together', () => { + expect(scaledTextSize(10, 15, { factor: 1.5, minPx: 0 })).toEqual({ font: 15, lineHeight: 22.5 }); + }); + it('raises text below the minimum, with its line height', () => { + expect(scaledTextSize(9, 12, { factor: 1, minPx: 18 })).toEqual({ font: 18, lineHeight: 24 }); + expect(scaledTextSize(20, null, { factor: 1, minPx: 18 })).toEqual({ font: 20, lineHeight: null }); + }); + it('applies the minimum after scaling', () => { + expect(scaledTextSize(10, null, { factor: 2, minPx: 16 }).font).toBe(20); + expect(scaledTextSize(6, null, { factor: 2, minPx: 16 }).font).toBe(16); + }); +}); + +function mailDoc(html: string): Document { + // A frame's document, so it has a window for getComputedStyle. + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const doc = frame.contentDocument!; + doc.body.innerHTML = html; + return doc; +} + +describe('applyMailTextSize', () => { + it('leaves a mail alone at 100 % with no minimum', () => { + const doc = mailDoc('

tiny

'); + expect(applyMailTextSize(doc, { factor: 1, minPx: 0 })).toBe(false); + expect(doc.querySelector('p')!.style.fontSize).toBe('10px'); + }); + + it('enlarges small text to the minimum and pins the rest', () => { + const doc = mailDoc('
fine print
'); + doc.body.style.fontSize = '14px'; + expect(applyMailTextSize(doc, { factor: 1, minPx: 12 })).toBe(true); + expect(doc.querySelector('span')!.style.getPropertyValue('font-size')).toBe('12px'); + expect(doc.querySelector('span')!.style.getPropertyPriority('font-size')).toBe('important'); + expect(doc.querySelector('div')!.style.getPropertyValue('font-size')).toBe('20px'); + }); + + it('scales every text size and runs once per document', () => { + const doc = mailDoc('

a

'); + doc.body.style.fontSize = '14px'; + applyMailTextSize(doc, { factor: 1.5, minPx: 0 }); + const p = doc.querySelector('p')!; + expect(p.style.getPropertyValue('font-size')).toBe('24px'); + expect(p.style.getPropertyValue('line-height')).toBe('30px'); + expect(doc.body.style.getPropertyValue('font-size')).toBe('21px'); + expect(applyMailTextSize(doc, { factor: 1.5, minPx: 0 })).toBe(false); + expect(p.style.getPropertyValue('font-size')).toBe('24px'); + }); +}); diff --git a/lib/mail-reading.ts b/lib/mail-reading.ts new file mode 100644 index 000000000..6f1147fc0 --- /dev/null +++ b/lib/mail-reading.ts @@ -0,0 +1,126 @@ +/** + * How the reading settings on Settings > Accessibility reach a message body. + * + * HTML mail renders in a sandboxed iframe that the app's stylesheet doesn't + * reach, so the text size, spacing, colours and motion settings have to be + * carried into that document: as CSS appended after the mail's own (so it + * wins the cascade) and, for text size, as a pass over the computed styles + * after the document parsed. Plain-text bodies sit in the app document and + * only need their own font size. + */ + +import { FONT_SIZE_SCALE, type FontSize } from '@/lib/accessibility-settings'; + +/** Size of body text in a message, in CSS px at 100 %: what mail used to be fixed at. */ +export const MAIL_BASE_FONT_PX = 14; + +/** + * The factor message text is drawn at: the mail text size, or the app's text + * size when that is set to follow it (0). + */ +export function mailTextFactor(mailTextScale: number, fontSize: FontSize): number { + if (mailTextScale > 0) return mailTextScale; + return FONT_SIZE_SCALE[fontSize] ?? 1; +} + +/** + * CSS font size for a plain-text body in the app document. Its rem already + * carries the app's text size, so only the part the mail size adds on top of + * that is multiplied in. The minimum, when set, is a floor in px. + */ +export function plainBodyFontSize(basePx: number, mailTextScale: number, fontSize: FontSize, minPx: number): string { + const appFactor = FONT_SIZE_SCALE[fontSize] ?? 1; + const extra = Math.round((mailTextFactor(mailTextScale, fontSize) / appFactor) * 10000) / 10000; + const rem = Math.round((basePx / 16) * 10000) / 10000; + const size = extra === 1 ? `${rem}rem` : `calc(${rem}rem * ${extra})`; + return minPx > 0 ? `max(${size}, ${minPx}px)` : size; +} + +/** + * One element's text size after scaling by `factor` and raising it to the + * minimum. A line height in px grows by the same ratio, so the enlarged text + * doesn't overlap the next line; "normal" (null) needs nothing. + */ +export function scaledTextSize( + fontPx: number, + lineHeightPx: number | null, + { factor, minPx }: { factor: number; minPx: number }, +): { font: number; lineHeight: number | null } { + if (!(fontPx > 0)) return { font: fontPx, lineHeight: lineHeightPx }; + const font = Math.max(fontPx * factor, minPx || 0); + const ratio = font / fontPx; + return { font, lineHeight: lineHeightPx === null ? null : lineHeightPx * ratio }; +} + +/** Elements that draw no text of their own. */ +const NO_TEXT = new Set(['IMG', 'BR', 'HR', 'WBR', 'PICTURE', 'SOURCE', 'VIDEO', 'AUDIO', 'CANVAS', 'IFRAME', 'OBJECT', 'EMBED', 'STYLE', 'SCRIPT', 'TEMPLATE']); + +export interface MailTextSizeOptions { + /** Multiplier on every text size in the mail. */ + factor: number; + /** Smallest text size, in CSS px; 0 = no minimum. */ + minPx: number; + /** Turn justified text into start-aligned text. */ + unjustify?: boolean; + /** Leave sizes alone (the override stylesheet sets them); only unjustify. */ + sizesFromCss?: boolean; +} + +const DONE_ATTR = 'data-bulwark-text-size'; + +/** + * Scales the text of a rendered mail document and raises text below the + * minimum, by reading every element's computed font size and writing the new + * one back inline. Reads all elements first and writes after, so the pass + * costs one style recalculation. Once one element changes, every element is + * pinned to its own px size: a child sized in em would otherwise grow a second + * time with its parent. Runs once per document; returns whether it changed + * anything. + * + * Text size works on the text rather than zooming the whole mail, so a + * fixed-width newsletter keeps its width and its text wraps inside it instead + * of pushing the layout off the side of the pane. + */ +export function applyMailTextSize(doc: Document, options: MailTextSizeOptions): boolean { + const body = doc.body; + const view = doc.defaultView; + if (!body || !view || body.hasAttribute(DONE_ATTR)) return false; + const factor = options.sizesFromCss ? 1 : options.factor; + const minPx = options.sizesFromCss ? 0 : options.minPx; + if (factor === 1 && !(minPx > 0) && !options.unjustify) return false; + body.setAttribute(DONE_ATTR, ''); + + const elements: HTMLElement[] = [body]; + body.querySelectorAll('*').forEach((el) => { + if (NO_TEXT.has(el.tagName) || el.closest('svg')) return; + elements.push(el); + }); + + const writes: Array<() => void> = []; + const sizes: Array<{ el: HTMLElement; font: number; lineHeight: number | null; changed: boolean }> = []; + let anySizeChanged = false; + for (const el of elements) { + const style = view.getComputedStyle(el); + if (options.unjustify && style.textAlign === 'justify') { + writes.push(() => el.style.setProperty('text-align', 'start', 'important')); + } + if (factor === 1 && !(minPx > 0)) continue; + const fontPx = parseFloat(style.fontSize); + if (!(fontPx > 0)) continue; + const lineHeightPx = style.lineHeight.endsWith('px') ? parseFloat(style.lineHeight) : null; + const next = scaledTextSize(fontPx, lineHeightPx, { factor, minPx }); + const changed = Math.abs(next.font - fontPx) > 0.01; + if (changed) anySizeChanged = true; + sizes.push({ el, font: next.font, lineHeight: next.lineHeight, changed }); + } + if (anySizeChanged) { + for (const { el, font, lineHeight } of sizes) { + writes.push(() => { + el.style.setProperty('font-size', `${Math.round(font * 100) / 100}px`, 'important'); + if (lineHeight !== null) el.style.setProperty('line-height', `${Math.round(lineHeight * 100) / 100}px`, 'important'); + }); + } + } + for (const write of writes) write(); + return writes.length > 0; +}