From 561541e7b46a6aa52ca8570b2cc32db19b0d64e6 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 28 May 2026 18:48:35 +0800
Subject: [PATCH] Consolidate app grid interaction state

---
 TODO.md                    |    3 
 Scripts/window_logic_qa.sh |  133 +++++++++++++++++++++
 Apptag/ContentView.swift   |  168 ++++++++++++++-------------
 3 files changed, 219 insertions(+), 85 deletions(-)

diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 3c68021..f94de57 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -304,6 +304,20 @@
     let arrowOffset: CGFloat
 }
 
+private struct AppGridInteractionState {
+    var appDragModeActive = false
+    var dropWarningToast: String? = nil
+    var dropRefreshVisible = false
+    var dropRefreshStartedAt: Date? = nil
+    var hoveredBubble: AppBubbleContext? = nil
+    var editingBubble: AppBubbleContext? = nil
+    var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
+    var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
+    var tagRemovalDropSuppressFuturePrompt = false
+    var appDragResetToken = 0
+    var bubbleDraftNote = ""
+}
+
 private struct EditActionFeedback: Identifiable {
     let id = UUID()
     let title: String
@@ -365,22 +379,12 @@
     @State private var tagNavReorderDidMove = false
     // Fixed interaction for "Colorless Container": hover fills persistently; click clears.
     @State private var filledColorlessContainer: String? = nil
-    @State private var appDragModeActive = false
-    @State private var dropWarningToast: String? = nil
+    @State private var appGridInteraction = AppGridInteractionState()
     @State private var smartStartNotice: SmartStartNotice? = nil
     @State private var pendingSmartStartDraft: SmartCategorizationDraft? = nil
-    @State private var dropRefreshVisible = false
-    @State private var dropRefreshStartedAt: Date? = nil
     @State private var refreshInProgress = false
     @State private var refreshAgainAfterCurrent = false
     @State private var refreshAgainForceLayout = false
-    @State private var hoveredBubble: AppBubbleContext? = nil
-    @State private var editingBubble: AppBubbleContext? = nil
-    @State private var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
-    @State private var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
-    @State private var tagRemovalDropSuppressFuturePrompt = false
-    @State private var appDragResetToken = 0
-    @State private var bubbleDraftNote = ""
     @FocusState private var bubbleNoteFocused: Bool
 
     // Quick Search
@@ -421,7 +425,9 @@
     private let floatingControlsTrailingInset: CGFloat = 20
     private let floatingControlsReservedWidth: CGFloat = 120
     private var appBubbleDisabled: Bool {
-        appDragModeActive || pendingUncategorizedDrop != nil || pendingTagRemovalDrop != nil
+        appGridInteraction.appDragModeActive
+            || appGridInteraction.pendingUncategorizedDrop != nil
+            || appGridInteraction.pendingTagRemovalDrop != nil
     }
     private let rightSidebarFloatingClearance: CGFloat = 44
 
@@ -484,7 +490,7 @@
 
             quickSearchOverlay
 
-            if shouldRenderAppGridBehindQuickSearch, let message = dropWarningToast {
+            if shouldRenderAppGridBehindQuickSearch, let message = appGridInteraction.dropWarningToast {
                 Text(message)
                     .font(.system(size: 16, weight: .semibold))
                     .foregroundStyle(.primary)
@@ -499,7 +505,7 @@
                     .allowsHitTesting(false)
             }
 
-            if shouldRenderAppGridBehindQuickSearch && dropRefreshVisible {
+            if shouldRenderAppGridBehindQuickSearch && appGridInteraction.dropRefreshVisible {
                 Color.black.opacity(0.08)
                     .ignoresSafeArea()
                     .transition(.opacity)
@@ -579,7 +585,7 @@
             )
         }
         .onChange(of: bubbleNoteFocused) { _, focused in
-            if editingBubble != nil && !focused {
+            if appGridInteraction.editingBubble != nil && !focused {
                 commitBubbleNote()
             }
         }
@@ -593,10 +599,10 @@
             quickSearchErrorMessage = nil
             refreshQuickSearchResults()
         }
-        .onChange(of: pendingUncategorizedDrop != nil) { _, _ in
+        .onChange(of: appGridInteraction.pendingUncategorizedDrop != nil) { _, _ in
             publishModalInteractionState()
         }
-        .onChange(of: pendingTagRemovalDrop != nil) { _, _ in
+        .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in
             publishModalInteractionState()
         }
         .onDisappear {
@@ -612,7 +618,7 @@
         NotificationCenter.default.post(
             name: .tagLauncherModalInteractionChanged,
             object: nil,
-            userInfo: ["active": pendingUncategorizedDrop != nil || pendingTagRemovalDrop != nil]
+            userInfo: ["active": appGridInteraction.pendingUncategorizedDrop != nil || appGridInteraction.pendingTagRemovalDrop != nil]
         )
     }
 
@@ -711,9 +717,9 @@
 
     private var canOpenQuickSearch: Bool {
         editPhase == .none
-            && pendingUncategorizedDrop == nil
+            && appGridInteraction.pendingUncategorizedDrop == nil
             && smartStartNotice == nil
-            && !dropRefreshVisible
+            && !appGridInteraction.dropRefreshVisible
             && !quickSearchVisible
     }
 
@@ -993,9 +999,9 @@
 
     private var uncommonAppBubbleOverlay: some View {
         GeometryReader { proxy in
-            if let context = editingBubble ?? hoveredBubble {
+            if let context = appGridInteraction.editingBubble ?? appGridInteraction.hoveredBubble {
                 let rootFrame = proxy.frame(in: .global)
-                let editing = editingBubble != nil
+                let editing = appGridInteraction.editingBubble != nil
                 let width = min(editing ? 440 : 520, max(260, proxy.size.width - 48))
                 let placement = bubblePlacement(for: context.frame, rootFrame: rootFrame)
                 let metrics = bubbleMetrics(
@@ -1013,7 +1019,7 @@
                     isEditing: editing,
                     placement: placement,
                     arrowOffset: metrics.arrowOffset,
-                    draftNote: $bubbleDraftNote,
+                    draftNote: $appGridInteraction.bubbleDraftNote,
                     noteFocused: $bubbleNoteFocused,
                     onCommit: commitBubbleNote,
                     onCancel: dismissAppBubble
@@ -1026,7 +1032,7 @@
             }
         }
         .ignoresSafeArea()
-        .allowsHitTesting(editingBubble != nil)
+        .allowsHitTesting(appGridInteraction.editingBubble != nil)
     }
 
     private var smartStartNoticeOverlay: some View {
@@ -1432,7 +1438,7 @@
 
     private var uncategorizedDropConfirmOverlay: some View {
         GeometryReader { proxy in
-            if let pendingDrop = pendingUncategorizedDrop {
+            if let pendingDrop = appGridInteraction.pendingUncategorizedDrop {
                 ZStack {
                     Color.black.opacity(0.14)
                         .ignoresSafeArea()
@@ -1453,12 +1459,12 @@
             }
         }
         .ignoresSafeArea()
-        .allowsHitTesting(pendingUncategorizedDrop != nil)
+        .allowsHitTesting(appGridInteraction.pendingUncategorizedDrop != nil)
     }
 
     private var tagRemovalDropConfirmOverlay: some View {
         GeometryReader { proxy in
-            if let pendingDrop = pendingTagRemovalDrop {
+            if let pendingDrop = appGridInteraction.pendingTagRemovalDrop {
                 ZStack {
                     Color.black.opacity(0.14)
                         .ignoresSafeArea()
@@ -1467,7 +1473,7 @@
                         title: tr("drop.removeTagConfirmTitle"),
                         message: tagRemovalConfirmMessage(for: pendingDrop),
                         doNotRemindTitle: tr("drop.removeTagDoNotAskAgain"),
-                        doNotRemind: $tagRemovalDropSuppressFuturePrompt,
+                        doNotRemind: $appGridInteraction.tagRemovalDropSuppressFuturePrompt,
                         cancelTitle: tr("drop.removeTagConfirmNo"),
                         confirmTitle: tr("drop.removeTagConfirmYes"),
                         onCancel: dismissTagRemovalDropConfirm,
@@ -1481,7 +1487,7 @@
             }
         }
         .ignoresSafeArea()
-        .allowsHitTesting(pendingTagRemovalDrop != nil)
+        .allowsHitTesting(appGridInteraction.pendingTagRemovalDrop != nil)
     }
 
     private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback {
@@ -1757,7 +1763,7 @@
     }
 
     private func handleAppGridScrollActivity() {
-        hoveredBubble = nil
+        appGridInteraction.hoveredBubble = nil
     }
 
     private func handleBubbleHover(app: AppInfo, frame: CGRect, event: AppBubbleHoverEvent) {
@@ -1765,17 +1771,17 @@
             clearAppBubbleState()
             return
         }
-        guard editingBubble == nil else { return }
+        guard appGridInteraction.editingBubble == nil else { return }
         switch event {
         case .entered(let canShowBubble):
             if canShowBubble {
-                hoveredBubble = AppBubbleContext(app: app, frame: frame)
+                appGridInteraction.hoveredBubble = AppBubbleContext(app: app, frame: frame)
             } else {
-                hoveredBubble = nil
+                appGridInteraction.hoveredBubble = nil
             }
         case .exited:
-            guard hoveredBubble?.app.path == app.path else { return }
-            hoveredBubble = nil
+            guard appGridInteraction.hoveredBubble?.app.path == app.path else { return }
+            appGridInteraction.hoveredBubble = nil
         }
     }
 
@@ -1784,9 +1790,9 @@
             clearAppBubbleState()
             return
         }
-        bubbleDraftNote = currentNote(for: app)
-        hoveredBubble = nil
-        editingBubble = AppBubbleContext(app: app, frame: frame)
+        appGridInteraction.bubbleDraftNote = currentNote(for: app)
+        appGridInteraction.hoveredBubble = nil
+        appGridInteraction.editingBubble = AppBubbleContext(app: app, frame: frame)
         notifyAppNoteEditing(active: true)
         DispatchQueue.main.async {
             bubbleNoteFocused = true
@@ -1794,33 +1800,33 @@
     }
 
     private func commitBubbleNote() {
-        guard let context = editingBubble else { return }
-        let limited = String(bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength))
+        guard let context = appGridInteraction.editingBubble else { return }
+        let limited = String(appGridInteraction.bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength))
             .trimmingCharacters(in: .whitespacesAndNewlines)
         TagEditor.setAppNote(limited, for: context.app.path.path)
-        bubbleDraftNote = limited
-        editingBubble = nil
+        appGridInteraction.bubbleDraftNote = limited
+        appGridInteraction.editingBubble = nil
         bubbleNoteFocused = false
         notifyAppNoteEditing(active: false)
         refreshApps()
     }
 
     private func dismissAppBubble() {
-        hoveredBubble = nil
-        if editingBubble != nil {
+        appGridInteraction.hoveredBubble = nil
+        if appGridInteraction.editingBubble != nil {
             notifyAppNoteEditing(active: false)
         }
-        editingBubble = nil
+        appGridInteraction.editingBubble = nil
         bubbleNoteFocused = false
     }
 
     private func clearAppBubbleState() {
-        if hoveredBubble != nil {
-            hoveredBubble = nil
+        if appGridInteraction.hoveredBubble != nil {
+            appGridInteraction.hoveredBubble = nil
         }
-        if editingBubble != nil {
+        if appGridInteraction.editingBubble != nil {
             notifyAppNoteEditing(active: false)
-            editingBubble = nil
+            appGridInteraction.editingBubble = nil
         }
         if bubbleNoteFocused {
             bubbleNoteFocused = false
@@ -2031,9 +2037,9 @@
         }
 
         clearAppBubbleState()
-        tagRemovalDropSuppressFuturePrompt = false
+        appGridInteraction.tagRemovalDropSuppressFuturePrompt = false
         withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
-            pendingTagRemovalDrop = PendingTagRemovalDrop(app: app, tagName: sourceTag)
+            appGridInteraction.pendingTagRemovalDrop = PendingTagRemovalDrop(app: app, tagName: sourceTag)
         }
     }
 
@@ -2045,7 +2051,7 @@
 
         clearAppBubbleState()
         withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
-            pendingUncategorizedDrop = PendingUncategorizedDrop(
+            appGridInteraction.pendingUncategorizedDrop = PendingUncategorizedDrop(
                 app: app,
                 assignedTags: assignedTags,
                 removableTags: removableTags
@@ -2099,17 +2105,17 @@
     private func dismissUncategorizedDropConfirm() {
         resetTransientDragState(keepingPendingUncategorizedDrop: true)
         withAnimation(.easeOut(duration: 0.18)) {
-            pendingUncategorizedDrop = nil
+            appGridInteraction.pendingUncategorizedDrop = nil
         }
     }
 
     private func confirmPendingUncategorizedDrop() {
-        guard let pendingDrop = pendingUncategorizedDrop else { return }
+        guard let pendingDrop = appGridInteraction.pendingUncategorizedDrop else { return }
         let path = pendingDrop.app.path.path
         let tags = pendingDrop.removableTags
         resetTransientDragState(keepingPendingUncategorizedDrop: true)
         withAnimation(.easeOut(duration: 0.16)) {
-            pendingUncategorizedDrop = nil
+            appGridInteraction.pendingUncategorizedDrop = nil
         }
         guard !tags.isEmpty else { return }
         TagEditor.removeTags(tags, from: [path])
@@ -2118,23 +2124,23 @@
     }
 
     private func dismissTagRemovalDropConfirm() {
-        tagRemovalDropSuppressFuturePrompt = false
+        appGridInteraction.tagRemovalDropSuppressFuturePrompt = false
         withAnimation(.easeOut(duration: 0.18)) {
-            pendingTagRemovalDrop = nil
+            appGridInteraction.pendingTagRemovalDrop = nil
         }
     }
 
     private func confirmPendingTagRemovalDrop() {
-        guard let pendingDrop = pendingTagRemovalDrop else { return }
+        guard let pendingDrop = appGridInteraction.pendingTagRemovalDrop else { return }
         let app = pendingDrop.app
         let tagName = pendingDrop.tagName
-        let shouldSuppressFuturePrompt = tagRemovalDropSuppressFuturePrompt
+        let shouldSuppressFuturePrompt = appGridInteraction.tagRemovalDropSuppressFuturePrompt
         if shouldSuppressFuturePrompt {
             skipTagRemovalDropConfirm = true
         }
-        tagRemovalDropSuppressFuturePrompt = false
+        appGridInteraction.tagRemovalDropSuppressFuturePrompt = false
         withAnimation(.easeOut(duration: 0.16)) {
-            pendingTagRemovalDrop = nil
+            appGridInteraction.pendingTagRemovalDrop = nil
         }
         DispatchQueue.main.async {
             removeTagFromDroppedApp(app: app, tagName: tagName)
@@ -2180,45 +2186,45 @@
 
     private func showDropWarning() {
         withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
-            dropWarningToast = tr("drop.systemDefaultWarning")
+            appGridInteraction.dropWarningToast = tr("drop.systemDefaultWarning")
         }
         DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) {
             withAnimation(.easeOut(duration: 0.18)) {
-                dropWarningToast = nil
+                appGridInteraction.dropWarningToast = nil
             }
         }
     }
 
     private func showDropRefresh() {
-        dropRefreshStartedAt = Date()
+        appGridInteraction.dropRefreshStartedAt = Date()
         withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) {
-            dropRefreshVisible = true
+            appGridInteraction.dropRefreshVisible = true
         }
     }
 
     private func finishDropRefreshAfterMinimumDuration() {
         let minimumDuration: TimeInterval = 0.85
-        let elapsed = dropRefreshStartedAt.map { Date().timeIntervalSince($0) } ?? minimumDuration
+        let elapsed = appGridInteraction.dropRefreshStartedAt.map { Date().timeIntervalSince($0) } ?? minimumDuration
         let delay = max(0, minimumDuration - elapsed)
         DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
             withAnimation(.easeOut(duration: 0.18)) {
-                dropRefreshVisible = false
+                appGridInteraction.dropRefreshVisible = false
             }
-            dropRefreshStartedAt = nil
+            appGridInteraction.dropRefreshStartedAt = nil
         }
     }
 
     private func setAppDragMode(_ active: Bool) {
-        guard appDragModeActive != active else { return }
+        guard appGridInteraction.appDragModeActive != active else { return }
         if active {
             endTagNavReorder()
             clearAppBubbleState()
         }
-        appDragModeActive = active
+        appGridInteraction.appDragModeActive = active
         if active {
             DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
-                if appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag {
-                    appDragModeActive = false
+                if appGridInteraction.appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag {
+                    appGridInteraction.appDragModeActive = false
                 }
             }
         }
@@ -2228,13 +2234,13 @@
         keepingPendingUncategorizedDrop: Bool = false,
         keepingPendingTagRemovalDrop: Bool = false
     ) {
-        let hadAppDragState = appDragModeActive
+        let hadAppDragState = appGridInteraction.appDragModeActive
         AppDragCoordinator.shared.cancelDrag()
-        if appDragModeActive {
-            appDragModeActive = false
+        if appGridInteraction.appDragModeActive {
+            appGridInteraction.appDragModeActive = false
         }
         if hadAppDragState {
-            appDragResetToken &+= 1
+            appGridInteraction.appDragResetToken &+= 1
         }
         if tagNavDragModeActive {
             tagNavDragModeActive = false
@@ -2249,11 +2255,11 @@
             dragItem = nil
         }
         clearAppBubbleState()
-        if !keepingPendingUncategorizedDrop, pendingUncategorizedDrop != nil {
-            pendingUncategorizedDrop = nil
+        if !keepingPendingUncategorizedDrop, appGridInteraction.pendingUncategorizedDrop != nil {
+            appGridInteraction.pendingUncategorizedDrop = nil
         }
-        if !keepingPendingTagRemovalDrop, pendingTagRemovalDrop != nil {
-            pendingTagRemovalDrop = nil
+        if !keepingPendingTagRemovalDrop, appGridInteraction.pendingTagRemovalDrop != nil {
+            appGridInteraction.pendingTagRemovalDrop = nil
         }
     }
 
@@ -2309,7 +2315,7 @@
         closeOverlayOnSuccess: Bool = true,
         onFailure: (() -> Void)? = nil
     ) {
-        appDragModeActive = false
+        appGridInteraction.appDragModeActive = false
         endTagNavReorder()
         let configuration = NSWorkspace.OpenConfiguration()
         NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in
diff --git a/Scripts/window_logic_qa.sh b/Scripts/window_logic_qa.sh
index 59b2b96..f93b3f2 100755
--- a/Scripts/window_logic_qa.sh
+++ b/Scripts/window_logic_qa.sh
@@ -14,6 +14,8 @@
 DEFAULTS_DOMAIN="$LAUNCH_AGENT_LABEL"
 SHOW_DOCK_ICON_WAS_SET=false
 SHOW_DOCK_ICON_VALUE=""
+APP_LANGUAGE_WAS_SET=false
+APP_LANGUAGE_VALUE=""
 FULLSCREEN_QA_PID=""
 CLICK_TOOL="${CLICK_TOOL:-$(command -v cliclick || true)}"
 
@@ -31,6 +33,10 @@
   if value="$(defaults read "$DEFAULTS_DOMAIN" showDockIcon 2>/dev/null)"; then
     SHOW_DOCK_ICON_WAS_SET=true
     SHOW_DOCK_ICON_VALUE="$value"
+  fi
+  if value="$(defaults read "$DEFAULTS_DOMAIN" appLanguage 2>/dev/null)"; then
+    APP_LANGUAGE_WAS_SET=true
+    APP_LANGUAGE_VALUE="$value"
   fi
 }
 
@@ -50,6 +56,12 @@
     fi
   else
     defaults delete "$DEFAULTS_DOMAIN" showDockIcon >/dev/null 2>&1 || true
+  fi
+
+  if [[ "$APP_LANGUAGE_WAS_SET" == true ]]; then
+    defaults write "$DEFAULTS_DOMAIN" appLanguage -string "$APP_LANGUAGE_VALUE"
+  else
+    defaults delete "$DEFAULTS_DOMAIN" appLanguage >/dev/null 2>&1 || true
   fi
 }
 
@@ -298,9 +310,10 @@
 
 assert_swift="$(mktemp -t taglauncher-window-assert.XXXXXX.swift)"
 coords_swift="$(mktemp -t taglauncher-window-coords.XXXXXX.swift)"
+settings_ax_swift="$(mktemp -t taglauncher-settings-ax.XXXXXX.swift)"
 screens_swift="$(mktemp -t taglauncher-screens.XXXXXX.swift)"
 fullscreen_swift="$(mktemp -t taglauncher-fullscreen-target.XXXXXX.swift)"
-trap 'cleanup; rm -f "$assert_swift" "$coords_swift" "$screens_swift" "$fullscreen_swift" "$LAUNCH_AGENT_BACKUP"' EXIT
+trap 'cleanup; rm -f "$assert_swift" "$coords_swift" "$settings_ax_swift" "$screens_swift" "$fullscreen_swift" "$LAUNCH_AGENT_BACKUP"' EXIT
 
 cat >"$assert_swift" <<'SWIFT'
 import AppKit
@@ -638,6 +651,111 @@
 }
 SWIFT
 
+cat >"$settings_ax_swift" <<'SWIFT'
+import AppKit
+import ApplicationServices
+import Foundation
+
+let mode = CommandLine.arguments.dropFirst().first ?? ""
+let bundleID = "com.taglauncher.app"
+
+guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).first else {
+    fputs("FAIL: TagLauncher is not running\n", stderr)
+    exit(1)
+}
+
+let root = AXUIElementCreateApplication(app.processIdentifier)
+
+func copyAttribute(_ element: AXUIElement, _ attribute: CFString) -> CFTypeRef? {
+    var value: CFTypeRef?
+    let result = AXUIElementCopyAttributeValue(element, attribute, &value)
+    guard result == .success else { return nil }
+    return value
+}
+
+func stringAttribute(_ element: AXUIElement, _ attribute: CFString) -> String? {
+    copyAttribute(element, attribute) as? String
+}
+
+func titleCandidates(for element: AXUIElement) -> [String] {
+    [
+        stringAttribute(element, kAXTitleAttribute as CFString),
+        stringAttribute(element, kAXDescriptionAttribute as CFString),
+        stringAttribute(element, kAXValueAttribute as CFString),
+        stringAttribute(element, kAXHelpAttribute as CFString)
+    ].compactMap { $0 }.filter { !$0.isEmpty }
+}
+
+func children(of element: AXUIElement) -> [AXUIElement] {
+    if let values = copyAttribute(element, kAXChildrenAttribute as CFString) as? [AXUIElement] {
+        return values
+    }
+    return []
+}
+
+func matches(_ element: AXUIElement, candidates: [String]) -> Bool {
+    let role = stringAttribute(element, kAXRoleAttribute as CFString) ?? ""
+    let titles = titleCandidates(for: element)
+    guard !titles.isEmpty else { return false }
+    let interactiveRole = role == kAXButtonRole as String
+        || role == kAXRadioButtonRole as String
+        || role == kAXTabGroupRole as String
+        || role == kAXMenuItemRole as String
+    guard interactiveRole else { return false }
+    return titles.contains { title in
+        candidates.contains { candidate in
+            title == candidate || title.localizedCaseInsensitiveContains(candidate)
+        }
+    }
+}
+
+func findMatchingElement(
+    _ element: AXUIElement,
+    candidates: [String],
+    depth: Int,
+    visited: inout Set<CFHashCode>
+) -> AXUIElement? {
+    guard depth >= 0 else { return nil }
+    let hash = CFHash(element)
+    guard !visited.contains(hash) else { return nil }
+    visited.insert(hash)
+
+    if matches(element, candidates: candidates) {
+        return element
+    }
+
+    for child in children(of: element) {
+        if let match = findMatchingElement(child, candidates: candidates, depth: depth - 1, visited: &visited) {
+            return match
+        }
+    }
+    return nil
+}
+
+let candidates: [String]
+switch mode {
+case "data-tab":
+    candidates = ["Data"]
+case "export":
+    candidates = ["Export"]
+default:
+    fputs("FAIL: unknown settings action \(mode)\n", stderr)
+    exit(1)
+}
+
+var visited = Set<CFHashCode>()
+guard let element = findMatchingElement(root, candidates: candidates, depth: 12, visited: &visited) else {
+    fputs("FAIL: could not find settings control for \(mode)\n", stderr)
+    exit(1)
+}
+
+let result = AXUIElementPerformAction(element, kAXPressAction as CFString)
+guard result == .success else {
+    fputs("FAIL: could not press settings control for \(mode): \(result.rawValue)\n", stderr)
+    exit(1)
+}
+SWIFT
+
 cat >"$screens_swift" <<'SWIFT'
 import AppKit
 import Foundation
@@ -760,6 +878,14 @@
   click_xy "$x" "$y"
 }
 
+click_settings_control() {
+  local mode="$1"
+  if swift "$settings_ax_swift" "$mode" >/dev/null 2>&1; then
+    return 0
+  fi
+  click_relative_to_settings "$mode"
+}
+
 click_overlay_outside_quick_search() {
   local coords
   coords="$(swift "$coords_swift" overlay-outside)"
@@ -810,6 +936,7 @@
 
 log "==> Preparing QA defaults"
 defaults write "$DEFAULTS_DOMAIN" showDockIcon -bool true
+defaults write "$DEFAULTS_DOMAIN" appLanguage -string en
 reset_dock_for_qa
 
 log "==> Starting clean app instance"
@@ -892,9 +1019,9 @@
 swift_assert settings
 
 log "==> QA 2/7: import/export file panel floats above settings"
-click_relative_to_settings data-tab
+click_settings_control data-tab
 sleep 0.3
-click_relative_to_settings export
+click_settings_control export
 sleep 0.7
 swift_assert file-panel
 send_keycode 53
diff --git a/TODO.md b/TODO.md
index 616bd5c..403fa88 100644
--- a/TODO.md
+++ b/TODO.md
@@ -16,7 +16,8 @@
   - 目标: 先做低风险状态收口,把 App Grid 的 bubble、drag、drop、refresh toast 等临时交互状态集中管理,降低后续标签栏 AppKit 化风险。
   - 原则: 不改变用户可见 UI,不改变拖拽/hover/Quick Search/窗口行为。
   - 验收: `bash build.sh`、`codesign --verify --deep --strict`、localization JSON 解析、App Grid 交互 targeted QA、必要时跑 `Scripts/window_logic_qa.sh`。
-  - QA: 自动化通过后,由 QA 屏幕点击复核;用户只做最终体验验收。
+  - QA: 自动化/半自动化 QA 已通过;待用户最终体验验收后移动到 `Done`。
+  - 结果: `ContentView` 中 App Grid 的 bubble、drag、drop、refresh toast 临时状态已收口到 `AppGridInteractionState`;窗口 8 逻辑 QA 已全部通过。
 
 ## Todo
 

--
Gitblit v1.9.3