From 0350c577c415db44985de5f336a51616ab27cc67 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 20 Sep 2026 19:27:47 +0700 Subject: [PATCH 1/2] fix(ai-chat): say what happened when a provider goes, a tool call is proposed, or an image will not attach --- CHANGELOG.md | 5 ++ TablePro/Views/AIChat/AIChatMessageView.swift | 8 ++- TablePro/Views/AIChat/AIChatPanelView.swift | 33 +++++++++++-- .../Views/AIChat/ChatComposerTextView.swift | 20 ++++++-- TablePro/Views/AIChat/ChatComposerView.swift | 12 +++-- .../Views/AIChat/ChatImageDropReport.swift | 31 ++++++++++++ .../Views/AIChat/ToolApprovalActionsRow.swift | 8 ++- .../AIChat/ChatImageDropReportTests.swift | 49 +++++++++++++++++++ docs/features/ai-assistant.mdx | 4 +- 9 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 TablePro/Views/AIChat/ChatImageDropReport.swift create mode 100644 TableProTests/Views/AIChat/ChatImageDropReportTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c1b7cf9a..625a10b1ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - 537 driver and import/export strings are now translatable, having only ever shown in English. +- Middle-dot separators dropped from the assistant transcript, slash command list and model picker. - Every plugin bundle compiled under the same concurrency settings as the app that loads it. - Release C optimization and link-time optimization scoped to the app, not to its Swift package dependencies. - Assistant conversations belong to one connection, and outlive the window that opened them. @@ -54,6 +55,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Colored highlight on the AI chat input ignoring Reduce Transparency and Increase Contrast. - AI chat input focus crossfade playing against Reduce Motion. - AI chat input announced with no name by VoiceOver. +- Assistant pane left with a transcript, no composer and no explanation after the active AI provider is removed. +- Identical unlabelled **Run** buttons announced for every tool call in a turn that proposes several. +- Images silently dropped from a multi-file drop on the AI chat input when only some of them failed. +- An unreadable image file pasted into the AI chat input inserting its path as text. - Oracle PL/SQL blocks split at their inner semicolons and sent as fragments, failing with PLS-00103. (#2984) - Oracle procedures, packages and triggers created from the editor stored INVALID while the run reported success. - SQL*Plus `/` lines, `q'[…]'` literals and backslashes in strings misread in Oracle scripts. diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index fc3bd041df..d1c2dc298f 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -40,11 +40,10 @@ struct AIChatMessageView: View, Equatable { VStack(alignment: .leading, spacing: 4) { if message.role == .user { VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { + HStack(spacing: 6) { Spacer(minLength: 0) Text("You") .fontWeight(.medium) - Text("·") Text(message.timestamp, style: .time) } .font(.caption2) @@ -99,7 +98,7 @@ struct AIChatMessageView: View, Equatable { } Spacer() if let usage = message.usage { - Text("\(usage.inputTokens) in · \(usage.outputTokens) out") + Text("\(usage.inputTokens) in, \(usage.outputTokens) out") .font(.caption2) .foregroundStyle(.tertiary) .monospacedDigit() @@ -159,10 +158,9 @@ struct AIChatMessageView: View, Equatable { } private var roleHeader: some View { - HStack(spacing: 4) { + HStack(spacing: 6) { Image(systemName: "sparkles") .font(.caption2) - Text("·") Text(message.timestamp, style: .time) } .font(.caption2) diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index da57d150f1..15570d6696 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -57,6 +57,8 @@ struct AIChatPanelView: View { } inputArea + } else if !viewModel.messages.isEmpty { + noProviderFooter } } .environment(\.chatPrimaryPendingToolUseId, primaryPendingToolUseId) @@ -100,6 +102,31 @@ struct AIChatPanelView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + /// A transcript outlives the provider that produced it, so removing the active provider leaves + /// this pane with messages and nothing to send another. Without this the pane keeps the + /// transcript and drops the composer, the model picker and the send button with no reason + /// given and no route back: the "Go to Settings…" affordance lives on the empty-transcript + /// branch alone. + private var noProviderFooter: some View { + VStack(alignment: .leading, spacing: 6) { + Divider() + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + Text("No AI provider is active, so this conversation is read-only.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + Button(String(localized: "Settings…")) { + WindowOpener.shared.openSettings(tab: .ai) + } + .controlSize(.small) + } + .padding(8) + } + } + private var noProviderState: some View { EmptyStateView( icon: "gear", @@ -480,7 +507,7 @@ struct AIChatPanelView: View { updateContext() viewModel.runSlashCommand(command) } label: { - Text("/\(command.name) · \(command.description)") + Text("/\(command.name) (\(command.description))") } } if !customCommands.isEmpty { @@ -494,7 +521,7 @@ struct AIChatPanelView: View { if command.description.isEmpty { Text("/\(command.name)") } else { - Text("/\(command.name) · \(command.description)") + Text("/\(command.name) (\(command.description))") } } } @@ -553,7 +580,7 @@ struct AIChatPanelView: View { viewModel.selectedModel = model } label: { HStack { - Text(showProviderPrefix ? "\(provider.displayName) · \(model)" : model) + Text(showProviderPrefix ? "\(provider.displayName) (\(model))" : model) if isSelected { Image(systemName: "checkmark") } diff --git a/TablePro/Views/AIChat/ChatComposerTextView.swift b/TablePro/Views/AIChat/ChatComposerTextView.swift index e5f68cb8c1..abbb44c972 100644 --- a/TablePro/Views/AIChat/ChatComposerTextView.swift +++ b/TablePro/Views/AIChat/ChatComposerTextView.swift @@ -25,6 +25,7 @@ struct ChatComposerTextView: NSViewRepresentable { let onTab: () -> Bool let onEscape: () -> Bool let onPasteImageData: (Data, String) -> Void + let onPasteImageFailed: (String) -> Void func makeNSView(context: Context) -> ChatComposerScrollView { let textView = ChatComposerNSTextView.make() @@ -32,6 +33,7 @@ struct ChatComposerTextView: NSViewRepresentable { textView.placeholder = placeholder textView.acceptsImagePaste = acceptsImages textView.onPasteImageData = onPasteImageData + textView.onPasteImageFailed = onPasteImageFailed textView.highlightEnabled = highlightEnabled textView.onToggleHighlight = onToggleHighlight @@ -214,6 +216,7 @@ final class ChatComposerNSTextView: NSTextView { var onSizeChange: (() -> Void)? var acceptsImagePaste: Bool = false var onPasteImageData: ((Data, String) -> Void)? + var onPasteImageFailed: ((String) -> Void)? var highlightEnabled: Bool = true var onToggleHighlight: (() -> Void)? @@ -321,10 +324,19 @@ final class ChatComposerNSTextView: NSTextView { return } if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], - let fileURL = urls.first(where: { (try? $0.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .image) ?? false }), - let data = try? Data(contentsOf: fileURL) { - let uti = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.identifier ?? UTType.image.identifier - onPasteImageData(data, uti) + let fileURL = urls.first(where: { (try? $0.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .image) ?? false }) { + /// Identifying the file and reading it are separate answers. Folding the read into the + /// same `if let` made an unreadable image (an iCloud file still in the cloud, a network + /// volume that went away) fall through to `super.paste`, which pastes its path as text + /// into the prompt. The user asked for the picture and silently got a file URL. + do { + let data = try Data(contentsOf: fileURL) + let uti = (try? fileURL.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.identifier + ?? UTType.image.identifier + onPasteImageData(data, uti) + } catch { + onPasteImageFailed?(error.localizedDescription) + } return } super.paste(sender) diff --git a/TablePro/Views/AIChat/ChatComposerView.swift b/TablePro/Views/AIChat/ChatComposerView.swift index a315f31bd1..9f5dfc77e0 100644 --- a/TablePro/Views/AIChat/ChatComposerView.swift +++ b/TablePro/Views/AIChat/ChatComposerView.swift @@ -80,7 +80,8 @@ struct ChatComposerView: View { onArrow: { delta in moveMention(by: delta) }, onTab: { commitMentionIfVisible() }, onEscape: { dismissMention() }, - onPasteImageData: handlePastedImageData + onPasteImageData: handlePastedImageData, + onPasteImageFailed: onImageAttachmentFailed ) .fixedSize(horizontal: false, vertical: true) .background(composerBackground) @@ -120,18 +121,19 @@ struct ChatComposerView: View { guard acceptsImages, !providers.isEmpty else { return false } Task { @MainActor in var collected: [ChatImageInput] = [] - var lastError: Error? + var failures: [String] = [] for provider in providers { do { collected.append(try await ChatImageConverter.convert(itemProvider: provider)) } catch { - lastError = error + failures.append(error.localizedDescription) } } if !collected.isEmpty { onAttachImages(collected) - } else if let lastError { - onImageAttachmentFailed(lastError.localizedDescription) + } + if let message = ChatImageDropReport.message(attached: collected.count, failures: failures) { + onImageAttachmentFailed(message) } } return true diff --git a/TablePro/Views/AIChat/ChatImageDropReport.swift b/TablePro/Views/AIChat/ChatImageDropReport.swift new file mode 100644 index 0000000000..449df49cd8 --- /dev/null +++ b/TablePro/Views/AIChat/ChatImageDropReport.swift @@ -0,0 +1,31 @@ +// +// ChatImageDropReport.swift +// TablePro +// + +import Foundation + +/// What to tell the user after a drop that carried more than one file. Reporting only when every +/// file failed is what let a mixed drop attach one image and discard the rest in silence, so the +/// prompt went out referring to images the model never received. +internal enum ChatImageDropReport { + static func message(attached: Int, failures: [String]) -> String? { + guard let first = failures.first else { return nil } + + if attached == 0 { + guard failures.count > 1 else { return first } + return String( + format: String(localized: "Could not attach %1$d files. %2$@"), + failures.count, + first + ) + } + + return String( + format: String(localized: "Attached %1$d of %2$d files. %3$@"), + attached, + attached + failures.count, + first + ) + } +} diff --git a/TablePro/Views/AIChat/ToolApprovalActionsRow.swift b/TablePro/Views/AIChat/ToolApprovalActionsRow.swift index 19fe280ac6..bf3e3398e9 100644 --- a/TablePro/Views/AIChat/ToolApprovalActionsRow.swift +++ b/TablePro/Views/AIChat/ToolApprovalActionsRow.swift @@ -39,6 +39,7 @@ struct ToolApprovalActionsRow: View { .buttonStyle(.borderedProminent) .controlSize(.small) .keyboardShortcut(takesDefaultAction ? .defaultAction : nil) + .accessibilityLabel(runLabel) if allowsStandingGrant { Button { @@ -80,7 +81,12 @@ struct ToolApprovalActionsRow: View { } /// Each button names its own call. A turn proposing three writes otherwise hands assistive - /// clients three identical "Run" buttons with nothing to tell them apart. + /// clients three identical "Run" buttons with nothing to tell them apart, and Run is the one + /// that executes the statement. + private var runLabel: String { + String(format: String(localized: "Run %@"), toolName) + } + private var rejectLabel: String { String(format: String(localized: "Reject %@"), toolName) } diff --git a/TableProTests/Views/AIChat/ChatImageDropReportTests.swift b/TableProTests/Views/AIChat/ChatImageDropReportTests.swift new file mode 100644 index 0000000000..a4646c2906 --- /dev/null +++ b/TableProTests/Views/AIChat/ChatImageDropReportTests.swift @@ -0,0 +1,49 @@ +// +// ChatImageDropReportTests.swift +// TableProTests +// +// A drop that attaches some files and discards the rest has to say so. Reporting only when +// everything failed is what let a mixed drop look like a success, so the prompt went out naming +// images the model never received. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Chat image drop report") +struct ChatImageDropReportTests { + @Test("A drop with nothing to report says nothing") + func noFailuresIsSilent() { + #expect(ChatImageDropReport.message(attached: 3, failures: []) == nil) + #expect(ChatImageDropReport.message(attached: 0, failures: []) == nil) + } + + @Test("A single total failure is reported verbatim") + func singleFailureIsTheErrorItself() { + #expect(ChatImageDropReport.message(attached: 0, failures: ["Unsupported image"]) == "Unsupported image") + } + + @Test("A partial failure names how many landed and how many were offered") + func partialFailureNamesBothCounts() throws { + let message = try #require(ChatImageDropReport.message(attached: 1, failures: ["Unsupported image"])) + #expect(message.contains("1")) + #expect(message.contains("2")) + #expect(message.contains("Unsupported image")) + } + + @Test("Several total failures name the count") + func manyFailuresNameTheCount() throws { + let message = try #require( + ChatImageDropReport.message(attached: 0, failures: ["Unsupported image", "Too large"]) + ) + #expect(message.contains("2")) + #expect(message.contains("Unsupported image")) + } + + /// The regression this type exists for: one success beside two failures used to report nothing. + @Test("A mixed drop is never silent") + func mixedDropAlwaysReports() { + #expect(ChatImageDropReport.message(attached: 1, failures: ["a", "b"]) != nil) + } +} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 2a7d378f44..a57083562f 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -26,7 +26,7 @@ Open **Settings > AI** (`Cmd+,`). **Enable AI Features** at the top gates the wh -The **Active Provider** picker decides which one handles requests. Where a model supports reasoning effort, its detail sheet adds a picker for it and replies render their thinking in a collapsible **Reasoning** block. +The **Active Provider** picker decides which one handles requests. Setting it to **None** leaves any open conversation readable but read-only, with a **Settings…** button under it. Where a model supports reasoning effort, its detail sheet adds a picker for it and replies render their thinking in a collapsible **Reasoning** block. With no preference, start with Claude or OpenAI on an API key: both take a key, tools, and images with no further setup. The others each come with something to know. @@ -60,7 +60,7 @@ Type a question and press Return. Code blocks carry **Copy** and **Insert**, and Conversations save themselves and take their title from your first message. The clock icon in the inspector header opens recent ones; the pencil-and-square icon starts a new one. Editing a message you already sent puts its text and attachments back in the composer and drops that turn and everything after it. -Paste or drag images into the composer on any provider that takes them, which is all of them except GitHub Copilot, Cursor, ChatGPT, and Claude Agent. +Paste or drag images into the composer on any provider that takes them, which is all of them except GitHub Copilot, Cursor, ChatGPT, and Claude Agent. A drop that carries files the composer cannot read attaches the rest and says how many it kept. The focused composer carries a colored highlight. Right-click it and turn **Highlight When Focused** off for the standard macOS focus ring instead. The choice is app-wide and survives restarts. Reduce Transparency and Increase Contrast, both under **System Settings > Accessibility > Display**, also replace the highlight with the focus ring. From ab9362b3a3fcae480a8fdb221b894786f4469b19 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 20 Sep 2026 19:34:23 +0700 Subject: [PATCH 2/2] test(hig): catch the animation modifier in the Reduce Motion gate, and gate the four call sites it was missing --- CHANGELOG.md | 3 ++ .../Extensions/View+SymbolEffectCompat.swift | 19 +++++--- TablePro/Views/AIChat/AIChatMessageView.swift | 6 +-- TablePro/Views/AIChat/AIChatPanelView.swift | 2 +- .../Components/SyncStatusIndicator.swift | 2 +- .../Views/Results/ForeignKeyPickerView.swift | 4 +- .../Views/ReduceMotionGateTests.swift | 43 +++++++++++++++---- 7 files changed, 57 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 625a10b1ef..3708341f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Colored highlight on the AI chat input painting over a window that is not key. - Colored highlight on the AI chat input ignoring Reduce Transparency and Increase Contrast. - AI chat input focus crossfade playing against Reduce Motion. +- Sync status and the assistant's scroll-to-bottom button animating against Reduce Motion. +- Pulsing toolbar symbols left dimmed instead of still under Reduce Motion. +- Foreign key label picker animating open and closed against Reduce Motion. - AI chat input announced with no name by VoiceOver. - Assistant pane left with a transcript, no composer and no explanation after the active AI provider is removed. - Identical unlabelled **Run** buttons announced for every tool call in a turn that proposes several. diff --git a/TablePro/Extensions/View+SymbolEffectCompat.swift b/TablePro/Extensions/View+SymbolEffectCompat.swift index ece7b23ac0..7f77476f40 100644 --- a/TablePro/Extensions/View+SymbolEffectCompat.swift +++ b/TablePro/Extensions/View+SymbolEffectCompat.swift @@ -34,14 +34,23 @@ private struct OpacityPulse: ViewModifier { @State private var dimmed = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// Reduce Motion withdraws the pulse rather than freezing it: gating the animation alone would + /// leave the view parked at the dimmed end of a pulse that never runs, which reads as a control + /// that has been disabled. + private var pulses: Bool { + isActive && !reduceMotion + } + func body(content: Content) -> some View { content - .opacity(isActive && dimmed ? 0.35 : 1) - .animation( - isActive ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : .default, + .opacity(pulses && dimmed ? 0.35 : 1) + .motionAnimation( + pulses ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : .default, value: dimmed ) - .onAppear { dimmed = isActive } - .onChange(of: isActive) { active in dimmed = active } + .onAppear { dimmed = pulses } + .onChange(of: pulses) { active in dimmed = active } } } diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index d1c2dc298f..4d6b3403f1 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -245,10 +245,8 @@ struct ChatTypingIndicatorView: View { .fill(Color(nsColor: .tertiaryLabelColor)) .frame(width: 6, height: 6) .offset(y: animating ? -3 : 0) - .animation( - reduceMotion - ? nil - : .easeInOut(duration: 0.4) + .motionAnimation( + .easeInOut(duration: 0.4) .repeatForever(autoreverses: true) .delay(Double(index) * 0.15), value: animating diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 15570d6696..4814910f5e 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -240,7 +240,7 @@ struct AIChatPanelView: View { .buttonStyle(.plain) .padding(.bottom, 8) .transition(.opacity) - .animation(.easeInOut(duration: 0.2), value: isUserScrolledUp) + .motionAnimation(.easeInOut(duration: 0.2), value: isUserScrolledUp) .accessibilityLabel(String(localized: "Scroll to latest message")) } } diff --git a/TablePro/Views/Components/SyncStatusIndicator.swift b/TablePro/Views/Components/SyncStatusIndicator.swift index d5892f84e9..1cad794415 100644 --- a/TablePro/Views/Components/SyncStatusIndicator.swift +++ b/TablePro/Views/Components/SyncStatusIndicator.swift @@ -25,7 +25,7 @@ struct SyncStatusIndicator: View { } .font(.subheadline) .foregroundStyle(foregroundStyle) - .animation(.default, value: syncCoordinator.syncStatus) + .motionAnimation(.default, value: syncCoordinator.syncStatus) } .buttonStyle(.plain) .help(helpText) diff --git a/TablePro/Views/Results/ForeignKeyPickerView.swift b/TablePro/Views/Results/ForeignKeyPickerView.swift index bb138ff1dc..948cf18f3a 100644 --- a/TablePro/Views/Results/ForeignKeyPickerView.swift +++ b/TablePro/Views/Results/ForeignKeyPickerView.swift @@ -74,7 +74,7 @@ struct ForeignKeyPickerView: View { listHeight: Self.listHeight, onToggle: toggleLabelColumn, onClear: { applyLabelChoice(ForeignKeyLabelChoice(columnNames: [])) }, - onDone: { withAnimation { isChoosingLabels = false } } + onDone: { withMotion { isChoosingLabels = false } } ) } @@ -237,7 +237,7 @@ struct ForeignKeyPickerView: View { private var footer: some View { HStack(spacing: 8) { Button { - withAnimation { isChoosingLabels = true } + withMotion { isChoosingLabels = true } } label: { Text(String(format: String(localized: "Label: %@"), labelSummary)) .lineLimit(1) diff --git a/TableProTests/Views/ReduceMotionGateTests.swift b/TableProTests/Views/ReduceMotionGateTests.swift index b214e9bc8a..afcf3e5264 100644 --- a/TableProTests/Views/ReduceMotionGateTests.swift +++ b/TableProTests/Views/ReduceMotionGateTests.swift @@ -49,15 +49,40 @@ struct ReduceMotionGateTests { var offenders: [String] = [] for (index, line) in source.components(separatedBy: .newlines).enumerated() { guard !line.trimmingCharacters(in: .whitespaces).hasPrefix("//") else { continue } - var searchStart = line.startIndex - while let found = line.range(of: "withAnimation", range: searchStart ..< line.endIndex) { - searchStart = found.upperBound - let rest = line[found.upperBound...] - /// `NSTableView.insertRows(at:withAnimation:)` spells its parameter the same way and - /// gates separately, and `withAnimation(nil)` is already the reduced behaviour. - guard !rest.hasPrefix(":"), !rest.hasPrefix("(nil)") else { continue } - offenders.append("\(relativePath):\(index + 1)") - } + offenders += withAnimationOffenders(in: line, path: relativePath, lineNumber: index + 1) + offenders += modifierOffenders(in: line, path: relativePath, lineNumber: index + 1) + } + return offenders + } + + private static func withAnimationOffenders(in line: String, path: String, lineNumber: Int) -> [String] { + var offenders: [String] = [] + var searchStart = line.startIndex + while let found = line.range(of: "withAnimation", range: searchStart ..< line.endIndex) { + searchStart = found.upperBound + let rest = line[found.upperBound...] + /// `NSTableView.insertRows(at:withAnimation:)` spells its parameter the same way and + /// gates separately, and `withAnimation(nil)` is already the reduced behaviour. + guard !rest.hasPrefix(":"), !rest.hasPrefix("(nil)") else { continue } + offenders.append("\(path):\(lineNumber)") + } + return offenders + } + + /// The modifier form escaped this scan entirely, which is how the AI composer's focus crossfade + /// ran at full duration under Reduce Motion: it is `.animation(_:value:)`, never `withAnimation`. + private static func modifierOffenders(in line: String, path: String, lineNumber: Int) -> [String] { + var offenders: [String] = [] + var searchStart = line.startIndex + while let found = line.range(of: ".animation(", range: searchStart ..< line.endIndex) { + searchStart = found.upperBound + let rest = line[found.upperBound...] + /// `.animation(nil, …)` is already the reduced behaviour, `.motionAnimation(` is the + /// gate itself, and `CALayer.animation(forKey:)` is a lookup rather than a change. + guard !rest.hasPrefix("nil"), !rest.hasPrefix("forKey") else { continue } + let before = line[line.startIndex ..< found.lowerBound] + guard !before.hasSuffix("motion"), !before.hasSuffix("layer") else { continue } + offenders.append("\(path):\(lineNumber)") } return offenders }