diff options
| -rw-r--r-- | Docs/ACCESSIBILITY.md | 21 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 16 | ||||
| -rw-r--r-- | DomainDig/DashboardView.swift | 3 | ||||
| -rw-r--r-- | DomainDig/DomainDigUI.swift | 43 | ||||
| -rw-r--r-- | DomainDigWidget/DomainDigPortfolioWidget.swift | 12 |
5 files changed, 84 insertions, 11 deletions
diff --git a/Docs/ACCESSIBILITY.md b/Docs/ACCESSIBILITY.md index 40acc1c..6032bf6 100644 --- a/Docs/ACCESSIBILITY.md +++ b/Docs/ACCESSIBILITY.md @@ -187,6 +187,27 @@ Pre-push rather than pre-commit deliberately: the suite takes ~85s, and at pre-commit that blocks every commit. A hook routinely bypassed with `--no-verify` is worse than no hook, because it trains you to ignore it. +## Layout gotchas found the hard way + +- **`Label` clips its own title.** Every empty-state heading reported as clipped + text. `.fixedSize` applied to the `Label` does not reach the `Text` inside it, + so the fix is to split it into an `HStack { Image; Text }` and put the modifier + on the `Text`. Changing the font design did **not** help — that hypothesis was + tested and discarded. +- **Splitting a `Label` exposes its icon to VoiceOver.** `Label` folds the image + into the title's accessibility element; an `HStack` does not, so the icon + starts announcing its raw SF Symbol name ("checklist.unchecked"). Decorative + icons split out of a `Label` need `.accessibilityHidden(true)`. +- **Placeholder text is always reported as clipped.** Search prompts and + `TextField` placeholders are flagged regardless of length — shortening + "Search portfolio" to "Search" changed nothing. Treat `textClipped` findings on + a `searchField` or `textField` element as noise rather than shortening useful + prompts to chase them. +- **`AppLayout.minimumTapTarget` is the floor for every control.** `@ScaledMetric` + scales *down* below the default text size as well as up, so a scaled dimension + needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for + users who prefer smaller text. + ## Notes - **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3 diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index f48e2a0..a4800ed 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -2418,19 +2418,31 @@ struct SectionTitleView: View { } } +/// A card that wraps its content by default. +/// +/// `allowsHorizontalScroll` used to default to `true`, so nine call sites put +/// their content behind a horizontal gesture instead of letting it wrap — a +/// WCAG 1.4.10 (Reflow) failure, and the mechanism behind clipped rows at large +/// text sizes. It also forced VoiceOver and Switch Control users onto a nested +/// scroll axis to reach data. +/// +/// The default is now `false`. Where horizontal scrolling genuinely suits wide +/// tabular content, it is still opt-in — but it is suppressed at accessibility +/// text sizes, where wrapping always beats a hidden axis. struct CardView<Content: View>: View { @Environment(\.appDensity) private var appDensity + @Environment(\.dynamicTypeSize) private var dynamicTypeSize let allowsHorizontalScroll: Bool let content: Content - init(allowsHorizontalScroll: Bool = true, @ViewBuilder content: () -> Content) { + init(allowsHorizontalScroll: Bool = false, @ViewBuilder content: () -> Content) { self.allowsHorizontalScroll = allowsHorizontalScroll self.content = content() } var body: some View { Group { - if allowsHorizontalScroll { + if allowsHorizontalScroll, !dynamicTypeSize.isAccessibilitySize { ScrollView(.horizontal) { cardContent .scrollTargetLayout() diff --git a/DomainDig/DashboardView.swift b/DomainDig/DashboardView.swift index 32af2a5..fc202d3 100644 --- a/DomainDig/DashboardView.swift +++ b/DomainDig/DashboardView.swift @@ -194,7 +194,8 @@ struct DashboardView: View { .font(appDensity.font(.caption, design: .default, weight: .semibold)) .foregroundStyle(Color(.appTextSecondary)) Text("\(value)") - .font(.system(size: 28, weight: .bold, design: .rounded)) + // Was a fixed 28pt, which ignored Dynamic Type entirely. + .font(.system(.title, design: .rounded, weight: .bold)) .foregroundStyle(.primary) HStack { Circle() 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<HeaderTrailing: View, Content: View>: 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) diff --git a/DomainDigWidget/DomainDigPortfolioWidget.swift b/DomainDigWidget/DomainDigPortfolioWidget.swift index 37a96a0..15019c8 100644 --- a/DomainDigWidget/DomainDigPortfolioWidget.swift +++ b/DomainDigWidget/DomainDigPortfolioWidget.swift @@ -34,6 +34,12 @@ struct DomainDigPortfolioWidget: Widget { StaticConfiguration(kind: kind, provider: DomainDigProvider()) { entry in DomainDigWidgetView(data: entry.data) .containerBackground(.fill.tertiary, for: .widget) + // Clamped here and ONLY here. A widget canvas is a fixed + // system-defined size and WidgetKit truncates overflow with no + // scroll affordance, so unclamped accessibility sizes produce + // less readable output, not more. In-app there is always a + // scroll view, so nothing there is clamped. + .dynamicTypeSize(...DynamicTypeSize.accessibility1) } .configurationDisplayName("Domain Portfolio") .description("Health and certificate status for your tracked domains.") @@ -83,7 +89,7 @@ struct DomainDigWidgetView: View { .foregroundStyle(Color(.appTextSecondary)) Text("\(data.totalDomains)") - .font(.system(size: 34, weight: .bold, design: .rounded)) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) Text("tracked") .font(.caption2) .foregroundStyle(Color(.appTextSecondary)) @@ -146,7 +152,7 @@ struct DomainDigWidgetView: View { .font(.headline) .foregroundStyle(color) Text(label) - .font(.system(size: 9)) + .font(.caption2) .foregroundStyle(Color(.appTextSecondary)) } .frame(maxWidth: .infinity, alignment: .leading) @@ -159,7 +165,7 @@ struct DomainDigWidgetView: View { .frame(width: 8, height: 8) if domain.isPinned { Image(systemName: "pin.fill") - .font(.system(size: 8)) + .font(.caption2) .foregroundStyle(Color(.appTextSecondary)) } Text(domain.domain) |
