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