ux-fixes: lock alignment, caption band, wrapping, tap targets, VoiceOver, theme sweep (#1366)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 14:22:38 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 8296630cf3
commit d285c6ad53
20 changed files with 439 additions and 121 deletions
+169 -1
View File
@@ -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" : {
+2 -8
View File
@@ -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)
}
}
}
@@ -54,7 +54,6 @@ struct CommandSuggestionsView: View {
buttonRow(for: command)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.themedOverlayPanel()
@@ -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) }
@@ -0,0 +1,29 @@
//
// SheetCloseButton.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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"))
}
}
@@ -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)
}
+37 -4
View File
@@ -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 {
+38 -11
View File
@@ -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")
+57 -27
View File
@@ -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)
}
}
+5 -8
View File
@@ -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) {
+5 -1
View File
@@ -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)
+38 -20
View File
@@ -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)
}
+7 -14
View File
@@ -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)
}
@@ -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)
+6 -3
View File
@@ -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)
}
}
+4 -2
View File
@@ -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) {
+1 -1
View File
@@ -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))
}
+12
View File
@@ -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)
}
+3 -1
View File
@@ -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)
+12 -11
View File
@@ -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)