Add separate light/dark mode accent colors

Adds an option in Appearance settings to use different accent colors
in light and dark mode, with a toggle to keep a single shared color
(the default, preserving existing behavior).

- New synced settings keys: accentColorDark, customAccentColorDark,
  useSeparateDarkAccentColor; dark values fall back to the light
  selection until explicitly set, so no migration is needed
- resolvedAccentColor returns a dynamic platform color (UIColor trait
  provider / NSColor appearance provider) when the toggle is on, so
  the root tint and all direct readers adapt to light/dark
  automatically without consumer changes
- Accent Color section shows two stacked preset+custom grids with
  Light/Dark headers when enabled, extracted into AccentColorGrid;
  CustomAccentColorButton now takes bindings so the macOS color panel
  edits whichever target was last opened
- New DarkAccentColorTests covering sync contract, default-off state,
  fallbacks, and toggle-off resolution

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
This commit is contained in:
Arkadiusz Fal
2026-07-05 21:06:26 +02:00
parent b47888fe04
commit b632f11b2f
6 changed files with 250 additions and 28 deletions

View File

@@ -81,6 +81,82 @@ struct AccentColorTests {
}
}
// MARK: - Dark Accent Color Tests
@Suite("Dark Accent Color Tests", .serialized)
@MainActor
struct DarkAccentColorTests {
private static let touchedKeys = [
SettingsKey.accentColor,
.customAccentColor,
.accentColorDark,
.customAccentColorDark,
.useSeparateDarkAccentColor,
]
private func withCleanDefaults(_ body: (SettingsManager) throws -> Void) rethrows {
let removeAll = {
for key in Self.touchedKeys {
UserDefaults.standard.removeObject(forKey: key.rawValue)
}
}
removeAll()
defer { removeAll() }
try body(SettingsManager())
}
@Test("New keys are part of the settings key set and sync globally")
func keysSyncContract() {
for key in [SettingsKey.accentColorDark, .customAccentColorDark, .useSeparateDarkAccentColor] {
#expect(SettingsKey.allCases.contains(key))
#expect(!key.isLocalOnly)
#expect(!key.isPlatformSpecific)
}
}
@Test("Separate dark accent color is disabled by default")
func separateDarkDisabledByDefault() {
withCleanDefaults { settings in
#expect(settings.useSeparateDarkAccentColor == false)
}
}
@Test("Dark accent color falls back to light selection until set")
func darkFallsBackToLight() {
withCleanDefaults { settings in
settings.accentColor = .green
#expect(settings.accentColorDark == .green)
settings.accentColorDark = .purple
#expect(settings.accentColorDark == .purple)
#expect(settings.accentColor == .green)
}
}
@Test("Custom dark accent color falls back to light custom color until set")
func customDarkFallsBackToLight() {
withCleanDefaults { settings in
settings.customAccentColor = Color(hex: "#336699")!
#expect(settings.customAccentColorDark.toHexString() == "#336699")
settings.customAccentColorDark = Color(hex: "#993366")!
#expect(settings.customAccentColorDark.toHexString() == "#993366")
#expect(settings.customAccentColor.toHexString() == "#336699")
}
}
@Test("Resolved accent color equals light resolution when toggle is off")
func resolvedMatchesLightWhenToggleOff() {
withCleanDefaults { settings in
settings.accentColor = .teal
settings.accentColorDark = .red
settings.useSeparateDarkAccentColor = false
#expect(settings.resolvedAccentColor == AccentColor.teal.color)
}
}
}
// MARK: - VideoQuality Tests
@Suite("VideoQuality Tests")