Add dynamic text contrast for mood colors and make theme preview full screen

- Add WCAG-compliant luminance calculation to Color extension
- Add contrastingTextColor method to MoodTints for automatic black/white text
- Update 12+ entry styles to use dynamic text colors instead of hardcoded white
- Change theme preview sheet to full screen presentation

Text now automatically switches between black and white based on background
brightness, fixing readability issues on light mood colors like yellow (Good).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-29 10:31:31 -06:00
parent 2de96495e0
commit 51c5777c03
4 changed files with 63 additions and 20 deletions

View File

@@ -99,6 +99,40 @@ extension String {
}
}
// MARK: - Luminance & Contrast
extension Color {
/// Calculate relative luminance using WCAG formula
/// Returns value between 0 (darkest) and 1 (lightest)
var luminance: Double {
guard let components = cgColor?.components, components.count >= 3 else {
return 0.5 // Default to mid-gray if can't determine
}
let r = components[0]
let g = components[1]
let b = components[2]
// Apply gamma correction per WCAG 2.0
func adjust(_ component: CGFloat) -> Double {
let c = Double(component)
return c <= 0.03928 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
}
return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
}
/// Returns true if the color is considered "light" (needs dark text for contrast)
var isLight: Bool {
luminance > 0.5
}
/// Returns black or white depending on which provides better contrast
var contrastingTextColor: Color {
isLight ? .black : .white
}
}
extension Color: @retroactive RawRepresentable {
// TODO: Sort out alpha
public init?(rawValue: Int) {

View File

@@ -53,7 +53,12 @@ enum MoodTints: Int, CaseIterable {
return PastelTint.secondary(forMood: mood)
}
}
/// Returns black or white text color based on the mood's background luminance
func contrastingTextColor(forMood mood: Mood) -> Color {
color(forMood: mood).contrastingTextColor
}
var moodTints: MoodTintable.Type {
switch self {
case .Default:

View File

@@ -252,8 +252,7 @@ struct AppThemePreviewSheet: View {
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationDetents([.large])
}
private var heroSection: some View {

View File

@@ -21,6 +21,11 @@ struct EntryListView: View {
moodTint.color(forMood: entry.mood)
}
/// Text color that contrasts with the mood's background color
private var moodContrastingTextColor: Color {
moodTint.contrastingTextColor(forMood: entry.mood)
}
private var isMissing: Bool {
entry.moodValue == Mood.missing.rawValue
}
@@ -109,7 +114,7 @@ struct EntryListView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.foregroundColor(isMissing ? .gray : .white)
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
.accessibilityLabel(entry.mood.strValue)
}
.shadow(color: isMissing ? .clear : moodColor.opacity(0.4), radius: 8, x: 0, y: 4)
@@ -284,13 +289,13 @@ struct EntryListView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundColor(isMissing ? .gray : .white)
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
.accessibilityLabel(entry.mood.strValue)
VStack(alignment: .leading, spacing: 2) {
Text(entry.forDate, format: .dateTime.weekday(.wide).day())
.font(.subheadline.weight(.semibold))
.foregroundColor(isMissing ? textColor : .white)
.foregroundColor(isMissing ? textColor : moodContrastingTextColor)
if isMissing {
Text(String(localized: "mood_value_missing_tap_to_add"))
@@ -299,7 +304,7 @@ struct EntryListView: View {
} else {
Text(entry.moodString)
.font(.caption.weight(.medium))
.foregroundColor(.white.opacity(0.85))
.foregroundColor(moodContrastingTextColor.opacity(0.85))
}
}
@@ -307,7 +312,7 @@ struct EntryListView: View {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(isMissing ? textColor.opacity(0.3) : .white.opacity(0.6))
.foregroundColor(isMissing ? textColor.opacity(0.3) : moodContrastingTextColor.opacity(0.6))
}
.padding(.horizontal, 18)
.padding(.vertical, 14)
@@ -345,7 +350,7 @@ struct EntryListView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.padding(16)
.foregroundColor(isMissing ? .gray : .white)
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
.accessibilityLabel(entry.mood.strValue)
}
.shadow(
@@ -429,7 +434,7 @@ struct EntryListView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.foregroundColor(isMissing ? .gray : .white)
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
.accessibilityLabel(entry.mood.strValue)
}
@@ -974,7 +979,7 @@ struct EntryListView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 26, height: 26)
.foregroundColor(isMissing ? .gray : .white)
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
.accessibilityLabel(entry.mood.strValue)
}
@@ -1182,7 +1187,7 @@ struct EntryListView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
.foregroundColor(.white)
.foregroundColor(moodContrastingTextColor)
.accessibilityLabel(entry.mood.strValue)
}
@@ -1379,7 +1384,7 @@ struct EntryListView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundColor(isMissing ? .gray : .white)
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
.accessibilityLabel(entry.mood.strValue)
.padding(.leading, 16)
@@ -1390,7 +1395,7 @@ struct EntryListView: View {
} else {
Text(entry.moodString)
.font(.body.weight(.semibold))
.foregroundColor(.white)
.foregroundColor(moodContrastingTextColor)
}
Spacer()
@@ -1398,7 +1403,7 @@ struct EntryListView: View {
// Month indicator
Text(entry.forDate, format: .dateTime.month(.abbreviated))
.font(.caption2.weight(.bold))
.foregroundColor(isMissing ? .gray : .white.opacity(0.7))
.foregroundColor(isMissing ? .gray : moodContrastingTextColor.opacity(0.7))
.padding(.trailing, 16)
}
}
@@ -1426,7 +1431,7 @@ struct EntryListView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 28, height: 28)
.foregroundColor(.white)
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
.accessibilityLabel(entry.mood.strValue)
}
@@ -1585,7 +1590,7 @@ struct EntryListView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 22, height: 22)
.foregroundColor(.white)
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
.shadow(color: Color.black.opacity(0.3), radius: 1, x: 0, y: 1)
.accessibilityLabel(entry.mood.strValue)
}
@@ -1718,7 +1723,7 @@ struct EntryListView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
.foregroundColor(isMissing ? .gray : .white)
.foregroundColor(isMissing ? .gray : moodContrastingTextColor)
.accessibilityLabel(entry.mood.strValue)
}
@@ -1946,7 +1951,7 @@ struct OrbitEntryView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
.foregroundColor(.white)
.foregroundColor(isMissing ? .gray : moodColor.contrastingTextColor)
.accessibilityLabel(entry.mood.strValue)
}
@@ -2130,7 +2135,7 @@ struct MotionCardView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.foregroundColor(.white)
.foregroundColor(isMissing ? .gray : moodColor.contrastingTextColor)
.offset(
x: -motionManager.xOffset * 0.3,
y: -motionManager.yOffset * 0.3