iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add VoiceOver accessibility labels across all views

Add .accessibilityLabel to icon-only buttons (like, comment, share,
close, delete, more options, pin/unpin, send, etc.) across 15 view
files. Mark decorative icons as .accessibilityHidden (avatar fallbacks,
particle animations, reply indicators). Add .accessibilityElement to
story strip bubbles. Fix "Your story" bubble alignment with other
author bubbles by adding matching padding. Nudge "+" badge position
on story avatar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+54 -1
+4
Grain/Views/Comments/CommentSheetContent.swift
··· 63 63 .font(.subheadline.weight(.semibold)) 64 64 .foregroundStyle(.primary) 65 65 } 66 + .accessibilityLabel("Close comments") 66 67 } 67 68 case .done: 68 69 ToolbarItem(placement: .cancellationAction) { ··· 138 139 Image(systemName: "arrowshape.turn.up.left.fill") 139 140 .font(.caption2) 140 141 .foregroundStyle(.primary) 142 + .accessibilityHidden(true) 141 143 Text("Replying to") 142 144 .foregroundStyle(.secondary) 143 145 Text("@\(replyTarget.author.handle)") ··· 153 155 .contentShape(Rectangle()) 154 156 } 155 157 .buttonStyle(.plain) 158 + .accessibilityLabel("Cancel reply") 156 159 } 157 160 .font(.subheadline.weight(.medium)) 158 161 .padding(.leading, 16) ··· 182 185 .foregroundStyle(Color("AccentColor")) 183 186 .frame(width: 44, height: 44) 184 187 } 188 + .accessibilityLabel("Send comment") 185 189 .glassEffect(.regular.interactive(), in: .circle) 186 190 .disabled(isPostingComment) 187 191 .transition(
+1
Grain/Views/Components/AvatarView.swift
··· 61 61 Image(systemName: "person.fill") 62 62 .font(.system(size: size * 0.45)) 63 63 .foregroundStyle(Color(.systemGray2)) 64 + .accessibilityHidden(true) 64 65 } 65 66 } 66 67 }
+9
Grain/Views/Components/GalleryCardView.swift
··· 88 88 } 89 89 .position(x: state.position.x, y: state.position.y) 90 90 .allowsHitTesting(false) 91 + .accessibilityHidden(true) 91 92 .onAppear { state.start() } 92 93 } 93 94 } ··· 305 306 .contentShape(Rectangle()) 306 307 } 307 308 .buttonStyle(.plain) 309 + .accessibilityLabel("More options") 308 310 .highPriorityGesture(TapGesture().onEnded { showCardActions = true }) 309 311 } 310 312 } ··· 431 433 .background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 4)) 432 434 .foregroundStyle(.white) 433 435 } 436 + .accessibilityLabel(showingAlt ? "Hide alt text" : "Show alt text") 434 437 } 435 438 .padding(8) 436 439 } ··· 458 461 } 459 462 } 460 463 .foregroundStyle(isFavorited ? Color("AccentColor") : .secondary) 464 + .accessibilityLabel(isFavorited ? "Unlike" : "Like") 465 + .accessibilityValue("\(gallery.favCount ?? 0) likes") 461 466 .overlay(alignment: .leading) { 462 467 ZStack { 463 468 ForEach(likeParticleBursts, id: \.self) { _ in ··· 468 473 } 469 474 .offset(x: 11) 470 475 .allowsHitTesting(false) 476 + .accessibilityHidden(true) 471 477 } 472 478 473 479 Button { ··· 480 486 } 481 487 } 482 488 .foregroundStyle(.secondary) 489 + .accessibilityLabel("Comments") 490 + .accessibilityValue("\(gallery.commentCount ?? 0)") 483 491 484 492 ShareLink(item: galleryShareURL) { 485 493 Image(systemName: "paperplane") ··· 493 501 ) 494 502 } 495 503 .foregroundStyle(.secondary) 504 + .accessibilityLabel("Share gallery") 496 505 .disabled(shareAnimating) 497 506 .simultaneousGesture( 498 507 LongPressGesture(minimumDuration: 0.5)
+1
Grain/Views/Create/PhotoThumbnailCell.swift
··· 90 90 .contentShape(Circle().scale(0.7)) 91 91 } 92 92 .buttonStyle(.plain) 93 + .accessibilityLabel("Remove photo") 93 94 } 94 95 } 95 96
+1
Grain/Views/Feed/CameraFeedView.swift
··· 70 70 .font(.system(size: 16, weight: .medium)) 71 71 } 72 72 .tint(.primary) 73 + .accessibilityLabel("More options") 73 74 } 74 75 } 75 76 .task {
+1
Grain/Views/Feed/FeedView.swift
··· 229 229 .glassEffect(.regular.interactive(), in: .circle) 230 230 } 231 231 .buttonStyle(.plain) 232 + .accessibilityLabel("Create gallery") 232 233 } 233 234 234 235 private func consumeDeepLink() {
+2
Grain/Views/Feed/FeedsManagementView.swift
··· 111 111 .foregroundStyle(.secondary) 112 112 } 113 113 .buttonStyle(.plain) 114 + .accessibilityLabel("Unpin feed") 114 115 } else { 115 116 Button { 116 117 Task { await prefsViewModel.pinFeed(feed, auth: auth.authContext()) } ··· 120 121 .foregroundStyle(Color.accentColor) 121 122 } 122 123 .buttonStyle(.plain) 124 + .accessibilityLabel("Pin feed") 123 125 } 124 126 } 125 127 .moveDisabled(!showPin)
+1
Grain/Views/Feed/HashtagFeedView.swift
··· 70 70 .font(.system(size: 16, weight: .medium)) 71 71 } 72 72 .tint(.primary) 73 + .accessibilityLabel("More options") 73 74 } 74 75 } 75 76 .task {
+3
Grain/Views/Feed/LocationFeedView.swift
··· 47 47 if !mapInteractive { 48 48 Color.clear 49 49 .contentShape(Rectangle()) 50 + .accessibilityLabel("Expand map") 51 + .accessibilityAddTraits(.isButton) 50 52 .onTapGesture { 51 53 withAnimation(.easeInOut(duration: 0.25)) { mapInteractive = true } 52 54 } ··· 126 128 .font(.system(size: 16, weight: .medium)) 127 129 } 128 130 .tint(.primary) 131 + .accessibilityLabel("More options") 129 132 } 130 133 } 131 134 .task {
+2
Grain/Views/LoginView.swift
··· 56 56 Image(systemName: "exclamationmark.circle.fill") 57 57 .font(.body.weight(.medium)) 58 58 .foregroundStyle(.white) 59 + .accessibilityHidden(true) 59 60 Text(reason) 60 61 .font(.subheadline) 61 62 .foregroundStyle(.white) ··· 91 92 Image(systemName: "at") 92 93 .font(.body.weight(.medium)) 93 94 .foregroundStyle(.white.opacity(0.5)) 95 + .accessibilityHidden(true) 94 96 95 97 TextField("e.g. user.bsky.social", text: $handle, prompt: Text("e.g. user.bsky.social").foregroundStyle(.white.opacity(0.5))) 96 98 .foregroundStyle(.white)
+12
Grain/Views/Notifications/NotificationsView.swift
··· 205 205 } 206 206 } 207 207 208 + private var label: String { 209 + switch reason { 210 + case .galleryFavorite, .storyFavorite: "Liked" 211 + case .follow: "Followed" 212 + case .galleryComment, .storyComment: "Commented" 213 + case .reply: "Replied" 214 + case .galleryCommentMention, .galleryMention: "Mentioned" 215 + case .unknown: "Notification" 216 + } 217 + } 218 + 208 219 var body: some View { 209 220 Image(systemName: iconName) 210 221 .foregroundStyle(Color("AccentColor")) 211 222 .font(.system(size: 18)) 212 223 .frame(width: 20) 224 + .accessibilityLabel(label) 213 225 } 214 226 } 215 227
+4
Grain/Views/Profile/ProfileView.swift
··· 81 81 .font(.system(size: 22)) 82 82 .foregroundStyle(.white, Color("AccentColor")) 83 83 .offset(x: 4, y: 4) 84 + .accessibilityHidden(true) 84 85 } 85 86 } 86 87 .padding(4) ··· 581 582 .contentShape(Rectangle()) 582 583 } 583 584 .buttonStyle(.plain) 585 + .accessibilityLabel("\(mode.rawValue.capitalized)") 584 586 } 585 587 586 588 private func setViewMode(_ mode: ProfileViewMode) { ··· 750 752 .foregroundStyle(.white) 751 753 .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) 752 754 .padding(6) 755 + .accessibilityHidden(true) 753 756 } 754 757 } 755 758 } ··· 881 884 .foregroundStyle(.white) 882 885 .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) 883 886 .padding(6) 887 + .accessibilityHidden(true) 884 888 } 885 889 } 886 890 }
+3
Grain/Views/Search/SearchView.swift
··· 229 229 .font(.system(size: 18)) 230 230 .foregroundStyle(.white, Color("AccentColor")) 231 231 } 232 + .accessibilityLabel("Remove \(profile.displayName ?? profile.handle ?? "") from recent") 232 233 } 233 234 234 235 Text(profile.displayName ?? profile.handle ?? "") ··· 251 252 Image(systemName: "magnifyingglass") 252 253 .foregroundStyle(.secondary) 253 254 .font(.subheadline) 255 + .accessibilityHidden(true) 254 256 Text(recent.query) 255 257 .font(.subheadline) 256 258 Spacer() ··· 261 263 .font(.caption2.weight(.bold)) 262 264 .foregroundStyle(.primary) 263 265 } 266 + .accessibilityLabel("Remove search") 264 267 } 265 268 .padding(.horizontal) 266 269 .contentShape(Rectangle())
+1
Grain/Views/Settings/EditProfileView.swift
··· 57 57 .frame(width: 32, height: 32) 58 58 .background(Color("AccentColor"), in: Circle()) 59 59 } 60 + .accessibilityLabel("Change profile photo") 60 61 } 61 62 62 63 if newAvatarImage != nil || (!removeAvatar && existingAvatarURL != nil) {
+9 -1
Grain/Views/Stories/StoryStripView.swift
··· 35 35 StoryRingView(hasStory: ownAuthor != nil, viewed: false, size: avatarSize) { 36 36 AvatarView(url: userAvatar, size: avatarSize) 37 37 } 38 + .padding(4) 38 39 Image(systemName: "plus.circle.fill") 39 40 .font(.system(size: 18)) 40 41 .foregroundStyle(.white, Color("AccentColor")) 41 - .offset(x: 2, y: 2) 42 + .offset(x: -1, y: -1) 43 + .accessibilityHidden(true) 42 44 } 43 45 Text("Your story") 44 46 .font(.caption2) 45 47 .foregroundStyle(.secondary) 46 48 .lineLimit(1) 47 49 } 50 + .accessibilityElement(children: .ignore) 51 + .accessibilityLabel(ownAuthor != nil ? "View your story" : "Create a story") 52 + .accessibilityAddTraits(.isButton) 48 53 .onTapGesture { 49 54 if let own = ownAuthor { 50 55 onAuthorTap(own, 0) ··· 85 90 .scaleEffect(isLifted ? 1.03 : 1.0) 86 91 .offset(y: isLifted ? -3 : 0) 87 92 .zIndex(isViewed ? 0 : 1) 93 + .accessibilityElement(children: .ignore) 94 + .accessibilityLabel("\(author.profile.displayName ?? author.profile.handle)'s story") 95 + .accessibilityAddTraits(.isButton) 88 96 .onTapGesture { onAuthorTap(author, 0) } 89 97 } 90 98 }