diff options
| author | Christian Cleberg <[email protected]> | 2026-07-21 20:00:46 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-21 20:38:45 -0500 |
| commit | dc479a3ba0d27fe86509d0eb2d27b01132c2b37f (patch) | |
| tree | 4cc69b5fff2895e2ef794354fcdf89d56fa80e27 /DomainDigWidget/DomainDigPortfolioWidget.swift | |
| parent | e2da09fec3d3d52ac6108b9256b2419405f76dd3 (diff) | |
| download | domain-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/DomainDigPortfolioWidget.swift')
| -rw-r--r-- | DomainDigWidget/DomainDigPortfolioWidget.swift | 36 |
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 { |
