From e2feadc20145090b473e12f9d2186e74e2300a41 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Mon, 14 Sep 2026 11:21:11 +0800 Subject: [PATCH] Fix transcript prominent button contrast --- TFM/Design/DesignTokens.swift | 13 ++- .../AI/ConversationTranscriptView.swift | 13 ++- TFM/Features/AI/TranscriptNativeRows.swift | 34 +++++-- TFMTests/AppAppearanceTests.swift | 20 ++++ TFMTests/TranscriptListViewTests.swift | 95 +++++++++++++++++++ 5 files changed, 163 insertions(+), 12 deletions(-) diff --git a/TFM/Design/DesignTokens.swift b/TFM/Design/DesignTokens.swift index ecafe440..fe8384e1 100644 --- a/TFM/Design/DesignTokens.swift +++ b/TFM/Design/DesignTokens.swift @@ -337,6 +337,9 @@ enum TermindColor { static var selectedForeground: Color { Color(nsColor: .alternateSelectedControlTextColor) } + static var transcriptProminentForeground: Color { + Color(nsColor: nativeTranscriptProminentForeground) + } static var separator: Color { Color(nsColor: .separatorColor) } static var shadow: Color { Color(nsColor: .shadowColor) } @@ -348,7 +351,15 @@ enum TermindColor { static var danger: Color { Color(nsColor: .systemRed) } static var nativeAccent: NSColor { .controlAccentColor } - static var nativeSelectedForeground: NSColor { .alternateSelectedControlTextColor } + static var nativeTranscriptProminentForeground: NSColor { + NSColor(name: nil) { appearance in + resolvedTranscriptProminentForeground(for: appearance) + } + } + + static func resolvedTranscriptProminentForeground(for appearance: NSAppearance) -> NSColor { + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? .black : .white + } /// CALayer stores a concrete `CGColor`; assigning a named dynamic NSColor /// directly can resolve it against the wrong appearance (or to clear while diff --git a/TFM/Features/AI/ConversationTranscriptView.swift b/TFM/Features/AI/ConversationTranscriptView.swift index 451db818..49dbc622 100644 --- a/TFM/Features/AI/ConversationTranscriptView.swift +++ b/TFM/Features/AI/ConversationTranscriptView.swift @@ -7,6 +7,13 @@ import TermindRemoteInfrastructure import struct TermindDomain.Host +extension View { + fileprivate func transcriptProminentButtonStyle() -> some View { + buttonStyle(.borderedProminent) + .foregroundStyle(TermindColor.transcriptProminentForeground) + } +} + private struct ConversationAttachmentDropModifier: ViewModifier { let conversation: ConversationModel let isEnabled: Bool @@ -198,7 +205,7 @@ struct ConversationView: View { Button("Unarchive") { window.setAssistantArchived(conversation.id, archived: false) } - .buttonStyle(.borderedProminent) + .transcriptProminentButtonStyle() } } @@ -572,7 +579,7 @@ private struct AgentApprovalCard: View { Button("Allow Once") { conversation.respondToApproval(request.id, allow: true) } - .buttonStyle(.borderedProminent) + .transcriptProminentButtonStyle() .accessibilityIdentifier("AssistantApproval.AllowOnce") } .fixedSize(horizontal: false, vertical: true) @@ -772,7 +779,7 @@ private struct AgentUserInputCard: View { Button(isLastQuestion ? "Submit" : "Next") { conversation.advanceUserInputQuestion() } - .buttonStyle(.borderedProminent) + .transcriptProminentButtonStyle() .keyboardShortcut(.defaultAction) .disabled(!conversation.canAdvanceUserInput) } diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index 8524de43..bcbe3c48 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -1057,6 +1057,7 @@ private enum NativeTranscriptFactory { bordered: true, prominent: true ) + implement.identifier = NSUserInterfaceItemIdentifier("Transcript.PlanImplement") implement.isEnabled = !context.configuration.isConversationStreaming implement.handler = { context.actions.implementPlan(card) } actions.addArrangedSubview(discard) @@ -1698,6 +1699,7 @@ private final class NativeMarkdownView: NSView { bordered: true, prominent: true ) + run.identifier = NSUserInterfaceItemIdentifier("Transcript.MarkdownShellRun") run.isEnabled = actions.enabled run.bezelColor = assessment.risk == .destructive ? .systemRed : .controlAccentColor run.handler = { [weak run] in @@ -2485,6 +2487,7 @@ private final class NativeMessageEditor: NSView, NSTextViewDelegate { cancel.contentTintColor = .secondaryLabelColor cancel.handler = { [weak self] in self?.onCancel() } let send = NativeButton(title: String(localized: "Send"), bordered: true, prominent: true) + send.identifier = NSUserInterfaceItemIdentifier("Transcript.UserEditorSend") send.handler = { [weak self] in guard let self else { return } onCommit(textView.string) @@ -2805,7 +2808,10 @@ private final class NativeCard: NSView { @MainActor private class NativeButton: NSButton { var handler: (() -> Void)? + private let usesProminentStyle: Bool + init(title: String = "", bordered: Bool = false, prominent: Bool = false) { + usesProminentStyle = prominent super.init(frame: .zero) self.title = title bezelStyle = bordered ? .rounded : .inline @@ -2813,14 +2819,7 @@ private class NativeButton: NSButton { controlSize = .small if prominent { bezelColor = .controlAccentColor - contentTintColor = TermindColor.nativeSelectedForeground - attributedTitle = NSAttributedString( - string: title, - attributes: [ - .font: font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize), - .foregroundColor: TermindColor.nativeSelectedForeground, - ] - ) + updateProminentForeground() } target = self action = #selector(run) @@ -2836,6 +2835,25 @@ private class NativeButton: NSButton { } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + if usesProminentStyle { updateProminentForeground() } + } + + private func updateProminentForeground() { + let foreground = TermindColor.resolvedTranscriptProminentForeground( + for: effectiveAppearance + ) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize), + .foregroundColor: foreground, + ] + contentTintColor = foreground + attributedTitle = NSAttributedString(string: title, attributes: attributes) + attributedAlternateTitle = NSAttributedString(string: title, attributes: attributes) + } + @objc private func run() { handler?() } } diff --git a/TFMTests/AppAppearanceTests.swift b/TFMTests/AppAppearanceTests.swift index 2b081cf2..a8e50a5e 100644 --- a/TFMTests/AppAppearanceTests.swift +++ b/TFMTests/AppAppearanceTests.swift @@ -11,6 +11,26 @@ import Testing @MainActor struct AppAppearanceTests { + @Test + func transcriptProminentForegroundIsWhiteInAquaAndBlackInDarkAqua() throws { + for (appearanceName, expectedWhite) in [ + (NSAppearance.Name.aqua, true), + (.darkAqua, false), + ] { + let appearance = try #require(NSAppearance(named: appearanceName)) + let color = try #require( + TermindColor.resolvedTranscriptProminentForeground(for: appearance) + .usingColorSpace(.deviceRGB) + ) + let expected: CGFloat = expectedWhite ? 1 : 0 + + #expect(abs(color.redComponent - expected) < 0.001) + #expect(abs(color.greenComponent - expected) < 0.001) + #expect(abs(color.blueComponent - expected) < 0.001) + #expect(abs(color.alphaComponent - 1) < 0.001) + } + } + @Test func missingAndInvalidPreferencesFollowSystem() throws { try withDefaults { defaults in diff --git a/TFMTests/TranscriptListViewTests.swift b/TFMTests/TranscriptListViewTests.swift index d41c258b..715c1eef 100644 --- a/TFMTests/TranscriptListViewTests.swift +++ b/TFMTests/TranscriptListViewTests.swift @@ -648,6 +648,77 @@ struct TranscriptListViewBridgeTests { #expect(run.isBordered) } + @Test + func nativeTranscriptProminentButtonsInvertTheirForegroundWithAppearance() throws { + let coordinator = TranscriptListView.Coordinator() + let listView = coordinator.makeListView() + let presentation = TranscriptPresentationState() + let userID = UUID() + let plan = AgentPlan( + title: "Implementation plan", + body: "Apply the requested change.", + scratchPath: "/tmp/plan.md" + ) + let commandID = UUID() + let markdownID = UUID() + listView.appearance = NSAppearance(named: .aqua) + listView.frame = CGRect(x: 0, y: 0, width: 520, height: 2_000) + presentation.beginEditingMessage(userID, text: "Edited message") + + update( + coordinator, + listView, + entries: [ + user(id: userID, text: "Original message"), + TranscriptEntry( + id: plan.id, + content: .plan(AgentPlanCard(plan: plan)) + ), + TranscriptEntry( + id: commandID, + content: .command( + SuggestedCommand( + id: commandID, + command: "pwd", + explanation: "Show the current directory." + ) + ) + ), + assistant( + id: markdownID, + text: "```bash\nrm -rf /tmp/termind-prominent-button-test\n```" + ), + ], + presentation: presentation, + isCompact: false, + showsTerminalActions: true, + actions: .init(canDeliverCommand: { true }) + ) + listView.layoutSubtreeIfNeeded() + + let buttonLocations = [ + (userID, "Transcript.UserEditorSend"), + (plan.id, "Transcript.PlanImplement"), + (commandID, "Transcript.CommandCardRun"), + (markdownID, "Transcript.MarkdownShellRun"), + ] + let buttons = try buttonLocations.map { rowID, identifier in + let row = try #require(listView.rowView(for: rowID)) + return try #require(descendant(in: row, identifier: identifier) as? NSButton) + } + + for button in buttons { + try expectProminentForeground(button, component: 1) + } + + listView.appearance = NSAppearance(named: .darkAqua) + listView.layoutSubtreeIfNeeded() + + for button in buttons { + try expectProminentForeground(button, component: 0) + } + } + @Test func userImagesAndChipsSitOutsideAndAboveTheTextBubble() throws { let coordinator = TranscriptListView.Coordinator() @@ -1278,6 +1349,30 @@ struct TranscriptListViewBridgeTests { return nil } + private func expectProminentForeground(_ button: NSButton, component: CGFloat) throws { + let titleColor = try #require( + button.attributedTitle.attribute( + .foregroundColor, + at: 0, + effectiveRange: nil + ) as? NSColor + ) + let alternateTitleColor = try #require( + button.attributedAlternateTitle.attribute( + .foregroundColor, + at: 0, + effectiveRange: nil + ) as? NSColor + ) + for color in [try #require(button.contentTintColor), titleColor, alternateTitleColor] { + let rgb = try #require(color.usingColorSpace(.deviceRGB)) + #expect(abs(rgb.redComponent - component) < 0.001) + #expect(abs(rgb.greenComponent - component) < 0.001) + #expect(abs(rgb.blueComponent - component) < 0.001) + #expect(abs(rgb.alphaComponent - 1) < 0.001) + } + } + private func work(id: UUID, state: AgentRunState) -> TranscriptEntry { let start = Date(timeIntervalSince1970: 1_000) return TranscriptEntry(