diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatSessionHeaderView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatSessionHeaderView.swift index de98f17272e1..7187ac7b08ac 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatSessionHeaderView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatSessionHeaderView.swift @@ -5,10 +5,10 @@ import SwiftUI /// The compact toolbar-principal header: a leading state indicator beside a /// two-line title (workspace name over tab name). /// -/// State is carried entirely by the indicator (color + motion + a symbol for -/// the two "attention" states), not words, so the narrow nav-bar center can -/// spend its width on the names rather than on "needs input ·". VoiceOver -/// still hears the full state via the accessibility value. +/// State is carried entirely by the indicator (color, motion, and shape), not +/// words, so the narrow nav-bar center can spend its width on the names rather +/// than on "needs input ·". VoiceOver still hears the full state via the +/// accessibility value. public struct ChatSessionHeaderView: View { private let descriptor: ChatSessionDescriptor private let agentState: ChatAgentState @@ -16,6 +16,7 @@ public struct ChatSessionHeaderView: View { private let titleOverride: String? private let subtitle: String? private let style: Style + private let toolbarForegroundColor: Color? /// Creates a session header. /// @@ -35,7 +36,8 @@ public struct ChatSessionHeaderView: View { isConnected: Bool, titleOverride: String? = nil, subtitle: String? = nil, - style: Style = .regular + style: Style = .regular, + toolbarForegroundColor: Color? = nil ) { self.descriptor = descriptor self.agentState = agentState @@ -43,11 +45,17 @@ public struct ChatSessionHeaderView: View { self.titleOverride = titleOverride self.subtitle = subtitle self.style = style + self.toolbarForegroundColor = toolbarForegroundColor } public var body: some View { HStack(spacing: 6) { - ChatStateIndicatorView(state: agentState, isConnected: isConnected, size: indicatorSize) + ChatStateIndicatorView( + state: agentState, + isConnected: isConnected, + size: indicatorSize, + foregroundColor: compactForegroundColor + ) titleStack } .padding(.horizontal, horizontalContentPadding) @@ -83,7 +91,18 @@ public struct ChatSessionHeaderView: View { } } case .toolbarCompact: - MobileCompactToolbarTitleStack(title: title, subtitle: subtitleLine) + MobileCompactToolbarTitleStack( + title: title, + subtitle: subtitleLine, + foregroundColor: toolbarForegroundColor + ) + } + } + + private var compactForegroundColor: Color? { + switch style { + case .regular: nil + case .toolbarCompact: toolbarForegroundColor } } @@ -148,16 +167,16 @@ public struct ChatSessionHeaderView: View { } } -/// The header's state glyph: color + motion for the two ambient states -/// (working pulses green, idle is a filled gray dot), and a distinct SF -/// Symbol shape for the two meaningful ones (needs-input is an orange -/// question mark, ended is a hollow ring) so the four states are -/// distinguishable by shape and motion, not color alone. While -/// reconnecting it desaturates and breathes regardless of state. +/// The header's state glyph uses color and motion in regular headers (working +/// pulses green, idle is a filled gray dot) and static shape in monochrome +/// toolbar headers (working is solid, idle is an inset ring). Needs-input is a +/// question-mark symbol and ended is a full-size ring in either palette. While +/// reconnecting the glyph desaturates and breathes regardless of state. struct ChatStateIndicatorView: View { let state: ChatAgentState let isConnected: Bool let size: CGFloat + var foregroundColor: Color? = nil @State private var pulseDimmed = false @@ -201,15 +220,27 @@ struct ChatStateIndicatorView: View { private var glyph: some View { switch state { case .working: - Circle().fill(Color.green) + Circle().fill(foregroundColor ?? .green) case .idle: - Circle().fill(Color.secondary) + if let foregroundColor { + Circle() + .stroke(foregroundColor, lineWidth: 1.3) + .padding(size * 0.22) + } else { + Circle().fill(Color.secondary) + } case .needsInput: - Image(systemName: "questionmark.circle.fill") - .font(.system(size: size, weight: .bold)) - .foregroundStyle(.white, .orange) + if let foregroundColor { + Image(systemName: "questionmark.circle.fill") + .font(.system(size: size, weight: .bold)) + .foregroundStyle(foregroundColor) + } else { + Image(systemName: "questionmark.circle.fill") + .font(.system(size: size, weight: .bold)) + .foregroundStyle(.white, .orange) + } case .ended: - Circle().stroke(Color.secondary, lineWidth: 1.3) + Circle().stroke(foregroundColor ?? .secondary, lineWidth: 1.3) } } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/Debug/ThemeParityPreviewDelivery.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/Debug/ThemeParityPreviewDelivery.swift index 1864c288b44c..4f5b04f5baa7 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/Debug/ThemeParityPreviewDelivery.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/Debug/ThemeParityPreviewDelivery.swift @@ -1,7 +1,43 @@ #if DEBUG public import CMUXMobileCore +import Foundation extension MobileShellComposite { + /// Suspends the DEBUG theme fixture until its production output consumer is attached. + /// + /// The state is checked both before and while registering the continuation, so + /// registration cannot be missed between observation and suspension. + public func waitForThemeParityPreviewOutputSink(surfaceID: String) async -> Bool { + guard !Task.isCancelled else { return false } + guard !hasTerminalOutputSink(surfaceID: surfaceID) else { return true } + + let waiterID = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(returning: false) + return + } + guard !hasTerminalOutputSink(surfaceID: surfaceID) else { + continuation.resume(returning: true) + return + } + themeParityPreviewOutputSinkWaitersBySurfaceID[ + surfaceID, + default: [:] + ][waiterID] = continuation + } + } onCancel: { + Task { @MainActor [weak self] in + self?.resolveThemeParityPreviewOutputSinkWaiter( + surfaceID: surfaceID, + waiterID: waiterID, + value: false + ) + } + } + } + /// Injects a render-grid frame through the production surface delivery path. /// /// The theme-parity UI fixture uses this to verify mounted Ghostty surfaces, @@ -14,5 +50,31 @@ extension MobileShellComposite { deliverAuthoritativeTerminalRenderGrid(frame, source: "event") return true } + + func resolveThemeParityPreviewOutputSinkWaiters(surfaceID: String) { + guard let waiters = themeParityPreviewOutputSinkWaitersBySurfaceID.removeValue( + forKey: surfaceID + ) else { + return + } + for continuation in waiters.values { + continuation.resume(returning: true) + } + } + + private func resolveThemeParityPreviewOutputSinkWaiter( + surfaceID: String, + waiterID: UUID, + value: Bool + ) { + guard let continuation = themeParityPreviewOutputSinkWaitersBySurfaceID[surfaceID]? + .removeValue(forKey: waiterID) else { + return + } + if themeParityPreviewOutputSinkWaitersBySurfaceID[surfaceID]?.isEmpty == true { + themeParityPreviewOutputSinkWaitersBySurfaceID.removeValue(forKey: surfaceID) + } + continuation.resume(returning: value) + } } #endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index add10e841596..6108c39f4a0d 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1312,6 +1312,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private var rawTerminalInputDrainWaiters: [CheckedContinuation] private var isRawTerminalInputDrainLoopRunning: Bool #if DEBUG + @ObservationIgnored + var themeParityPreviewOutputSinkWaitersBySurfaceID: [ + String: [UUID: CheckedContinuation] + ] var latencyProbeAutoNavigationTask: Task? var latencyProbeTask: Task? private var rawTerminalInputLatencyBatchNumber: UInt64 @@ -1648,6 +1652,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.rawTerminalInputDrainWaiters = [] self.isRawTerminalInputDrainLoopRunning = false #if DEBUG + self.themeParityPreviewOutputSinkWaitersBySurfaceID = [:] self.latencyProbeAutoNavigationTask = nil self.latencyProbeTask = nil self.rawTerminalInputLatencyBatchNumber = 0 @@ -11595,6 +11600,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) pendingTerminalInputDroppedRenderGridSurfaceIDs.remove(surfaceID) #if DEBUG + resolveThemeParityPreviewOutputSinkWaiters(surfaceID: surfaceID) mobileShellLog.info("CMUX_REPLAY register sink surface=\(surfaceID, privacy: .public) connected=\(self.connectionState == .connected, privacy: .public) hasClient=\(self.remoteClient != nil, privacy: .public) workspaceCount=\(self.workspaces.count, privacy: .public)") startLatencyProbeIfReady() #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AltScreenNoticeButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AltScreenNoticeButton.swift index 27592fc20745..c7ebb7235318 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AltScreenNoticeButton.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AltScreenNoticeButton.swift @@ -2,6 +2,7 @@ import CmuxMobileSupport import SwiftUI struct AltScreenNoticeButton: View { + var controlForegroundColor: Color = .orange let dismissNotice: () -> Void @State private var isPresentingExplanation = false @@ -12,7 +13,7 @@ struct AltScreenNoticeButton: View { Label(buttonAccessibilityLabel, systemImage: "exclamationmark.triangle.fill") } .labelStyle(.iconOnly) - .foregroundStyle(.orange) + .foregroundStyle(controlForegroundColor) .accessibilityLabel(buttonAccessibilityLabel) .accessibilityIdentifier("MobileTerminalAltScreenNoticeButton") .popover(isPresented: $isPresentingExplanation) { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTheme+SwiftUI.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTheme+SwiftUI.swift index b021837f5289..b97eb036c962 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTheme+SwiftUI.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalTheme+SwiftUI.swift @@ -2,6 +2,25 @@ import CMUXMobileCore import Foundation import SwiftUI +/// One immutable color pair for terminal-owned chrome. The opaque backing and +/// its readable foreground are resolved from the same terminal theme snapshot. +@MainActor +struct MobileTerminalChromeStyle { + let background: Color + let foreground: Color + let colorScheme: ColorScheme + + init(theme: TerminalTheme) { + background = theme.terminalBackgroundColor + foreground = theme.terminalChromeForegroundColor + colorScheme = theme.terminalColorScheme + } + + var workspaceBackButtonBadgeContrast: WorkspaceBackButtonBadgeContrast { + colorScheme == .light ? .lightBackground : .darkBackground + } +} + @MainActor extension TerminalTheme { var terminalBackgroundColor: Color { background.terminalColor } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/View+MobileNavigationChrome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/View+MobileNavigationChrome.swift index b4db91896ae2..804d292c533c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/View+MobileNavigationChrome.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/View+MobileNavigationChrome.swift @@ -18,25 +18,42 @@ extension View { @ViewBuilder func mobileTerminalNavigationChrome(theme: TerminalTheme? = nil) -> some View { #if os(iOS) - let colorScheme = theme.map { $0.terminalColorScheme } ?? .dark if let theme { + let style = MobileTerminalChromeStyle(theme: theme) self .navigationBarTitleDisplayMode(.inline) - .toolbarBackground(theme.terminalBackgroundColor, for: .navigationBar) + .toolbarBackground(style.background, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) - .toolbarColorScheme(colorScheme, for: .navigationBar) + .toolbarColorScheme(style.colorScheme, for: .navigationBar) } else { self .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.ultraThinMaterial, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) - .toolbarColorScheme(colorScheme, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) } #else self #endif } + /// Draws a toolbar control on an opaque terminal-owned backing. Both colors + /// come from one theme snapshot, so private adaptive material cannot lag the + /// foreground during a live terminal-theme change. + @ViewBuilder + func mobileTerminalChromeControl(theme: TerminalTheme) -> some View { + #if os(iOS) + let style = MobileTerminalChromeStyle(theme: theme) + self + .foregroundStyle(style.foreground) + .tint(style.foreground) + .background(style.background) + .environment(\.colorScheme, style.colorScheme) + #else + self + #endif + } + /// Keeps the legacy chat top gap on pre-iOS 26 material bars. On iOS 26 the /// UIKit chat controller handles the top underlap for native scroll-edge /// blending, so the host should not add an extra spacer. @@ -53,3 +70,20 @@ extension View { #endif } } + +extension ToolbarContent { + /// Removes iOS 26's shared glass effect while retaining native toolbar item + /// layout, grouping, actions, and accessibility identity. + @ToolbarContentBuilder + func mobileTerminalSharedBackgroundHidden() -> some ToolbarContent { + #if os(iOS) + if #available(iOS 26.0, *) { + self.sharedBackgroundVisibility(.hidden) + } else { + self + } + #else + self + #endif + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift index 4e98c96304fb..df61c5a04f72 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButton.swift @@ -8,6 +8,7 @@ import SwiftUI struct WorkspaceBackButton: View { let unreadCount: Int var badgeContrast: WorkspaceBackButtonBadgeContrast = .lightBackground + var foregroundColor: Color = .primary let action: () -> Void var body: some View { @@ -16,7 +17,7 @@ struct WorkspaceBackButton: View { Image(systemName: "chevron.backward") .font(.system(size: 17, weight: .semibold)) .imageScale(.medium) - .foregroundStyle(.primary) + .foregroundStyle(foregroundColor) .frame(width: 17, height: 22) if unreadCount > 0 { Text(countText) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButtonBadgeContrast.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButtonBadgeContrast.swift index b26d82faab82..3d3c1a5d0d7e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButtonBadgeContrast.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButtonBadgeContrast.swift @@ -1,4 +1,4 @@ -enum WorkspaceBackButtonBadgeContrast { +enum WorkspaceBackButtonBadgeContrast: Equatable { /// Use on dark terminal chrome: white circle, black text. case darkBackground /// Use on light chrome: black circle, white text. diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButtonConfiguration.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButtonConfiguration.swift index d0f61960ed1f..3b770eca9fbf 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButtonConfiguration.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceBackButtonConfiguration.swift @@ -1,7 +1,4 @@ -import SwiftUI - struct WorkspaceBackButtonConfiguration { let unreadCount: Int - let badgeContrast: WorkspaceBackButtonBadgeContrast let action: () -> Void } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesChipLabel.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesChipLabel.swift index be96597a95b8..d648613606e6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesChipLabel.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesChipLabel.swift @@ -9,6 +9,8 @@ struct WorkspaceChangesChipLabel: View { var showsCapsuleBackground = true /// Stacks +N over −M for width-constrained hosts (the toolbar button). var stacksVertically = false + /// Optional monochrome color for contrast-critical toolbar presentation. + var foregroundColor: Color? = nil @Environment(\.colorScheme) private var colorScheme var body: some View { @@ -34,26 +36,26 @@ struct WorkspaceChangesChipLabel: View { let text = chipText if stacksVertically, let secondary = text.secondary { VStack(spacing: 0) { - Text(text.primary) - .foregroundStyle(theme.addedStatus) - Text(secondary) - .foregroundStyle(theme.deletedStatus) + statusText(text.primary, defaultColor: theme.addedStatus) + statusText(secondary, defaultColor: theme.deletedStatus) } } else { HStack(spacing: 3) { if let secondary = text.secondary { - Text(text.primary) - .foregroundStyle(theme.addedStatus) - Text(secondary) - .foregroundStyle(theme.deletedStatus) + statusText(text.primary, defaultColor: theme.addedStatus) + statusText(secondary, defaultColor: theme.deletedStatus) } else { - Text(text.primary) - .foregroundStyle(.secondary) + statusText(text.primary, defaultColor: .secondary) } } } } + private func statusText(_ text: String, defaultColor: Color) -> some View { + Text(text) + .foregroundStyle(foregroundColor ?? defaultColor) + } + private var chipText: WorkspaceChangesChipText { WorkspaceChangesChipTextPolicy().text( filesChanged: chip.filesChanged, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesToolbarButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesToolbarButton.swift index ecd46471755b..a5f4e87dc2c0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesToolbarButton.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesToolbarButton.swift @@ -9,6 +9,7 @@ import SwiftUI struct WorkspaceChangesToolbarButton: View { let chip: MobileWorkspaceChangesChip? let workspaceID: String + let foregroundColor: Color let action: @MainActor () -> Void var body: some View { @@ -18,7 +19,8 @@ struct WorkspaceChangesToolbarButton: View { chip: chip, workspaceID: workspaceID, showsCapsuleBackground: false, - stacksVertically: true + stacksVertically: true, + foregroundColor: foregroundColor ) .frame(minWidth: 30, minHeight: 30) } else { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailDelayedTerminalPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailDelayedTerminalPreviewView.swift index ee8d695e653f..0776768d6075 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailDelayedTerminalPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailDelayedTerminalPreviewView.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI #if os(iOS) && DEBUG +/// DEBUG/UI-test-only workspace fixture. It is not linked into release builds. struct WorkspaceDetailDelayedTerminalPreviewView: View { private static let workspaceID = MobileWorkspacePreview.ID(rawValue: "workspace-delayed-terminal") private static let terminalID = MobileTerminalPreview.ID(rawValue: "terminal-delayed") @@ -26,6 +27,9 @@ struct WorkspaceDetailDelayedTerminalPreviewView: View { @State private var simulatorStreamStore = MobileSimulatorStreamStore() @State private var didStartFixture = false @State private var themeStage = "loading" + @State private var themeStageIndex = -1 + @State private var themeAdvancePending = false + @State private var themeAdvanceTask: Task? var body: some View { WorkspaceShellView( @@ -36,6 +40,7 @@ struct WorkspaceDetailDelayedTerminalPreviewView: View { .environment(browserStore) .environment(browserStreamStore) .environment(simulatorStreamStore) + .preferredColorScheme(Self.themeParitySystemAppearance) .overlay(alignment: .topLeading) { if Self.showsThemeParitySequence { Color.clear @@ -44,6 +49,19 @@ struct WorkspaceDetailDelayedTerminalPreviewView: View { .accessibilityIdentifier("TerminalThemeStage-\(themeStage)") } } + .overlay { + if Self.showsThemeParitySequence { + // XCUITest runs in a separate process and cannot use @testable + // injection, so accessibility provides the controlled entrypoint. + Button(action: advanceThemeParitySequence) { + Color.clear + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("TerminalThemeAdvance") + } + } .task { guard !didStartFixture else { return } didStartFixture = true @@ -72,7 +90,7 @@ struct WorkspaceDetailDelayedTerminalPreviewView: View { store.selectedWorkspaceID = Self.workspaceID store.selectedTerminalID = Self.terminalID if Self.showsThemeParitySequence { - await runThemeParitySequence() + await applyThemeParityStage(at: 0) } if Self.showsChatToggle { store.rememberChatSessions( @@ -91,6 +109,7 @@ struct WorkspaceDetailDelayedTerminalPreviewView: View { ) } } + .onDisappear(perform: cancelThemeAdvance) } private static var usesLongTitle: Bool { @@ -105,27 +124,61 @@ struct WorkspaceDetailDelayedTerminalPreviewView: View { ProcessInfo.processInfo.environment["CMUX_UITEST_THEME_PARITY_PREVIEW"] == "1" } - private func runThemeParitySequence() async { - let themes = [ - (stage: "dark", background: "#101522", foreground: "#e6edf3"), - (stage: "light", background: "#f4f0df", foreground: "#17212b"), - (stage: "custom", background: "#063f46", foreground: "#fff2a8"), - ] - for (index, fixture) in themes.enumerated() { - guard !Task.isCancelled, - let frame = try? themeParityFrame( - background: fixture.background, - foreground: fixture.foreground, - revision: UInt64(index + 1) - ) else { return } - while !store.deliverThemeParityPreviewFrame(frame) { - guard !Task.isCancelled else { return } - await Task.yield() - } - themeStage = fixture.stage - if fixture.stage != themes.last?.stage { - try? await ContinuousClock().sleep(for: .seconds(5)) + private static let themeParityFixtures = [ + (stage: "dark", background: "#101522", foreground: "#e6edf3"), + (stage: "light", background: "#f4f0df", foreground: "#17212b"), + (stage: "custom", background: "#063f46", foreground: "#fff2a8"), + ] + + private func advanceThemeParitySequence() { + let nextIndex = themeStageIndex + 1 + guard !themeAdvancePending, Self.themeParityFixtures.indices.contains(nextIndex) else { return } + themeAdvancePending = true + themeAdvanceTask = Task { @MainActor in + defer { + themeAdvancePending = false + themeAdvanceTask = nil } + try? await ContinuousClock().sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + await applyThemeParityStage(at: nextIndex) + } + } + + private func cancelThemeAdvance() { + themeAdvanceTask?.cancel() + themeAdvanceTask = nil + themeAdvancePending = false + } + + private func applyThemeParityStage(at index: Int) async { + guard Self.themeParityFixtures.indices.contains(index) else { return } + let fixture = Self.themeParityFixtures[index] + guard let frame = try? themeParityFrame( + background: fixture.background, + foreground: fixture.foreground, + revision: UInt64(index + 1) + ) else { + themeStage = "failed-\(fixture.stage)" + return + } + guard await store.waitForThemeParityPreviewOutputSink(surfaceID: Self.terminalID.rawValue), + !Task.isCancelled else { + return + } + guard store.deliverThemeParityPreviewFrame(frame) else { + themeStage = "failed-\(fixture.stage)" + return + } + themeStageIndex = index + themeStage = fixture.stage + } + + private static var themeParitySystemAppearance: ColorScheme? { + switch ProcessInfo.processInfo.environment["CMUX_UITEST_THEME_PARITY_SYSTEM_APPEARANCE"] { + case "light": .light + case "dark": .dark + default: nil } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 26402cea38b0..c007f6cbeb85 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -207,38 +207,49 @@ struct WorkspaceDetailView: View { if backButtonConfiguration != nil { ToolbarItem(id: "workspace-back", placement: .topBarLeading) { workspaceBackToolbarButton + .mobileTerminalChromeControl(theme: store.activeTerminalTheme) } + .mobileTerminalSharedBackgroundHidden() if #available(iOS 26.0, *) { ToolbarSpacer(.fixed, placement: .topBarLeading) } } ToolbarItem(id: "workspace-title", placement: .topBarLeading) { workspaceTitleToolbarMenu + .mobileTerminalChromeControl(theme: store.activeTerminalTheme) } + .mobileTerminalSharedBackgroundHidden() if let selectedTerminalID, store.isAlternateScreen(surfaceID: selectedTerminalID), displaySettings.showAltScreenNotice { ToolbarItem(id: "workspace-altscreen-notice", placement: .topBarTrailing) { - AltScreenNoticeButton { + AltScreenNoticeButton(controlForegroundColor: terminalChromeStyle.foreground) { displaySettings.showAltScreenNotice = false } + .mobileTerminalChromeControl(theme: store.activeTerminalTheme) } + .mobileTerminalSharedBackgroundHidden() } if workspaceChangesAreAvailable { ToolbarItem(id: "workspace-changes", placement: .topBarTrailing) { WorkspaceChangesToolbarButton( chip: workspaceChangesChip, workspaceID: workspace.rpcWorkspaceID.rawValue, + foregroundColor: terminalChromeStyle.foreground, action: openWorkspaceChanges ) // The chrome sits on the terminal theme's background, not the // system scheme; resolve the counts' green/red for that. .environment(\.colorScheme, store.activeTerminalTheme.terminalColorScheme) + .mobileTerminalChromeControl(theme: store.activeTerminalTheme) } + .mobileTerminalSharedBackgroundHidden() } ToolbarItem(id: "workspace-trailing", placement: .topBarTrailing) { toolbarTrailingCluster + .mobileTerminalChromeControl(theme: store.activeTerminalTheme) } + .mobileTerminalSharedBackgroundHidden() } private var workspaceTitleToolbarMenu: some View { @@ -288,7 +299,8 @@ struct WorkspaceDetailView: View { isConnected: isConnected, titleOverride: titleOverride, subtitle: subtitle, - style: .toolbarCompact + style: .toolbarCompact, + toolbarForegroundColor: terminalChromeStyle.foreground ) case .browser(let title): Text(title) @@ -297,7 +309,11 @@ struct WorkspaceDetailView: View { .truncationMode(.tail) .foregroundStyle(value.terminalTheme.terminalChromeForegroundColor) case .standard(let title, let subtitle): - WorkspaceToolbarTitleView(title: title, subtitle: subtitle) + WorkspaceToolbarTitleView( + title: title, + subtitle: subtitle, + foregroundColor: terminalChromeStyle.foreground + ) } } ) @@ -490,6 +506,10 @@ struct WorkspaceDetailView: View { } #if os(iOS) + private var terminalChromeStyle: MobileTerminalChromeStyle { + MobileTerminalChromeStyle(theme: store.activeTerminalTheme) + } + private func resignTerminalInputIfBlocked(_ isBlocked: Bool) { // resignActiveInput() acts on the process-wide active surface, and // hidden details retained by other tab stacks observe their own @@ -589,13 +609,15 @@ struct WorkspaceDetailView: View { } #if os(iOS) - /// Leading back-button island; iOS 26 supplies toolbar glass. + /// Leading back-button control hosted by the native toolbar. @ViewBuilder private var workspaceBackToolbarButton: some View { if let backButtonConfiguration { + let chromeStyle = terminalChromeStyle WorkspaceBackButton( unreadCount: backButtonConfiguration.unreadCount, - badgeContrast: backButtonConfiguration.badgeContrast, + badgeContrast: chromeStyle.workspaceBackButtonBadgeContrast, + foregroundColor: chromeStyle.foreground, action: backButtonConfiguration.action ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 774d53f3d63b..317960f0fccf 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -507,7 +507,6 @@ struct WorkspaceShellView: View { canCreateWorkspaceForSelection: canCreateWorkspaceForSelection, backButtonConfiguration: WorkspaceBackButtonConfiguration( unreadCount: unreadWorkspaceCount(excluding: workspaceID), - badgeContrast: .darkBackground, action: popCompactStack ) ) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceToolbarTitleView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceToolbarTitleView.swift index e9d18854564e..296fbfe635f7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceToolbarTitleView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceToolbarTitleView.swift @@ -4,15 +4,20 @@ import SwiftUI struct WorkspaceToolbarTitleView: View { let title: String let subtitle: String? + let foregroundColor: Color var body: some View { HStack(spacing: 6) { Circle() - .fill(Color.secondary) + .fill(foregroundColor) .frame(width: 10, height: 10) .accessibilityHidden(true) - MobileCompactToolbarTitleStack(title: title, subtitle: subtitleLine) + MobileCompactToolbarTitleStack( + title: title, + subtitle: subtitleLine, + foregroundColor: foregroundColor + ) } .padding(.horizontal, MobileCompactToolbarTitleStack.horizontalContentPadding) .accessibilityElement(children: .combine) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPaletteTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPaletteTests.swift index 7c0221dad0c1..649b8957568b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPaletteTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPaletteTests.swift @@ -10,8 +10,10 @@ import Testing #expect(theme.terminalChromeForegroundColor == Color.black) #expect(theme.terminalColorScheme == .light) + #expect(MobileTerminalChromeStyle(theme: theme).workspaceBackButtonBadgeContrast == .lightBackground) theme.background = "#333333" #expect(theme.terminalChromeForegroundColor == Color.white) #expect(theme.terminalColorScheme == .dark) + #expect(MobileTerminalChromeStyle(theme: theme).workspaceBackButtonBadgeContrast == .darkBackground) } diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileCompactToolbarTitleStack.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileCompactToolbarTitleStack.swift index 294b9eac7c4d..daea6cc4cea5 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileCompactToolbarTitleStack.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileCompactToolbarTitleStack.swift @@ -15,6 +15,7 @@ public struct MobileCompactToolbarTitleStack: View { private let subtitle: String? private let titleFont: Font private let subtitleFont: Font + private let foregroundColor: Color? /// Creates a compact two-line title stack. /// @@ -27,22 +28,25 @@ public struct MobileCompactToolbarTitleStack: View { title: String, subtitle: String?, titleFont: Font = .system(size: 14, weight: .semibold), - subtitleFont: Font = .system(size: 11, weight: .regular) + subtitleFont: Font = .system(size: 11, weight: .regular), + foregroundColor: Color? = nil ) { self.title = title self.subtitle = subtitle self.titleFont = titleFont self.subtitleFont = subtitleFont + self.foregroundColor = foregroundColor } /// The rendered compact title stack. public var body: some View { VStack(alignment: .leading, spacing: Self.rowSpacing) { line(title, font: titleFont, height: Self.titleRowHeight) + .foregroundStyle(foregroundColor ?? .primary) if let subtitleLine { line(subtitleLine, font: subtitleFont, height: Self.subtitleRowHeight) - .foregroundStyle(.secondary) + .foregroundStyle(foregroundColor ?? .secondary) } } } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift index 1f9760e9f0d0..7fe8d47be453 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/AccessoryActionButton.swift @@ -13,33 +13,6 @@ final class AccessoryActionButton: UIButton { /// The configurable item this button triggers. let item: ResolvedToolbarItem - /// Whether this modifier is double-tap *sticky-locked* (vs. single-tap armed). - /// - /// A sticky-locked modifier stays applied to every keystroke until the user - /// taps it off, whereas an armed modifier is consumed by the next key. On - /// iOS 26 both states share the same prominent-glass blue fill, so the lock - /// needs its own visual cue: a white capsule border drawn on the button's - /// layer, *over* the glass, mirroring the 2pt white stroke the pre-26 flat - /// style already used for the locked state. The border is drawn at the layer - /// level (not via `UIButton.Configuration.background.strokeColor`) so it - /// composites on top of Liquid Glass regardless of how the glass material - /// renders its own background, and adds zero intrinsic width so it does not - /// fight the bar's min-width sizing. - var isStickyLocked = false { - didSet { - guard oldValue != isStickyLocked else { return } - updateStickyLockBorder() - } - } - - /// Contrasting stroke used to distinguish the sticky modifier state. - var stickyLockBorderColor: UIColor = .white { - didSet { updateStickyLockBorder() } - } - - /// Width of the sticky-lock capsule border, matching the pre-26 flat stroke. - private static let stickyLockBorderWidth: CGFloat = 2 - /// Creates a button bound to a resolved toolbar item. /// - Parameter item: The built-in or custom action the button represents. init(item: ResolvedToolbarItem) { @@ -51,28 +24,4 @@ final class AccessoryActionButton: UIButton { required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - - override func layoutSubviews() { - super.layoutSubviews() - // Keep the lock border a true capsule that hugs the glass pill as the - // button's bounds settle (height is fixed, but the corner radius is - // derived here so the border tracks any future sizing change). - updateStickyLockBorder() - } - - /// Sync the layer-level white capsule border to ``isStickyLocked``. - /// - /// Always clears the border when not locked, so a button that transitions - /// locked → armed → resting never keeps a stale border. - private func updateStickyLockBorder() { - if isStickyLocked { - layer.cornerRadius = bounds.height / 2 - layer.cornerCurve = .continuous - layer.borderColor = stickyLockBorderColor.cgColor - layer.borderWidth = Self.stickyLockBorderWidth - } else { - layer.borderWidth = 0 - layer.borderColor = nil - } - } } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index 50678f5095b7..69efb76614c8 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -1006,11 +1006,10 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } /// Build (or rebuild) a button's configuration for `item` and its current - /// armed/sticky state. On iOS 26 the bar uses real Liquid Glass - /// (`.glass()` resting, `.prominentGlass()` armed/sticky); earlier OSes keep - /// the flat gray/blue fill the bar shipped with. Built-in modifier titles - /// follow `isMacRemote`; custom actions render their saved title/icon and - /// never arm. + /// armed/sticky state. Every OS uses a direct, terminal-owned background so + /// the backing and foreground repaint together when the terminal theme + /// changes. Built-in modifier titles follow `isMacRemote`; custom actions + /// render their saved title/icon and never arm. private func applyAccessoryButtonStyle( _ button: UIButton, item: ResolvedToolbarItem, @@ -1051,33 +1050,11 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } config.contentInsets = Self.accessoryButtonContentInsets button.configuration = config - if let actionButton = button as? AccessoryActionButton { - actionButton.stickyLockBorderColor = UIColor.systemBlue.terminalReadableForeground - // On iOS 26 the armed and sticky states share the same prominent-glass blue fill, so the double-tap *lock* is - // distinguished by a white capsule border drawn over the glass (see - // ``AccessoryActionButton/isStickyLocked``). On earlier OSes the - // flat style already renders the locked white stroke through the - // background configuration, so the layer border stays off to avoid - // a doubled stroke. - if #available(iOS 26.0, *) { - actionButton.isStickyLocked = sticky - } else { - actionButton.isStickyLocked = false - } - } } private func accessoryButtonConfiguration(armed: Bool, sticky: Bool) -> UIButton.Configuration { let activeBackground = UIColor.systemBlue let activeForeground = activeBackground.terminalReadableForeground - if #available(iOS 26.0, *) { - var config: UIButton.Configuration = (armed || sticky) ? .prominentGlass() : .glass() - config.baseForegroundColor = armed || sticky ? activeForeground : themeChromeColor - if armed || sticky { - config.baseBackgroundColor = activeBackground - } - return config - } var config = UIButton.Configuration.plain() var background = UIBackgroundConfiguration.clear() if sticky { diff --git a/ios/cmuxUITests/TerminalThemeParityUITests.swift b/ios/cmuxUITests/TerminalThemeParityUITests.swift index 05728acf52a3..5150c67aedc3 100644 --- a/ios/cmuxUITests/TerminalThemeParityUITests.swift +++ b/ios/cmuxUITests/TerminalThemeParityUITests.swift @@ -2,26 +2,126 @@ import XCTest import UIKit final class TerminalThemeParityUITests: XCTestCase { + private enum SystemAppearance: String, CaseIterable { + case light + case dark + } + + private struct ThemeStage { + let name: String + let background: (red: Int, green: Int, blue: Int) + } + + private struct ToolbarControl { + let identifier: String + let frame: CGRect + let minimumContrast: Double + let isEnabled: Bool + } + + private static let themeStages = [ + ThemeStage(name: "dark", background: (16, 21, 34)), + ThemeStage(name: "light", background: (244, 240, 223)), + ThemeStage(name: "custom", background: (6, 63, 70)), + ] + + private static let requiredTopControls: [( + identifier: String, + minimumContrast: Double, + requiresHitTarget: Bool + )] = [ + ("MobileWorkspaceBackButton", 3, true), + ("MobileWorkspaceTitleMenu", 4.5, false), + ("MobileTerminalDropdown", 3, true), + ] + + private static let requiredAccessoryControls: [( + identifier: String, + minimumContrast: Double, + requiresHitTarget: Bool + )] = [ + ("terminal.inputAccessory.hideKeyboard", 3, true), + ("terminal.inputAccessory.composer", 3, true), + ("terminal.inputAccessory.control", 4.5, true), + ("terminal.inputAccessory.alt", 4.5, true), + ("terminal.inputAccessory.command", 4.5, true), + ("terminal.inputAccessory.shift", 4.5, true), + ] + + private static let iconAccessoryIdentifiers: Set = [ + "terminal.inputAccessory.hideKeyboard", + "terminal.inputAccessory.composer", + "terminal.inputAccessory.files", + "terminal.inputAccessory.hideChrome", + "terminal.inputAccessory.customize", + "terminal.inputAccessory.paste", + "terminal.inputAccessory.zoomIn", + "terminal.inputAccessory.zoomOut", + ] + + private static let maximumTransitionBurstSamples = 12 + override func setUpWithError() throws { continueAfterFailure = false } @MainActor func testChromeRepaintsForLiveThemes() throws { + for appearance in SystemAppearance.allCases { + try verifyThemeSequence(systemAppearance: appearance) + } + } + + @MainActor + private func verifyThemeSequence(systemAppearance: SystemAppearance) throws { let app = XCUIApplication() app.launchEnvironment["CMUX_UITEST_MOCK_DATA"] = "0" app.launchEnvironment["CMUX_UITEST_WORKSPACE_DETAIL_DELAYED_TERMINAL"] = "1" app.launchEnvironment["CMUX_UITEST_THEME_PARITY_PREVIEW"] = "1" + app.launchEnvironment["CMUX_UITEST_THEME_PARITY_SYSTEM_APPEARANCE"] = systemAppearance.rawValue app.launchEnvironment["CMUX_MOBILE_SOAK_OPEN_SELECTED_WORKSPACE"] = "1" app.launch() defer { app.terminate() } - try waitForStage("dark", in: app) - try capture(app, name: "dark-theme", expectedBackground: (16, 21, 34)) - try waitForStage("light", in: app) - try capture(app, name: "light-theme-live-reload", expectedBackground: (244, 240, 223)) - try waitForStage("custom", in: app) - try capture(app, name: "custom-theme-live-reload", expectedBackground: (6, 63, 70)) + var baselineFrames: [String: CGRect] = [:] + var previousStageCapture: (pixels: ScreenshotPixels, controls: [String: ToolbarControl])? + var customThemeCapture: (pixels: ScreenshotPixels, controls: [String: ToolbarControl])? + for (index, stage) in Self.themeStages.enumerated() { + if index > 0, let previousStageCapture { + let previousStage = Self.themeStages[index - 1] + let advanceButton = app.buttons["TerminalThemeAdvance"] + XCTAssertTrue(advanceButton.waitForExistence(timeout: 3), "Missing controlled theme advance entrypoint.") + advanceButton.tap() + try assertThemeTransitionBurstHasReadableControls( + in: app, + from: previousStage, + toward: stage, + previousStageCapture: previousStageCapture, + systemAppearance: systemAppearance + ) + } + try waitForStage(stage.name, in: app) + let result = try capture( + app, + name: "\(systemAppearance.rawValue)-system-\(stage.name)-theme", + expectedBackground: stage.background, + baselineFrames: baselineFrames + ) + if baselineFrames.isEmpty { + baselineFrames = result.controls.mapValues(\.frame) + } + previousStageCapture = result + if stage.name == "custom" { + customThemeCapture = result + } + } + + let settledCustomThemeCapture = try XCTUnwrap(customThemeCapture) + try assertModifierStatesRemainDistinct( + in: app, + resting: settledCustomThemeCapture, + systemAppearance: systemAppearance + ) } @MainActor @@ -32,14 +132,139 @@ final class TerminalThemeParityUITests: XCTestCase { ) } + @MainActor + private func assertThemeTransitionBurstHasReadableControls( + in app: XCUIApplication, + from previousStage: ThemeStage, + toward nextStage: ThemeStage, + previousStageCapture: (pixels: ScreenshotPixels, controls: [String: ToolbarControl]), + systemAppearance: SystemAppearance + ) throws { + let previousBackground = previousStageCapture.pixels.color(xUnit: 0.5, yUnit: 0.5) + let clock = ContinuousClock() + let transitionDeadline = clock.now.advanced(by: .seconds(15)) + var samples: [XCUIScreenshot] = [] + var sampledBackground = previousBackground + + while clock.now < transitionDeadline { + let screenshot = app.screenshot() + let pixels = try ScreenshotPixels(image: screenshot.image) + let background = pixels.color(xUnit: 0.5, yUnit: 0.5) + sampledBackground = background + if backgroundHasChanged( + background, + from: previousBackground, + toward: nextStage.background + ) { + samples.append(screenshot) + break + } + } + + _ = try XCTUnwrap( + samples.first, + "Terminal background did not transition from \(previousStage.name) toward \(nextStage.name); " + + "last center pixel was \(sampledBackground)." + ) + + let burstDeadline = clock.now.advanced(by: .seconds(1)) + while samples.count < Self.maximumTransitionBurstSamples, + clock.now < burstDeadline { + samples.append(app.screenshot()) + } + XCTAssertFalse( + samples.isEmpty, + "\(previousStage.name)->\(nextStage.name) transition burst captured no post-change samples." + ) + + let previousExpectsDarkContent = statusBarUsesDarkGlyphs(on: previousStage.background) + var failedSamples: [(index: Int, controls: [String])] = [] + for (index, screenshot) in samples.enumerated() { + let pixels = try ScreenshotPixels(image: screenshot.image) + var failures: [String] = [] + for control in previousStageCapture.controls.values + .filter(\.isEnabled) + .sorted(by: { $0.identifier < $1.identifier }) { + let result = pixels.referencedContentContrast( + in: control.frame, + screenFrame: app.frame, + reference: previousStageCapture.pixels, + referenceExpectsDarkContent: previousExpectsDarkContent, + minimumContrast: control.minimumContrast + ) + guard result.conservativeContrast < control.minimumContrast else { continue } + failures.append( + "\(control.identifier)=\(String(format: "%.2f", result.conservativeContrast)):1 " + + "(required \(String(format: "%.1f", control.minimumContrast)):1, " + + "mask pixels \(result.sampleCount))" + ) + } + if !failures.isEmpty { + failedSamples.append((index, failures)) + } + } + + let attachmentIndices = Set([0] + failedSamples.map(\.index)) + for index in attachmentIndices.sorted() { + let isFailure = failedSamples.contains { $0.index == index } + let suffix = index == 0 + ? (isFailure ? "first-failed" : "first") + : "failed" + let attachment = XCTAttachment(screenshot: samples[index]) + attachment.name = "\(systemAppearance.rawValue)-system-\(previousStage.name)-to-\(nextStage.name)" + + "-\(suffix)-sample-\(index + 1)-of-\(samples.count)" + attachment.lifetime = .keepAlways + add(attachment) + } + + XCTAssertTrue( + failedSamples.isEmpty, + "\(previousStage.name)->\(nextStage.name) transition burst has unreadable active controls " + + "in \(failedSamples.count) of \(samples.count) samples: " + + failedSamples.map { "sample \($0.index + 1): \($0.controls.joined(separator: ", "))" } + .joined(separator: "; ") + ) + } + + private func backgroundHasChanged( + _ actual: (red: Int, green: Int, blue: Int), + from previous: (red: Int, green: Int, blue: Int), + toward target: (red: Int, green: Int, blue: Int) + ) -> Bool { + let distanceFromPrevious = colorDistance(actual, previous) + return distanceFromPrevious >= 24 + && colorDistance(actual, target) < colorDistance(previous, target) + } + + private func colorDistance( + _ first: (red: Int, green: Int, blue: Int), + _ second: (red: Int, green: Int, blue: Int) + ) -> Int { + abs(first.red - second.red) + + abs(first.green - second.green) + + abs(first.blue - second.blue) + } + @MainActor private func capture( _ app: XCUIApplication, name: String, - expectedBackground: (red: Int, green: Int, blue: Int) - ) throws { + expectedBackground: (red: Int, green: Int, blue: Int), + baselineFrames: [String: CGRect] + ) throws -> (pixels: ScreenshotPixels, controls: [String: ToolbarControl]) { + let controls = try visibleToolbarControls(in: app) let screenshot = app.screenshot() let pixels = try ScreenshotPixels(image: screenshot.image) + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + if let directory = ProcessInfo.processInfo.environment["CMUX_THEME_EVIDENCE_DIR"] { + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + try screenshot.pngRepresentation.write( + to: URL(fileURLWithPath: directory).appendingPathComponent("\(name).png") + ) + } for point in [(0.01, 0.05), (0.5, 0.5), (0.01, 0.9)] { let actual = pixels.color(xUnit: point.0, yUnit: point.1) XCTAssertEqual(actual.red, expectedBackground.red, accuracy: 8, "red at \(point)") @@ -50,13 +275,202 @@ final class TerminalThemeParityUITests: XCTestCase { pixels, expectsDarkGlyphs: statusBarUsesDarkGlyphs(on: expectedBackground) ) - let attachment = XCTAttachment(screenshot: screenshot) - attachment.name = name - attachment.lifetime = .keepAlways - add(attachment) - guard let directory = ProcessInfo.processInfo.environment["CMUX_THEME_EVIDENCE_DIR"] else { return } - try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) - try screenshot.pngRepresentation.write(to: URL(fileURLWithPath: directory).appendingPathComponent("\(name).png")) + let expectsDarkContent = statusBarUsesDarkGlyphs(on: expectedBackground) + for control in controls.values { + let result = pixels.contentContrast( + in: control.frame, + screenFrame: app.frame, + expectsDarkContent: expectsDarkContent, + minimumContrast: control.minimumContrast + ) + if control.isEnabled { + XCTAssertGreaterThanOrEqual( + result.qualifyingPixelFraction, + 0.004, + "\(control.identifier) content contrast \(result.maximumContrast):1 on local background " + + "\(result.backgroundLuminance) should reach \(control.minimumContrast):1; " + + "qualifying fraction \(result.qualifyingPixelFraction)." + ) + } else { + XCTAssertGreaterThanOrEqual( + result.maximumContrast, + 3, + "Disabled \(control.identifier) should remain recognizable." + ) + } + if let baselineFrame = baselineFrames[control.identifier] { + assertFrame(control.frame, equals: baselineFrame, identifier: control.identifier) + } + } + if !baselineFrames.isEmpty { + XCTAssertEqual(Set(controls.keys), Set(baselineFrames.keys), "Theme change added or removed toolbar controls.") + } + return (pixels, controls) + } + + @MainActor + private func visibleToolbarControls(in app: XCUIApplication) throws -> [String: ToolbarControl] { + var controls: [String: ToolbarControl] = [:] + for expected in Self.requiredTopControls + Self.requiredAccessoryControls { + let element = try toolbarControlElement( + identifier: expected.identifier, + requiresHitTarget: expected.requiresHitTarget, + in: app + ) + controls[expected.identifier] = ToolbarControl( + identifier: expected.identifier, + frame: element.frame, + minimumContrast: expected.minimumContrast, + isEnabled: element.isEnabled + ) + } + + let accessoryQuery = app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", "terminal.inputAccessory.") + ) + for element in accessoryQuery.allElementsBoundByIndex where element.exists && element.isHittable { + let identifier = element.identifier + guard !identifier.isEmpty, + controls[identifier] == nil, + app.frame.contains(element.frame) else { continue } + controls[identifier] = ToolbarControl( + identifier: identifier, + frame: element.frame, + minimumContrast: Self.iconAccessoryIdentifiers.contains(identifier) ? 3 : 4.5, + isEnabled: element.isEnabled + ) + } + return controls + } + + @MainActor + private func toolbarControlElement( + identifier: String, + requiresHitTarget: Bool, + in app: XCUIApplication + ) throws -> XCUIElement { + let query = requiresHitTarget + ? app.buttons.matching(identifier: identifier) + : app.descendants(matching: .any).matching(identifier: identifier) + XCTAssertTrue(query.firstMatch.waitForExistence(timeout: 3), "Missing \(identifier).") + let candidates = query.allElementsBoundByIndex.filter(\.exists) + if requiresHitTarget { + return try XCTUnwrap( + candidates.first(where: { $0.isHittable }), + "\(identifier) lost its hit target." + ) + } + return try XCTUnwrap(candidates.first, "Missing \(identifier).") + } + + @MainActor + private func assertModifierStatesRemainDistinct( + in app: XCUIApplication, + resting: (pixels: ScreenshotPixels, controls: [String: ToolbarControl]), + systemAppearance: SystemAppearance + ) throws { + let identifier = "terminal.inputAccessory.control" + let control = try toolbarControlElement(identifier: identifier, requiresHitTarget: true, in: app) + let restingControl = try XCTUnwrap(resting.controls[identifier]) + func renderedDifference(_ pixels: ScreenshotPixels, from reference: ScreenshotPixels) -> Double { + pixels.differenceFraction( + from: reference, + in: restingControl.frame, + screenFrame: app.frame + ) + } + + // The first tap arms the one-shot modifier. + control.tap() + let (armedScreenshot, armedPixels) = try waitForScreenshot( + in: app, + timeout: .seconds(3), + failureMessage: "Armed modifier should have a distinct rendered state." + ) { pixels in + renderedDifference(pixels, from: resting.pixels) > 0.08 + } + XCTAssertTrue(control.isHittable, "Armed modifier lost its hit target.") + assertFrame(control.frame, equals: restingControl.frame, identifier: identifier) + XCTAssertGreaterThan( + renderedDifference(armedPixels, from: resting.pixels), + 0.08, + "Armed modifier should have a distinct rendered state." + ) + + // Escape follows the real non-modifier action path, which consumes the + // one-shot Control modifier and clears its pending double-tap window. + let escape = try toolbarControlElement( + identifier: "terminal.inputAccessory.escape", + requiresHitTarget: true, + in: app + ) + XCTAssertTrue(escape.isHittable, "Escape action lost its hit target.") + escape.tap() + _ = try waitForScreenshot( + in: app, + timeout: .seconds(3), + failureMessage: "Modifier did not return to its resting presentation after disarming." + ) { pixels in + renderedDifference(pixels, from: resting.pixels) < 0.01 + } + + // From that fully consumed state, the double tap arms the sticky lock. + control.doubleTap() + let (stickyScreenshot, stickyPixels) = try waitForScreenshot( + in: app, + timeout: .seconds(3), + failureMessage: "Sticky modifier should differ from resting and one-shot armed presentations." + ) { pixels in + renderedDifference(pixels, from: resting.pixels) > 0.08 + && renderedDifference(pixels, from: armedPixels) > 0.01 + } + XCTAssertTrue(control.isHittable, "Sticky modifier lost its hit target.") + assertFrame(control.frame, equals: restingControl.frame, identifier: identifier) + XCTAssertGreaterThan( + renderedDifference(stickyPixels, from: resting.pixels), + 0.08, + "Sticky modifier should differ from the resting presentation." + ) + XCTAssertGreaterThan( + renderedDifference(stickyPixels, from: armedPixels), + 0.01, + "Sticky modifier should remain distinguishable from one-shot armed." + ) + + for (state, screenshot) in [("armed", armedScreenshot), ("sticky", stickyScreenshot)] { + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = "\(systemAppearance.rawValue)-system-custom-theme-\(state)-modifier" + attachment.lifetime = .keepAlways + add(attachment) + } + } + + @MainActor + private func waitForScreenshot( + in app: XCUIApplication, + timeout: Duration, + failureMessage: String, + matching predicate: (ScreenshotPixels) -> Bool + ) throws -> (screenshot: XCUIScreenshot, pixels: ScreenshotPixels) { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + var match: (screenshot: XCUIScreenshot, pixels: ScreenshotPixels)? + repeat { + let screenshot = app.screenshot() + let pixels = try ScreenshotPixels(image: screenshot.image) + if predicate(pixels) { + match = (screenshot, pixels) + break + } + } while clock.now < deadline + return try XCTUnwrap(match, failureMessage) + } + + private func assertFrame(_ actual: CGRect, equals expected: CGRect, identifier: String) { + XCTAssertEqual(actual.minX, expected.minX, accuracy: 0.5, "\(identifier) shifted horizontally.") + XCTAssertEqual(actual.minY, expected.minY, accuracy: 0.5, "\(identifier) shifted vertically.") + XCTAssertEqual(actual.width, expected.width, accuracy: 0.5, "\(identifier) changed width.") + XCTAssertEqual(actual.height, expected.height, accuracy: 0.5, "\(identifier) changed height.") } private func assertStatusBarContrast( @@ -140,4 +554,145 @@ private struct ScreenshotPixels { } return (minimum, maximum) } + + func contentContrast( + in frame: CGRect, + screenFrame: CGRect, + expectsDarkContent: Bool, + minimumContrast: Double + ) -> (backgroundLuminance: Double, maximumContrast: Double, qualifyingPixelFraction: Double) { + let bounds = pixelBounds(for: frame, screenFrame: screenFrame) + let luminances = pixelLuminances(in: bounds) + let background = dominantLuminance(in: luminances) + let contentBounds = bounds.insetBy( + dx: max(1, CGFloat(bounds.width) * 0.18), + dy: max(1, CGFloat(bounds.height) * 0.18) + ).integral + let contentLuminances = pixelLuminances(in: contentBounds) + var qualifyingCount = 0 + var maximumContrast = 1.0 + for luminance in contentLuminances { + let contrast = contrastRatio(luminance, background) + let expectedDirection = expectsDarkContent ? luminance < background : luminance > background + if expectedDirection { + maximumContrast = max(maximumContrast, contrast) + if contrast >= minimumContrast { + qualifyingCount += 1 + } + } + } + return ( + background, + maximumContrast, + contentLuminances.isEmpty ? 0 : Double(qualifyingCount) / Double(contentLuminances.count) + ) + } + + func referencedContentContrast( + in frame: CGRect, + screenFrame: CGRect, + reference: ScreenshotPixels, + referenceExpectsDarkContent: Bool, + minimumContrast: Double + ) -> (conservativeContrast: Double, sampleCount: Int) { + guard width == reference.width, height == reference.height else { return (1, 0) } + let bounds = pixelBounds(for: frame, screenFrame: screenFrame) + let background = dominantLuminance(in: pixelLuminances(in: bounds)) + let referenceBackground = reference.dominantLuminance( + in: reference.pixelLuminances(in: bounds) + ) + let contentBounds = bounds.insetBy( + dx: max(1, CGFloat(bounds.width) * 0.18), + dy: max(1, CGFloat(bounds.height) * 0.18) + ).integral + let luminances = pixelLuminances(in: contentBounds) + let referenceLuminances = reference.pixelLuminances(in: contentBounds) + var contrasts: [Double] = [] + for (luminance, referenceLuminance) in zip(luminances, referenceLuminances) { + let referenceDirectionMatches = referenceExpectsDarkContent + ? referenceLuminance < referenceBackground + : referenceLuminance > referenceBackground + guard referenceDirectionMatches, + contrastRatio(referenceLuminance, referenceBackground) >= minimumContrast else { continue } + contrasts.append(contrastRatio(luminance, background)) + } + contrasts.sort() + guard !contrasts.isEmpty else { return (1, 0) } + let percentileIndex = min(contrasts.count - 1, Int(Double(contrasts.count) * 0.1)) + return (contrasts[percentileIndex], contrasts.count) + } + + func differenceFraction( + from other: ScreenshotPixels, + in frame: CGRect, + screenFrame: CGRect + ) -> Double { + guard width == other.width, height == other.height else { return 1 } + let bounds = pixelBounds(for: frame, screenFrame: screenFrame) + var changed = 0 + var total = 0 + for y in Int(bounds.minY) ..< Int(bounds.maxY) { + for x in Int(bounds.minX) ..< Int(bounds.maxX) { + let offset = (y * width + x) * 4 + let delta = abs(Int(bytes[offset]) - Int(other.bytes[offset])) + + abs(Int(bytes[offset + 1]) - Int(other.bytes[offset + 1])) + + abs(Int(bytes[offset + 2]) - Int(other.bytes[offset + 2])) + if delta >= 24 { changed += 1 } + total += 1 + } + } + return total == 0 ? 0 : Double(changed) / Double(total) + } + + private func pixelBounds(for frame: CGRect, screenFrame: CGRect) -> CGRect { + let scaleX = CGFloat(width) / screenFrame.width + let scaleY = CGFloat(height) / screenFrame.height + let minX = max(0, floor((frame.minX - screenFrame.minX) * scaleX)) + let minY = max(0, floor((frame.minY - screenFrame.minY) * scaleY)) + let maxX = min(CGFloat(width), ceil((frame.maxX - screenFrame.minX) * scaleX)) + let maxY = min(CGFloat(height), ceil((frame.maxY - screenFrame.minY) * scaleY)) + return CGRect(x: minX, y: minY, width: max(0, maxX - minX), height: max(0, maxY - minY)) + } + + private func pixelLuminances(in bounds: CGRect) -> [Double] { + guard bounds.width > 0, bounds.height > 0 else { return [] } + var result: [Double] = [] + result.reserveCapacity(Int(bounds.width * bounds.height)) + for y in Int(bounds.minY) ..< Int(bounds.maxY) { + for x in Int(bounds.minX) ..< Int(bounds.maxX) { + let offset = (y * width + x) * 4 + result.append(relativeLuminance( + red: bytes[offset], + green: bytes[offset + 1], + blue: bytes[offset + 2] + )) + } + } + return result + } + + private func dominantLuminance(in luminances: [Double]) -> Double { + guard !luminances.isEmpty else { return 0 } + var bins = [Int](repeating: 0, count: 101) + for luminance in luminances { + bins[min(100, max(0, Int((luminance * 100).rounded())))] += 1 + } + let dominantIndex = bins.indices.max { bins[$0] < bins[$1] } ?? 0 + let candidates = luminances.filter { + min(100, max(0, Int(($0 * 100).rounded()))) == dominantIndex + }.sorted() + return candidates[candidates.count / 2] + } + + private func contrastRatio(_ first: Double, _ second: Double) -> Double { + (max(first, second) + 0.05) / (min(first, second) + 0.05) + } + + private func relativeLuminance(red: UInt8, green: UInt8, blue: UInt8) -> Double { + let channels = [red, green, blue].map { value -> Double in + let channel = Double(value) / 255 + return channel <= 0.04045 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4) + } + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] + } }