krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

f1169f2adcdeabdec3f496cec2b52a2594065f0f

signed_unknown_key

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T05:37:45Z
committer: <noreply@github.com>

Accessibility pass; bump to 3.12.0 (#42)

VoiceOver had nothing to announce for three kinds of control:

Icon-only buttons, which reached VoiceOver with no label at all — the error
banner's dismiss, log search next/previous match, account switching, four
`plus` creates (paste, tracker, ACL, label), artifact download, the
system-status info button, and assignee unassign.

Selection carried only by a checkmark or a tint: applied ticket labels in two
views, the label filter, the saved-filter chip, and the paste visibility
picker. These now carry `.isSelected`.

Disclosure carried only by a chevron: diff file, diff hunk, inbox message.
`PatchsetDetailView` already had the hint; these match it.

Also: the project external-link row now says it leaves the app, and the commit
SHA button says it copies.

Decorative chevrons inside controls that already carry text were left alone —
an unlabelled SF Symbol contributes nothing to a combined label, so hiding them
would change no announcement.

Not device-verified. Builds clean and 260 tests pass, but neither is a VoiceOver
pass; ROADMAP.txt keeps that open.
 Hutch.xcodeproj/project.pbxproj                 | 24 ++++++++--------
 Hutch/Extensions/ErrorViews.swift               |  1 +
 Hutch/Views/Builds/BuildTaskLogView.swift       |  2 ++
 Hutch/Views/Inbox/ThreadDetailView.swift        |  1 +
 Hutch/Views/More/MoreView.swift                 |  1 +
 Hutch/Views/Pastes/PasteDetailView.swift        |  1 +
 Hutch/Views/Pastes/PasteListView.swift          |  1 +
 Hutch/Views/Projects/ProjectDetailView.swift    |  1 +
 Hutch/Views/Repositories/ArtifactsView.swift    |  1 +
 Hutch/Views/Repositories/CommitDetailView.swift |  1 +
 Hutch/Views/Repositories/DiffView.swift         |  2 ++
 Hutch/Views/Tickets/TicketDetailView.swift      |  3 ++
 Hutch/Views/Tickets/TicketListView.swift        |  7 +++++
 Hutch/Views/Tickets/TrackerListView.swift       |  1 +
 Hutch/Views/Tickets/TrackerManagementView.swift |  2 ++
 ROADMAP.txt                                     | 37 +++++++++++++++++++++----
 16 files changed, 68 insertions(+), 18 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 66b72bf..15f93a3 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -597,7 +597,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 97;
+				CURRENT_PROJECT_VERSION = 98;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.11.1;
+				MARKETING_VERSION = 3.12.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 97;
+				CURRENT_PROJECT_VERSION = 98;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.11.1;
+				MARKETING_VERSION = 3.12.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -714,7 +714,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 97;
+				CURRENT_PROJECT_VERSION = 98;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.11.1;
+				MARKETING_VERSION = 3.12.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -743,7 +743,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 97;
+				CURRENT_PROJECT_VERSION = 98;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.11.1;
+				MARKETING_VERSION = 3.12.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -772,7 +772,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 97;
+				CURRENT_PROJECT_VERSION = 98;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.11.1;
+				MARKETING_VERSION = 3.12.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -801,7 +801,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 97;
+				CURRENT_PROJECT_VERSION = 98;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.11.1;
+				MARKETING_VERSION = 3.12.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Extensions/ErrorViews.swift b/Hutch/Extensions/ErrorViews.swift
index 086c0de..16c765e 100644
--- a/Hutch/Extensions/ErrorViews.swift
+++ b/Hutch/Extensions/ErrorViews.swift
@@ -37,6 +37,7 @@ struct SRHTErrorBanner: ViewModifier {
                 Image(systemName: "xmark.circle.fill")
                     .foregroundStyle(.white.opacity(0.8))
             }
+            .accessibilityLabel("Dismiss error")
         }
         .padding(12)
         .background(Color.red.gradient, in: RoundedRectangle(cornerRadius: 12))
diff --git a/Hutch/Views/Builds/BuildTaskLogView.swift b/Hutch/Views/Builds/BuildTaskLogView.swift
index 3011e34..5166ad9 100644
--- a/Hutch/Views/Builds/BuildTaskLogView.swift
+++ b/Hutch/Views/Builds/BuildTaskLogView.swift
@@ -164,6 +164,7 @@ private struct BuildTaskLogContentView: View {
                         Image(systemName: "chevron.up")
                     }
                     .disabled(matches.isEmpty)
+                    .accessibilityLabel("Previous match")
 
                     Button {
                         moveSelection(step: 1)
@@ -171,6 +172,7 @@ private struct BuildTaskLogContentView: View {
                         Image(systemName: "chevron.down")
                     }
                     .disabled(matches.isEmpty)
+                    .accessibilityLabel("Next match")
                 }
                 .buttonStyle(.borderless)
                 .labelStyle(.iconOnly)
diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift
index 028964c..ea02b45 100644
--- a/Hutch/Views/Inbox/ThreadDetailView.swift
+++ b/Hutch/Views/Inbox/ThreadDetailView.swift
@@ -295,6 +295,7 @@ private struct InboxMessageRow: View {
                 }
             }
             .buttonStyle(.plain)
+            .accessibilityHint(isCollapsed ? "Expands this message" : "Collapses this message")
 
             if !isCollapsed {
                 ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in
diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift
index d6f7d8a..42859b9 100644
--- a/Hutch/Views/More/MoreView.swift
+++ b/Hutch/Views/More/MoreView.swift
@@ -96,6 +96,7 @@ struct MoreView: View {
                 } label: {
                     Image(systemName: "person.crop.circle.badge.plus")
                 }
+                .accessibilityLabel("Switch account")
             }
         }
         .sheet(isPresented: $showAccountSwitcher) {
diff --git a/Hutch/Views/Pastes/PasteDetailView.swift b/Hutch/Views/Pastes/PasteDetailView.swift
index 49d4fa6..3787bac 100644
--- a/Hutch/Views/Pastes/PasteDetailView.swift
+++ b/Hutch/Views/Pastes/PasteDetailView.swift
@@ -413,6 +413,7 @@ private struct PasteVisibilitySheet: View {
                         .contentShape(Rectangle())
                     }
                     .buttonStyle(.plain)
+                    .accessibilityAddTraits(visibility == option ? [.isSelected] : [])
                 }
                 .themedRow()
             }
diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift
index b2c7838..c3796da 100644
--- a/Hutch/Views/Pastes/PasteListView.swift
+++ b/Hutch/Views/Pastes/PasteListView.swift
@@ -25,6 +25,7 @@ struct PasteListView: View {
                     } label: {
                         Image(systemName: "plus")
                     }
+                    .accessibilityLabel("Create paste")
                 }
             }
         }
diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift
index 0685a83..d416929 100644
--- a/Hutch/Views/Projects/ProjectDetailView.swift
+++ b/Hutch/Views/Projects/ProjectDetailView.swift
@@ -217,6 +217,7 @@ struct ProjectDetailView: View {
                         }
                     }
                     .buttonStyle(.plain)
+                    .accessibilityHint("Opens in your browser")
                 }
                 .themedRow()
             }
diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift
index 69da0d6..7cb9c49 100644
--- a/Hutch/Views/Repositories/ArtifactsView.swift
+++ b/Hutch/Views/Repositories/ArtifactsView.swift
@@ -206,6 +206,7 @@ private struct ArtifactRow: View {
                 Image(systemName: "arrow.down.circle")
                     .imageScale(.large)
             }
+            .accessibilityLabel("Download artifact")
         }
     }
 }
diff --git a/Hutch/Views/Repositories/CommitDetailView.swift b/Hutch/Views/Repositories/CommitDetailView.swift
index 1dcbf68..06b21a1 100644
--- a/Hutch/Views/Repositories/CommitDetailView.swift
+++ b/Hutch/Views/Repositories/CommitDetailView.swift
@@ -153,6 +153,7 @@ struct CommitDetailView: View {
                 }
                 .foregroundStyle(.secondary)
             }
+            .accessibilityHint("Copies the commit SHA")
 
             // Author
             HStack {
diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift
index f105ff0..c6f8231 100644
--- a/Hutch/Views/Repositories/DiffView.swift
+++ b/Hutch/Views/Repositories/DiffView.swift
@@ -58,6 +58,7 @@ private struct DiffFileSectionView: View {
                 .contentShape(Rectangle())
             }
             .buttonStyle(.plain)
+            .accessibilityHint(isExpanded ? "Collapses this file" : "Expands this file")
             .background(Color(.tertiarySystemBackground))
 
             if isExpanded {
@@ -169,6 +170,7 @@ private struct DiffHunkView: View {
                 .contentShape(Rectangle())
             }
             .buttonStyle(.plain)
+            .accessibilityHint(isExpanded ? "Collapses this hunk" : "Expands this hunk")
             .background(Color(.systemBackground).opacity(0.5))
 
             if isExpanded {
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index 1ace6b6..8631734 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -606,6 +606,7 @@ private struct EventRow: View {
                                             .foregroundStyle(.gray)
                                     }
                                     .buttonStyle(.plain)
+                                    .accessibilityLabel("About this status change")
                                 }
                             } else {
                                 Text(descriptionText)
@@ -948,6 +949,7 @@ private struct AssignSheet: View {
                                         .foregroundStyle(.red)
                                 }
                                 .buttonStyle(.plain)
+                                .accessibilityLabel("Unassign \(assignee.canonicalName)")
                             }
                         }
                         .themedRow()
@@ -1152,5 +1154,6 @@ private struct LabelToggleRow: View {
             }
         }
         .disabled(isLoading)
+        .accessibilityAddTraits(isApplied ? [.isSelected] : [])
     }
 }
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index 650554c..f03c634 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -857,6 +857,9 @@ private struct TicketQuickFilterBar: View {
                                     in: Capsule()
                                 )
                             }
+                            .accessibilityAddTraits(
+                                activeSavedFilterID == savedFilter.id ? [.isSelected] : []
+                            )
                             .contextMenu {
                                 Button(role: .destructive) {
                                     onDeleteSavedFilter(savedFilter)
@@ -964,6 +967,9 @@ private struct TicketFilterLabelsSheet: View {
                                     }
                                 }
                                 .buttonStyle(.plain)
+                                .accessibilityAddTraits(
+                                    viewModel.selectedLabelIDs.contains(label.id) ? [.isSelected] : []
+                                )
                             }
                             .themedRow()
                         }
@@ -1051,6 +1057,7 @@ private struct TicketListLabelToggleRow: View {
             }
         }
         .disabled(isLoading)
+        .accessibilityAddTraits(isApplied ? [.isSelected] : [])
     }
 }
 
diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift
index cb4a59c..aa0a119 100644
--- a/Hutch/Views/Tickets/TrackerListView.swift
+++ b/Hutch/Views/Tickets/TrackerListView.swift
@@ -25,6 +25,7 @@ struct TrackerListView: View {
                     } label: {
                         Image(systemName: "plus")
                     }
+                    .accessibilityLabel("Create tracker")
                 }
             }
         }
diff --git a/Hutch/Views/Tickets/TrackerManagementView.swift b/Hutch/Views/Tickets/TrackerManagementView.swift
index 73b2a08..0738d62 100644
--- a/Hutch/Views/Tickets/TrackerManagementView.swift
+++ b/Hutch/Views/Tickets/TrackerManagementView.swift
@@ -785,6 +785,7 @@ struct TrackerACLManagementSheet: View {
                         Image(systemName: "plus")
                     }
                     .disabled(viewModel.isSavingACL)
+                    .accessibilityLabel("Add ACL")
                 }
             }
             .task {
@@ -1155,6 +1156,7 @@ struct TrackerLabelManagementSheet: View {
                         Image(systemName: "plus")
                     }
                     .disabled(viewModel.isSavingLabel)
+                    .accessibilityLabel("Create label")
                 }
             }
             .task {
diff --git a/ROADMAP.txt b/ROADMAP.txt
index 4352b9e..e656c63 100644
--- a/ROADMAP.txt
+++ b/ROADMAP.txt
@@ -156,7 +156,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*.
 | v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session |
 | v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); ~~"What's cooking" ingest + doc truth-up~~ (done) | Ships one feature, corrects the map |
 | v3.11.0 | ~~Mailing list subscribe/unsubscribe toggle~~ (shipped) | Ingest-surfaced; state via the `subscriptions` query (the `subscription` field is a trap) |
-| v3.12.0 | Accessibility | Independent, device-verified |
+| v3.12.0 | ~~Accessibility~~ (done in code) | Still wants a VoiceOver pass on a device |
 | v4.0.0 | Localization *with* translations | The only true re-presentation |
 | — | ~~Swift 6 language mode~~ (done); cache reads | Internal; ride along, no tag |
 
@@ -206,11 +206,36 @@ the major number on regression risk while delivering nothing — the wrong trade
 Hold the catalog until a translation lands. If it ever ships unbundled, it is
 groundwork and belongs in a quiet minor, not a 4.0.
 
-### Accessibility — v3.11.0
-
-Labels and hints appear in 17 of 89 view files. Mechanical and low-risk, but it
-cannot be verified from a build — it needs VoiceOver driven on a device.
-Independent of every other bucket, so it can move if a device pass is convenient.
+### Accessibility — done in code (v3.12.0)
+
+The earlier count here (17 of 89 view files) was wrong on both numbers: 22 of 95
+carried a modifier, and of those, labels were the whole story — one hint in the
+whole app, no traits at all. Counting files also hid the shape of the gap, which
+was not spread evenly but concentrated in three kinds of control:
+
+- **Icon-only controls**, which VoiceOver reaches with nothing to announce.
+  Eleven of them: the error banner's dismiss, log search next/previous match,
+  account switching, four `plus` creates (paste, tracker, ACL, label), artifact
+  download, the system-status info button, and assignee unassign.
+- **Selection state carried only by a checkmark or a tint.** Five: applied
+  ticket labels in two views, the label filter, the saved-filter chip, and the
+  paste visibility picker. These now carry `.isSelected` rather than relying on
+  an icon VoiceOver does not read.
+- **Disclosure state carried only by a chevron.** Three: diff file, diff hunk,
+  and inbox message. `PatchsetDetailView` already had the hint; the others now
+  match it.
+
+Two more: the project external-link row says it leaves the app, and the commit
+SHA button says it copies — both read as bare text before.
+
+Decorative chevrons inside a control that already carries text were left alone.
+An unlabelled SF Symbol contributes nothing to a combined label, so hiding them
+would be churn with no announced difference.
+
+Still open: **this is not device-verified.** It builds and the suite is green,
+but neither proves a VoiceOver pass. What is verified is that no icon-only
+control is left unlabelled — a static check over the enclosing control's brace
+span, not a guess at line proximity.
 
 ### SonarCloud backlog — done in code (v3.8.1)