krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

dc479a3ba0d27fe86509d0eb2d27b01132c2b37f

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-22T01:00:46Z

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.
 Docs/ACCESSIBILITY.md                          | 43 ++++++++++++++++++
 DomainDig/BatchResultsView.swift               | 60 ++++++++++++++++++++++++++
 DomainDig/ContentView.swift                    | 51 +++++++++++++++++++---
 DomainDig/DashboardView.swift                  |  1 +
 DomainDig/DomainDig/AuditViews.swift           |  3 ++
 DomainDig/DomainDigUI.swift                    | 25 +++++++++++
 DomainDig/DomainViewModel.swift                | 22 ++++++++--
 DomainDig/HistoryView.swift                    |  1 +
 DomainDig/TimelineView.swift                   |  1 +
 DomainDig/WatchlistView.swift                  | 31 +++++++++++++
 DomainDig/WorkflowsView.swift                  |  5 +++
 DomainDigWidget/DomainDigPortfolioWidget.swift | 36 ++++++++++++++--
 12 files changed, 266 insertions(+), 13 deletions(-)

diff --git a/Docs/ACCESSIBILITY.md b/Docs/ACCESSIBILITY.md
index 6032bf6..bc6f061 100644
--- a/Docs/ACCESSIBILITY.md
+++ b/Docs/ACCESSIBILITY.md
@@ -208,6 +208,49 @@ pre-commit that blocks every commit. A hook routinely bypassed with
   needs `max(scaled, AppLayout.minimumTapTarget)` or it drops under 44pt for
   users who prefer smaller text.
 
+## VoiceOver conventions
+
+- **Dense rows use combine-for-summary, custom-content-for-detail.**
+  `BatchResultRowView` and `WatchlistRowView` each hold 8–9 text elements.
+  Reading them inline makes a long sweep unnavigable, so each row is one element:
+  `.accessibilityElement(children: .ignore)` + domain label + status value, with
+  the rest on `.accessibilityCustomContent(...)`. `.high` importance is spoken
+  inline; everything else reaches the More Content rotor on a vertical swipe.
+  Rows with only 3–4 elements (the portfolio activity/attention/expiry rows) are
+  left to `NavigationLink`'s automatic combine — custom content is for the dense
+  case, per WWDC21-10121.
+- **The custom-content chain must live in a `ViewModifier`.** Inlined onto a row
+  body, six `.accessibilityCustomContent` calls plus the visual layout blow the
+  Swift type-checker's budget ("unable to type-check in reasonable time").
+  `BatchRowAccessibility` / `WatchlistRowAccessibility` exist for that reason.
+- **Splitting a `Label` exposes its icon; combining a header swallows its
+  trailing controls.** Two opposite traps. A decorative icon pulled out of a
+  `Label` needs `.accessibilityHidden(true)`. A header built as a `Button` must
+  *not* get `.accessibilityElement(children: .combine)` if its label contains
+  other controls (`CollapsibleSectionView`'s `trailing()` holds Track/Pin) —
+  combine would merge them into the header and make them unreachable.
+- **Label-in-name (WCAG 2.5.3).** Every `accessibilityLabel` added to a control
+  with visible text keeps that text, so Voice Control still works. Free-form
+  labels are used only where the control is genuinely icon-only.
+- **Technical strings** get `speechStyle: .technical` on `InfoRowViewData`, which
+  applies `.speechAlwaysIncludesPunctuation()` and
+  `.accessibilityTextContentType(.sourceCode)`. Set today on DNS record values
+  and cipher suites; extend it wherever the view model emits a fingerprint,
+  serial, or record string.
+
+### What the automated audit cannot check
+
+`performAccessibilityAudit()` validates descriptions, traits, contrast, hit
+regions, and clipping. It does **not** exercise VoiceOver speech, the More
+Content rotor, custom-content ordering, or announcements. Those are verified by
+construction and a manual VoiceOver pass (Phase 6), not by the suite. A green
+audit is necessary, not sufficient, for the row and speech work.
+
+Additionally, the dense rows (`BatchResultRowView`, `WatchlistRowView`) and the
+widget never render in the audit — the test simulator has no tracked domains or
+batch results. Their treatment is unverified by the suite for the same reason the
+Phase 3 `ViewThatFits` work was deferred: absence of findings is absence of data.
+
 ## Notes
 
 - **Disabled controls are a false positive, and are suppressed.** WCAG 1.4.3
diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift
index 4b4f6fd..aeae48f 100644
--- a/DomainDig/BatchResultsView.swift
+++ b/DomainDig/BatchResultsView.swift
@@ -116,6 +116,39 @@ struct BatchResultRowView: View {
         .frame(maxWidth: .infinity, alignment: .leading)
         .padding(.vertical, 4)
         .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
+        // One VoiceOver stop per row: domain as the label, status as the value,
+        // everything else on the More Content rotor. Reading all eight text
+        // elements inline would make a 200-domain sweep unnavigable. `.high`
+        // importance is spoken without the rotor; the rest waits for a swipe.
+        // Extracted to a modifier — inlined, the chain broke the type-checker.
+        .modifier(BatchRowAccessibility(
+            domain: result.domain,
+            status: "\(quickStatusBadge.title), \(availabilityText)",
+            risk: riskDescription,
+            ip: result.primaryIP ?? "none",
+            checked: result.timestamp.formatted(date: .abbreviated, time: .shortened),
+            source: result.resultSource.label,
+            changeLabel: changeContentLabel,
+            changeValue: changeContentValue
+        ))
+    }
+
+    private var changeContentLabel: String {
+        result.changeClassification != nil ? "Impact" : "Status"
+    }
+
+    private var changeContentValue: String {
+        if let change = result.changeClassification {
+            return change.title
+        }
+        return result.errorMessage ?? result.summaryMessage ?? quickStatusBadge.title
+    }
+
+    private var riskDescription: String {
+        if let score = result.riskScore, let level = result.riskLevel {
+            return "\(score), \(level.title)"
+        }
+        return "not scored"
     }
 
     private var availabilityText: String {
@@ -164,3 +197,30 @@ struct BatchResultRowView: View {
         }
     }
 }
+
+/// Row-level VoiceOver treatment for a batch result: a single element whose
+/// label is the domain and whose value is the status, with the remaining fields
+/// on the More Content rotor. Extracted from the row body because inlining the
+/// full modifier chain broke Swift's type-checker.
+private struct BatchRowAccessibility: ViewModifier {
+    let domain: String
+    let status: String
+    let risk: String
+    let ip: String
+    let checked: String
+    let source: String
+    let changeLabel: String
+    let changeValue: String
+
+    func body(content: Content) -> some View {
+        content
+            .accessibilityElement(children: .ignore)
+            .accessibilityLabel(domain)
+            .accessibilityValue(status)
+            .accessibilityCustomContent("Risk", risk, importance: .high)
+            .accessibilityCustomContent("IP address", ip)
+            .accessibilityCustomContent("Checked", checked)
+            .accessibilityCustomContent("Source", source)
+            .accessibilityCustomContent(LocalizedStringResource(stringLiteral: changeLabel), changeValue)
+    }
+}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index a4800ed..bc159d9 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -182,6 +182,7 @@ struct ContentView: View {
                             Image(systemName: "xmark.circle")
                                 .foregroundStyle(Color(.appTextSecondary))
                         }
+                        .accessibilityLabel("Clear results")
                     }
                 }
             }
@@ -195,6 +196,15 @@ struct ContentView: View {
         .onChange(of: viewModel.searchedDomain) { _, _ in
             collapsedSections = defaultCollapsedSections
         }
+        .onChange(of: viewModel.resultsLoaded) { wasLoaded, isLoaded in
+            // Single-lookup completion has no single view-model moment
+            // (`resultsLoaded` is derived from many loading flags), so the
+            // announcement is posted from the view where the transition is
+            // observable. The batch path announces from the view model directly.
+            guard !wasLoaded, isLoaded, viewModel.hasRun else { return }
+            let summary = AppStatusFactory.availability(viewModel.availabilityResult?.status).title
+            AppAccessibility.announce("Lookup complete for \(viewModel.searchedDomain). \(summary).")
+        }
         .onChange(of: viewModel.rerunNavigationToken) { _, _ in
             navigationPath = NavigationPath()
             focusedInputField = nil
@@ -544,6 +554,7 @@ struct ContentView: View {
                         .font(appDensity.font(.body, design: .default))
                         .foregroundStyle(Color(.appTextSecondary))
                 }
+                .accessibilityLabel("Actions")
                 Button {
                     viewModel.toggleSavedDomain()
                 } label: {
@@ -551,6 +562,9 @@ struct ContentView: View {
                         .font(appDensity.font(.body, design: .default))
                         .foregroundStyle(viewModel.isCurrentDomainSaved ? Color(.statusWarning) : .secondary)
                 }
+                .accessibilityLabel("Save domain")
+                .accessibilityValue(viewModel.isCurrentDomainSaved ? "Saved" : "Not saved")
+                .accessibilityAddTraits(viewModel.isCurrentDomainSaved ? .isSelected : [])
                 Menu {
                     Button("Export TXT") {
                         shareSingleResults(format: .text)
@@ -583,6 +597,7 @@ struct ContentView: View {
                         .font(appDensity.font(.body, design: .default))
                         .foregroundStyle(Color(.appTextSecondary))
                 }
+                .accessibilityLabel("Export")
             }
         }
     }
@@ -1345,6 +1360,9 @@ struct DomainSectionView: View {
                     }
                     .buttonStyle(.bordered)
                     .font(appDensity.font(.caption))
+                    .accessibilityLabel("Pin domain")
+                    .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned")
+                    .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : [])
                     if let onEditNote {
                         Button("Note") {
                             onEditNote()
@@ -2415,6 +2433,7 @@ struct SectionTitleView: View {
         Text(title)
             .font(appDensity.font(.headline, design: .default, weight: .semibold))
             .foregroundStyle(.primary)
+            .accessibilityAddTraits(.isHeader)
     }
 }
 
@@ -2570,12 +2589,7 @@ struct LabeledValueRow: View {
                     Text(row.label)
                         .font(appDensity.font(.caption2))
                         .foregroundStyle(Color(.appTextSecondary))
-                    Text(row.value)
-                        .font(appDensity.font(.caption))
-                        .foregroundStyle(ResultColors.color(for: row.tone))
-                        .lineLimit(nil)
-                        .fixedSize(horizontal: false, vertical: true)
-                        .textSelection(.enabled)
+                    valueText
                 }
                 .frame(maxWidth: .infinity, alignment: .leading)
                 .layoutPriority(1)
@@ -2587,6 +2601,31 @@ struct LabeledValueRow: View {
         }
         .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
     }
+
+    @ViewBuilder
+    private var valueText: some View {
+        let base = Text(row.value)
+            .font(appDensity.font(.caption))
+            .foregroundStyle(ResultColors.color(for: row.tone))
+
+        switch row.speechStyle {
+        case .plain:
+            base
+                .lineLimit(nil)
+                .fixedSize(horizontal: false, vertical: true)
+                .textSelection(.enabled)
+        case .technical:
+            // Record values and identifiers: keep punctuation audible (SPF/DMARC
+            // separators are semantically load-bearing) and let VoiceOver use its
+            // code-reading heuristics.
+            base
+                .speechAlwaysIncludesPunctuation()
+                .accessibilityTextContentType(.sourceCode)
+                .lineLimit(nil)
+                .fixedSize(horizontal: false, vertical: true)
+                .textSelection(.enabled)
+        }
+    }
 }
 
 /// Maps a row's semantic tone onto the app palette.
diff --git a/DomainDig/DashboardView.swift b/DomainDig/DashboardView.swift
index fc202d3..24955bb 100644
--- a/DomainDig/DashboardView.swift
+++ b/DomainDig/DashboardView.swift
@@ -166,6 +166,7 @@ struct DashboardView: View {
                     } label: {
                         Image(systemName: "arrow.clockwise")
                     }
+                    .accessibilityLabel("Refresh all tracked domains")
                     .disabled(viewModel.batchLookupRunning)
                 }
             }
diff --git a/DomainDig/DomainDig/AuditViews.swift b/DomainDig/DomainDig/AuditViews.swift
index 4e57837..90e64f8 100644
--- a/DomainDig/DomainDig/AuditViews.swift
+++ b/DomainDig/DomainDig/AuditViews.swift
@@ -261,6 +261,8 @@ struct AuditSessionDetailView: View {
                                 }
                             }
                             .buttonStyle(.plain)
+                            .accessibilityAddTraits(item.isComplete ? .isSelected : [])
+                            .accessibilityHint(item.isComplete ? "Marks incomplete" : "Marks complete")
                         }
                     }
                     .listRowBackground(Color(.appSurface))
@@ -479,6 +481,7 @@ private struct AuditFindingEditorView: View {
                             }
                         }
                         .buttonStyle(.plain)
+                        .accessibilityAddTraits(selectedAreas.contains(area) ? .isSelected : [])
                     }
                 }
 
diff --git a/DomainDig/DomainDigUI.swift b/DomainDig/DomainDigUI.swift
index bf06b46..de93a56 100644
--- a/DomainDig/DomainDigUI.swift
+++ b/DomainDig/DomainDigUI.swift
@@ -251,6 +251,10 @@ struct AppStatusBadgeView: View {
         .padding(.vertical, 5)
         .background(model.backgroundColor)
         .clipShape(Capsule())
+        // Read as one word ("Critical"), not "icon, Critical". The symbol
+        // duplicates the title for VoiceOver.
+        .accessibilityElement(children: .ignore)
+        .accessibilityLabel(model.title)
     }
 }
 
@@ -305,6 +309,19 @@ enum AppClipboard {
     }
 }
 
+enum AppAccessibility {
+    /// Speaks a status update through VoiceOver without moving focus. Used at
+    /// lookup and sweep completion so a blind user hears the result land instead
+    /// of having to hunt for whether anything changed.
+    static func announce(_ message: String) {
+        #if canImport(UIKit)
+        var announcement = AttributedString(message)
+        announcement.accessibilitySpeechAnnouncementPriority = .high
+        AccessibilityNotification.Announcement(announcement).post()
+        #endif
+    }
+}
+
 enum AppHaptics {
     static func copy() {
         #if canImport(UIKit)
@@ -438,11 +455,19 @@ struct CollapsibleSectionView<HeaderTrailing: View, Content: View>: View {
                     Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
                         .font(.caption.weight(.semibold))
                         .foregroundStyle(Color(.appTextSecondary))
+                        .accessibilityHidden(true)
                 }
                 .contentShape(Rectangle())
                 .frame(minHeight: appDensity.metrics.controlMinHeight, alignment: .center)
             }
             .buttonStyle(.plain)
+            // A header that is also the expand/collapse control. The chevron is
+            // decorative; state and hint carry it to VoiceOver instead. No
+            // `children: .combine` here — `trailing()` may hold its own controls
+            // (Track, Pin), and combining would swallow them into the header.
+            .accessibilityAddTraits(.isHeader)
+            .accessibilityValue(isCollapsed ? "Collapsed" : "Expanded")
+            .accessibilityHint(isCollapsed ? "Expands the section" : "Collapses the section")
 
             if !isCollapsed {
                 content()
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index f7d1d7e..de17cbc 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -17,11 +17,24 @@ struct SummaryFieldViewData: Identifiable {
     let tone: ResultTone
 }
 
+/// How VoiceOver should pronounce a row's value.
+///
+/// DNS records, cipher suites, and the like are read as prose by default, which
+/// mangles load-bearing punctuation (`;`, `~`, `_`) and technical tokens. See
+/// `Docs/ACCESSIBILITY.md`.
+enum RowSpeechStyle {
+    /// Normal prose.
+    case plain
+    /// Record values and identifiers: include punctuation, use code heuristics.
+    case technical
+}
+
 struct InfoRowViewData: Identifiable {
     let id = UUID()
     let label: String
     let value: String
     let tone: ResultTone
+    var speechStyle: RowSpeechStyle = .plain
 }
 
 struct SectionMessageViewData {
@@ -3036,6 +3049,9 @@ final class DomainViewModel {
         )
         latestBatchSweepSummary = summary
         SweepActivityController.shared.end(changed: changedCount, warnings: warningCount)
+        AppAccessibility.announce(
+            "Sweep complete. \(summary.results.count) domains, \(changedCount) changed, \(warningCount) warnings."
+        )
 
         if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
             let workflowReports: [DomainReport] = summary.results.compactMap { result in
@@ -4088,8 +4104,8 @@ final class DomainViewModel {
         snapshot.dnsSections.map { section in
             DNSRecordSectionViewData(
                 title: section.recordType.rawValue,
-                rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
-                wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
+                rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) },
+                wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary, speechStyle: .technical) },
                 wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
                 message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
                     ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
@@ -4126,7 +4142,7 @@ final class DomainViewModel {
             rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
         }
         if let cipherSuite = sslInfo.cipherSuite {
-            rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary))
+            rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary, speechStyle: .technical))
         }
         if let hstsPreloaded = snapshot.hstsPreloaded {
             rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary))
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index f291c81..5f4fe94 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -106,6 +106,7 @@ struct HistoryView: View {
                     } label: {
                         Image(systemName: "line.3.horizontal.decrease.circle")
                     }
+                    .accessibilityLabel("Filter")
 
                     EditButton()
                 }
diff --git a/DomainDig/TimelineView.swift b/DomainDig/TimelineView.swift
index 5352a94..9b79adf 100644
--- a/DomainDig/TimelineView.swift
+++ b/DomainDig/TimelineView.swift
@@ -63,6 +63,7 @@ struct TimelineView: View {
                 } label: {
                     Image(systemName: "line.3.horizontal.decrease.circle")
                 }
+                .accessibilityLabel("Group timeline")
 
                 Button("Compare") {
                     if viewModel.selectedSnapshots.count == 2 {
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index dda97ad..72eb04e 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -106,6 +106,7 @@ struct WatchlistView: View {
                 } label: {
                     Image(systemName: "plus")
                 }
+                .accessibilityLabel("Add domain")
 
                 if !viewModel.filteredTrackedDomains.isEmpty {
                     Menu {
@@ -181,6 +182,7 @@ struct WatchlistView: View {
                     } label: {
                         Image(systemName: "line.3.horizontal.decrease.circle")
                     }
+                    .accessibilityLabel("Filter and sort")
 
                     EditButton()
                 }
@@ -485,6 +487,7 @@ struct WatchlistRowView: View {
         }
         .frame(maxWidth: .infinity, alignment: .leading)
         .padding(.vertical, 4)
+        .modifier(WatchlistRowAccessibility(trackedDomain: trackedDomain, isRefreshing: isRefreshing))
     }
 
     private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
@@ -542,6 +545,34 @@ struct WatchlistRowView: View {
     }
 }
 
+/// Row-level VoiceOver treatment for a tracked domain: domain as label,
+/// availability as value, the rest on the More Content rotor. Same rationale as
+/// the batch row — up to nine text elements would be one unnavigable utterance.
+private struct WatchlistRowAccessibility: ViewModifier {
+    let trackedDomain: TrackedDomain
+    let isRefreshing: Bool
+
+    func body(content: Content) -> some View {
+        content
+            .accessibilityElement(children: .ignore)
+            .accessibilityLabel(trackedDomain.domain)
+            .accessibilityValue(isRefreshing ? "Refreshing" : AppStatusFactory.availability(trackedDomain.lastKnownAvailability).title)
+            .accessibilityCustomContent("Certificate", certificateContent, importance: .high)
+            .accessibilityCustomContent("Monitoring", trackedDomain.monitoringEnabled ? "on" : "off")
+            .accessibilityCustomContent("Updated", trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))
+            .accessibilityCustomContent("Pinned", trackedDomain.isPinned ? "yes" : "no")
+    }
+
+    private var certificateContent: String {
+        let days = trackedDomain.certificateDaysRemaining.map { "\($0) days" } ?? "unknown"
+        switch trackedDomain.certificateWarningLevel {
+        case .critical: return "invalid, \(days)"
+        case .warning: return "expiring, \(days)"
+        case .none: return "valid"
+        }
+    }
+}
+
 struct TrackedDomainDetailView: View {
     @Bindable var viewModel: DomainViewModel
     let trackedDomain: TrackedDomain
diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
index d628ba4..a6799e5 100644
--- a/DomainDig/WorkflowsView.swift
+++ b/DomainDig/WorkflowsView.swift
@@ -42,6 +42,7 @@ struct WorkflowsView: View {
                 } label: {
                     Image(systemName: "plus.circle")
                 }
+                .accessibilityLabel("Create workflow")
 
                 if !viewModel.workflows.isEmpty {
                     EditButton()
@@ -133,6 +134,7 @@ private struct WorkflowRowView: View {
                     Image(systemName: "person.2.fill")
                         .font(.caption2)
                         .foregroundStyle(Color(.statusInfo))
+                        .accessibilityLabel("Shared")
                 }
                 Text(workflow.name)
                     .font(appDensity.font(.callout, design: .default, weight: .semibold))
@@ -563,6 +565,7 @@ struct WorkflowRunSummaryView: View {
                     } label: {
                         Image(systemName: "square.and.arrow.up")
                     }
+                    .accessibilityLabel("Export summary")
 
                     if let workflow = viewModel.workflow(withID: summary.workflowID) {
                         Button {
@@ -570,6 +573,7 @@ struct WorkflowRunSummaryView: View {
                         } label: {
                             Image(systemName: "arrow.clockwise")
                         }
+                        .accessibilityLabel("Re-run workflow")
                         .disabled(viewModel.batchLookupRunning)
                     }
                 }
@@ -640,6 +644,7 @@ struct WorkflowBulkAddSheet: View {
                             }
                         }
                         .buttonStyle(.plain)
+                        .accessibilityAddTraits(selectedDomains.contains(domain) ? .isSelected : [])
                     }
                 }
 
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 {