Skip to content

feat(a11y): Dynamic Type, VoiceOver, 44pt targets and Reduce Motion across the app - #41

Merged
Ivan-LB merged 5 commits into
devfrom
feat/a11y-pass
Aug 16, 2026
Merged

Ivan-LB merged 5 commits into
devfrom
feat/a11y-pass

Conversation

@Ivan-LB

@Ivan-LB Ivan-LB commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

Executes docs/plans/pr-d3-accessibility.md — the app-wide VoiceOver / Dynamic Type / touch-target pass behind the WCAG AA commitment in PRODUCT.md. The default look does not change (unit-tested identity at the default content size).

  • Dynamic Type via one helper (f78dae9, c970f1b): .warmFont(size, weight:, design:) in Font+Warm.swift renders exactly the design size at the default content size and scales on the curve of the nearest text style (headings grow less than body at AX sizes). It is a ViewModifier that reads the size category, so views re-render on change instead of baking a stale Font. 285 fixed .system(size:) sites converted; the two Text concatenations in the wizard scale through @ScaledMetric. Fixed widths that would clip (Money inline amount fields, edit-sheet number fields) now scale with the text. WarmTypeTests: identity across the whole size table, AX scaling up / small down, heading curve < body curve, style mapping.
  • VoiceOver (529ec8b): every icon-only control has a name — "+" shell (with hint), tab items (.isTabBar container, .isSelected), sheet back/close chevrons, keypad ⌫ / decimal point, inline "+" buttons, Goals "+", favorite star (label flips), wizard back/close/clear/month arrows. Rows read as one element with a value: recent transactions, expense/income rows (category/source + long-press hint), monthly cells, goal cards, achievements. Rings/bars expose "N percent"; the keypad amount reads "Expense amount, 500 dollars"; the trend badge reads "Month over month, up 12 percent". Decorative tiles/glyphs hidden. 0 Toggle("") left (the last two, in the wizard, were fixed in C).
  • 44pt targets: .tappable44() on every sub-44 control (32/36/40pt visuals unchanged; hit area grows).
  • Reduce Motion: .honorsReduceMotion() makes every animation in a subtree instant; applied at the app root and at each presented root (quick-add sheet, goal wizard cover, edit sheet, deposit sheet), since sheets are their own tree.
  • Localization is part of AA here: literals go through the catalog; interpolated spoken strings use Text/String(localized:) so the key is a real format string. es-419 added for all 37 new keys.
  • Bars that can't carry AX text cap like UIKit's (e15e15a): at AX3 the four tab labels wrapped to single letters — the tab bar and the numeric keypad now cap at xxxLarge, exactly what UITabBar / the system number pad do; VoiceOver labels + 44pt targets carry accessibility there.
  • gotchas.yaml + DESIGN.md record the rules (warmFont, tappable44, labels, honorsReduceMotion, capping).

Numbers

before after
fixed .system(size:) 291 0 (+2 @ScaledMetric concatenations, +1 inside the modifier)
accessibilityLabel 4 30 (+ hints/values on rows, rings, amounts)
accessibilityHidden (decorative) 1 13
Toggle("") 1 0
sub-44pt controls without a hit area 22 0 (tappable44 ×14, rest were already ≥44)
Reduce Motion roots splash only app + 4 presented roots

Test plan

  • xcodebuild build — BUILD SUCCEEDED
  • xcodebuild test -skip-testing:SavelyUITests — TEST SUCCEEDED (+4 WarmTypeTests)
  • swiftlint lint --strict — 0
  • AX3 (accessibility-extra-extra-extra-large) screenshot of the Dashboard via simctl ui content_size: tab bar labels intact after the cap; text scales
  • NOT done by me (Claude Code simulator panel down for this session — no taps, and the notification-permission alert covers the launch screenshots): Accessibility Inspector audit per screen; VoiceOver walk (rows read as one element with value; toggles announce name + state); AX1/AX3 screenshots of Goals, Money, sheets, onboarding, Profile — the likeliest soft spots are the 3-up stats strip on Profile, the goal-card percent + star column, and the 7-bar income chart labels; Reduce Motion on → sheets/wizard/ring/celebration instant.
  • Test data uninstalled

Risks

  • .warmFont returns some View (was Text) — compiled clean everywhere; the only Text-returning need was the two concatenations, handled.
  • .tappable44() grows a control's layout frame to 44pt when its visual is smaller — a few HStacks gain 2–6pt around a chevron/star. Nothing perceptible in the AX3 shot, worth an eyeball at default size.
  • .honorsReduceMotion() disables all animation under Reduce Motion (instant, not crossfade). The plan asked for instant.
  • Deliberate cap on the tab bar and keypad Dynamic Type — matches UIKit; documented in gotchas.

Checklist

  • feat/ branch, Conventional Commits, no AI attribution
  • New spoken strings localized (catalog, en + es-419)
  • No secrets staged
  • gotchas.yaml / DESIGN.md updated with the new invariant
  • Tests added

Every text in the app was a fixed .system(size:) — Dynamic Type did
nothing. .warmFont(size, weight:, design:) renders at exactly the design
size at the default content size (unit-tested as an identity across the
scale) and follows the user's reading size from there, on the curve of
the text style closest to that size so headings grow less than body at
accessibility sizes. It is a ViewModifier that reads the size category
from the environment, so views re-render on change instead of baking a
stale Font value.

.tappable44() enlarges a small control's hit area to the 44pt minimum
without changing its look.
The mechanical half of the pass: 285 `.font(.system(size:…))` sites
across the app become `.warmFont(…)` with the same size, weight and
design — pixel-identical at the default content size, scaling from
there. The two Text concatenations in AddGoalFlow (which need a Font
value, not a modifier) scale through @ScaledMetric instead.

Fixed widths that would clip at accessibility sizes now scale with the
text: the inline amount fields on the Money tab and the two number
fields in the goal edit sheet.
- Every icon-only control has a name: the "+" shell (with a hint), tab
  bar items (isTabBar container, isSelected on the active one), sheet
  back/close chevrons, the keypad's ⌫ and decimal point, the Money tab's
  inline "+" buttons, the Goals "+" buttons, the favorite star (label
  flips with state), the wizard's back/close, clear-name and month
  arrows. All of them get a ≥44pt hit area via tappable44().
- Rows read as one element with a spoken value: recent transactions,
  expense and income rows (with the category/source and a long-press
  hint), monthly summary cells, goal cards, achievement rows. Rings and
  bars expose "N percent". The keypad amount reads "Expense amount, 500
  dollars" instead of three fragments; the income trend badge reads
  "Month over month, up 12 percent". Decorative tiles/stars/glyphs are
  hidden from VoiceOver.
- honorsReduceMotion(): under Reduce Motion every animation in a subtree
  becomes instant. Applied at the app root, the quick-add sheet, the
  goal wizard cover, the goal edit sheet and the deposit sheet — the
  presented roots the app-level modifier cannot reach.
- Every new spoken string is localized: literals go through the String
  Catalog, interpolations through String(localized:) / Text so the key
  is a real format string; es-419 added for all 37 new keys.
At accessibility sizes the four tab labels wrapped to single letters and
the keypad glyphs outgrew their 52pt cells. UITabBar and the system
number pad do not scale into the accessibility range either — they rely
on VoiceOver labels and hit targets — so both cap at xxxLarge.

gotchas.yaml and DESIGN.md record the rules this pass introduced:
warmFont over .system(size:), tappable44, labels on icon-only controls,
honorsReduceMotion on presented roots, localized spoken strings.
@Ivan-LB
Ivan-LB merged commit fe08bc2 into dev Aug 16, 2026
1 check passed
@Ivan-LB
Ivan-LB deleted the feat/a11y-pass branch August 16, 2026 18:55
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