Commit 91f4fc6315

91f4fc6315bc6ebd6517cd17461705af6a948f9f

parent: c1f3cadd80

Unsigned

cmc <hello@cleberg.net> · 2026-08-23 05:37 UTC
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.

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +12 −12
@@ -597,7 +597,7 @@
597 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 597 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
598 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 598 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
599 CODE_SIGN_STYLE = Automatic; 599 CODE_SIGN_STYLE = Automatic;
600 CURRENT_PROJECT_VERSION = 97; 600 CURRENT_PROJECT_VERSION = 98;
601 DEVELOPMENT_TEAM = ZCNAX3VL9D; 601 DEVELOPMENT_TEAM = ZCNAX3VL9D;
602 ENABLE_PREVIEWS = YES; 602 ENABLE_PREVIEWS = YES;
603 GENERATE_INFOPLIST_FILE = YES; 603 GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
614 "$(inherited)", 614 "$(inherited)",
615 "@executable_path/Frameworks", 615 "@executable_path/Frameworks",
616 ); 616 );
617 MARKETING_VERSION = 3.11.1; 617 MARKETING_VERSION = 3.12.0;
618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
619 PRODUCT_NAME = "$(TARGET_NAME)"; 619 PRODUCT_NAME = "$(TARGET_NAME)";
620 STRING_CATALOG_GENERATE_SYMBOLS = YES; 620 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
634 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 634 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
635 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 635 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
636 CODE_SIGN_STYLE = Automatic; 636 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 97; 637 CURRENT_PROJECT_VERSION = 98;
638 DEVELOPMENT_TEAM = ZCNAX3VL9D; 638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639 ENABLE_PREVIEWS = YES; 639 ENABLE_PREVIEWS = YES;
640 GENERATE_INFOPLIST_FILE = YES; 640 GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
651 "$(inherited)", 651 "$(inherited)",
652 "@executable_path/Frameworks", 652 "@executable_path/Frameworks",
653 ); 653 );
654 MARKETING_VERSION = 3.11.1; 654 MARKETING_VERSION = 3.12.0;
655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
656 PRODUCT_NAME = "$(TARGET_NAME)"; 656 PRODUCT_NAME = "$(TARGET_NAME)";
657 STRING_CATALOG_GENERATE_SYMBOLS = YES; 657 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -714,7 +714,7 @@
714 APPLICATION_EXTENSION_API_ONLY = YES; 714 APPLICATION_EXTENSION_API_ONLY = YES;
715 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 715 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
716 CODE_SIGN_STYLE = Automatic; 716 CODE_SIGN_STYLE = Automatic;
717 CURRENT_PROJECT_VERSION = 97; 717 CURRENT_PROJECT_VERSION = 98;
718 DEVELOPMENT_TEAM = ZCNAX3VL9D; 718 DEVELOPMENT_TEAM = ZCNAX3VL9D;
719 GENERATE_INFOPLIST_FILE = NO; 719 GENERATE_INFOPLIST_FILE = NO;
720 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 720 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
724 "@executable_path/Frameworks", 724 "@executable_path/Frameworks",
725 "@executable_path/../../Frameworks", 725 "@executable_path/../../Frameworks",
726 ); 726 );
727 MARKETING_VERSION = 3.11.1; 727 MARKETING_VERSION = 3.12.0;
728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
729 PRODUCT_NAME = "$(TARGET_NAME)"; 729 PRODUCT_NAME = "$(TARGET_NAME)";
730 SKIP_INSTALL = YES; 730 SKIP_INSTALL = YES;
@@ -743,7 +743,7 @@
743 APPLICATION_EXTENSION_API_ONLY = YES; 743 APPLICATION_EXTENSION_API_ONLY = YES;
744 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 744 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
745 CODE_SIGN_STYLE = Automatic; 745 CODE_SIGN_STYLE = Automatic;
746 CURRENT_PROJECT_VERSION = 97; 746 CURRENT_PROJECT_VERSION = 98;
747 DEVELOPMENT_TEAM = ZCNAX3VL9D; 747 DEVELOPMENT_TEAM = ZCNAX3VL9D;
748 GENERATE_INFOPLIST_FILE = NO; 748 GENERATE_INFOPLIST_FILE = NO;
749 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 749 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
753 "@executable_path/Frameworks", 753 "@executable_path/Frameworks",
754 "@executable_path/../../Frameworks", 754 "@executable_path/../../Frameworks",
755 ); 755 );
756 MARKETING_VERSION = 3.11.1; 756 MARKETING_VERSION = 3.12.0;
757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
758 PRODUCT_NAME = "$(TARGET_NAME)"; 758 PRODUCT_NAME = "$(TARGET_NAME)";
759 SKIP_INSTALL = YES; 759 SKIP_INSTALL = YES;
@@ -772,7 +772,7 @@
772 APPLICATION_EXTENSION_API_ONLY = YES; 772 APPLICATION_EXTENSION_API_ONLY = YES;
773 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 773 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
774 CODE_SIGN_STYLE = Automatic; 774 CODE_SIGN_STYLE = Automatic;
775 CURRENT_PROJECT_VERSION = 97; 775 CURRENT_PROJECT_VERSION = 98;
776 DEVELOPMENT_TEAM = ZCNAX3VL9D; 776 DEVELOPMENT_TEAM = ZCNAX3VL9D;
777 GENERATE_INFOPLIST_FILE = NO; 777 GENERATE_INFOPLIST_FILE = NO;
778 INFOPLIST_FILE = HutchSafariExtension/Info.plist; 778 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
782 "@executable_path/Frameworks", 782 "@executable_path/Frameworks",
783 "@executable_path/../../Frameworks", 783 "@executable_path/../../Frameworks",
784 ); 784 );
785 MARKETING_VERSION = 3.11.1; 785 MARKETING_VERSION = 3.12.0;
786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; 786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
787 PRODUCT_NAME = "$(TARGET_NAME)"; 787 PRODUCT_NAME = "$(TARGET_NAME)";
788 SKIP_INSTALL = YES; 788 SKIP_INSTALL = YES;
@@ -801,7 +801,7 @@
801 APPLICATION_EXTENSION_API_ONLY = YES; 801 APPLICATION_EXTENSION_API_ONLY = YES;
802 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 802 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
803 CODE_SIGN_STYLE = Automatic; 803 CODE_SIGN_STYLE = Automatic;
804 CURRENT_PROJECT_VERSION = 97; 804 CURRENT_PROJECT_VERSION = 98;
805 DEVELOPMENT_TEAM = ZCNAX3VL9D; 805 DEVELOPMENT_TEAM = ZCNAX3VL9D;
806 GENERATE_INFOPLIST_FILE = NO; 806 GENERATE_INFOPLIST_FILE = NO;
807 INFOPLIST_FILE = HutchSafariExtension/Info.plist; 807 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
811 "@executable_path/Frameworks", 811 "@executable_path/Frameworks",
812 "@executable_path/../../Frameworks", 812 "@executable_path/../../Frameworks",
813 ); 813 );
814 MARKETING_VERSION = 3.11.1; 814 MARKETING_VERSION = 3.12.0;
815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; 815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
816 PRODUCT_NAME = "$(TARGET_NAME)"; 816 PRODUCT_NAME = "$(TARGET_NAME)";
817 SKIP_INSTALL = YES; 817 SKIP_INSTALL = YES;
Hutch/Extensions/ErrorViews.swift +1
@@ -37,6 +37,7 @@ struct SRHTErrorBanner: ViewModifier {
37 Image(systemName: "xmark.circle.fill") 37 Image(systemName: "xmark.circle.fill")
38 .foregroundStyle(.white.opacity(0.8)) 38 .foregroundStyle(.white.opacity(0.8))
39 } 39 }
40 .accessibilityLabel("Dismiss error")
40 } 41 }
41 .padding(12) 42 .padding(12)
42 .background(Color.red.gradient, in: RoundedRectangle(cornerRadius: 12)) 43 .background(Color.red.gradient, in: RoundedRectangle(cornerRadius: 12))
Hutch/Views/Builds/BuildTaskLogView.swift +2
@@ -164,6 +164,7 @@ private struct BuildTaskLogContentView: View {
164 Image(systemName: "chevron.up") 164 Image(systemName: "chevron.up")
165 } 165 }
166 .disabled(matches.isEmpty) 166 .disabled(matches.isEmpty)
167 .accessibilityLabel("Previous match")
167 168
168 Button { 169 Button {
169 moveSelection(step: 1) 170 moveSelection(step: 1)
@@ -171,6 +172,7 @@ private struct BuildTaskLogContentView: View {
171 Image(systemName: "chevron.down") 172 Image(systemName: "chevron.down")
172 } 173 }
173 .disabled(matches.isEmpty) 174 .disabled(matches.isEmpty)
175 .accessibilityLabel("Next match")
174 } 176 }
175 .buttonStyle(.borderless) 177 .buttonStyle(.borderless)
176 .labelStyle(.iconOnly) 178 .labelStyle(.iconOnly)
Hutch/Views/Inbox/ThreadDetailView.swift +1
@@ -295,6 +295,7 @@ private struct InboxMessageRow: View {
295 } 295 }
296 } 296 }
297 .buttonStyle(.plain) 297 .buttonStyle(.plain)
298 .accessibilityHint(isCollapsed ? "Expands this message" : "Collapses this message")
298 299
299 if !isCollapsed { 300 if !isCollapsed {
300 ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in 301 ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in
Hutch/Views/More/MoreView.swift +1
@@ -96,6 +96,7 @@ struct MoreView: View {
96 } label: { 96 } label: {
97 Image(systemName: "person.crop.circle.badge.plus") 97 Image(systemName: "person.crop.circle.badge.plus")
98 } 98 }
99 .accessibilityLabel("Switch account")
99 } 100 }
100 } 101 }
101 .sheet(isPresented: $showAccountSwitcher) { 102 .sheet(isPresented: $showAccountSwitcher) {
Hutch/Views/Pastes/PasteDetailView.swift +1
@@ -413,6 +413,7 @@ private struct PasteVisibilitySheet: View {
413 .contentShape(Rectangle()) 413 .contentShape(Rectangle())
414 } 414 }
415 .buttonStyle(.plain) 415 .buttonStyle(.plain)
416 .accessibilityAddTraits(visibility == option ? [.isSelected] : [])
416 } 417 }
417 .themedRow() 418 .themedRow()
418 } 419 }
Hutch/Views/Pastes/PasteListView.swift +1
@@ -25,6 +25,7 @@ struct PasteListView: View {
25 } label: { 25 } label: {
26 Image(systemName: "plus") 26 Image(systemName: "plus")
27 } 27 }
28 .accessibilityLabel("Create paste")
28 } 29 }
29 } 30 }
30 } 31 }
Hutch/Views/Projects/ProjectDetailView.swift +1
@@ -217,6 +217,7 @@ struct ProjectDetailView: View {
217 } 217 }
218 } 218 }
219 .buttonStyle(.plain) 219 .buttonStyle(.plain)
220 .accessibilityHint("Opens in your browser")
220 } 221 }
221 .themedRow() 222 .themedRow()
222 } 223 }
Hutch/Views/Repositories/ArtifactsView.swift +1
@@ -206,6 +206,7 @@ private struct ArtifactRow: View {
206 Image(systemName: "arrow.down.circle") 206 Image(systemName: "arrow.down.circle")
207 .imageScale(.large) 207 .imageScale(.large)
208 } 208 }
209 .accessibilityLabel("Download artifact")
209 } 210 }
210 } 211 }
211} 212}
Hutch/Views/Repositories/CommitDetailView.swift +1
@@ -153,6 +153,7 @@ struct CommitDetailView: View {
153 } 153 }
154 .foregroundStyle(.secondary) 154 .foregroundStyle(.secondary)
155 } 155 }
156 .accessibilityHint("Copies the commit SHA")
156 157
157 // Author 158 // Author
158 HStack { 159 HStack {
Hutch/Views/Repositories/DiffView.swift +2
@@ -58,6 +58,7 @@ private struct DiffFileSectionView: View {
58 .contentShape(Rectangle()) 58 .contentShape(Rectangle())
59 } 59 }
60 .buttonStyle(.plain) 60 .buttonStyle(.plain)
61 .accessibilityHint(isExpanded ? "Collapses this file" : "Expands this file")
61 .background(Color(.tertiarySystemBackground)) 62 .background(Color(.tertiarySystemBackground))
62 63
63 if isExpanded { 64 if isExpanded {
@@ -169,6 +170,7 @@ private struct DiffHunkView: View {
169 .contentShape(Rectangle()) 170 .contentShape(Rectangle())
170 } 171 }
171 .buttonStyle(.plain) 172 .buttonStyle(.plain)
173 .accessibilityHint(isExpanded ? "Collapses this hunk" : "Expands this hunk")
172 .background(Color(.systemBackground).opacity(0.5)) 174 .background(Color(.systemBackground).opacity(0.5))
173 175
174 if isExpanded { 176 if isExpanded {
Hutch/Views/Tickets/TicketDetailView.swift +3
@@ -606,6 +606,7 @@ private struct EventRow: View {
606 .foregroundStyle(.gray) 606 .foregroundStyle(.gray)
607 } 607 }
608 .buttonStyle(.plain) 608 .buttonStyle(.plain)
609 .accessibilityLabel("About this status change")
609 } 610 }
610 } else { 611 } else {
611 Text(descriptionText) 612 Text(descriptionText)
@@ -948,6 +949,7 @@ private struct AssignSheet: View {
948 .foregroundStyle(.red) 949 .foregroundStyle(.red)
949 } 950 }
950 .buttonStyle(.plain) 951 .buttonStyle(.plain)
952 .accessibilityLabel("Unassign \(assignee.canonicalName)")
951 } 953 }
952 } 954 }
953 .themedRow() 955 .themedRow()
@@ -1152,5 +1154,6 @@ private struct LabelToggleRow: View {
1152 } 1154 }
1153 } 1155 }
1154 .disabled(isLoading) 1156 .disabled(isLoading)
1157 .accessibilityAddTraits(isApplied ? [.isSelected] : [])
1155 } 1158 }
1156} 1159}
Hutch/Views/Tickets/TicketListView.swift +7
@@ -857,6 +857,9 @@ private struct TicketQuickFilterBar: View {
857 in: Capsule() 857 in: Capsule()
858 ) 858 )
859 } 859 }
860 .accessibilityAddTraits(
861 activeSavedFilterID == savedFilter.id ? [.isSelected] : []
862 )
860 .contextMenu { 863 .contextMenu {
861 Button(role: .destructive) { 864 Button(role: .destructive) {
862 onDeleteSavedFilter(savedFilter) 865 onDeleteSavedFilter(savedFilter)
@@ -964,6 +967,9 @@ private struct TicketFilterLabelsSheet: View {
964 } 967 }
965 } 968 }
966 .buttonStyle(.plain) 969 .buttonStyle(.plain)
970 .accessibilityAddTraits(
971 viewModel.selectedLabelIDs.contains(label.id) ? [.isSelected] : []
972 )
967 } 973 }
968 .themedRow() 974 .themedRow()
969 } 975 }
@@ -1051,6 +1057,7 @@ private struct TicketListLabelToggleRow: View {
1051 } 1057 }
1052 } 1058 }
1053 .disabled(isLoading) 1059 .disabled(isLoading)
1060 .accessibilityAddTraits(isApplied ? [.isSelected] : [])
1054 } 1061 }
1055} 1062}
1056 1063
Hutch/Views/Tickets/TrackerListView.swift +1
@@ -25,6 +25,7 @@ struct TrackerListView: View {
25 } label: { 25 } label: {
26 Image(systemName: "plus") 26 Image(systemName: "plus")
27 } 27 }
28 .accessibilityLabel("Create tracker")
28 } 29 }
29 } 30 }
30 } 31 }
Hutch/Views/Tickets/TrackerManagementView.swift +2
@@ -785,6 +785,7 @@ struct TrackerACLManagementSheet: View {
785 Image(systemName: "plus") 785 Image(systemName: "plus")
786 } 786 }
787 .disabled(viewModel.isSavingACL) 787 .disabled(viewModel.isSavingACL)
788 .accessibilityLabel("Add ACL")
788 } 789 }
789 } 790 }
790 .task { 791 .task {
@@ -1155,6 +1156,7 @@ struct TrackerLabelManagementSheet: View {
1155 Image(systemName: "plus") 1156 Image(systemName: "plus")
1156 } 1157 }
1157 .disabled(viewModel.isSavingLabel) 1158 .disabled(viewModel.isSavingLabel)
1159 .accessibilityLabel("Create label")
1158 } 1160 }
1159 } 1161 }
1160 .task { 1162 .task {
ROADMAP.txt +31 −6
@@ -156,7 +156,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*.
156| 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 | 156| 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 |
157| v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); ~~"What's cooking" ingest + doc truth-up~~ (done) | Ships one feature, corrects the map | 157| v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); ~~"What's cooking" ingest + doc truth-up~~ (done) | Ships one feature, corrects the map |
158| v3.11.0 | ~~Mailing list subscribe/unsubscribe toggle~~ (shipped) | Ingest-surfaced; state via the `subscriptions` query (the `subscription` field is a trap) | 158| v3.11.0 | ~~Mailing list subscribe/unsubscribe toggle~~ (shipped) | Ingest-surfaced; state via the `subscriptions` query (the `subscription` field is a trap) |
159| v3.12.0 | Accessibility | Independent, device-verified | 159| v3.12.0 | ~~Accessibility~~ (done in code) | Still wants a VoiceOver pass on a device |
160| v4.0.0 | Localization *with* translations | The only true re-presentation | 160| v4.0.0 | Localization *with* translations | The only true re-presentation |
161| — | ~~Swift 6 language mode~~ (done); cache reads | Internal; ride along, no tag | 161| — | ~~Swift 6 language mode~~ (done); cache reads | Internal; ride along, no tag |
162 162
@@ -206,11 +206,36 @@ the major number on regression risk while delivering nothing — the wrong trade
206Hold the catalog until a translation lands. If it ever ships unbundled, it is 206Hold the catalog until a translation lands. If it ever ships unbundled, it is
207groundwork and belongs in a quiet minor, not a 4.0. 207groundwork and belongs in a quiet minor, not a 4.0.
208 208
209### Accessibility — v3.11.0 209### Accessibility — done in code (v3.12.0)
210 210
211Labels and hints appear in 17 of 89 view files. Mechanical and low-risk, but it 211The earlier count here (17 of 89 view files) was wrong on both numbers: 22 of 95
212cannot be verified from a build — it needs VoiceOver driven on a device. 212carried a modifier, and of those, labels were the whole story — one hint in the
213Independent of every other bucket, so it can move if a device pass is convenient. 213whole app, no traits at all. Counting files also hid the shape of the gap, which
214was not spread evenly but concentrated in three kinds of control:
215
216- **Icon-only controls**, which VoiceOver reaches with nothing to announce.
217 Eleven of them: the error banner's dismiss, log search next/previous match,
218 account switching, four `plus` creates (paste, tracker, ACL, label), artifact
219 download, the system-status info button, and assignee unassign.
220- **Selection state carried only by a checkmark or a tint.** Five: applied
221 ticket labels in two views, the label filter, the saved-filter chip, and the
222 paste visibility picker. These now carry `.isSelected` rather than relying on
223 an icon VoiceOver does not read.
224- **Disclosure state carried only by a chevron.** Three: diff file, diff hunk,
225 and inbox message. `PatchsetDetailView` already had the hint; the others now
226 match it.
227
228Two more: the project external-link row says it leaves the app, and the commit
229SHA button says it copies — both read as bare text before.
230
231Decorative chevrons inside a control that already carries text were left alone.
232An unlabelled SF Symbol contributes nothing to a combined label, so hiding them
233would be churn with no announced difference.
234
235Still open: **this is not device-verified.** It builds and the suite is green,
236but neither proves a VoiceOver pass. What is verified is that no icon-only
237control is left unlabelled — a static check over the enclosing control's brace
238span, not a guess at line proximity.
214 239
215### SonarCloud backlog — done in code (v3.8.1) 240### SonarCloud backlog — done in code (v3.8.1)
216 241