From e2da09fec3d3d52ac6108b9256b2419405f76dd3 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 20 Jul 2026 21:49:57 -0500 Subject: feat(a11y): Dynamic Type reflow and tap targets (#21 phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- DomainDig/DomainDigUI.swift | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) (limited to 'DomainDig/DomainDigUI.swift') diff --git a/DomainDig/DomainDigUI.swift b/DomainDig/DomainDigUI.swift index fab76d5..bf06b46 100644 --- a/DomainDig/DomainDigUI.swift +++ b/DomainDig/DomainDigUI.swift @@ -72,7 +72,9 @@ enum AppDensity: String, CaseIterable, Identifiable { rowSpacing: 4, rowMinHeight: 30, controlVerticalPadding: 10, - controlMinHeight: 42, + // Was 42, which put every control using it under the 44pt + // minimum in compact density — section headers, Run, Run Batch. + controlMinHeight: AppLayout.minimumTapTarget, cardCornerRadius: 10 ) case .comfortable: @@ -98,6 +100,13 @@ enum AppDensity: String, CaseIterable, Identifiable { } } +/// Layout constants that are not density-dependent. +enum AppLayout { + /// The HIG minimum for an interactive control, and WCAG 2.5.8's floor. + /// Controls scale up from here with Dynamic Type; none may sit below it. + static let minimumTapTarget: CGFloat = 44 +} + struct AppDensityMetrics: Equatable { let sectionSpacing: CGFloat let cardSpacing: CGFloat @@ -249,6 +258,11 @@ struct AppCopyButton: View { @Environment(\.appDensity) private var appDensity @State private var didCopy = false + /// Grows with Dynamic Type. The `max(_, minimumTapTarget)` floor matters + /// because `@ScaledMetric` also scales *down* below the default text size, + /// which would push this back under the 44pt minimum. + @ScaledMetric(relativeTo: .caption) private var size: CGFloat = AppLayout.minimumTapTarget + let value: String let label: String @@ -270,8 +284,8 @@ struct AppCopyButton: View { } label: { Image(systemName: didCopy ? "checkmark" : "doc.on.doc") .font(appDensity.font(.caption)) - .foregroundStyle(didCopy ? Color(.statusPositive) : .secondary) - .frame(width: 30, height: 30) + .foregroundStyle(didCopy ? Color(.statusPositive) : Color(.appTextSecondary)) + .frame(width: max(size, AppLayout.minimumTapTarget), height: max(size, AppLayout.minimumTapTarget)) .background(Color(.appSurfaceElevated)) .clipShape(RoundedRectangle(cornerRadius: 8)) } @@ -339,7 +353,25 @@ struct EmptyStateCardView: View { var body: some View { VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { - Label(title, systemImage: systemImage) + // `Text(message)` already carried `fixedSize`; the title and + // suggestion did not, which is why the audit reported the *title* + // clipped on every empty state while the body beneath it wrapped. + // Deliberately an HStack rather than `Label`. `Label` constrains its + // own title text and `.fixedSize` applied to the Label does not + // reach the Text inside, so every empty-state heading reported as + // clipped. Splitting it lets the modifier land on the Text itself. + // Verified: doing this alone cleared the finding on all four empty + // states; changing the font design did not. + HStack(alignment: .firstTextBaseline, spacing: 8) { + // Decorative. `Label` used to fold the icon into the title's + // element; splitting them exposed it as its own, announcing the + // raw SF Symbol name ("checklist.unchecked") to VoiceOver. + Image(systemName: systemImage) + .accessibilityHidden(true) + Text(title) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + } .font(appDensity.font(.headline, weight: .semibold)) .foregroundStyle(.primary) @@ -351,6 +383,7 @@ struct EmptyStateCardView: View { Text(suggestion) .font(appDensity.font(.caption)) .foregroundStyle(Color(.statusInfo)) + .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) .padding(appDensity.metrics.cardPadding) @@ -392,7 +425,7 @@ struct CollapsibleSectionView: View { HStack(alignment: .center, spacing: 10) { VStack(alignment: .leading, spacing: 3) { Text(title) - .font(appDensity.font(.headline, design: .default, weight: .semibold)) + .font(appDensity.font(.headline, weight: .semibold)) .foregroundStyle(.primary) if let subtitle { Text(subtitle) -- cgit v1.2.3