Skip to content

Make text scale with Dynamic Type - #557

Merged
bradleymackey merged 1 commit into
mainfrom
native-standardization-dynamic-type
Sep 14, 2026
Merged

bradleymackey merged 1 commit into
mainfrom
native-standardization-dynamic-type

Conversation

@bradleymackey

Copy link
Copy Markdown
Member

The app's primary content — the OTP code — was a fixed 36pt, and its
status text was 7pt. Neither responded to Dynamic Type at all.

Fixed point sizes become text styles

Location Before After
LoadingBarLabel .system(size: 7, weight: .semibold) .caption2.weight(.semibold)
EncryptedItemPreviewView badge .system(size: 9, weight: .medium) .caption2.weight(.medium)
TOTPCodePreviewView, HOTPCodePreviewView, OTPWidgetSmallView .system(size: 36, design: .monospaced) .system(.largeTitle, design: .monospaced)
OTPCodeButtonView .system(size: 24, weight:) .title2.weight(_:)
BackupImportCodeStateVisualizerView .system(size: 28) / .system(size: 24).bold() .largeTitle / .title2.bold()

7pt is well below the legibility floor, and it was the only text carrying
"Code locked", "Update required" and the error titles.

The OTP code keeps its exact monospaced look — .system(_:design:) has a
text style overload, which OTPCodeTextView was already using in its
own preview. Only the scaling behaviour changes.

The three remaining .system(size:) uses are decorative glyphs (the 64pt
autofill header, the 100pt QR placeholder, and FormRow's tile glyph)
and are left alone.

The String.count font ladders are gone

Five copies of the same anti-pattern — a switch on the title's character
count picking between a text style and two or three fixed point sizes:

  • TOTPCodePreviewView, HOTPCodePreviewView and OTPWidgetSmallView
    held the same issuerFont three times
  • SecureNotePreviewView had an eight-way tuple variant
  • EncryptedItemPreviewView a four-way one

Every one bottomed out at a fixed 14–20pt, so a user at an accessibility
text size saw 14pt text inside a card sized for their setting — the exact
inversion of what Dynamic Type is for.

Each is replaced by one text style plus .minimumScaleFactor(0.7) and
.allowsTightening(true), which is what actually does the fitting.
SecureNotePreviewView keeps its one meaningful distinction — the title
is heavier when there is no description to share the card with — now
expressed as a single ternary rather than a tuple ladder.

Metrics that box text now scale

  • FormRow's 28pt glyph tile and PlaceholderView's 40pt icon frame
    become @ScaledMetric. The PlaceholderView frame was clipping a
    .largeTitle glyph at accessibility sizes.
  • OTPCodeDetailView's preview card: .frame(width: 180) →
    .frame(maxWidth: 240).
  • VaultAboutView's logo: .frame(height: 21.6) → 22. A fractional
    point height lands off the pixel grid.

The global appearance proxy is gone

VaultMainScene.init called
UITextView.appearance().textContainerInset = …, which applied to every
UITextView in the process — including ones the app does not own.

The three TextEditors that relied on it now set
.contentMargins(12, for: .scrollContent) themselves. SelectableText
also relied on it and is not a SwiftUI scroll view, so it sets
textContainerInset on its own UITextView.

While there, SelectableText stops scaling a hardcoded 16pt base and
derives from the text style's own font, so weight and tracking match the
style. Note preferredFont(forTextStyle:compatibleWith:) is already
scaled for the given content size category — it must not also be passed
through UIFontMetrics, which would scale it twice.

Verification

Local, iPhone 18 Pro Max / iOS 27.0:

  • xcodebuild build-for-testing — TEST BUILD SUCCEEDED
  • Full suite, -parallel-testing-enabled NO — TEST EXECUTE SUCCEEDED,
    2834 passed, 0 failures across 24 bundles
  • 150 snapshots re-recorded, of which 101 changed, across 12 suites; the
    remaining 49 re-recorded byte-identical. Reviewed with attention to the
    xxLarge variants, which are the point of the change.
  • make format + make lint — clean

⚠️ Automatic CI is still disabled (#548), so this is local verification only.

🤖 Generated with Claude Code

@bradleymackey
bradleymackey force-pushed the native-standardization-forms branch from 9c52731 to 4c73962 Compare September 14, 2026 16:50
Base automatically changed from native-standardization-forms to main September 14, 2026 16:50
The app's primary content — the OTP code — was a fixed 36pt, and its
status text was 7pt. Neither responded to Dynamic Type at all.

## Fixed point sizes become text styles

| Location | Before | After |
| --- | --- | --- |
| `LoadingBarLabel` | `.system(size: 7, weight: .semibold)` | `.caption2.weight(.semibold)` |
| `EncryptedItemPreviewView` badge | `.system(size: 9, weight: .medium)` | `.caption2.weight(.medium)` |
| `TOTPCodePreviewView`, `HOTPCodePreviewView`, `OTPWidgetSmallView` | `.system(size: 36, design: .monospaced)` | `.system(.largeTitle, design: .monospaced)` |
| `OTPCodeButtonView` | `.system(size: 24, weight:)` | `.title2.weight(_:)` |
| `BackupImportCodeStateVisualizerView` | `.system(size: 28)` / `.system(size: 24).bold()` | `.largeTitle` / `.title2.bold()` |

7pt is well below the legibility floor, and it was the only text carrying
"Code locked", "Update required" and the error titles.

The OTP code keeps its exact monospaced look — `.system(_:design:)` has a
*text style* overload, which `OTPCodeTextView` was already using in its
own preview. Only the scaling behaviour changes.

The three remaining `.system(size:)` uses are decorative glyphs (the 64pt
autofill header, the 100pt QR placeholder, and `FormRow`'s tile glyph)
and are left alone.

## The `String.count` font ladders are gone

Five copies of the same anti-pattern — a `switch` on the title's character
count picking between a text style and two or three fixed point sizes:

- `TOTPCodePreviewView`, `HOTPCodePreviewView` and `OTPWidgetSmallView`
  held **the same `issuerFont` three times**
- `SecureNotePreviewView` had an eight-way tuple variant
- `EncryptedItemPreviewView` a four-way one

Every one bottomed out at a fixed 14–20pt, so a user at an accessibility
text size saw 14pt text inside a card sized for their setting — the exact
inversion of what Dynamic Type is for.

Each is replaced by one text style plus `.minimumScaleFactor(0.7)` and
`.allowsTightening(true)`, which is what actually does the fitting.
`SecureNotePreviewView` keeps its one meaningful distinction — the title
is heavier when there is no description to share the card with — now
expressed as a single ternary rather than a tuple ladder.

## Metrics that box text now scale

- `FormRow`'s 28pt glyph tile and `PlaceholderView`'s 40pt icon frame
  become `@ScaledMetric`. The `PlaceholderView` frame was clipping a
  `.largeTitle` glyph at accessibility sizes.
- `OTPCodeDetailView`'s preview card: `.frame(width: 180)` →
  `.frame(maxWidth: 240)`.
- `VaultAboutView`'s logo: `.frame(height: 21.6)` → `22`. A fractional
  point height lands off the pixel grid.

## The global appearance proxy is gone

`VaultMainScene.init` called
`UITextView.appearance().textContainerInset = …`, which applied to every
`UITextView` in the process — including ones the app does not own.

The three `TextEditor`s that relied on it now set
`.contentMargins(12, for: .scrollContent)` themselves. `SelectableText`
also relied on it and is not a SwiftUI scroll view, so it sets
`textContainerInset` on its own `UITextView`.

While there, `SelectableText` stops scaling a hardcoded 16pt base and
derives from the text style's own font, so weight and tracking match the
style. Note `preferredFont(forTextStyle:compatibleWith:)` is already
scaled for the given content size category — it must not also be passed
through `UIFontMetrics`, which would scale it twice.

## Verification

Local, iPhone 18 Pro Max / iOS 27.0:

- `xcodebuild build-for-testing` — `TEST BUILD SUCCEEDED`
- Full suite, `-parallel-testing-enabled NO` — `TEST EXECUTE SUCCEEDED`,
  2834 passed, 0 failures across 24 bundles
- 150 snapshots re-recorded, of which 101 changed, across 12 suites; the
  remaining 49 re-recorded byte-identical. Reviewed with attention to the
  `xxLarge` variants, which are the point of the change.
- `make format` + `make lint` — clean

⚠️ Automatic CI is still disabled (#548), so this is local verification only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bradleymackey
bradleymackey force-pushed the native-standardization-dynamic-type branch from ac0af5d to 2b07fb4 Compare September 14, 2026 16:50
@bradleymackey
bradleymackey merged commit 7b1a91d into main Sep 14, 2026
@bradleymackey
bradleymackey deleted the native-standardization-dynamic-type branch September 14, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant