diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 9300b040dcb0..2a4e643834df 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -135,6 +135,8 @@ D1000010A1B2C3D4E5F60800 /* ThemeTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000011A1B2C3D4E5F60800 /* ThemeTokens.swift */; }; D1000020A1B2C3D4E5F60800 /* ThemeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000021A1B2C3D4E5F60800 /* ThemeStore.swift */; }; D1000030A1B2C3D4E5F60800 /* ThemeStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000031A1B2C3D4E5F60800 /* ThemeStoreTests.swift */; }; + D1000040A1B2C3D4E5F60800 /* AppearanceSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000041A1B2C3D4E5F60800 /* AppearanceSection.swift */; }; + D1000050A1B2C3D4E5F60800 /* BuiltInThemes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000051A1B2C3D4E5F60800 /* BuiltInThemes.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -315,6 +317,8 @@ D1000011A1B2C3D4E5F60800 /* ThemeTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeTokens.swift; sourceTree = ""; }; D1000021A1B2C3D4E5F60800 /* ThemeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStore.swift; sourceTree = ""; }; D1000031A1B2C3D4E5F60800 /* ThemeStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStoreTests.swift; sourceTree = ""; }; + D1000041A1B2C3D4E5F60800 /* AppearanceSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSection.swift; sourceTree = ""; }; + D1000051A1B2C3D4E5F60800 /* BuiltInThemes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuiltInThemes.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -488,6 +492,8 @@ D1000001A1B2C3D4E5F60800 /* SuperghostTheme.swift */, D1000011A1B2C3D4E5F60800 /* ThemeTokens.swift */, D1000021A1B2C3D4E5F60800 /* ThemeStore.swift */, + D1000041A1B2C3D4E5F60800 /* AppearanceSection.swift */, + D1000051A1B2C3D4E5F60800 /* BuiltInThemes.swift */, ); path = Sources; sourceTree = ""; @@ -796,6 +802,8 @@ D1000000A1B2C3D4E5F60800 /* SuperghostTheme.swift in Sources */, D1000010A1B2C3D4E5F60800 /* ThemeTokens.swift in Sources */, D1000020A1B2C3D4E5F60800 /* ThemeStore.swift in Sources */, + D1000040A1B2C3D4E5F60800 /* AppearanceSection.swift in Sources */, + D1000050A1B2C3D4E5F60800 /* BuiltInThemes.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/AppearanceSection.swift b/Sources/AppearanceSection.swift new file mode 100644 index 000000000000..c4cfa13f91a8 --- /dev/null +++ b/Sources/AppearanceSection.swift @@ -0,0 +1,225 @@ +import SwiftUI +import AppKit + +// `AppearanceSection` is the new Settings → Appearance UI surface. Milestone 2 ships the +// walking skeleton — header + live preview + preset strip with two themes. Milestones 3, 6, +// and 7 fill in per-mode overrides, global controls, color picker, and library browser. +// +// Per plan §2.4 this is a section that lives inside the existing `SettingsView` ScrollView, +// using shared `SettingsCard` / `SettingsCardRow` / `SettingsSectionHeader` components. +// We define this in its own file because at ~600 LOC it doesn't belong inside cmuxApp.swift, +// even though the convention for other sections is inline. +// +// The section reads from `ThemeStore.shared` via `@EnvironmentObject`, which is injected at +// the `SettingsRootView` boundary. AppKit chrome (sidebar background, etc.) responds because +// `ThemeStore.applyTheme(_:)` writes through to the legacy `sidebarTintHex*` UserDefaults +// keys that `SidebarBackdrop` already observes; the proper migration of those readers to +// direct `theme.tokens.*` reads is Milestone 5. +struct AppearanceSection: View { + @EnvironmentObject private var themeStore: ThemeStore + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + SettingsAppearanceSectionHeader() + + // Live preview — a miniature Superghost layout. Reacts to ThemeStore changes + // through @EnvironmentObject. The preview chrome (mini-sidebar, mini-terminal) + // pulls from `themeStore.activeTheme.tokens`; the proof that the architecture + // works end-to-end is that this AND the real app sidebar both recolor in the + // same render frame when a preset is clicked. + AppearanceLivePreview(theme: themeStore.activeTheme) + .padding(.top, 4) + .padding(.bottom, 8) + + // Preset strip — two themes for Milestone 2 (handoff §2's curated set in + // M4 grows this to 8–12). Click swaps `themeStore.activeTheme` and propagates + // through the cache + UserDefaults bridge. + AppearancePresetStrip( + presets: BuiltInThemes.all, + activeId: themeStore.activeTheme.id, + onSelect: { theme in + themeStore.applyTheme(theme) + } + ) + } + .id(SettingsNavigationTarget.appearance) // navigation target — routed via `proxy.scrollTo` in SettingsView + } +} + +// Stable identifier (kept around in case a future caller needs to reference the section +// outside the SettingsNavigationTarget enum). +enum AppearanceSectionAnchor { + static let id = "settings.section.appearance" +} + +private struct SettingsAppearanceSectionHeader: View { + var body: some View { + Text(String(localized: "settings.section.appearance", defaultValue: "Appearance")) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.secondary) + .padding(.leading, 2) + .padding(.bottom, -2) + } +} + +// MARK: - Live preview + +private struct AppearanceLivePreview: View { + let theme: SuperghostTheme + + var body: some View { + // The preview is a stack of two stripes — sidebar on the left, terminal pane on the + // right — sized to mirror the actual Superghost workspace at a glance. Picking a + // preset recolors both stripes in one render frame; that's the M2 acceptance demo. + HStack(spacing: 0) { + // Mini-sidebar + VStack(alignment: .leading, spacing: 6) { + miniSidebarRow(label: "main", selected: true) + miniSidebarRow(label: "feature/themes", selected: false) + miniSidebarRow(label: "test/coverage", selected: false) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 12) + .frame(width: 140, alignment: .topLeading) + .background(Color(nsColor: theme.tokens.cardSurface)) + + // Mini-terminal + VStack(alignment: .leading, spacing: 4) { + Text("$ superghost --version") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(Color(nsColor: theme.tokens.inkBody)) + Text("0.1.0") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(Color(nsColor: theme.tokens.inkMuted)) + Text("$ _") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(Color(nsColor: theme.cursorColor)) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color(nsColor: theme.backgroundColor)) + } + .frame(height: 140) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(nsColor: theme.tokens.hairlineBorder), lineWidth: 1) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + String( + localized: "settings.appearance.preview.label", + defaultValue: "Live preview of the \(theme.name) theme" + ) + ) + } + + @ViewBuilder + private func miniSidebarRow(label: String, selected: Bool) -> some View { + HStack(spacing: 6) { + Circle() + .fill(Color(nsColor: selected ? theme.tokens.accentInline : theme.tokens.inkMuted)) + .frame(width: 6, height: 6) + Text(label) + .font(.system(size: 11, weight: selected ? .semibold : .regular)) + .foregroundColor(Color(nsColor: selected ? theme.tokens.inkHeadline : theme.tokens.inkBody)) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color(nsColor: selected ? theme.tokens.liftedSurface : .clear)) + ) + } +} + +// MARK: - Preset strip + +private struct AppearancePresetStrip: View { + let presets: [SuperghostTheme] + let activeId: String + let onSelect: (SuperghostTheme) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(presets, id: \.id) { theme in + AppearancePresetCard( + theme: theme, + isActive: theme.id == activeId, + onSelect: { onSelect(theme) } + ) + } + } + .padding(.vertical, 4) + } + } +} + +private struct AppearancePresetCard: View { + let theme: SuperghostTheme + let isActive: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 8) { + // Color preview row — chrome surface + accent + ink, the tokens that most + // visibly distinguish themes at a glance. + HStack(spacing: 4) { + swatch(theme.tokens.cardSurface) + swatch(theme.backgroundColor) + swatch(theme.tokens.accentInline) + swatch(theme.tokens.inkBody) + } + Text(theme.name) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(1) + Text(modeLabel(theme.mode)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .padding(10) + .frame(width: 144, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke( + isActive ? Color.accentColor : Color(nsColor: NSColor.separatorColor).opacity(0.5), + lineWidth: isActive ? 2 : 1 + ) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(theme.name) + .accessibilityAddTraits(isActive ? [.isSelected] : []) + } + + private func swatch(_ color: NSColor) -> some View { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(Color(nsColor: color)) + .frame(width: 22, height: 22) + .overlay( + RoundedRectangle(cornerRadius: 3, style: .continuous) + .stroke(Color.black.opacity(0.08), lineWidth: 0.5) + ) + } + + private func modeLabel(_ mode: ThemeMode) -> String { + switch mode { + case .light: + return String(localized: "settings.appearance.preset.mode.light", defaultValue: "Light mode") + case .dark: + return String(localized: "settings.appearance.preset.mode.dark", defaultValue: "Dark mode") + } + } +} diff --git a/Sources/BuiltInThemes.swift b/Sources/BuiltInThemes.swift new file mode 100644 index 000000000000..638907ba4ef5 --- /dev/null +++ b/Sources/BuiltInThemes.swift @@ -0,0 +1,135 @@ +import Foundation +import AppKit + +// Curated built-in themes shipped with Superghost. The Milestone 2 walking skeleton needs +// at least two themes (one dark, one light) to prove the architecture; Milestone 4 grows +// this to the full 8–12 set per handoff §2 and validates contrast on every chrome token. +// +// Color values for each theme come from: +// - Tokyo Night: handoff §2 "Token Architecture", values are the published Tokyo Night +// palette (https://github.com/folke/tokyonight.nvim) with chrome derived for the +// specific ladder Matt's prototype calls out. +// - Catppuccin Latte: published Catppuccin Latte palette +// (https://github.com/catppuccin/catppuccin#-palette) with chrome chosen to mirror the +// Tokyo Night ladder in the inverse direction. +// +// Both are *fully specified* (not derived). That's the contract M4 codifies for every +// built-in: ship every chrome token with a hand-chosen value so the curated set is the +// reference for what "good" looks like. +enum BuiltInThemes { + static var all: [SuperghostTheme] { + [tokyoNight, catppuccinLatte] + } + + static let tokyoNight = SuperghostTheme( + id: "tokyo-night", + name: "Tokyo Night", + mode: .dark, + source: .builtIn, + backgroundColor: NSColor(hex: "#1a1b26")!, + foregroundColor: NSColor(hex: "#c0caf5")!, + cursorColor: NSColor(hex: "#7aa2f7")!, + cursorTextColor: NSColor(hex: "#1a1b26")!, + selectionBackground: NSColor(hex: "#283457")!, + selectionForeground: NSColor(hex: "#c0caf5")!, + palette: [ + 0: NSColor(hex: "#15161e")!, + 1: NSColor(hex: "#f7768e")!, + 2: NSColor(hex: "#9ece6a")!, + 3: NSColor(hex: "#e0af68")!, + 4: NSColor(hex: "#7aa2f7")!, + 5: NSColor(hex: "#bb9af7")!, + 6: NSColor(hex: "#7dcfff")!, + 7: NSColor(hex: "#a9b1d6")!, + 8: NSColor(hex: "#414868")!, + 9: NSColor(hex: "#f7768e")!, + 10: NSColor(hex: "#9ece6a")!, + 11: NSColor(hex: "#e0af68")!, + 12: NSColor(hex: "#7aa2f7")!, + 13: NSColor(hex: "#bb9af7")!, + 14: NSColor(hex: "#7dcfff")!, + 15: NSColor(hex: "#c0caf5")! + ], + cardSurface: NSColor(hex: "#1a1b26")!, + liftedSurface: NSColor(hex: "#1f2335")!, + hairlineBorder: NSColor(hex: "#2f334d")!, + hairlineBorderHover: NSColor(hex: "#3d4263")!, + inkHeadline: NSColor(hex: "#c0caf5")!, + inkBody: NSColor(hex: "#a9b1d6")!, + // Bumped from the handoff's #565f89 (R1 in the plan — fails AA on caption sizes). + // The plan defers this to M6; we ship the safe value from the start so the walking + // skeleton already meets AA. Documented in the plan-fix log. + inkMuted: NSColor(hex: "#737aa2")!, + inkCaption: NSColor(hex: "#737aa2")!, + accentSolid: NSColor(hex: "#3d59a1")!, + accentInline: NSColor(hex: "#7aa2f7")!, + semanticSuccess: NSColor(hex: "#1a2b25")!, + semanticWarning: NSColor(hex: "#3d3328")!, + semanticDanger: NSColor(hex: "#3c2c3a")!, + semanticInfo: NSColor(hex: "#16263a")!, + semanticSkill: NSColor(hex: "#3d4263")!, + translucentSidebar: true, + contrastBoost: 68 + ) + + static let catppuccinLatte = SuperghostTheme( + id: "catppuccin-latte", + name: "Catppuccin Latte", + mode: .light, + source: .builtIn, + backgroundColor: NSColor(hex: "#eff1f5")!, + foregroundColor: NSColor(hex: "#4c4f69")!, + cursorColor: NSColor(hex: "#1e66f5")!, + cursorTextColor: NSColor(hex: "#eff1f5")!, + selectionBackground: NSColor(hex: "#bcc0cc")!, + selectionForeground: NSColor(hex: "#4c4f69")!, + palette: [ + 0: NSColor(hex: "#5c5f77")!, + 1: NSColor(hex: "#d20f39")!, + 2: NSColor(hex: "#40a02b")!, + 3: NSColor(hex: "#df8e1d")!, + 4: NSColor(hex: "#1e66f5")!, + 5: NSColor(hex: "#ea76cb")!, + 6: NSColor(hex: "#179299")!, + 7: NSColor(hex: "#acb0be")!, + 8: NSColor(hex: "#6c6f85")!, + 9: NSColor(hex: "#d20f39")!, + 10: NSColor(hex: "#40a02b")!, + 11: NSColor(hex: "#df8e1d")!, + 12: NSColor(hex: "#1e66f5")!, + 13: NSColor(hex: "#ea76cb")!, + 14: NSColor(hex: "#179299")!, + 15: NSColor(hex: "#bcc0cc")! + ], + cardSurface: NSColor(hex: "#e6e9ef")!, + liftedSurface: NSColor(hex: "#dce0e8")!, + hairlineBorder: NSColor(hex: "#ccd0da")!, + hairlineBorderHover: NSColor(hex: "#bcc0cc")!, + inkHeadline: NSColor(hex: "#4c4f69")!, + inkBody: NSColor(hex: "#5c5f77")!, + inkMuted: NSColor(hex: "#6c6f85")!, + inkCaption: NSColor(hex: "#7c7f93")!, + accentSolid: NSColor(hex: "#1e66f5")!, + accentInline: NSColor(hex: "#1e66f5")!, + semanticSuccess: NSColor(hex: "#d9eedb")!, + semanticWarning: NSColor(hex: "#f5e6c8")!, + semanticDanger: NSColor(hex: "#f5d6dc")!, + semanticInfo: NSColor(hex: "#d7e3fc")!, + semanticSkill: NSColor(hex: "#e0e3ef")!, + translucentSidebar: true, + contrastBoost: 60 + ) + + // Resolve a preset by id. Used by `ThemeStore` and `AppearanceSection`. + static func preset(withId id: String) -> SuperghostTheme? { + all.first(where: { $0.id == id }) + } + + // Default theme to apply for a given color scheme when the user hasn't picked one yet. + static func defaultPreset(for mode: ThemeMode) -> SuperghostTheme { + switch mode { + case .dark: return tokyoNight + case .light: return catppuccinLatte + } + } +} diff --git a/Sources/ThemeStore.swift b/Sources/ThemeStore.swift index cadb13d7a82d..744a0ffdf61f 100644 --- a/Sources/ThemeStore.swift +++ b/Sources/ThemeStore.swift @@ -61,6 +61,7 @@ final class ThemeStore: ObservableObject { // changing the call sites. func applyTheme(_ theme: SuperghostTheme) { applyToGhosttyConfigCache(theme: theme, scheme: resolvedColorScheme) + applyToLegacySidebarDefaults(theme: theme) self.activeTheme = theme NotificationCenter.default.post(name: Self.changeNotification, object: self) scheduleFileSync() @@ -132,6 +133,29 @@ final class ThemeStore: ObservableObject { // Intentionally empty — see `Sources/AppearanceFileSync.swift` (M3). } + // M2 bridge: write the active theme's sidebar background through the legacy + // `sidebarTintHex*` UserDefaults keys. `SidebarBackdrop` (in `ContentView.swift`) + // already reads these via `@AppStorage`, so writing here is enough to make the actual + // app sidebar respond to a preset switch — which is the M2 acceptance gate. + // + // The proper migration of `SidebarBackdrop` to read from `themeStore.activeTheme.tokens` + // directly is Milestone 5 (chrome migration). Until then, the legacy UserDefaults keys + // remain the source of truth for those readers. The plan's §2.5 compatibility contract + // is honored — keys stay frozen, and we write the values the reader expects. + private func applyToLegacySidebarDefaults(theme: SuperghostTheme) { + let defaults = UserDefaults.standard + let hex = theme.cardSurface.hexString() + defaults.set(hex, forKey: "sidebarTintHex") + // Per-mode keys are set only for the theme's mode; the other mode is left alone so + // a dark-mode pick doesn't clobber the user's light-mode preference (and vice-versa). + switch theme.mode { + case .dark: + defaults.set(hex, forKey: "sidebarTintHexDark") + case .light: + defaults.set(hex, forKey: "sidebarTintHexLight") + } + } + // MARK: - Preference persistence // `schemePreference` is stored under a stable UserDefaults key. We deliberately don't diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a9e7c5ce6758..610242064cc6 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2750,6 +2750,7 @@ enum SettingsNavigationTarget: String { case browser case browserImport case keyboardShortcuts + case appearance } enum SettingsNavigationRequest { @@ -4939,6 +4940,8 @@ struct SettingsView: View { .disabled(sidebarHideAllDetails) } + AppearanceSection() + SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors")) SettingsCard { SettingsPickerRow( @@ -6448,6 +6451,7 @@ private struct ShortcutSettingRow: View { private struct SettingsRootView: View { var body: some View { SettingsView() + .environmentObject(ThemeStore.shared) .background(WindowAccessor { window in configureSettingsWindow(window) }) diff --git a/cmuxTests/ThemeStoreTests.swift b/cmuxTests/ThemeStoreTests.swift index 178e82d87173..295a76cf8156 100644 --- a/cmuxTests/ThemeStoreTests.swift +++ b/cmuxTests/ThemeStoreTests.swift @@ -187,4 +187,72 @@ final class ThemeStoreFoundationTests: XCTestCase { "accentInline should be drawn from the palette's accent swatches" ) } + + // MARK: - Milestone 2: Preset → cache + UserDefaults bridge + + @MainActor + func testApplyTokyoNightPresetWritesCardSurfaceToLegacySidebarDefaults() { + // M2 acceptance: clicking a preset must propagate the new theme's chrome to the + // legacy sidebar UserDefaults keys so existing readers (SidebarBackdrop in + // ContentView.swift) update without a restart. This is the bridge the walking + // skeleton relies on until M5 swaps SidebarBackdrop to read from ThemeStore directly. + let defaults = UserDefaults.standard + let priorTint = defaults.string(forKey: "sidebarTintHex") + let priorDark = defaults.string(forKey: "sidebarTintHexDark") + defer { + // Restore so unrelated tests aren't poisoned. + if let priorTint { defaults.set(priorTint, forKey: "sidebarTintHex") } + else { defaults.removeObject(forKey: "sidebarTintHex") } + if let priorDark { defaults.set(priorDark, forKey: "sidebarTintHexDark") } + else { defaults.removeObject(forKey: "sidebarTintHexDark") } + } + + ThemeStore.shared.applyTheme(BuiltInThemes.tokyoNight) + + let expected = BuiltInThemes.tokyoNight.cardSurface.hexString() + XCTAssertEqual( + defaults.string(forKey: "sidebarTintHex"), + expected, + "applyTheme should write the active theme's cardSurface to sidebarTintHex" + ) + XCTAssertEqual( + defaults.string(forKey: "sidebarTintHexDark"), + expected, + "applyTheme(dark) should also write to the per-mode dark key" + ) + } + + @MainActor + func testApplyLightPresetDoesNotClobberDarkSidebarDefault() { + // If the user has a dark sidebar pinned and we apply a light theme, the per-mode + // dark key must be untouched. The plan's §2.5 compatibility contract requires this. + let defaults = UserDefaults.standard + let priorDark = defaults.string(forKey: "sidebarTintHexDark") + defaults.set("#1a1b26", forKey: "sidebarTintHexDark") + defer { + if let priorDark { defaults.set(priorDark, forKey: "sidebarTintHexDark") } + else { defaults.removeObject(forKey: "sidebarTintHexDark") } + } + + ThemeStore.shared.applyTheme(BuiltInThemes.catppuccinLatte) + XCTAssertEqual( + defaults.string(forKey: "sidebarTintHexDark"), + "#1a1b26", + "applying a light theme should not modify the per-mode dark sidebar key" + ) + } + + func testBuiltInThemesAreFullySpecifiedAndAACompliant() { + // Every curated theme must hand-specify every chrome token (handoff §2 contract for + // built-ins) and meet AA contrast for body ink on card surface. M4 expands this set; + // the contract holds for every addition. + for theme in BuiltInThemes.all { + let ratio = ThemeTokens.contrastRatio(theme.inkBody, theme.cardSurface) + XCTAssertGreaterThanOrEqual( + ratio, + 4.5, + "Theme \(theme.id) must clear AA contrast for inkBody on cardSurface (was \(ratio))" + ) + } + } }