Liquid Glass theme with in-app appearance switcher (#1337)

* Centralize UI theme colors into semantic ThemePalette tokens

Introduce AppTheme/ThemePalette (Utils/Theme.swift) with an environment
key and @ThemedPalette property wrapper, persisted via @AppStorage.
Views now resolve background/primary/secondary/accentBlue/alertRed/
divider tokens from the environment instead of computing colors inline,
removing the backgroundColor/textColor/secondaryTextColor prop-drilling
through the header, composer, and people-sheet hierarchy.

Matrix theme output is pixel-identical; this is groundwork for a
user-selectable theme switcher.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add liquid glass theme with in-app appearance switcher

Add a .liquidGlass AppTheme alongside the matrix terminal theme,
selectable from a one-line APPEARANCE row in the app info sheet
(persisted via @AppStorage, applies live).

Liquid glass renders system fonts and colors over a subtle gradient
backdrop, with the header and composer floating as Liquid Glass panels
(real .glassEffect() on iOS/macOS 26, compiler-gated with an
ultraThinMaterial fallback for older SDKs). The message list scrolls
underneath the chrome via safe-area insets, and all sheets share the
same backdrop and surface language. The matrix theme is unchanged.

Details:
- Theme is threaded through ChatMessageFormatter so message
  AttributedStrings switch font design per theme; the per-message
  format cache gains a variant key so themes never serve each other's
  cached strings
- New palette tokens: accent (interactive tint) and locationAccent
  (geohash green), replacing scattered hardcoded greens/blues in the
  voice note, waveform, verification, and people-sheet views
- Header controls get full-height tap targets and the people count
  becomes a real Button (previously a tap gesture on the cluster)
- CommandSuggestionsView renders nothing when empty instead of a
  zero-height view that pushed the composer input off-center
- New appearance strings localized for all 29 catalog languages

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-06-12 01:13:04 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent c8381737bb
commit af954b05ea
27 changed files with 1347 additions and 498 deletions
+31 -54
View File
@@ -25,10 +25,8 @@ struct ContentPeopleSheetView: View {
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
@ThemedPalette private var palette
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
let onSendMessage: () -> Void
@@ -56,9 +54,6 @@ struct ContentPeopleSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: onSendMessage,
showImagePicker: $showImagePicker,
@@ -77,9 +72,6 @@ struct ContentPeopleSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker
@@ -88,9 +80,6 @@ struct ContentPeopleSheetView: View {
} else {
ContentPeopleListView(
showSidebar: $showSidebar,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight
)
}
@@ -109,8 +98,8 @@ struct ContentPeopleSheetView: View {
}
}
}
.background(backgroundColor)
.foregroundColor(textColor)
.themedSheetBackground()
.foregroundColor(palette.primary)
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
@@ -148,12 +137,10 @@ private struct ContentPeopleListView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss
@ThemedPalette private var palette
@Binding var showSidebar: Bool
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
@State private var showVerifySheet = false
@@ -163,8 +150,8 @@ private struct ContentPeopleListView: View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(peopleSheetTitle)
.font(.bitchatSystem(size: 18, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 18)
.foregroundColor(palette.primary)
Spacer()
if case .mesh = locationChannelsModel.selectedChannel {
Button(action: { showVerifySheet = true }) {
@@ -185,7 +172,7 @@ private struct ContentPeopleListView: View {
}
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -201,9 +188,9 @@ private struct ContentPeopleListView: View {
let subtitleColor: Color = {
switch locationChannelsModel.selectedChannel {
case .mesh:
return Color.blue
return palette.accentBlue
case .location:
return Color.green
return palette.locationAccent
}
}()
@@ -213,32 +200,28 @@ private struct ContentPeopleListView: View {
Text(activeText)
.foregroundColor(.secondary)
}
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
} else {
Text(activeText)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.background(backgroundColor)
.themedSurface()
ScrollView {
VStack(alignment: .leading, spacing: 6) {
if case .location = locationChannelsModel.selectedChannel {
GeohashPeopleList(
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPerson: {
showSidebar = true
}
)
} else {
MeshPeerList(
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
@@ -302,10 +285,9 @@ private struct ContentPrivateChatSheetView: View {
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
let onSendMessage: () -> Void
@@ -327,7 +309,7 @@ private struct ContentPrivateChatSheetView: View {
}) {
Image(systemName: "chevron.left")
.font(.bitchatSystem(size: 12))
.foregroundColor(textColor)
.foregroundColor(palette.primary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
@@ -341,8 +323,7 @@ private struct ContentPrivateChatSheetView: View {
HStack(spacing: 8) {
ContentPrivateHeaderInfoButton(
headerState: headerState,
headerHeight: headerHeight,
textColor: textColor
headerHeight: headerHeight
)
if headerState.supportsFavoriteToggle {
@@ -351,7 +332,7 @@ private struct ContentPrivateChatSheetView: View {
}) {
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : textColor)
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -372,7 +353,7 @@ private struct ContentPrivateChatSheetView: View {
}
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -382,7 +363,7 @@ private struct ContentPrivateChatSheetView: View {
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.background(backgroundColor)
.themedSurface()
}
MessageListView(
@@ -397,10 +378,12 @@ private struct ContentPrivateChatSheetView: View {
showSidebar: $showSidebar,
isTextFieldFocused: isTextFieldFocused
)
.background(backgroundColor)
.themedSurface()
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
if !theme.usesGlassChrome {
Divider()
}
#if os(iOS)
ContentComposerView(
@@ -408,9 +391,6 @@ private struct ContentPrivateChatSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: onSendMessage,
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
@@ -421,16 +401,13 @@ private struct ContentPrivateChatSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker
)
#endif
}
.background(backgroundColor)
.foregroundColor(textColor)
.themedSheetBackground()
.foregroundColor(palette.primary)
.highPriorityGesture(
DragGesture(minimumDistance: 25, coordinateSpace: .local)
.onEnded { value in
@@ -448,10 +425,10 @@ private struct ContentPrivateChatSheetView: View {
private struct ContentPrivateHeaderInfoButton: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@ThemedPalette private var palette
let headerState: PrivateConversationHeaderState
let headerHeight: CGFloat
let textColor: Color
var body: some View {
Button(action: {
@@ -462,12 +439,12 @@ private struct ContentPrivateHeaderInfoButton: View {
case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
case .meshReachable:
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
case .nostrAvailable:
Image(systemName: "globe")
@@ -479,8 +456,8 @@ private struct ContentPrivateHeaderInfoButton: View {
}
Text(headerState.displayName)
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 16, weight: .medium)
.foregroundColor(palette.primary)
if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon {
@@ -488,7 +465,7 @@ private struct ContentPrivateHeaderInfoButton: View {
.font(.bitchatSystem(size: 14))
.foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? textColor
? palette.primary
: Color.red
)
.accessibilityLabel(