From d285c6ad53ba64f49395b92e8062f0dc2a9a1914 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:22:38 +0200 Subject: [PATCH] ux-fixes: lock alignment, caption band, wrapping, tap targets, VoiceOver, theme sweep (#1366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix lock glyph alignment, privacy-caption band, and empty-state wrapping - Message-row locks: align to first text baseline instead of a hardcoded top padding that left the lock ~4pt below the line's visual center - Header/caption locks: 1pt optical lift (lock.fill ink is bottom-heavy; geometric centering reads low); seal badge stays untouched - DM privacy caption: sit on the themed surface like the rest of the bottom chrome instead of painting its own orange band - Empty-state lines: non-breaking spaces so the closing * can't orphan and 'bitchat/ for help' can't break right after the slash Co-Authored-By: Claude Fable 5 * Unify sheet close buttons, widen tiny tap targets, handle long nicknames - New SheetCloseButton component: one glyph size/weight (13 semibold), 32pt visual box, 44pt hit target; adopted by all 7 sheets (sizes had drifted across 12/13/14pt, two had no frame at all) - Favorite star buttons get real tap targets (peer list + DM header) - DM header nickname: single line with middle truncation instead of wrapping into the fixed-height header; peer-list names truncate tail - Geohash people rows: leading glyph 12 -> 10 to match mesh rows - Sidebar lock glyphs get the same optical lift as the DM header Co-Authored-By: Claude Fable 5 * Make channel switching, voice notes, and header actions work under VoiceOver - Channel rows in the location sheet are now single activatable buttons (label + selected trait + switch hint) with the bookmark toggle mirrored as a named accessibility action; bookmark buttons labeled - Voice-note mic: press-and-hold drag gestures can't be activated by VoiceOver, so the default accessibility action now toggles start/stop-and-send; announces 'recording' state; localized labels - Attachment button: camera (long-press) path exposed as a named action; labels localized instead of hardcoded English - People-count button announces connected vs no-one-reachable (was color-only); verification QR button gains a spoken name (.help is only a hint on iOS); bitchat/ logo exposes its tap-for-app-info as a button (panic triple-tap stays undiscoverable on purpose) Co-Authored-By: Claude Fable 5 * Theme-correctness sweep: palette colors everywhere, AX-size header growth - Fingerprint/verification sheet cards: palette-tinted boxes instead of fixed gray bands that ignored matrix green and occluded glass - Voice-note card: palette background (translucent) instead of opaque white/black; waveform + payment chips + image placeholder follow suit - .secondary/.primary/Color.blue swapped for palette.secondary/primary/ accentBlue across location sheets, people sheets, message captions, and the header count (system gray read wrong under matrix green) - Autocomplete/command rows: dropped the uniform gray wash that dulled the themed overlay panel - 'tap to reveal' caption follows the theme font instead of hardcoding monospaced - Headers use minHeight so two-line accessibility text sizes grow the bar instead of clipping inside it Co-Authored-By: Claude Fable 5 * Fix main header expanding to fill the screen The header bar's fixed height was load-bearing: its children fill the bar with .frame(maxHeight: .infinity) tap targets, so switching to an open-ended minHeight let the header expand to swallow all available vertical space, centering the title mid-screen and crushing the timeline into the composer. Restore the fixed height — headerHeight is a @ScaledMetric, so it already grows with Dynamic Type. Reproduced and verified both layouts with an offscreen render harness. Co-Authored-By: Claude Fable 5 * DM header: floating glass panel instead of muddy orange wash under glass Orange at 14% over the backdrop gradient reads as a gray-beige band, not a privacy signature. Under liquid glass the DM header now uses the same floating chrome panel as the main header; the private signature is already carried by the orange lock, caption, and composer accents. Matrix keeps its orange wash over the opaque themed surface, unchanged. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Localizable.xcstrings | 170 +++++++++++++++++- bitchat/Views/AppInfoView.swift | 10 +- .../Components/CommandSuggestionsView.swift | 1 - .../Views/Components/PaymentChipView.swift | 2 +- .../Views/Components/SheetCloseButton.swift | 29 +++ .../Views/Components/TextMessageView.swift | 11 +- bitchat/Views/ContentComposerView.swift | 41 ++++- bitchat/Views/ContentHeaderView.swift | 49 +++-- bitchat/Views/ContentSheetViews.swift | 84 ++++++--- bitchat/Views/FingerprintView.swift | 13 +- bitchat/Views/GeohashPeopleList.swift | 6 +- bitchat/Views/LocationChannelsSheet.swift | 58 +++--- bitchat/Views/LocationNotesView.swift | 21 +-- .../Views/Media/BlockRevealImageView.swift | 9 +- bitchat/Views/Media/MediaMessageView.swift | 9 +- bitchat/Views/Media/VoiceNoteView.swift | 6 +- bitchat/Views/Media/WaveformView.swift | 2 +- bitchat/Views/MeshPeerList.swift | 12 ++ bitchat/Views/MessageListView.swift | 4 +- bitchat/Views/VerificationViews.swift | 23 +-- 20 files changed, 439 insertions(+), 121 deletions(-) create mode 100644 bitchat/Views/Components/SheetCloseButton.swift diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index ea1aab1f..7301e613 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -8572,6 +8572,42 @@ } } }, + "content.accessibility.app_info_hint" : { + "comment" : "Accessibility hint on the bitchat/ logo explaining a tap opens app info", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "shows app info" + } + } + } + }, + "content.accessibility.attach_photo" : { + "comment" : "Accessibility label for the photo attachment button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "attach photo" + } + } + } + }, + "content.accessibility.attach_photo_hint" : { + "comment" : "Accessibility hint explaining the attachment button opens the photo library", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the photo library; use the take photo action for the camera" + } + } + } + }, "content.accessibility.available_nostr" : { "extractionState" : "manual", "localizations" : { @@ -8930,6 +8966,18 @@ } } }, + "content.accessibility.choose_photo" : { + "comment" : "Accessibility label for the macOS photo picker button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "choose photo" + } + } + } + }, "content.accessibility.connected_mesh" : { "extractionState" : "manual", "localizations" : { @@ -9849,6 +9897,30 @@ } } }, + "content.accessibility.peers_connected" : { + "comment" : "Accessibility value when peers are reachable", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connected" + } + } + } + }, + "content.accessibility.peers_none" : { + "comment" : "Accessibility value when no peers are reachable", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no one reachable" + } + } + } + }, "content.accessibility.people_count" : { "extractionState" : "manual", "localizations" : { @@ -10770,6 +10842,42 @@ } } }, + "content.accessibility.record_voice_hint" : { + "comment" : "Accessibility hint explaining double-tap toggles voice recording", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "double-tap to start recording, double-tap again to send" + } + } + } + }, + "content.accessibility.record_voice_note" : { + "comment" : "Accessibility label for the voice note button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "record voice note" + } + } + } + }, + "content.accessibility.recording" : { + "comment" : "Accessibility value announced while a voice note is recording", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "recording" + } + } + } + }, "content.accessibility.remove_favorite" : { "extractionState" : "manual", "localizations" : { @@ -11486,6 +11594,18 @@ } } }, + "content.accessibility.take_photo" : { + "comment" : "Accessibility action name for taking a photo with the camera", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "take photo with camera" + } + } + } + }, "content.accessibility.toggle_bookmark" : { "extractionState" : "manual", "localizations" : { @@ -11844,6 +11964,18 @@ } } }, + "content.accessibility.verification" : { + "comment" : "Accessibility label for the verification QR button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "verify encryption" + } + } + } + }, "content.accessibility.view_fingerprint_hint" : { "extractionState" : "manual", "localizations" : { @@ -17735,7 +17867,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "tap the channel name above to switch · tap bitchat/ for help" + "value" : "tap the channel name above to switch · tap bitchat/ for help" } } } @@ -24292,6 +24424,42 @@ } } }, + "location_channels.accessibility.add_bookmark" : { + "comment" : "Accessibility action name for bookmarking a channel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bookmark channel" + } + } + } + }, + "location_channels.accessibility.remove_bookmark" : { + "comment" : "Accessibility action name for removing a channel bookmark", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "remove bookmark" + } + } + } + }, + "location_channels.accessibility.switch_hint" : { + "comment" : "Accessibility hint on a channel row explaining activation switches to it", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "switches to this channel" + } + } + } + }, "location_channels.action.open_settings" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index 05a0154a..152c09df 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -138,14 +138,8 @@ struct AppInfoView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { dismiss() }) { - Image(systemName: "xmark") - .bitchatFont(size: 13, weight: .semibold) - .foregroundColor(textColor) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel("app_info.close") + SheetCloseButton { dismiss() } + .foregroundColor(textColor) } } } diff --git a/bitchat/Views/Components/CommandSuggestionsView.swift b/bitchat/Views/Components/CommandSuggestionsView.swift index ca357771..cc4f9609 100644 --- a/bitchat/Views/Components/CommandSuggestionsView.swift +++ b/bitchat/Views/Components/CommandSuggestionsView.swift @@ -54,7 +54,6 @@ struct CommandSuggestionsView: View { buttonRow(for: command) } .buttonStyle(.plain) - .background(Color.gray.opacity(0.1)) } } .themedOverlayPanel() diff --git a/bitchat/Views/Components/PaymentChipView.swift b/bitchat/Views/Components/PaymentChipView.swift index 605f7b47..e01887d0 100644 --- a/bitchat/Views/Components/PaymentChipView.swift +++ b/bitchat/Views/Components/PaymentChipView.swift @@ -57,7 +57,7 @@ struct PaymentChipView: View { private var fgColor: Color { palette.primary } private var bgColor: Color { - colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12) + palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12) } private var border: Color { fgColor.opacity(0.25) } diff --git a/bitchat/Views/Components/SheetCloseButton.swift b/bitchat/Views/Components/SheetCloseButton.swift new file mode 100644 index 00000000..e2891e35 --- /dev/null +++ b/bitchat/Views/Components/SheetCloseButton.swift @@ -0,0 +1,29 @@ +// +// SheetCloseButton.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// The close "X" every sheet and header shares. One glyph size and weight +/// everywhere (the sheets had drifted across 12/13/14pt), a 32pt visual box +/// so existing header metrics don't move, and a hit target extended to 44pt +/// per platform guidelines. Tint comes from the environment, so callers keep +/// their own foreground color. +struct SheetCloseButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "xmark") + .bitchatFont(size: 13, weight: .semibold) + .frame(width: 32, height: 32) + .contentShape(Rectangle().inset(by: -6)) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons")) + } +} diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index ff310a7e..4c96b201 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -12,6 +12,7 @@ import BitFoundation struct TextMessageView: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.appTheme) private var theme + @ThemedPalette private var palette @EnvironmentObject private var conversationUIModel: ConversationUIModel let message: BitchatMessage @@ -36,14 +37,16 @@ struct TextMessageView: View { // Precompute heavy token scans once per row let cashuLinks = message.content.extractCashuLinks() let lightningLinks = message.content.extractLightningLinks() - HStack(alignment: .top, spacing: 0) { + // Baseline alignment keeps the lock and delivery glyphs on the + // first text line; a fixed top padding left the lock's solid body + // hanging below the line's visual center. + HStack(alignment: .firstTextBaseline, spacing: 0) { let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty let isExpanded = expandedMessageIDs.contains(message.id) if message.isPrivate { Image(systemName: "lock.fill") .font(.bitchatSystem(size: 8)) .foregroundColor(Color.orange.opacity(0.75)) - .padding(.top, 5) .padding(.trailing, 4) .accessibilityHidden(true) } @@ -84,7 +87,7 @@ struct TextMessageView: View { } else if showDeliveryDetail { Text(verbatim: status.bitchatDescription) .bitchatFont(size: 11) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) .fixedSize(horizontal: false, vertical: true) .padding(.top, 2) } @@ -99,7 +102,7 @@ struct TextMessageView: View { else { expandedMessageIDs.insert(message.id) } } .bitchatFont(size: 11, weight: .medium) - .foregroundColor(Color.blue) + .foregroundColor(palette.accentBlue) .padding(.top, 4) } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index 36f88098..a3ec8d0f 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -44,7 +44,6 @@ struct ContentComposerView: View { .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) - .background(Color.gray.opacity(0.1)) } } .themedOverlayPanel() @@ -184,7 +183,19 @@ private extension ContentComposerView { imagePickerSourceType = .camera showImagePicker = true } - .accessibilityLabel("Tap for library, long press for camera") + .accessibilityLabel( + String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button") + ) + .accessibilityHint( + String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library") + ) + .accessibilityAddTraits(.isButton) + // The long-press → camera path is unreachable for VoiceOver users; + // mirror it as a named action. + .accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) { + imagePickerSourceType = .camera + showImagePicker = true + } #else Button(action: { showMacImagePicker = true }) { Image(systemName: "photo.circle.fill") @@ -193,7 +204,9 @@ private extension ContentComposerView { .frame(width: 36, height: 36) } .buttonStyle(.plain) - .accessibilityLabel("Choose photo") + .accessibilityLabel( + String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button") + ) #endif } @@ -235,7 +248,27 @@ private extension ContentComposerView { } ) ) - .accessibilityLabel("Hold to record a voice note") + .accessibilityLabel( + String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button") + ) + .accessibilityValue( + voiceRecordingVM.state.isActive + ? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording") + : "" + ) + .accessibilityHint( + String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording") + ) + .accessibilityAddTraits(.isButton) + // Press-and-hold drag gestures can't be activated by VoiceOver; + // give it a start/stop toggle as the default action. + .accessibilityAction { + if voiceRecordingVM.state.isActive { + voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote) + } else { + voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext) + } + } } func sendButtonView(enabled: Bool) -> some View { diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 3c20baa7..71c36c74 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -33,6 +33,16 @@ struct ContentHeaderView: View { .onTapGesture(count: 1) { appChromeModel.presentAppInfo() } + // This is the only entry point to App Info, but it reads as + // static text; surface the tap. (The triple-tap panic wipe + // stays undiscoverable on purpose — it's destructive.) + .accessibilityAddTraits(.isButton) + .accessibilityHint( + String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info") + ) + .accessibilityAction { + appChromeModel.presentAppInfo() + } HStack(spacing: 0) { Text(verbatim: "@") @@ -183,6 +193,13 @@ struct ContentHeaderView: View { headerOtherPeersCount ) ) + // Connected-vs-nobody is otherwise encoded only in the icon's + // color; say it. + .accessibilityValue( + headerPeersReachable + ? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable") + : String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable") + ) } .layoutPriority(3) .sheet(isPresented: $showVerifySheet) { @@ -190,6 +207,11 @@ struct ContentHeaderView: View { .environmentObject(verificationModel) } } + // Fixed height is load-bearing: children fill the bar with + // .frame(maxHeight: .infinity) tap targets, so an open-ended + // minHeight lets the header expand to swallow the whole screen. + // headerHeight is a @ScaledMetric, so it still grows with Dynamic + // Type. .frame(height: headerHeight) .padding(.horizontal, 12) .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { @@ -266,14 +288,25 @@ private extension ContentHeaderView { dynamicTypeSize.isAccessibilitySize ? 2 : 1 } + /// Whether anyone is actually reachable on the current channel — the + /// state the count icon's color encodes visually. + var headerPeersReachable: Bool { + switch locationChannelsModel.selectedChannel { + case .location: + return peerListModel.visibleGeohashPeerCount > 0 + case .mesh: + return peerListModel.connectedMeshPeerCount > 0 + } + } + func channelPeopleCountAndColor() -> (Int, Color) { switch locationChannelsModel.selectedChannel { case .location: let count = peerListModel.visibleGeohashPeerCount - return (count, count > 0 ? palette.locationAccent : Color.secondary) + return (count, count > 0 ? palette.locationAccent : palette.secondary) case .mesh: let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) - let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary + let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary return (peerListModel.reachableMeshPeerCount, color) } } @@ -293,16 +326,10 @@ private struct ContentLocationNotesUnavailableView: View { Text("content.notes.title") .bitchatFont(size: 16, weight: .bold) Spacer() - Button(action: { showLocationNotes = false }) { - Image(systemName: "xmark") - .bitchatFont(size: 13, weight: .semibold) - .foregroundColor(palette.primary) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons")) + SheetCloseButton { showLocationNotes = false } + .foregroundColor(palette.primary) } - .frame(height: headerHeight) + .frame(minHeight: headerHeight) .padding(.horizontal, 12) .themedChromePanel(edge: .top) Text("content.notes.location_unavailable") diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index d8da54cd..dd72d8b6 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -160,24 +160,23 @@ private struct ContentPeopleListView: View { .font(.bitchatSystem(size: 14)) } .buttonStyle(.plain) + // .help maps to the accessibility *hint* on iOS, so the + // button still needs a spoken name. + .accessibilityLabel( + String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button") + ) .help( String(localized: "content.help.verification", comment: "Help text for verification button") ) } - Button(action: { + SheetCloseButton { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { dismiss() showSidebar = false showVerifySheet = false privateConversationModel.endConversation() } - }) { - Image(systemName: "xmark") - .bitchatFont(size: 12, weight: .semibold) - .frame(width: 32, height: 32) } - .buttonStyle(.plain) - .accessibilityLabel("Close") } let activeText = String.localizedStringWithFormat( @@ -199,13 +198,13 @@ private struct ContentPeopleListView: View { Text(subtitle) .foregroundColor(subtitleColor) Text(activeText) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) } .bitchatFont(size: 12) } else { Text(activeText) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) } } .padding(.horizontal, 16) @@ -341,6 +340,9 @@ private struct ContentPrivateChatSheetView: View { Image(systemName: headerState.isFavorite ? "star.fill" : "star") .font(.bitchatSystem(size: 14)) .foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary) + // Same visual box + 44pt hit target as SheetCloseButton. + .frame(width: 32, height: 32) + .contentShape(Rectangle().inset(by: -6)) } .buttonStyle(.plain) .accessibilityLabel( @@ -354,30 +356,20 @@ private struct ContentPrivateChatSheetView: View { Spacer(minLength: 0) - Button(action: { + SheetCloseButton { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { privateConversationModel.endConversation() showSidebar = true } - }) { - Image(systemName: "xmark") - .bitchatFont(size: 12, weight: .semibold) - .frame(width: 32, height: 32) } - .buttonStyle(.plain) - .accessibilityLabel("Close") } - .frame(height: headerHeight) + // minHeight so scaled text at accessibility sizes grows the + // bar instead of clipping inside it. + .frame(minHeight: headerHeight) .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 12) - // Orange tint before themedSurface so it layers in front of the - // (opaque, in matrix) themed background rather than behind it. - // Glass has no opaque backing — themedSurface is a no-op there, - // so the wash needs more opacity to read over the backdrop - // gradient's blue/purple glows. - .background(Color.orange.opacity(theme.usesGlassChrome ? 0.14 : 0.06)) - .themedSurface() + .modifier(PrivateHeaderChrome()) } MessageListView( @@ -446,13 +438,19 @@ private struct ContentPrivateChatSheetView: View { HStack(spacing: 5) { Image(systemName: "lock.fill") .font(.bitchatSystem(size: 9)) + // Optical centering: lock.fill's ink is bottom-heavy, so + // geometric centering reads low next to the caption text. + .offset(y: -1) Text(verbatim: privacyCaptionText) .bitchatFont(size: 11, weight: .medium) } .foregroundColor(Color.orange) .frame(maxWidth: .infinity) .padding(.vertical, 4) - .background(Color.orange.opacity(0.08)) + // The orange text is signature enough; a tinted band here reads as a + // stray strip against the untinted composer chrome below it, so the + // caption sits on the same surface as the rest of the bottom chrome. + .themedSurface() .accessibilityElement(children: .combine) } @@ -474,6 +472,28 @@ private struct ContentPrivateChatSheetView: View { } } +/// Chrome for the private-chat header. Matrix keeps its orange privacy wash +/// over an opaque themed surface. Glass gets the same floating panel as the +/// main header instead: an orange wash over the backdrop gradient reads as a +/// muddy gray-beige band, and the DM signature is already carried by the +/// orange lock, caption, and composer accents. +private struct PrivateHeaderChrome: ViewModifier { + @Environment(\.appTheme) private var theme + + @ViewBuilder + func body(content: Content) -> some View { + if theme.usesGlassChrome { + content.themedChromePanel(edge: .top) + } else { + // Orange tint before themedSurface so it layers in front of the + // opaque themed background rather than behind it. + content + .background(Color.orange.opacity(0.06)) + .themedSurface() + } + } +} + private struct ContentPrivateHeaderInfoButton: View { @EnvironmentObject private var appChromeModel: AppChromeModel @ThemedPalette private var palette @@ -506,17 +526,27 @@ private struct ContentPrivateHeaderInfoButton: View { // Absence of a glyph was the only offline signal; say it. Text("mesh_peers.state.offline") .bitchatFont(size: 11) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) } Text(headerState.displayName) .bitchatFont(size: 16, weight: .medium) .foregroundColor(palette.primary) + // Middle truncation keeps the identity suffix visible on + // long nicknames instead of wrapping into the fixed-height + // header. + .lineLimit(1) + .truncationMode(.middle) if let encryptionStatus = headerState.encryptionStatus, let icon = encryptionStatus.icon { Image(systemName: icon) .font(.bitchatSystem(size: 14)) + // Optical centering: the lock glyphs' ink is bottom-heavy + // (solid body, thin shackle), so geometric centering reads + // ~1pt low next to the name. The seal badge is symmetric + // and needs no lift. + .offset(y: icon.hasPrefix("lock") ? -1 : 0) .foregroundColor( encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured ? palette.primary @@ -543,6 +573,6 @@ private struct ContentPrivateHeaderInfoButton: View { .accessibilityHint( String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") ) - .frame(height: headerHeight) + .frame(minHeight: headerHeight) } } diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index cbac2d2a..06fc865f 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -54,11 +54,8 @@ struct FingerprintView: View { Spacer() - Button(action: { dismiss() }) { - Image(systemName: "xmark") - .font(.bitchatSystem(size: 14, weight: .semibold)) - } - .foregroundColor(textColor) + SheetCloseButton { dismiss() } + .foregroundColor(textColor) } .padding() @@ -83,7 +80,7 @@ struct FingerprintView: View { Spacer() } .padding() - .background(Color.gray.opacity(0.1)) + .background(palette.secondary.opacity(0.1)) .cornerRadius(8) // Their fingerprint @@ -101,7 +98,7 @@ struct FingerprintView: View { .fixedSize(horizontal: false, vertical: true) .padding() .frame(maxWidth: .infinity) - .background(Color.gray.opacity(0.1)) + .background(palette.secondary.opacity(0.1)) .cornerRadius(8) .contextMenu { Button(Strings.copy) { @@ -135,7 +132,7 @@ struct FingerprintView: View { .fixedSize(horizontal: false, vertical: true) .padding() .frame(maxWidth: .infinity) - .background(Color.gray.opacity(0.1)) + .background(palette.secondary.opacity(0.1)) .cornerRadius(8) .contextMenu { Button(Strings.copy) { diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 0df2ce50..9613f227 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -54,7 +54,9 @@ struct GeohashPeopleList: View { let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark) let rowColor: Color = person.isMe ? .orange : assignedColor Image(systemName: icon) - .font(.bitchatSystem(size: 12)) + // Size 10 to match the mesh rows' leading glyphs — + // both lists share the sidebar. + .font(.bitchatSystem(size: 10)) .foregroundColor(rowColor) .help(person.isTeleported ? Strings.teleported : Strings.nearby) @@ -64,6 +66,8 @@ struct GeohashPeopleList: View { .bitchatFont(size: 14) .fontWeight(person.isMe ? .bold : .regular) .foregroundColor(rowColor) + .lineLimit(1) + .truncationMode(.tail) if !suffix.isEmpty { let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) Text(suffix) diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index addc835c..77b45a21 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -31,6 +31,9 @@ struct LocationChannelsSheet: View { static let toggleOff: LocalizedStringKey = "common.toggle.off" static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid") + static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it") + static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel") + static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark") static func meshTitle(_ count: Int) -> String { let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row") @@ -103,7 +106,7 @@ struct LocationChannelsSheet: View { } Text(Strings.description) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) Group { switch locationChannelsModel.permissionState { @@ -122,7 +125,7 @@ struct LocationChannelsSheet: View { VStack(alignment: .leading, spacing: 8) { Text(Strings.permissionDenied) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) Button(Strings.openSettings, action: SystemSettings.location.open) .buttonStyle(.plain) } @@ -169,13 +172,7 @@ struct LocationChannelsSheet: View { } private var closeButton: some View { - Button(action: { isPresented = false }) { - Image(systemName: "xmark") - .bitchatFont(size: 13, weight: .semibold) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel("Close") + SheetCloseButton { isPresented = false } } private var channelList: some View { @@ -210,7 +207,10 @@ struct LocationChannelsSheet: View { } .buttonStyle(.plain) .padding(.leading, 8) - } + .accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark) + }, + accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark, + accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) } ) { locationChannelsModel.markTeleported(for: channel.geohash, false) locationChannelsModel.select(ChannelID.location(channel)) @@ -277,7 +277,7 @@ struct LocationChannelsSheet: View { HStack(spacing: 2) { Text(verbatim: "#") .bitchatFont(size: 14) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) TextField("geohash", text: $customGeohash) #if os(iOS) .textInputAutocapitalization(.never) @@ -319,7 +319,7 @@ struct LocationChannelsSheet: View { .bitchatFont(size: 14) .padding(.vertical, 6) .padding(.horizontal, 10) - .background(Color.secondary.opacity(0.12)) + .background(palette.secondary.opacity(0.12)) .cornerRadius(6) .opacity(isValid ? 1.0 : 0.4) .disabled(!isValid) @@ -336,7 +336,7 @@ struct LocationChannelsSheet: View { VStack(alignment: .leading, spacing: 8) { Text(Strings.bookmarked) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) LazyVStack(spacing: 0) { ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in let level = levelForLength(gh.count) @@ -357,7 +357,10 @@ struct LocationChannelsSheet: View { } .buttonStyle(.plain) .padding(.leading, 8) - } + .accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark) + }, + accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark, + accessoryAction: { locationChannelsModel.toggleBookmark(gh) } ) { let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh } if !inRegional && !locationChannelsModel.availableChannels.isEmpty { @@ -399,6 +402,8 @@ struct LocationChannelsSheet: View { titleColor: Color? = nil, titleBold: Bool = false, @ViewBuilder trailingAccessory: () -> some View = { EmptyView() }, + accessoryActionTitle: String? = nil, + accessoryAction: (() -> Void)? = nil, action: @escaping () -> Void ) -> some View { HStack(alignment: .center, spacing: 8) { @@ -409,17 +414,17 @@ struct LocationChannelsSheet: View { Text(parts.base) .bitchatFont(size: 14) .fontWeight(titleBold ? .bold : .regular) - .foregroundColor(titleColor ?? Color.primary) + .foregroundColor(titleColor ?? palette.primary) if let count = parts.countSuffix, !count.isEmpty { Text(count) .bitchatFont(size: 11) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) } } let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName) Text(subtitleFull) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) .lineLimit(1) .truncationMode(.tail) } @@ -434,6 +439,19 @@ struct LocationChannelsSheet: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture(perform: action) + // The row is a plain HStack with a tap gesture, which VoiceOver reads + // as disconnected static text. Expose it as one activatable button; + // the visible bookmark accessory is mirrored as a named action. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: "\(title), \(Strings.subtitle(prefix: subtitlePrefix, name: subtitleName))")) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton]) + .accessibilityHint(Strings.switchChannelHint) + .accessibilityAction(.default, action) + .accessibilityActions { + if let accessoryActionTitle, let accessoryAction { + Button(accessoryActionTitle, action: accessoryAction) + } + } } // Split a title like "#mesh [3 people]" into base and suffix "[3 people]" @@ -477,16 +495,16 @@ extension LocationChannelsSheet { VStack(alignment: .leading, spacing: 2) { Text(Strings.torTitle) .bitchatFont(size: 12, weight: .semibold) - .foregroundColor(.primary) + .foregroundColor(palette.primary) Text(Strings.torSubtitle) .bitchatFont(size: 11) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) } } .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) } .padding(12) - .background(Color.secondary.opacity(0.12)) + .background(palette.secondary.opacity(0.12)) .cornerRadius(8) } diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift index 9c79f21e..d2cd3f16 100644 --- a/bitchat/Views/LocationNotesView.swift +++ b/bitchat/Views/LocationNotesView.swift @@ -30,7 +30,6 @@ struct LocationNotesView: View { private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } private enum Strings { - static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons") static let description: LocalizedStringKey = "location_notes.description" static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent" static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused" @@ -97,13 +96,7 @@ struct LocationNotesView: View { } private var closeButton: some View { - Button(action: { dismiss() }) { - Image(systemName: "xmark") - .bitchatFont(size: 13, weight: .semibold) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel(Strings.closeAccessibility) + SheetCloseButton { dismiss() } } private var headerSection: some View { @@ -126,12 +119,12 @@ struct LocationNotesView: View { } Text(Strings.description) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) .fixedSize(horizontal: false, vertical: true) if manager.state == .noRelays { Text(Strings.relaysPaused) .bitchatFont(size: 11) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) } } .padding(.horizontal, 16) @@ -180,7 +173,7 @@ struct LocationNotesView: View { if !ts.isEmpty { Text(ts) .bitchatFont(size: 11) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) } Spacer() } @@ -197,7 +190,7 @@ struct LocationNotesView: View { .bitchatFont(size: 13, weight: .semibold) Text(Strings.relaysRetryHint) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) Button(Strings.retry) { manager.refresh() } .bitchatFont(size: 12) .buttonStyle(.plain) @@ -210,7 +203,7 @@ struct LocationNotesView: View { ProgressView() Text(Strings.loadingNotes) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) Spacer() } .padding(.vertical, 8) @@ -222,7 +215,7 @@ struct LocationNotesView: View { .bitchatFont(size: 13, weight: .semibold) Text(Strings.emptySubtitle) .bitchatFont(size: 12) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) } .padding(.vertical, 6) } diff --git a/bitchat/Views/Media/BlockRevealImageView.swift b/bitchat/Views/Media/BlockRevealImageView.swift index 163665a5..8e5d8bd2 100644 --- a/bitchat/Views/Media/BlockRevealImageView.swift +++ b/bitchat/Views/Media/BlockRevealImageView.swift @@ -9,6 +9,7 @@ private typealias PlatformImage = NSImage #endif struct BlockRevealImageView: View { + @ThemedPalette private var palette private let url: URL private let revealProgress: Double? private let isSending: Bool @@ -92,7 +93,9 @@ struct BlockRevealImageView: View { Image(systemName: "eye.slash.fill") .font(.bitchatSystem(size: 24, weight: .semibold)) Text(verbatim: Strings.tapToReveal) - .font(.bitchatSystem(size: 12, weight: .medium, design: .monospaced)) + // Themed: monospaced under matrix, + // system under liquid glass. + .bitchatFont(size: 12, weight: .medium) } .foregroundColor(.white.opacity(0.85)) ) @@ -100,13 +103,13 @@ struct BlockRevealImageView: View { } } else { RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.gray.opacity(0.2)) + .fill(palette.secondary.opacity(0.2)) .frame(height: 200) .overlay { if loadFailed { Image(systemName: "photo") .font(.bitchatSystem(size: 24, weight: .semibold)) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) } else { ProgressView() .progressViewStyle(.circular) diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index cb05e065..cf5194af 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -11,6 +11,7 @@ import BitFoundation struct MediaMessageView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.appTheme) private var theme + @ThemedPalette private var palette @EnvironmentObject private var conversationUIModel: ConversationUIModel let message: BitchatMessage let media: BitchatMessage.Media @@ -36,12 +37,14 @@ struct MediaMessageView: View { let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message) let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil - HStack(alignment: .top, spacing: 0) { + // Baseline alignment (via the header text inside the VStack) keeps the + // lock on the header line; a fixed top padding left its solid body + // hanging below the line's visual center. + HStack(alignment: .firstTextBaseline, spacing: 0) { if message.isPrivate { Image(systemName: "lock.fill") .font(.bitchatSystem(size: 8)) .foregroundColor(Color.orange.opacity(0.75)) - .padding(.top, 5) .padding(.trailing, 4) .accessibilityHidden(true) } @@ -81,7 +84,7 @@ struct MediaMessageView: View { } else if showDeliveryDetail { Text(verbatim: status.bitchatDescription) .bitchatFont(size: 11) - .foregroundColor(.secondary) + .foregroundColor(palette.secondary) .fixedSize(horizontal: false, vertical: true) } } diff --git a/bitchat/Views/Media/VoiceNoteView.swift b/bitchat/Views/Media/VoiceNoteView.swift index 50828430..d2a63e6e 100644 --- a/bitchat/Views/Media/VoiceNoteView.swift +++ b/bitchat/Views/Media/VoiceNoteView.swift @@ -28,7 +28,9 @@ struct VoiceNoteView: View { } private var backgroundColor: Color { - colorScheme == .dark ? Color.black.opacity(0.6) : Color.white + // Palette-based and slightly translucent so the card doesn't sit as + // an opaque white/black box over the glass gradient. + palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7) } private var borderColor: Color { @@ -69,7 +71,7 @@ struct VoiceNoteView: View { Text(playbackLabel) .bitchatFont(size: 13) - .foregroundColor(Color.secondary) + .foregroundColor(palette.secondary) if let onCancel = onCancel, isSending { Button(action: onCancel) { diff --git a/bitchat/Views/Media/WaveformView.swift b/bitchat/Views/Media/WaveformView.swift index c925a1d5..b12ec6ee 100644 --- a/bitchat/Views/Media/WaveformView.swift +++ b/bitchat/Views/Media/WaveformView.swift @@ -42,7 +42,7 @@ struct WaveformView: View { } else if let send = clampedSend, binPosition <= send { color = palette.accentBlue } else { - color = Color.gray.opacity(0.35) + color = palette.secondary.opacity(0.35) } context.fill(Path(rect), with: .color(color)) } diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index dffc08d2..244cd6dc 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -92,6 +92,8 @@ struct MeshPeerList: View { Text(base) .bitchatFont(size: 14) .foregroundColor(baseColor) + .lineLimit(1) + .truncationMode(.tail) if !suffix.isEmpty { let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6) Text(suffix) @@ -112,6 +114,10 @@ struct MeshPeerList: View { if let icon = peer.encryptionStatus.icon { Image(systemName: icon) .font(.bitchatSystem(size: 10)) + // Optical centering: lock glyph ink is + // bottom-heavy, so geometric centering + // reads low next to the name. + .offset(y: icon.hasPrefix("lock") ? -0.5 : 0) .foregroundColor(baseColor) } } else { @@ -124,6 +130,7 @@ struct MeshPeerList: View { // Fallback to whatever status says (likely lock if we had a past session) Image(systemName: icon) .font(.bitchatSystem(size: 10)) + .offset(y: icon.hasPrefix("lock") ? -0.5 : 0) .foregroundColor(baseColor) } } @@ -144,6 +151,11 @@ struct MeshPeerList: View { Image(systemName: peer.isFavorite ? "star.fill" : "star") .font(.bitchatSystem(size: 12)) .foregroundColor(peer.isFavorite ? .yellow : palette.secondary) + // Widen the tap target beyond the bare glyph; + // height stays row-bound so neighboring rows + // keep their own taps. + .frame(width: 36) + .contentShape(Rectangle()) } .buttonStyle(.plain) } diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 696fcb8f..1506c1da 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -297,7 +297,9 @@ private extension MessageListView { } func emptyStateLine(_ text: String) -> some View { - Text(verbatim: "* \(text) *") + // Non-breaking space before the closing asterisk so a tight wrap + // can't orphan a lone "*" onto its own line. + Text(verbatim: "* \(text)\u{00A0}*") .bitchatFont(size: 13) .foregroundColor(palette.secondary.opacity(0.9)) .fixedSize(horizontal: false, vertical: true) diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index 61697ad6..1fba4f2d 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -11,7 +11,10 @@ import AppKit struct MyQRView: View { let qrString: String @Environment(\.colorScheme) var colorScheme - private var boxColor: Color { Color.gray.opacity(0.1) } + @ThemedPalette private var palette + // Palette-tinted so the box follows the theme (green under matrix) + // instead of a fixed gray band over the glass gradient. + private var boxColor: Color { palette.secondary.opacity(0.1) } private enum Strings { static let title: LocalizedStringKey = "verification.my_qr.title" @@ -50,6 +53,7 @@ struct MyQRView: View { struct QRCodeImage: View { let data: String let size: CGFloat + @ThemedPalette private var palette private let context = CIContext() private let filter = CIFilter.qrCodeGenerator() @@ -65,12 +69,12 @@ struct QRCodeImage: View { .frame(width: size, height: size) } else { RoundedRectangle(cornerRadius: 8) - .stroke(Color.gray.opacity(0.5), lineWidth: 1) + .stroke(palette.secondary.opacity(0.5), lineWidth: 1) .frame(width: size, height: size) .overlay( Text(Strings.unavailable) .bitchatFont(size: 12) - .foregroundColor(.gray) + .foregroundColor(palette.secondary) ) } } @@ -108,6 +112,7 @@ struct ImageWrapper: View { /// Placeholder scanner UI; real camera scanning will be added later. struct QRScanView: View { @EnvironmentObject private var verificationModel: VerificationModel + @ThemedPalette private var palette var isActive: Bool = true var onSuccess: (() -> Void)? = nil // Called when verification succeeds @State private var input = "" @@ -153,7 +158,7 @@ struct QRScanView: View { .bitchatFont(size: 14, weight: .medium) TextEditor(text: $input) .frame(height: 100) - .border(Color.gray.opacity(0.4)) + .border(palette.secondary.opacity(0.4)) Button(Strings.validate) { // Deduplicate: ignore if we just processed this exact QR guard input != lastValid else { @@ -285,7 +290,7 @@ struct VerificationSheetView: View { private var backgroundColor: Color { palette.background } private var accentColor: Color { palette.accent } - private var boxColor: Color { Color.gray.opacity(0.1) } + private var boxColor: Color { palette.secondary.opacity(0.1) } var body: some View { VStack(spacing: 0) { @@ -295,15 +300,11 @@ struct VerificationSheetView: View { .bitchatFont(size: 14, weight: .bold) .foregroundColor(accentColor) Spacer() - Button(action: { + SheetCloseButton { showingScanner = false isPresented = false - }) { - Image(systemName: "xmark") - .font(.bitchatSystem(size: 14, weight: .semibold)) - .foregroundColor(accentColor) } - .buttonStyle(.plain) + .foregroundColor(accentColor) } .padding(.horizontal, 16) .padding(.top, 12)