summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Docs/ACCESSIBILITY.md21
-rw-r--r--DomainDig/ContentView.swift16
-rw-r--r--DomainDig/DashboardView.swift3
-rw-r--r--DomainDig/DomainDigUI.swift43
-rw-r--r--DomainDigWidget/DomainDigPortfolioWidget.swift12
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)