Commit e2da09fec3

e2da09fec3d3d52ac6108b9256b2419405f76dd3

parent: 1c2830fbfc

Unsigned

cmc <hello@cleberg.net> · 2026-07-21 02:49 UTC

feat(a11y): Dynamic Type reflow and tap targets (#21 phase 3)

Takes the audit from 18 findings to 11 in dark mode. Everything that
remains is system-rendered or placeholder noise, characterised below.

The largest win was not where the plan expected. Every empty-state
heading reported as clipped text, and the cause was `Label`: it
constrains its own title, and `.fixedSize` applied to the Label does not
reach the `Text` inside. Splitting into `HStack { Image; Text }` and
putting the modifier on the Text cleared all four empty states at both
default and accessibility sizes.

That fix then caused a regression the audit caught immediately. `Label`
folds its image into the title's accessibility element; an HStack does
not, so the icon began announcing its raw SF Symbol name
("checklist.unchecked") to VoiceOver. Decorative icons split out of a
Label now carry .accessibilityHidden(true).

Tap targets:

- AppCopyButton was a literal 30x30 on nearly every data row. Now
  @ScaledMetric from 44, floored at AppLayout.minimumTapTarget —
  @ScaledMetric scales down below the default text size as well as up,
  so the floor is load-bearing.
- controlMinHeight was 42 in compact density, putting every collapsible
  section header and both Run buttons under the minimum.

Reflow:

- CardView's allowsHorizontalScroll defaulted to true, so nine call sites
  hid content behind a horizontal gesture instead of wrapping — a WCAG
  1.4.10 failure and the mechanism behind clipped rows at large text
  sizes. The default is now false, and the remaining opt-in is suppressed
  at accessibility sizes.
- Fixed .system(size:) point sizes replaced with text styles in the app
  and the widget.
- The widget is clamped at accessibility1, the one place clamping is
  correct: a widget canvas is a fixed size and WidgetKit truncates
  overflow with no scroll affordance.

Two hypotheses were tested and discarded rather than left in. Monospaced
fonts looked like the clipping culprit — the app is 82% monospaced and
hyphenates mid-word at accessibility sizes — but switching the empty
state to proportional changed nothing, and prose typography is a design
decision rather than an accessibility fix. Shortening search prompts and
the domain placeholder also changed nothing: placeholder text is reported
clipped regardless of length, so "Search" is flagged exactly as "Search
portfolio" was.

Not done: ViewThatFits reflow for BatchResultRowView and
WatchlistRowView. Those rows never render in the audit because the test
simulator has no tracked domains or batch results, so any change there
would be unverifiable. Absence of findings is absence of data.

Layout: unified · split

Docs/ACCESSIBILITY.md +21
@@ -187,6 +187,27 @@ Pre-push rather than pre-commit deliberately: the suite takes ~85s, and at
187187pre-commit that blocks every commit. A hook routinely bypassed with
188188`--no-verify` is worse than no hook, because it trains you to ignore it.
189189
190## Layout gotchas found the hard way
191
192- **`Label` clips its own title.** Every empty-state heading reported as clipped
193 text. `.fixedSize` applied to the `Label` does not reach the `Text` inside it,
194 so the fix is to split it into an `HStack { Image; Text }` and put the modifier
195 on the `Text`. Changing the font design did **not** help — that hypothesis was
196 tested and discarded.
197- **Splitting a `Label` exposes its icon to VoiceOver.** `Label` folds the image
198 into the title's accessibility element; an `HStack` does not, so the icon
199 starts announcing its raw SF Symbol name ("checklist.unchecked"). Decorative
200 icons split out of a `Label` need `.accessibilityHidden(true)`.
201- **Placeholder text is always reported as clipped.** Search prompts and
202 `TextField` placeholders are flagged regardless of length — shortening
203 "Search portfolio" to "Search" changed nothing. Treat `textClipped` findings on
204 a `searchField` or `textField` element as noise rather than shortening useful
205 prompts to chase them.
206- **`AppLayout.minimumTapTarget` is the floor for every control.** `@ScaledMetric`
207 scales *down* below the default text size as well as up, so a scaled dimension
208 needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for
209 users who prefer smaller text.
210
190211## Notes
191212
192213- **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3
DomainDig/ContentView.swift +14 −2
@@ -2418,19 +2418,31 @@ struct SectionTitleView: View {
24182418 }
24192419}
24202420
2421/// A card that wraps its content by default.
2422///
2423/// `allowsHorizontalScroll` used to default to `true`, so nine call sites put
2424/// their content behind a horizontal gesture instead of letting it wrap — a
2425/// WCAG 1.4.10 (Reflow) failure, and the mechanism behind clipped rows at large
2426/// text sizes. It also forced VoiceOver and Switch Control users onto a nested
2427/// scroll axis to reach data.
2428///
2429/// The default is now `false`. Where horizontal scrolling genuinely suits wide
2430/// tabular content, it is still opt-in — but it is suppressed at accessibility
2431/// text sizes, where wrapping always beats a hidden axis.
24212432struct CardView<Content: View>: View {
24222433 @Environment(\.appDensity) private var appDensity
2434 @Environment(\.dynamicTypeSize) private var dynamicTypeSize
24232435 let allowsHorizontalScroll: Bool
24242436 let content: Content
24252437
2426 init(allowsHorizontalScroll: Bool = true, @ViewBuilder content: () -> Content) {
2438 init(allowsHorizontalScroll: Bool = false, @ViewBuilder content: () -> Content) {
24272439 self.allowsHorizontalScroll = allowsHorizontalScroll
24282440 self.content = content()
24292441 }
24302442
24312443 var body: some View {
24322444 Group {
2433 if allowsHorizontalScroll {
2445 if allowsHorizontalScroll, !dynamicTypeSize.isAccessibilitySize {
24342446 ScrollView(.horizontal) {
24352447 cardContent
24362448 .scrollTargetLayout()
DomainDig/DashboardView.swift +2 −1
@@ -194,7 +194,8 @@ struct DashboardView: View {
194194 .font(appDensity.font(.caption, design: .default, weight: .semibold))
195195 .foregroundStyle(Color(.appTextSecondary))
196196 Text("\(value)")
197 .font(.system(size: 28, weight: .bold, design: .rounded))
197 // Was a fixed 28pt, which ignored Dynamic Type entirely.
198 .font(.system(.title, design: .rounded, weight: .bold))
198199 .foregroundStyle(.primary)
199200 HStack {
200201 Circle()
DomainDig/DomainDigUI.swift +38 −5
@@ -72,7 +72,9 @@ enum AppDensity: String, CaseIterable, Identifiable {
7272 rowSpacing: 4,
7373 rowMinHeight: 30,
7474 controlVerticalPadding: 10,
75 controlMinHeight: 42,
75 // Was 42, which put every control using it under the 44pt
76 // minimum in compact density — section headers, Run, Run Batch.
77 controlMinHeight: AppLayout.minimumTapTarget,
7678 cardCornerRadius: 10
7779 )
7880 case .comfortable:
@@ -98,6 +100,13 @@ enum AppDensity: String, CaseIterable, Identifiable {
98100 }
99101}
100102
103/// Layout constants that are not density-dependent.
104enum AppLayout {
105 /// The HIG minimum for an interactive control, and WCAG 2.5.8's floor.
106 /// Controls scale up from here with Dynamic Type; none may sit below it.
107 static let minimumTapTarget: CGFloat = 44
108}
109
101110struct AppDensityMetrics: Equatable {
102111 let sectionSpacing: CGFloat
103112 let cardSpacing: CGFloat
@@ -249,6 +258,11 @@ struct AppCopyButton: View {
249258 @Environment(\.appDensity) private var appDensity
250259 @State private var didCopy = false
251260
261 /// Grows with Dynamic Type. The `max(_, minimumTapTarget)` floor matters
262 /// because `@ScaledMetric` also scales *down* below the default text size,
263 /// which would push this back under the 44pt minimum.
264 @ScaledMetric(relativeTo: .caption) private var size: CGFloat = AppLayout.minimumTapTarget
265
252266 let value: String
253267 let label: String
254268
@@ -270,8 +284,8 @@ struct AppCopyButton: View {
270284 } label: {
271285 Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
272286 .font(appDensity.font(.caption))
273 .foregroundStyle(didCopy ? Color(.statusPositive) : .secondary)
274 .frame(width: 30, height: 30)
287 .foregroundStyle(didCopy ? Color(.statusPositive) : Color(.appTextSecondary))
288 .frame(width: max(size, AppLayout.minimumTapTarget), height: max(size, AppLayout.minimumTapTarget))
275289 .background(Color(.appSurfaceElevated))
276290 .clipShape(RoundedRectangle(cornerRadius: 8))
277291 }
@@ -339,7 +353,25 @@ struct EmptyStateCardView: View {
339353
340354 var body: some View {
341355 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
342 Label(title, systemImage: systemImage)
356 // `Text(message)` already carried `fixedSize`; the title and
357 // suggestion did not, which is why the audit reported the *title*
358 // clipped on every empty state while the body beneath it wrapped.
359 // Deliberately an HStack rather than `Label`. `Label` constrains its
360 // own title text and `.fixedSize` applied to the Label does not
361 // reach the Text inside, so every empty-state heading reported as
362 // clipped. Splitting it lets the modifier land on the Text itself.
363 // Verified: doing this alone cleared the finding on all four empty
364 // states; changing the font design did not.
365 HStack(alignment: .firstTextBaseline, spacing: 8) {
366 // Decorative. `Label` used to fold the icon into the title's
367 // element; splitting them exposed it as its own, announcing the
368 // raw SF Symbol name ("checklist.unchecked") to VoiceOver.
369 Image(systemName: systemImage)
370 .accessibilityHidden(true)
371 Text(title)
372 .fixedSize(horizontal: false, vertical: true)
373 .multilineTextAlignment(.leading)
374 }
343375 .font(appDensity.font(.headline, weight: .semibold))
344376 .foregroundStyle(.primary)
345377
@@ -351,6 +383,7 @@ struct EmptyStateCardView: View {
351383 Text(suggestion)
352384 .font(appDensity.font(.caption))
353385 .foregroundStyle(Color(.statusInfo))
386 .fixedSize(horizontal: false, vertical: true)
354387 }
355388 .frame(maxWidth: .infinity, alignment: .leading)
356389 .padding(appDensity.metrics.cardPadding)
@@ -392,7 +425,7 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
392425 HStack(alignment: .center, spacing: 10) {
393426 VStack(alignment: .leading, spacing: 3) {
394427 Text(title)
395 .font(appDensity.font(.headline, design: .default, weight: .semibold))
428 .font(appDensity.font(.headline, weight: .semibold))
396429 .foregroundStyle(.primary)
397430 if let subtitle {
398431 Text(subtitle)
DomainDigWidget/DomainDigPortfolioWidget.swift +9 −3
@@ -34,6 +34,12 @@ struct DomainDigPortfolioWidget: Widget {
3434 StaticConfiguration(kind: kind, provider: DomainDigProvider()) { entry in
3535 DomainDigWidgetView(data: entry.data)
3636 .containerBackground(.fill.tertiary, for: .widget)
37 // Clamped here and ONLY here. A widget canvas is a fixed
38 // system-defined size and WidgetKit truncates overflow with no
39 // scroll affordance, so unclamped accessibility sizes produce
40 // less readable output, not more. In-app there is always a
41 // scroll view, so nothing there is clamped.
42 .dynamicTypeSize(...DynamicTypeSize.accessibility1)
3743 }
3844 .configurationDisplayName("Domain Portfolio")
3945 .description("Health and certificate status for your tracked domains.")
@@ -83,7 +89,7 @@ struct DomainDigWidgetView: View {
8389 .foregroundStyle(Color(.appTextSecondary))
8490
8591 Text("\(data.totalDomains)")
86 .font(.system(size: 34, weight: .bold, design: .rounded))
92 .font(.system(.largeTitle, design: .rounded, weight: .bold))
8793 Text("tracked")
8894 .font(.caption2)
8995 .foregroundStyle(Color(.appTextSecondary))
@@ -146,7 +152,7 @@ struct DomainDigWidgetView: View {
146152 .font(.headline)
147153 .foregroundStyle(color)
148154 Text(label)
149 .font(.system(size: 9))
155 .font(.caption2)
150156 .foregroundStyle(Color(.appTextSecondary))
151157 }
152158 .frame(maxWidth: .infinity, alignment: .leading)
@@ -159,7 +165,7 @@ struct DomainDigWidgetView: View {
159165 .frame(width: 8, height: 8)
160166 if domain.isPinned {
161167 Image(systemName: "pin.fill")
162 .font(.system(size: 8))
168 .font(.caption2)
163169 .foregroundStyle(Color(.appTextSecondary))
164170 }
165171 Text(domain.domain)