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
187pre-commit that blocks every commit. A hook routinely bypassed with 187pre-commit that blocks every commit. A hook routinely bypassed with
188`--no-verify` is worse than no hook, because it trains you to ignore it. 188`--no-verify` is worse than no hook, because it trains you to ignore it.
189 189
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
190## Notes 211## Notes
191 212
192- **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3 213- **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 {
2418 } 2418 }
2419} 2419}
2420 2420
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.
2421struct CardView<Content: View>: View { 2432struct CardView<Content: View>: View {
2422 @Environment(\.appDensity) private var appDensity 2433 @Environment(\.appDensity) private var appDensity
2434 @Environment(\.dynamicTypeSize) private var dynamicTypeSize
2423 let allowsHorizontalScroll: Bool 2435 let allowsHorizontalScroll: Bool
2424 let content: Content 2436 let content: Content
2425 2437
2426 init(allowsHorizontalScroll: Bool = true, @ViewBuilder content: () -> Content) { 2438 init(allowsHorizontalScroll: Bool = false, @ViewBuilder content: () -> Content) {
2427 self.allowsHorizontalScroll = allowsHorizontalScroll 2439 self.allowsHorizontalScroll = allowsHorizontalScroll
2428 self.content = content() 2440 self.content = content()
2429 } 2441 }
2430 2442
2431 var body: some View { 2443 var body: some View {
2432 Group { 2444 Group {
2433 if allowsHorizontalScroll { 2445 if allowsHorizontalScroll, !dynamicTypeSize.isAccessibilitySize {
2434 ScrollView(.horizontal) { 2446 ScrollView(.horizontal) {
2435 cardContent 2447 cardContent
2436 .scrollTargetLayout() 2448 .scrollTargetLayout()
DomainDig/DashboardView.swift +2 −1
@@ -194,7 +194,8 @@ struct DashboardView: View {
194 .font(appDensity.font(.caption, design: .default, weight: .semibold)) 194 .font(appDensity.font(.caption, design: .default, weight: .semibold))
195 .foregroundStyle(Color(.appTextSecondary)) 195 .foregroundStyle(Color(.appTextSecondary))
196 Text("\(value)") 196 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))
198 .foregroundStyle(.primary) 199 .foregroundStyle(.primary)
199 HStack { 200 HStack {
200 Circle() 201 Circle()
DomainDig/DomainDigUI.swift +38 −5
@@ -72,7 +72,9 @@ enum AppDensity: String, CaseIterable, Identifiable {
72 rowSpacing: 4, 72 rowSpacing: 4,
73 rowMinHeight: 30, 73 rowMinHeight: 30,
74 controlVerticalPadding: 10, 74 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,
76 cardCornerRadius: 10 78 cardCornerRadius: 10
77 ) 79 )
78 case .comfortable: 80 case .comfortable:
@@ -98,6 +100,13 @@ enum AppDensity: String, CaseIterable, Identifiable {
98 } 100 }
99} 101}
100 102
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
101struct AppDensityMetrics: Equatable { 110struct AppDensityMetrics: Equatable {
102 let sectionSpacing: CGFloat 111 let sectionSpacing: CGFloat
103 let cardSpacing: CGFloat 112 let cardSpacing: CGFloat
@@ -249,6 +258,11 @@ struct AppCopyButton: View {
249 @Environment(\.appDensity) private var appDensity 258 @Environment(\.appDensity) private var appDensity
250 @State private var didCopy = false 259 @State private var didCopy = false
251 260
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
252 let value: String 266 let value: String
253 let label: String 267 let label: String
254 268
@@ -270,8 +284,8 @@ struct AppCopyButton: View {
270 } label: { 284 } label: {
271 Image(systemName: didCopy ? "checkmark" : "doc.on.doc") 285 Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
272 .font(appDensity.font(.caption)) 286 .font(appDensity.font(.caption))
273 .foregroundStyle(didCopy ? Color(.statusPositive) : .secondary) 287 .foregroundStyle(didCopy ? Color(.statusPositive) : Color(.appTextSecondary))
274 .frame(width: 30, height: 30) 288 .frame(width: max(size, AppLayout.minimumTapTarget), height: max(size, AppLayout.minimumTapTarget))
275 .background(Color(.appSurfaceElevated)) 289 .background(Color(.appSurfaceElevated))
276 .clipShape(RoundedRectangle(cornerRadius: 8)) 290 .clipShape(RoundedRectangle(cornerRadius: 8))
277 } 291 }
@@ -339,7 +353,25 @@ struct EmptyStateCardView: View {
339 353
340 var body: some View { 354 var body: some View {
341 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { 355 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 }
343 .font(appDensity.font(.headline, weight: .semibold)) 375 .font(appDensity.font(.headline, weight: .semibold))
344 .foregroundStyle(.primary) 376 .foregroundStyle(.primary)
345 377
@@ -351,6 +383,7 @@ struct EmptyStateCardView: View {
351 Text(suggestion) 383 Text(suggestion)
352 .font(appDensity.font(.caption)) 384 .font(appDensity.font(.caption))
353 .foregroundStyle(Color(.statusInfo)) 385 .foregroundStyle(Color(.statusInfo))
386 .fixedSize(horizontal: false, vertical: true)
354 } 387 }
355 .frame(maxWidth: .infinity, alignment: .leading) 388 .frame(maxWidth: .infinity, alignment: .leading)
356 .padding(appDensity.metrics.cardPadding) 389 .padding(appDensity.metrics.cardPadding)
@@ -392,7 +425,7 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
392 HStack(alignment: .center, spacing: 10) { 425 HStack(alignment: .center, spacing: 10) {
393 VStack(alignment: .leading, spacing: 3) { 426 VStack(alignment: .leading, spacing: 3) {
394 Text(title) 427 Text(title)
395 .font(appDensity.font(.headline, design: .default, weight: .semibold)) 428 .font(appDensity.font(.headline, weight: .semibold))
396 .foregroundStyle(.primary) 429 .foregroundStyle(.primary)
397 if let subtitle { 430 if let subtitle {
398 Text(subtitle) 431 Text(subtitle)
DomainDigWidget/DomainDigPortfolioWidget.swift +9 −3
@@ -34,6 +34,12 @@ struct DomainDigPortfolioWidget: Widget {
34 StaticConfiguration(kind: kind, provider: DomainDigProvider()) { entry in 34 StaticConfiguration(kind: kind, provider: DomainDigProvider()) { entry in
35 DomainDigWidgetView(data: entry.data) 35 DomainDigWidgetView(data: entry.data)
36 .containerBackground(.fill.tertiary, for: .widget) 36 .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)
37 } 43 }
38 .configurationDisplayName("Domain Portfolio") 44 .configurationDisplayName("Domain Portfolio")
39 .description("Health and certificate status for your tracked domains.") 45 .description("Health and certificate status for your tracked domains.")
@@ -83,7 +89,7 @@ struct DomainDigWidgetView: View {
83 .foregroundStyle(Color(.appTextSecondary)) 89 .foregroundStyle(Color(.appTextSecondary))
84 90
85 Text("\(data.totalDomains)") 91 Text("\(data.totalDomains)")
86 .font(.system(size: 34, weight: .bold, design: .rounded)) 92 .font(.system(.largeTitle, design: .rounded, weight: .bold))
87 Text("tracked") 93 Text("tracked")
88 .font(.caption2) 94 .font(.caption2)
89 .foregroundStyle(Color(.appTextSecondary)) 95 .foregroundStyle(Color(.appTextSecondary))
@@ -146,7 +152,7 @@ struct DomainDigWidgetView: View {
146 .font(.headline) 152 .font(.headline)
147 .foregroundStyle(color) 153 .foregroundStyle(color)
148 Text(label) 154 Text(label)
149 .font(.system(size: 9)) 155 .font(.caption2)
150 .foregroundStyle(Color(.appTextSecondary)) 156 .foregroundStyle(Color(.appTextSecondary))
151 } 157 }
152 .frame(maxWidth: .infinity, alignment: .leading) 158 .frame(maxWidth: .infinity, alignment: .leading)
@@ -159,7 +165,7 @@ struct DomainDigWidgetView: View {
159 .frame(width: 8, height: 8) 165 .frame(width: 8, height: 8)
160 if domain.isPinned { 166 if domain.isPinned {
161 Image(systemName: "pin.fill") 167 Image(systemName: "pin.fill")
162 .font(.system(size: 8)) 168 .font(.caption2)
163 .foregroundStyle(Color(.appTextSecondary)) 169 .foregroundStyle(Color(.appTextSecondary))
164 } 170 }
165 Text(domain.domain) 171 Text(domain.domain)