summaryrefslogtreecommitdiff
path: root/DomainDigWidget
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-21 20:00:46 -0500
committerChristian Cleberg <[email protected]>2026-07-21 20:38:45 -0500
commitdc479a3ba0d27fe86509d0eb2d27b01132c2b37f (patch)
tree4cc69b5fff2895e2ef794354fcdf89d56fa80e27 /DomainDigWidget
parente2da09fec3d3d52ac6108b9256b2419405f76dd3 (diff)
downloaddomain-dig-dc479a3ba0d27fe86509d0eb2d27b01132c2b37f.tar.gz
domain-dig-dc479a3ba0d27fe86509d0eb2d27b01132c2b37f.tar.bz2
domain-dig-dc479a3ba0d27fe86509d0eb2d27b01132c2b37f.zip
feat(a11y): VoiceOver labels, dense-row rotor content, announcements (#21 phase 4)
The audit count is unchanged at 11 dark, and that is the expected result: performAccessibilityAudit validates descriptions, traits, contrast, hit regions, and clipping, but exercises none of VoiceOver's speech, the More Content rotor, custom-content ordering, or announcements — which is the entire substance of this phase. It is verified by construction and stays green with no regressions; the manual VoiceOver pass is Phase 6. Icon-only controls (~14) get accessibilityLabel, obeying label-in-name: where a control has visible text the label keeps it, so Voice Control still works. The pin and bookmark toggles gain accessibilityValue and .isSelected; the audit and workflow checkboxes gain .isSelected and a hint. Decorative icons split out of Labels are hidden. AppStatusBadgeView now reads as one word ("Critical"), not "icon, Critical", via children: .ignore + label. SectionTitleView and CollapsibleSectionView headers get the .isHeader trait for rotor navigation; the collapsible header also exposes expanded/collapsed as a value with a hint. The header deliberately does NOT use children: .combine — its trailing() closure can hold Track/Pin controls, and combining would swallow them. Dense rows use combine-for-summary, custom-content-for-detail. BatchResultRowView (8 elements) and WatchlistRowView (up to 9) become a single element — domain as label, status as value — with risk, IP, timestamp, source, certificate, and monitoring on the More Content rotor, risk and certificate at .high importance. Reading all of it inline would make a long sweep unnavigable. The custom-content chains live in ViewModifiers because inlining six of them plus the layout broke the type-checker. The shorter 3-4 element portfolio rows are left to NavigationLink's automatic combine, per WWDC21-10121. Technical strings get a speechStyle field on InfoRowViewData: .technical applies speechAlwaysIncludesPunctuation and accessibilityTextContentType(.sourceCode), set on DNS record values and cipher suites so load-bearing punctuation is not swallowed. Completion announcements: the sweep posts from the view model; the single lookup posts from an onChange in the view, since resultsLoaded is derived from many loading flags and has no single view-model moment. Widget: each domain row was a silent 8pt status dot plus a bare "12d" countdown. Rows now read as one phrase ("example.com, critical, certificate expires in 12 days"); the count pills are labelled. Not verifiable by the suite: the dense rows and the widget never render in the audit (no tracked domains or batch results in the test simulator), same limit as the deferred Phase 3 row reflow. Documented in Docs/ACCESSIBILITY.md.
Diffstat (limited to 'DomainDigWidget')
-rw-r--r--DomainDigWidget/DomainDigPortfolioWidget.swift36
1 files changed, 32 insertions, 4 deletions
diff --git a/DomainDigWidget/DomainDigPortfolioWidget.swift b/DomainDigWidget/DomainDigPortfolioWidget.swift
index 15019c8..66002c5 100644
--- a/DomainDigWidget/DomainDigPortfolioWidget.swift
+++ b/DomainDigWidget/DomainDigPortfolioWidget.swift
@@ -97,18 +97,21 @@ struct DomainDigWidgetView: View {
Spacer(minLength: 0)
HStack(spacing: 10) {
- countPill(data.healthyCount, Color(.statusPositive))
- countPill(data.warningCount, Color(.statusWarning))
- countPill(data.criticalCount, Color(.statusCritical))
+ countPill(data.healthyCount, Color(.statusPositive), "healthy")
+ countPill(data.warningCount, Color(.statusWarning), "warning")
+ countPill(data.criticalCount, Color(.statusCritical), "critical")
}
}
}
- private func countPill(_ value: Int, _ color: Color) -> some View {
+ private func countPill(_ value: Int, _ color: Color, _ label: String) -> some View {
HStack(spacing: 3) {
Circle().fill(color).frame(width: 7, height: 7)
Text("\(value)").font(.caption).fontWeight(.medium)
}
+ // A coloured dot and a number say nothing on their own.
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("\(value) \(label)")
}
// MARK: Medium / Large
@@ -176,6 +179,31 @@ struct DomainDigWidgetView: View {
.font(.caption2)
.foregroundStyle(Color(.appTextSecondary))
}
+ // The status is a silent 8pt dot and the cert countdown is bare ("12d"),
+ // both meaningless to VoiceOver. Collapse the row into one spoken phrase.
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(rowAccessibilityLabel(domain))
+ }
+
+ private func rowAccessibilityLabel(_ domain: DomainDigWidgetDomain) -> String {
+ var parts = [domain.domain, statusLabel(domain.status)]
+ if domain.isPinned { parts.append("pinned") }
+ parts.append(certAccessibilityLabel(domain))
+ return parts.joined(separator: ", ")
+ }
+
+ private func statusLabel(_ status: DomainDigWidgetStatus) -> String {
+ switch status {
+ case .healthy: return "healthy"
+ case .warning: return "warning"
+ case .critical: return "critical"
+ }
+ }
+
+ private func certAccessibilityLabel(_ domain: DomainDigWidgetDomain) -> String {
+ guard let days = domain.certDaysRemaining else { return "certificate status unknown" }
+ if days < 0 { return "certificate expired" }
+ return "certificate expires in \(days) day\(days == 1 ? "" : "s")"
}
private func certLabel(for domain: DomainDigWidgetDomain) -> String {