diff --git a/supacode/Domain/WindowChromeTint.swift b/supacode/Domain/WindowChromeTint.swift index e221a857..98dbf727 100644 --- a/supacode/Domain/WindowChromeTint.swift +++ b/supacode/Domain/WindowChromeTint.swift @@ -14,8 +14,8 @@ import SwiftUI /// `WindowTintMode` selects the source: /// - `.none` ⇒ no band (neutral system chrome). /// - `.repositoryColor` ⇒ the active repo's pinned color, or a neutral -/// surface when uncolored — the same `repositoryBase`/`repositoryPeakAlpha` -/// pair the Shelf spine uses, so the chrome and the open spine match. +/// surface when uncolored. Shelf spines can use the same repo-color pair, +/// or their own user-selected fallback behavior. /// - `.custom` ⇒ a single user-chosen color, ignoring per-repo colors. enum WindowChromeTint { enum ToolbarFallbackEvent { @@ -58,8 +58,8 @@ enum WindowChromeTint { static let neutralPeakAlpha: Double = 0.10 /// Base hue for a repository-color surface: the pinned color, or - /// `Color.primary` for the neutral fallback. Used by the Shelf spine - /// (always) and by `.repositoryColor` chrome bands. + /// `Color.primary` for the neutral fallback. Used by `.repositoryColor` + /// chrome bands. static func repositoryBase(for color: RepositoryColorChoice?) -> Color { color?.color ?? .primary } @@ -70,6 +70,42 @@ enum WindowChromeTint { color == nil ? neutralPeakAlpha : saturatedPeakAlpha } + /// Base hue for a Shelf spine surface. When `followsRepositoryColor` is + /// true, a pinned repo color wins; otherwise every spine uses the user's + /// fallback style. + static func shelfSpineBase( + for color: RepositoryColorChoice?, + fallback: ShelfSpineTintFallback, + followsRepositoryColor: Bool + ) -> Color { + if followsRepositoryColor, let color { + return color.color + } + return shelfSpineFallbackBase(fallback) + } + + /// Peak alpha for a Shelf spine surface. Neutral fallbacks stay gentler; + /// pinned repo colors and system tint use the saturated shelf/chrome peak. + static func shelfSpinePeakAlpha( + for color: RepositoryColorChoice?, + fallback: ShelfSpineTintFallback, + followsRepositoryColor: Bool + ) -> Double { + if followsRepositoryColor, color != nil { + return saturatedPeakAlpha + } + return fallback == .neutral ? neutralPeakAlpha : saturatedPeakAlpha + } + + private static func shelfSpineFallbackBase(_ fallback: ShelfSpineTintFallback) -> Color { + switch fallback { + case .neutral: + return .primary + case .systemTint: + return .accentColor + } + } + /// Resolves the chrome band for the given tint mode. Returns `nil` when /// no band should be drawn (`.none`, or `.custom` without a color). /// diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index cc1cbe72..95f4ebdd 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -31,6 +31,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var autoShowActiveAgentsPanel: Bool var windowTintMode: WindowTintMode var windowTintCustomColor: TintColor + var shelfSpineTintFallback: ShelfSpineTintFallback + var shelfSpineTintFollowsRepositoryColor: Bool static let `default` = GlobalSettings( appearanceMode: .dark, @@ -64,7 +66,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { dimUnfocusedSplits: true, autoShowActiveAgentsPanel: false, windowTintMode: .repositoryColor, - windowTintCustomColor: .default + windowTintCustomColor: .default, + shelfSpineTintFallback: .neutral, + shelfSpineTintFollowsRepositoryColor: true ) init( @@ -99,7 +103,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { dimUnfocusedSplits: Bool = true, autoShowActiveAgentsPanel: Bool = false, windowTintMode: WindowTintMode = .repositoryColor, - windowTintCustomColor: TintColor = .default + windowTintCustomColor: TintColor = .default, + shelfSpineTintFallback: ShelfSpineTintFallback = .neutral, + shelfSpineTintFollowsRepositoryColor: Bool = true ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -133,6 +139,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.autoShowActiveAgentsPanel = autoShowActiveAgentsPanel self.windowTintMode = windowTintMode self.windowTintCustomColor = windowTintCustomColor + self.shelfSpineTintFallback = shelfSpineTintFallback + self.shelfSpineTintFollowsRepositoryColor = shelfSpineTintFollowsRepositoryColor } func encode(to encoder: any Encoder) throws { @@ -169,6 +177,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(autoShowActiveAgentsPanel, forKey: .autoShowActiveAgentsPanel) try container.encode(windowTintMode, forKey: .windowTintMode) try container.encode(windowTintCustomColor, forKey: .windowTintCustomColor) + try container.encode(shelfSpineTintFallback, forKey: .shelfSpineTintFallback) + try container.encode(shelfSpineTintFollowsRepositoryColor, forKey: .shelfSpineTintFollowsRepositoryColor) } private enum CodingKeys: String, CodingKey { @@ -204,6 +214,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case autoShowActiveAgentsPanel case windowTintMode case windowTintCustomColor + case shelfSpineTintFallback + case shelfSpineTintFollowsRepositoryColor // Legacy key for migration case automaticallyArchiveMergedWorktrees } @@ -305,8 +317,27 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { windowTintMode = try container.decodeIfPresent(WindowTintMode.self, forKey: .windowTintMode) ?? Self.default.windowTintMode - windowTintCustomColor = - try container.decodeIfPresent(TintColor.self, forKey: .windowTintCustomColor) + windowTintCustomColor = try Self.decodeWindowTintCustomColor(from: container) + (shelfSpineTintFallback, shelfSpineTintFollowsRepositoryColor) = + try Self.decodeShelfSpineTint(from: container) + } + + private static func decodeWindowTintCustomColor( + from container: KeyedDecodingContainer + ) throws -> TintColor { + try container.decodeIfPresent(TintColor.self, forKey: .windowTintCustomColor) ?? Self.default.windowTintCustomColor } + + private static func decodeShelfSpineTint( + from container: KeyedDecodingContainer + ) throws -> (ShelfSpineTintFallback, Bool) { + let fallback = + try container.decodeIfPresent(ShelfSpineTintFallback.self, forKey: .shelfSpineTintFallback) + ?? Self.default.shelfSpineTintFallback + let followsRepositoryColor = + try container.decodeIfPresent(Bool.self, forKey: .shelfSpineTintFollowsRepositoryColor) + ?? Self.default.shelfSpineTintFollowsRepositoryColor + return (fallback, followsRepositoryColor) + } } diff --git a/supacode/Features/Settings/Models/ShelfSpineTintFallback.swift b/supacode/Features/Settings/Models/ShelfSpineTintFallback.swift new file mode 100644 index 00000000..e3b5117d --- /dev/null +++ b/supacode/Features/Settings/Models/ShelfSpineTintFallback.swift @@ -0,0 +1,23 @@ +/// Fallback color used by Shelf spine surfaces when a repository has no +/// pinned color, or for every spine when repository colors are ignored. +/// +/// Persistence: encoded as the raw `String` (case name). Cases must never +/// be renamed once shipped because user JSON references them by name. +enum ShelfSpineTintFallback: String, CaseIterable, Identifiable, Codable, Sendable { + /// Use the neutral primary-color surface (near-black in dark mode / + /// near-white in light) matching the current Shelf behavior. + case neutral + /// Use the system accent tint for the spine surface. + case systemTint + + var id: String { rawValue } + + var title: String { + switch self { + case .neutral: + return "Neutral" + case .systemTint: + return "System Tint" + } + } +} diff --git a/supacode/Features/Settings/Models/WindowTintMode.swift b/supacode/Features/Settings/Models/WindowTintMode.swift index 8b81fec3..4865cd0b 100644 --- a/supacode/Features/Settings/Models/WindowTintMode.swift +++ b/supacode/Features/Settings/Models/WindowTintMode.swift @@ -2,8 +2,8 @@ /// floating sidebar and the toolbar band behind the titlebar — across /// every view mode (Normal, Shelf, Canvas). /// -/// The Shelf spine is *not* governed by this setting: it always tints with -/// the open repo's pinned color (see `WindowChromeTint.repositoryBase`). +/// Shelf spine tinting has its own adjacent settings: the fallback style for +/// uncolored repositories and whether spines follow per-repository colors. /// /// Persistence: encoded as the raw `String` (case name). Cases must never /// be renamed once shipped because user JSON references them by name. @@ -11,8 +11,8 @@ enum WindowTintMode: String, CaseIterable, Identifiable, Codable, Sendable { /// No chrome tint. The nav and toolbar fall back to the neutral system /// chrome (the default, untinted look). case none - /// Tint the chrome with the active repository's pinned color, matching - /// the Shelf spine. An uncolored repo falls back to a neutral surface + /// Tint the chrome with the active repository's pinned color. An + /// uncolored repo falls back to a neutral surface /// (near-black in dark mode / near-white in light). case repositoryColor /// Tint the chrome with a single user-chosen color, unconditionally — diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 237c517c..fe7ee635 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -37,6 +37,8 @@ struct SettingsFeature { var dimUnfocusedSplits: Bool var autoShowActiveAgentsPanel: Bool var windowTintMode: WindowTintMode + var shelfSpineTintFallback: ShelfSpineTintFallback + var shelfSpineTintFollowsRepositoryColor: Bool /// Mirrors `GlobalSettings.windowTintCustomColor` as a live `Color` so /// the `ColorPicker` can bind to it directly; converted back to the /// persistable `TintColor` at the `globalSettings` boundary. @@ -81,6 +83,8 @@ struct SettingsFeature { dimUnfocusedSplits = settings.dimUnfocusedSplits autoShowActiveAgentsPanel = settings.autoShowActiveAgentsPanel windowTintMode = settings.windowTintMode + shelfSpineTintFallback = settings.shelfSpineTintFallback + shelfSpineTintFollowsRepositoryColor = settings.shelfSpineTintFollowsRepositoryColor windowTintCustomColor = settings.windowTintCustomColor.color } @@ -119,7 +123,9 @@ struct SettingsFeature { dimUnfocusedSplits: dimUnfocusedSplits, autoShowActiveAgentsPanel: autoShowActiveAgentsPanel, windowTintMode: windowTintMode, - windowTintCustomColor: TintColor(windowTintCustomColor) + windowTintCustomColor: TintColor(windowTintCustomColor), + shelfSpineTintFallback: shelfSpineTintFallback, + shelfSpineTintFollowsRepositoryColor: shelfSpineTintFollowsRepositoryColor ) } } @@ -223,6 +229,8 @@ struct SettingsFeature { state.dimUnfocusedSplits = normalizedSettings.dimUnfocusedSplits state.autoShowActiveAgentsPanel = normalizedSettings.autoShowActiveAgentsPanel state.windowTintMode = normalizedSettings.windowTintMode + state.shelfSpineTintFallback = normalizedSettings.shelfSpineTintFallback + state.shelfSpineTintFollowsRepositoryColor = normalizedSettings.shelfSpineTintFollowsRepositoryColor state.windowTintCustomColor = normalizedSettings.windowTintCustomColor.color state.syncGlobalDefaults(from: normalizedSettings) return .send(.delegate(.settingsChanged(normalizedSettings))) diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index 1470e04d..7a30d77b 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -52,9 +52,7 @@ struct AppearanceSettingsView: View { Text(mode.title).tag(mode) } } - .help( - "Color the navigation panel and toolbar. The Shelf spine always uses its repository color." - ) + .help("Color the navigation panel and toolbar.") if store.windowTintMode == .custom { ColorPicker( "Custom tint color", @@ -66,6 +64,23 @@ struct AppearanceSettingsView: View { Text(tintFootnote) .font(.callout) .foregroundStyle(.secondary) + + Divider() + + Picker("Tint spines in Shelf View", selection: $store.shelfSpineTintFallback) { + ForEach(ShelfSpineTintFallback.allCases) { fallback in + Text(fallback.title).tag(fallback) + } + } + .help("Choose how Shelf spines are tinted when no repository color is available.") + Toggle( + "Follow Repo Color Setting", + isOn: $store.shelfSpineTintFollowsRepositoryColor + ) + .help("When disabled, all Shelf spines use the selected Neutral or System Tint style.") + Text(shelfSpineTintFootnote) + .font(.callout) + .foregroundStyle(.secondary) } Section("Splits") { Toggle( @@ -129,4 +144,20 @@ struct AppearanceSettingsView: View { return "Uses your chosen color everywhere, regardless of per-repository colors." } } + + private var shelfSpineTintFootnote: String { + let fallback = + switch store.shelfSpineTintFallback { + case .neutral: + "Uncolored repositories use a neutral spine." + case .systemTint: + "Uncolored repositories use the system tint color." + } + + if store.shelfSpineTintFollowsRepositoryColor { + return fallback + " Repositories with a custom color still use that color." + } else { + return fallback + " Repository colors are ignored for Shelf spines." + } + } } diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index f121ec84..ba115b8f 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -20,6 +20,8 @@ struct ShelfSpineView: View { /// glance instead of every non-open spine looking identical. let distanceFromOpen: Int? let terminalState: WorktreeTerminalState? + let tintFallback: ShelfSpineTintFallback + let followsRepositoryColor: Bool let onOpenBook: () -> Void let onSelectTab: (TerminalTabID) -> Void /// Bottom controls — provided only for the open book's spine. `nil` @@ -121,30 +123,41 @@ struct ShelfSpineView: View { /// "this is interactable" affordance that sits just below the open /// book and animates in/out smoothly. /// - /// The surface hue/alpha come from `WindowChromeTint`'s repository-color - /// surface: a repo with a user-pinned color tints its spine with that + /// The surface hue/alpha come from the Shelf spine tint settings. By + /// default, a repo with a user-pinned color tints its spine with that /// color, while an uncolored repo uses a neutral surface (near-black in - /// dark mode, near-white in light) so the shelf stays calm and the open - /// book's spine reads as one continuous "L" with the toolbar tint band - /// above it. The spine always uses the repo color and ignores the global - /// `WindowTintMode` — only the chrome bands honor that setting. The - /// proximity ladder is unchanged — we only swap the base. + /// dark mode, near-white in light). Users can switch uncolored spines to + /// the system tint, or disable repository-color following so every spine + /// uses the selected fallback style. The proximity ladder is unchanged — + /// we only swap the base. private var spineBackgroundColor: Color { guard distanceFromOpen != nil else { return Color.primary.opacity(0.06) } let multiplier = isHovering && !isOpen ? 0.8 : accentProximityMultiplier - return WindowChromeTint.repositoryBase(for: appearance.color) - .opacity(WindowChromeTint.repositoryPeakAlpha(for: appearance.color) * multiplier) + return WindowChromeTint.shelfSpineBase( + for: appearance.color, + fallback: tintFallback, + followsRepositoryColor: followsRepositoryColor + ) + .opacity( + WindowChromeTint.shelfSpinePeakAlpha( + for: appearance.color, + fallback: tintFallback, + followsRepositoryColor: followsRepositoryColor + ) * multiplier + ) } - /// Repo's pinned color, or `.accentColor` when none — used as the header - /// icon tint and the active-tab highlight. The spine *surface* fill - /// instead routes through `WindowChromeTint` (neutral when uncolored), so - /// an uncolored repo keeps an accent icon / tab marker on an otherwise - /// neutral spine. + /// Tint used as the header icon color and the active-tab highlight. It + /// follows the same repo-color override as the spine surface, but falls + /// back to system tint so the neutral default preserves the previous + /// accent icon / tab marker on an otherwise neutral spine. private var effectiveTintColor: Color { - appearance.color?.color ?? .accentColor + if followsRepositoryColor, let color = appearance.color { + return color.color + } + return .accentColor } private var appearance: RepositoryAppearance { diff --git a/supacode/Features/Shelf/Views/ShelfView.swift b/supacode/Features/Shelf/Views/ShelfView.swift index 7fb0b40f..897ee1b5 100644 --- a/supacode/Features/Shelf/Views/ShelfView.swift +++ b/supacode/Features/Shelf/Views/ShelfView.swift @@ -24,9 +24,8 @@ struct ShelfView: View { /// terminal surface and empty-state area. @Environment(\.surfaceBackgroundOpacity) private var surfaceBackgroundOpacity @Shared(.repositoryAppearances) private var repositoryAppearances - /// Drives the chrome tint mode / custom color. The Shelf *spine* always - /// uses the open repo's color regardless of this setting; only the - /// toolbar / nav bands below honor it. + /// Drives the chrome tint mode / custom color and the Shelf spine tint + /// preferences. @Shared(.settingsFile) private var settingsFile var body: some View { @@ -48,7 +47,7 @@ struct ShelfView: View { let openColor = openBook.flatMap { repositoryAppearances[$0.repositoryID]?.color } // Chrome band fill for the toolbar (top) and nav (leading), honoring the // user's window tint mode. Only shown when a book is open; an empty - // shelf keeps its bare chrome. The spine itself is unaffected. + // shelf keeps its bare chrome. let chromeFill = openBook == nil ? nil @@ -92,6 +91,8 @@ struct ShelfView: View { isOpen: open, distanceFromOpen: distance, terminalState: terminalManager.stateIfExists(for: book.id), + tintFallback: settingsFile.global.shelfSpineTintFallback, + followsRepositoryColor: settingsFile.global.shelfSpineTintFollowsRepositoryColor, onOpenBook: { openBook(book, selectingTab: nil) }, onSelectTab: { tabID in openBook(book, selectingTab: tabID) }, onNewTab: { diff --git a/supacodeTests/WindowChromeTintTests.swift b/supacodeTests/WindowChromeTintTests.swift index e52fcc6d..d861a4b5 100644 --- a/supacodeTests/WindowChromeTintTests.swift +++ b/supacodeTests/WindowChromeTintTests.swift @@ -44,6 +44,60 @@ struct WindowChromeTintTests { #expect(WindowChromeTint.repositoryPeakAlpha(for: .blue) == WindowChromeTint.saturatedPeakAlpha) } + @Test + func shelfSpineUsesNeutralFallbackByDefaultForUncoloredRepositories() { + #expect( + WindowChromeTint.shelfSpineBase( + for: nil, + fallback: .neutral, + followsRepositoryColor: true + ) == .primary + ) + #expect( + WindowChromeTint.shelfSpinePeakAlpha( + for: nil, + fallback: .neutral, + followsRepositoryColor: true + ) == WindowChromeTint.neutralPeakAlpha + ) + } + + @Test + func shelfSpineCanUseSystemTintFallbackForUncoloredRepositories() { + #expect( + WindowChromeTint.shelfSpineBase( + for: nil, + fallback: .systemTint, + followsRepositoryColor: true + ) == .accentColor + ) + #expect( + WindowChromeTint.shelfSpinePeakAlpha( + for: nil, + fallback: .systemTint, + followsRepositoryColor: true + ) == WindowChromeTint.saturatedPeakAlpha + ) + } + + @Test + func shelfSpineCanIgnoreRepositoryColors() { + #expect( + WindowChromeTint.shelfSpineBase( + for: .green, + fallback: .neutral, + followsRepositoryColor: false + ) == .primary + ) + #expect( + WindowChromeTint.shelfSpineBase( + for: .green, + fallback: .systemTint, + followsRepositoryColor: false + ) == .accentColor + ) + } + // MARK: - Toolbar background resolution @Test