Fix transcript prominent button contrast

This commit is contained in:
Kaede Akino committed 2026-09-14 11:21:39 +08:00
1 parent aa3c6498de
commit e2feadc201
5 files changed
+163 -12

No files matched your search

+12 -1
View File
@@ -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
@@ -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)
}
+26 -8
View File
@@ -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?() }
}
+20
View File
@@ -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
+95
View File
@@ -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(