From 6a49b26ff3ef989844673cb77d2700404242c424 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Tue, 09 Jun 2026 20:58:01 +0800
Subject: [PATCH] Release 0.0.48 verification candidate

---
 C1.source/Resources/AppIcon.icns                                               |    0 
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png          |    0 
 C3.tools/round1-app-shelf-fixture-qa.sh                                        |   37 
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png     |    0 
 K2.项目管理/项目经理-经验与教训.md                                                          |    8 
 C3.tools/round1-app-column-alignment-fixture-qa.sh                             |    2 
 C1.source/Sources/Aligner/AlignerApplicationDelegate.swift                     |   35 
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png       |    0 
 C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift            |   30 
 C3.tools/round1-three-zone-linkage-fixture-qa.sh                               |  289 ++++
 C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift                      |   67 
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png          |    0 
 C1.source/Sources/Aligner/QuickSwitchRootView.swift                            |  960 +++++++++++++---
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png        |    0 
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png     |    0 
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png        |    0 
 C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift |  365 ++++++
 C3.tools/round1-session-snapshot-qa.sh                                         |   75 +
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png       |    0 
 C3.tools/round1-keyboard-navigation-fixture-qa.sh                              |    6 
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json           |    2 
 C3.tools/round1-waterfall-fixture-qa.sh                                        |   22 
 C1.source/Sources/Aligner/DebugWindowCloseService.swift                        |   25 
 C1.source/Sources/AlignerCore/Protocols/ServiceProtocols.swift                 |   13 
 C1.source/Sources/Aligner/QuickSwitchSessionController.swift                   |  448 +++++++
 C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift                        |   34 
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024_preview.png   |    0 
 C1.source/Sources/Aligner/QuickSwitchCloseRequest.swift                        |   71 +
 K2.项目管理/开发工作-经验与教训.md                                                          |   11 
 C3.tools/round1-close-fixture-qa.sh                                            |  547 +++++++++
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png        |    0 
 C1.source/Resources/Aligner-Info.plist                                         |    4 
 C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png     |    0 
 C3.tools/round1-mouse-interaction-fixture-qa.sh                                |   21 
 C3.tools/generate-app-icon.py                                                  |  269 ---
 35 files changed, 2,827 insertions(+), 514 deletions(-)

diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index a772c18..6930b37 100644
--- a/C1.source/Resources/Aligner-Info.plist
+++ b/C1.source/Resources/Aligner-Info.plist
@@ -17,9 +17,9 @@
 	<key>CFBundlePackageType</key>
 	<string>APPL</string>
 	<key>CFBundleShortVersionString</key>
-	<string>0.0.31</string>
+    <string>0.0.48</string>
 	<key>CFBundleVersion</key>
-	<string>20260606.2302</string>
+    <string>20260609.2055</string>
 	<key>LSMinimumSystemVersion</key>
 	<string>26.0</string>
 	<key>NSHighResolutionCapable</key>
diff --git a/C1.source/Resources/AppIcon.icns b/C1.source/Resources/AppIcon.icns
index 5afe187..6865453 100644
--- a/C1.source/Resources/AppIcon.icns
+++ b/C1.source/Resources/AppIcon.icns
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
index 8815e3e..9a97ba8 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -62,7 +62,7 @@
     }
   ],
   "info": {
-    "author": "xcode",
+    "author": "designer",
     "version": 1
   }
 }
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024_preview.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024_preview.png
index 4fec401..6feda06 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024_preview.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024_preview.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
index 0c0edfa..22b6b0b 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
index 9c66029..1f373b7 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
index c867cee..6499f86 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
index 2155d64..8c80484 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
index 9c66029..1f373b7 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
index a79eb5b..c8ce320 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
index 2155d64..8c80484 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
index 4d496ad..eee7d04 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
index a79eb5b..c8ce320 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
index 4fec401..6feda06 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
Binary files differ
diff --git a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
index 4218d77..3b968f2 100644
--- a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
+++ b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -4,6 +4,8 @@
 
 @MainActor
 final class AlignerApplicationDelegate: NSObject, NSApplicationDelegate {
+    private static let quickSwitchConfirmBeforeCloseDefaultsKey = "QuickSwitchConfirmBeforeClose"
+
     private let permissionChecker = SystemPermissionChecker()
     private let launchOptions = Round0DebugLaunchOptions.parse(arguments: ProcessInfo.processInfo.arguments)
     private let round1PoCOptions = Round1PerformancePoCOptions.parse(arguments: ProcessInfo.processInfo.arguments)
@@ -352,7 +354,12 @@
             let controller = QuickSwitchSessionController(
                 snapshotLoader: quickSwitchSnapshotLoader(),
                 windowActivationService: quickSwitchWindowActivationService(),
+                windowCloseService: quickSwitchWindowCloseService(),
                 disableScreenshotRefresh: round1QuickSwitchOptions.disableScreenshotRefresh,
+                closeConfirmationRequired: quickSwitchConfirmBeforeClose,
+                onCloseConfirmationDisabled: { [weak self] in
+                    self?.setQuickSwitchConfirmBeforeClose(false)
+                },
                 debugHoveredAppGroupIndex: round1QuickSwitchOptions.debugHoveredAppGroupIndex,
                 debugOverlayWidth: round1QuickSwitchOptions.debugOverlayWidth,
                 debugKeySequence: round1QuickSwitchOptions.debugKeySequence,
@@ -421,7 +428,12 @@
             let controller = QuickSwitchSessionController(
                 snapshotLoader: quickSwitchSnapshotLoader(),
                 windowActivationService: quickSwitchWindowActivationService(),
+                windowCloseService: quickSwitchWindowCloseService(),
                 disableScreenshotRefresh: round1QuickSwitchOptions.disableScreenshotRefresh,
+                closeConfirmationRequired: quickSwitchConfirmBeforeClose,
+                onCloseConfirmationDisabled: { [weak self] in
+                    self?.setQuickSwitchConfirmBeforeClose(false)
+                },
                 debugHoveredAppGroupIndex: round1QuickSwitchOptions.debugHoveredAppGroupIndex,
                 debugOverlayWidth: round1QuickSwitchOptions.debugOverlayWidth,
                 debugKeySequence: round1QuickSwitchOptions.debugKeySequence,
@@ -509,7 +521,8 @@
             return FixtureQuickSwitchSnapshotLoader(
                 appCount: fixtureAppCount,
                 windowsPerApp: round1QuickSwitchOptions.fixtureWindowsPerApp ?? 1,
-                mode: .layout
+                mode: .layout,
+                usesMultipleDisplays: round1QuickSwitchOptions.fixtureMultiDisplay
             )
         }
 
@@ -529,6 +542,26 @@
         return CGWindowAXWindowService(spaceIDsByWindowIDProvider: { _ in [:] })
     }
 
+    private func quickSwitchWindowCloseService() -> any WindowCloseServiceProtocol {
+        if round1QuickSwitchOptions.debugWindowClose {
+            return DebugWindowCloseService()
+        }
+
+        return CGWindowAXWindowService(spaceIDsByWindowIDProvider: { _ in [:] })
+    }
+
+    private var quickSwitchConfirmBeforeClose: Bool {
+        let value = UserDefaults.standard.object(forKey: Self.quickSwitchConfirmBeforeCloseDefaultsKey)
+        return (value as? Bool) ?? true
+    }
+
+    private func setQuickSwitchConfirmBeforeClose(_ enabled: Bool) {
+        UserDefaults.standard.set(enabled, forKey: Self.quickSwitchConfirmBeforeCloseDefaultsKey)
+        DevelopmentDiagnostics.log("app.quickSwitch.closeConfirmationPreferenceChanged", [
+            "confirmBeforeClose": enabled
+        ])
+    }
+
     @objc private func showPermissions() {
         DevelopmentDiagnostics.log("app.permissions.show", [
             "accessibility": permissionStatusString(accessibilityStatus()),
diff --git a/C1.source/Sources/Aligner/DebugWindowCloseService.swift b/C1.source/Sources/Aligner/DebugWindowCloseService.swift
new file mode 100644
index 0000000..924e6dd
--- /dev/null
+++ b/C1.source/Sources/Aligner/DebugWindowCloseService.swift
@@ -0,0 +1,25 @@
+import Foundation
+import AlignerCore
+
+final class DebugWindowCloseService: WindowCloseServiceProtocol {
+    private(set) var closedWindowIDs: [UInt32] = []
+    private(set) var closedAppProcessIdentifiers: [Int32] = []
+
+    func close(window: AlignerWindow) throws -> WindowCloseResult {
+        closedWindowIDs.append(window.id)
+        if ProcessInfo.processInfo.environment["ALIGNER_DEBUG_WINDOW_CLOSE_RESULT"] == "windowNotFound" {
+            return .windowNotFound
+        }
+        return .requested
+    }
+
+    func close(app: AlignerApp) throws -> WindowCloseResult {
+        if let processIdentifier = app.processIdentifier {
+            closedAppProcessIdentifiers.append(processIdentifier)
+        }
+        if ProcessInfo.processInfo.environment["ALIGNER_DEBUG_WINDOW_CLOSE_RESULT"] == "windowNotFound" {
+            return .windowNotFound
+        }
+        return .requested
+    }
+}
diff --git a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
index 799c87d..bba0912 100644
--- a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
+++ b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
@@ -1,10 +1,10 @@
 import AppKit
-import ApplicationServices
+@preconcurrency import ApplicationServices
 import CoreGraphics
 import Foundation
 import AlignerCore
 
-final class CGWindowAXWindowService: WindowServiceProtocol, WindowRestorationServiceProtocol, WindowActivationServiceProtocol {
+final class CGWindowAXWindowService: WindowServiceProtocol, WindowRestorationServiceProtocol, WindowActivationServiceProtocol, WindowCloseServiceProtocol {
     weak var delegate: WindowServiceDelegate?
 
     private let appCategorizer: any AppCategorizer
@@ -255,6 +255,286 @@
         return didPrivatelyActivate || focusedBeforeActivate || focusedAfterActivate || raisedBeforeActivate || raisedAfterActivate
             ? .activated
             : .appActivatedOnly
+    }
+
+    func close(window: AlignerWindow) throws -> WindowCloseResult {
+        DevelopmentDiagnostics.log("windowClose.window.start", [
+            "windowID": window.id,
+            "identifierSource": String(describing: window.identifierSource),
+            "titleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
+            "titleLength": window.title.count,
+            "titleIsEmpty": window.title.isEmpty,
+            "appName": window.app.name,
+            "bundleIdentifier": window.app.bundleIdentifier,
+            "pid": window.app.processIdentifier
+        ])
+
+        let axMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer)
+        var matchedAXWindow = axWindow(for: window, in: axMetadata)
+        var finalAXMetadata = axMetadata
+
+        if matchedAXWindow == nil {
+            Thread.sleep(forTimeInterval: 0.12)
+            finalAXMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer)
+            matchedAXWindow = axWindow(for: window, in: finalAXMetadata, matchAttempt: "retry")
+        }
+
+        guard let matchedAXWindow else {
+            DevelopmentDiagnostics.log("windowClose.window.axMissing", [
+                "windowID": window.id,
+                "appName": window.app.name,
+                "pid": window.app.processIdentifier,
+                "candidateCount": finalAXMetadata.filter { $0.processIdentifier == window.app.processIdentifier }.count,
+                "matchingTitleCandidateCount": matchingAXTitleCandidateCount(for: window, in: finalAXMetadata),
+                "matchingSizeCandidateCount": matchingAXSizeCandidateCount(for: window, in: finalAXMetadata)
+            ])
+            return .windowNotFound
+        }
+
+        guard let closeButton = closeButton(for: matchedAXWindow) else {
+            Thread.sleep(forTimeInterval: 0.12)
+            let retryMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer)
+            if let retryAXWindow = axWindow(for: window, in: retryMetadata, matchAttempt: "closeButtonRetry"),
+               let retryCloseButton = closeButton(for: retryAXWindow) {
+                return performCloseButtonPress(
+                    retryCloseButton,
+                    window: window,
+                    processIdentifier: retryAXWindow.processIdentifier,
+                    attempt: "closeButtonRetry"
+                )
+            }
+            DevelopmentDiagnostics.log("windowClose.window.closeButtonMissing", [
+                "windowID": window.id,
+                "pid": matchedAXWindow.processIdentifier,
+                "appName": window.app.name
+            ])
+            return .windowNotFound
+        }
+
+        return performCloseButtonPress(
+            closeButton,
+            window: window,
+            processIdentifier: matchedAXWindow.processIdentifier,
+            attempt: "initial"
+        )
+    }
+
+    private func closeButton(for axWindow: AXWindowMetadata) -> AXUIElement? {
+        var closeButtonValue: CFTypeRef?
+        let closeButtonLookupResult = AXUIElementCopyAttributeValue(
+            axWindow.element,
+            kAXCloseButtonAttribute as CFString,
+            &closeButtonValue
+        )
+        guard closeButtonLookupResult == .success,
+              let closeButton = closeButtonValue,
+              CFGetTypeID(closeButton) == AXUIElementGetTypeID()
+        else {
+            DevelopmentDiagnostics.log("windowClose.window.closeButtonLookupFailed", [
+                "pid": axWindow.processIdentifier,
+                "axError": String(describing: closeButtonLookupResult)
+            ])
+            return nil
+        }
+
+        return (closeButton as! AXUIElement)
+    }
+
+    private func performCloseButtonPress(
+        _ closeButton: AXUIElement,
+        window: AlignerWindow,
+        processIdentifier: Int32,
+        attempt: String
+    ) -> WindowCloseResult {
+        let closeResult = AXUIElementPerformAction(closeButton, kAXPressAction as CFString)
+        DevelopmentDiagnostics.log("windowClose.window.result", [
+            "windowID": window.id,
+            "pid": processIdentifier,
+            "attempt": attempt,
+            "axError": String(describing: closeResult)
+        ])
+
+        switch closeResult {
+        case .success:
+            return .requested
+        case .actionUnsupported, .notImplemented:
+            return .unsupported
+        default:
+            return .failed(String(describing: closeResult))
+        }
+    }
+
+    private func axWindow(
+        for window: AlignerWindow,
+        in axMetadata: [AXWindowMetadata],
+        matchAttempt: String = "initial"
+    ) -> AXWindowMetadata? {
+        if let directMatch = axMetadata.first(where: { $0.windowID == window.id }) {
+            DevelopmentDiagnostics.log("windowClose.axMatch.directWindowID", [
+                "windowID": window.id,
+                "pid": window.app.processIdentifier,
+                "attempt": matchAttempt
+            ])
+            return directMatch
+        }
+
+        guard let processIdentifier = window.app.processIdentifier else {
+            return nil
+        }
+
+        if let cgFingerprint = cgWindowFingerprint(for: window) {
+            let fingerprintMatches = axMetadata.filter { metadata in
+                AXWindowFingerprint(metadata) == cgFingerprint
+            }
+            if fingerprintMatches.count == 1 {
+                DevelopmentDiagnostics.log("windowClose.axMatch.fingerprint", [
+                    "windowID": window.id,
+                    "pid": processIdentifier,
+                    "attempt": matchAttempt
+                ])
+                return fingerprintMatches[0]
+            }
+
+            if fingerprintMatches.count > 1 {
+                DevelopmentDiagnostics.log("windowClose.axMatch.fingerprintAmbiguous", [
+                    "windowID": window.id,
+                    "pid": processIdentifier,
+                    "candidateCount": fingerprintMatches.count
+                ])
+            }
+        }
+
+        let normalizedTitle = normalizedWindowTitle(window.title)
+        let titleMatches = axMetadata.filter { metadata in
+            metadata.processIdentifier == processIdentifier
+                && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle
+        }
+        if titleMatches.count == 1 {
+            DevelopmentDiagnostics.log("windowClose.axMatch.normalizedTitle", [
+                "windowID": window.id,
+                "pid": processIdentifier,
+                "attempt": matchAttempt
+            ])
+            return titleMatches[0]
+        }
+
+        if window.app.bundleIdentifier == "com.apple.finder",
+           let finderMatch = finderFallbackAXWindow(
+            for: window,
+            processIdentifier: processIdentifier,
+            in: axMetadata
+           ) {
+            DevelopmentDiagnostics.log("windowClose.axMatch.finderFallback", [
+                "windowID": window.id,
+                "pid": processIdentifier,
+                "attempt": matchAttempt
+            ])
+            return finderMatch
+        }
+
+        if titleMatches.count > 1 {
+            DevelopmentDiagnostics.log("windowClose.axMatch.titleAmbiguous", [
+                "windowID": window.id,
+                "pid": processIdentifier,
+                "candidateCount": titleMatches.count
+            ])
+        }
+
+        return nil
+    }
+
+    private func finderFallbackAXWindow(
+        for window: AlignerWindow,
+        processIdentifier: Int32,
+        in axMetadata: [AXWindowMetadata]
+    ) -> AXWindowMetadata? {
+        let normalizedTitle = normalizedWindowTitle(window.title)
+        guard !normalizedTitle.isEmpty else { return nil }
+
+        let finderCandidates = axMetadata.filter { metadata in
+            metadata.processIdentifier == processIdentifier
+                && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle
+                && metadata.subrole == .standard
+        }
+        return finderCandidates.count == 1 ? finderCandidates[0] : nil
+    }
+
+    private func cgWindowFingerprint(for window: AlignerWindow) -> AXWindowFingerprint? {
+        guard let rawWindows = CGWindowListCopyWindowInfo(
+            [.optionIncludingWindow],
+            CGWindowID(window.id)
+        ) as? [[String: Any]],
+              let rawWindow = rawWindows.first,
+              let ownerPID = int32Value(rawWindow[kCGWindowOwnerPID as String])
+        else {
+            return nil
+        }
+
+        return AXWindowFingerprint(
+            processIdentifier: ownerPID,
+            title: normalizedWindowTitle(title(rawWindow: rawWindow, axWindow: nil)),
+            size: bounds(rawWindow[kCGWindowBounds as String]).size
+        )
+    }
+
+    private func matchingAXTitleCandidateCount(
+        for window: AlignerWindow,
+        in axMetadata: [AXWindowMetadata]
+    ) -> Int {
+        guard let processIdentifier = window.app.processIdentifier else { return 0 }
+        let normalizedTitle = normalizedWindowTitle(window.title)
+        return axMetadata.filter { metadata in
+            metadata.processIdentifier == processIdentifier
+                && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle
+        }.count
+    }
+
+    private func matchingAXSizeCandidateCount(
+        for window: AlignerWindow,
+        in axMetadata: [AXWindowMetadata]
+    ) -> Int {
+        guard let processIdentifier = window.app.processIdentifier,
+              let cgFingerprint = cgWindowFingerprint(for: window)
+        else {
+            return 0
+        }
+
+        return axMetadata.filter { metadata in
+            metadata.processIdentifier == processIdentifier
+                && Int(metadata.size.width.rounded()) == cgFingerprint.width
+                && Int(metadata.size.height.rounded()) == cgFingerprint.height
+        }.count
+    }
+
+    func close(app: AlignerApp) throws -> WindowCloseResult {
+        DevelopmentDiagnostics.log("windowClose.app.start", [
+            "appName": app.name,
+            "bundleIdentifier": app.bundleIdentifier,
+            "pid": app.processIdentifier
+        ])
+
+        guard let processIdentifier = app.processIdentifier else {
+            DevelopmentDiagnostics.log("windowClose.app.missingPID", [
+                "appName": app.name,
+                "bundleIdentifier": app.bundleIdentifier
+            ])
+            return .appNotFound
+        }
+        guard let runningApplication = NSRunningApplication(processIdentifier: processIdentifier) else {
+            DevelopmentDiagnostics.log("windowClose.app.missingApplication", [
+                "appName": app.name,
+                "pid": processIdentifier
+            ])
+            return .appNotFound
+        }
+
+        let requested = runningApplication.terminate()
+        DevelopmentDiagnostics.log("windowClose.app.result", [
+            "appName": app.name,
+            "pid": processIdentifier,
+            "requested": requested
+        ])
+        return requested ? .requested : .failed("terminateFailed")
     }
 
     private func activateViaPrivateWindowServerAPI(_ window: AlignerWindow) -> PrivateWindowActivationOutcome? {
@@ -639,7 +919,8 @@
         let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
 
         if app.bundleIdentifier == "cc.ffitch.shottr" {
-            return alpha > 0
+            return isOnscreen
+                && alpha > 0
                 && trimmedTitle == "Shottr"
                 && rawBounds.width > WindowEnumerationPolicy.minimumVisibleWidth
                 && rawBounds.height > WindowEnumerationPolicy.minimumVisibleHeight
@@ -699,6 +980,7 @@
             return "activationFailed"
         }
     }
+
 }
 
 private struct AXWindowMetadata {
@@ -794,12 +1076,8 @@
             let app = app(from: application, appCategorizer: appCategorizer)
 
             let appElement = AXUIElementCreateApplication(application.processIdentifier)
-            var windowsValue: CFTypeRef?
-            guard AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsValue) == .success,
-                  let windows = windowsValue as? [AXUIElement]
-            else {
-                continue
-            }
+            let windows = windows(for: appElement)
+            guard !windows.isEmpty else { continue }
 
             for (windowIndex, window) in windows.enumerated() {
                 let subrole = subrole(for: window)
@@ -818,6 +1096,38 @@
                     element: window
                 ))
             }
+        }
+
+        return result
+    }
+
+    private static func windows(for appElement: AXUIElement) -> [AXUIElement] {
+        var result: [AXUIElement] = []
+        var seen = Set<AXWindowElementKey>()
+
+        func append(_ window: AXUIElement) {
+            let key = AXWindowElementKey(window)
+            guard !seen.contains(key) else { return }
+            seen.insert(key)
+            result.append(window)
+        }
+
+        var windowsValue: CFTypeRef?
+        if AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsValue) == .success,
+           let windows = windowsValue as? [AXUIElement] {
+            windows.forEach(append)
+        }
+
+        for attribute in [kAXFocusedWindowAttribute, kAXMainWindowAttribute] {
+            var windowValue: CFTypeRef?
+            guard AXUIElementCopyAttributeValue(appElement, attribute as CFString, &windowValue) == .success,
+                  let window = windowValue,
+                  CFGetTypeID(window) == AXUIElementGetTypeID()
+            else {
+                continue
+            }
+
+            append(window as! AXUIElement)
         }
 
         return result
@@ -925,6 +1235,39 @@
     }
 }
 
+private struct AXWindowElementKey: Hashable {
+    private let typeID: CFTypeID
+    private let pointer: UInt
+    private let windowID: UInt32?
+
+    init(_ element: AXUIElement) {
+        self.typeID = CFGetTypeID(element)
+        self.pointer = UInt(bitPattern: Unmanaged.passUnretained(element).toOpaque())
+        self.windowID = PrivateAXWindowIDReader.windowID(for: element)
+    }
+
+    static func == (lhs: AXWindowElementKey, rhs: AXWindowElementKey) -> Bool {
+        if let lhsWindowID = lhs.windowID,
+           let rhsWindowID = rhs.windowID {
+            return lhsWindowID == rhsWindowID
+        }
+
+        return lhs.typeID == rhs.typeID && lhs.pointer == rhs.pointer
+    }
+
+    func hash(into hasher: inout Hasher) {
+        if let windowID {
+            hasher.combine("windowID")
+            hasher.combine(windowID)
+            return
+        }
+
+        hasher.combine("pointer")
+        hasher.combine(typeID)
+        hasher.combine(pointer)
+    }
+}
+
 private extension AlignerWindowSubrole {
     var isExplicitlySupported: Bool {
         switch self {
@@ -963,6 +1306,10 @@
     value as? String
 }
 
+private func normalizedWindowTitle(_ title: String) -> String {
+    title.trimmingCharacters(in: .whitespacesAndNewlines)
+}
+
 private func boolValue(_ value: Any?) -> Bool {
     switch value {
     case let bool as Bool:
diff --git a/C1.source/Sources/Aligner/QuickSwitchCloseRequest.swift b/C1.source/Sources/Aligner/QuickSwitchCloseRequest.swift
new file mode 100644
index 0000000..aa2e5d3
--- /dev/null
+++ b/C1.source/Sources/Aligner/QuickSwitchCloseRequest.swift
@@ -0,0 +1,71 @@
+import Foundation
+import AlignerCore
+
+enum QuickSwitchCloseRequest {
+    case app(QuickSwitchAppShelfItemViewModel)
+    case window(QuickSwitchWindowCardViewModel)
+
+    var kindDescription: String {
+        switch self {
+        case .app:
+            return "app"
+        case .window:
+            return "window"
+        }
+    }
+
+    var appGroupIndex: Int {
+        switch self {
+        case .app(let item):
+            return item.appGroupIndex
+        case .window(let card):
+            return card.appGroupIndex
+        }
+    }
+
+    var windowID: UInt32? {
+        switch self {
+        case .app:
+            return nil
+        case .window(let card):
+            return card.window.id
+        }
+    }
+
+    var appName: String {
+        switch self {
+        case .app(let item):
+            return item.app.name
+        case .window(let card):
+            return card.window.app.name
+        }
+    }
+
+    var title: String {
+        switch self {
+        case .app(let item):
+            return item.app.name
+        case .window(let card):
+            let trimmedTitle = card.window.title.trimmingCharacters(in: .whitespacesAndNewlines)
+            return trimmedTitle.isEmpty ? "Untitled Window" : trimmedTitle
+        }
+    }
+
+    var app: AlignerApp {
+        switch self {
+        case .app(let item):
+            return item.app
+        case .window(let card):
+            return card.window.app
+        }
+    }
+
+    var window: AlignerWindow? {
+        switch self {
+        case .app:
+            return nil
+        case .window(let card):
+            return card.window
+        }
+    }
+}
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 7c3bd30..d8451c2 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -22,11 +22,14 @@
     private let waterfallLayer = CALayer()
     private let waterfallContentLayer = CALayer()
     private lazy var closeConfirmationLayers = makeCloseConfirmationLayers()
+    private let closeFeedbackLayer = CALayer()
+    private let closeFeedbackTextLayer = CATextLayer()
     private let appIconProvider: any AppIconProviderProtocol = WorkspaceAppIconProvider()
     private let skeletonThumbnailProvider = NativeSkeletonThumbnailProvider(size: NSSize(width: 160, height: 96))
     private var screenshotSourcesByWindowID: [UInt32: ScreenshotResolutionSource] = [:]
     private var screenshotSkippedReasonsByWindowID: [UInt32: String] = [:]
     private var currentViewModel: QuickSwitchViewModel?
+    private var spaceLaneDisplayGroups: [SpaceLaneDisplayGroupLayers] = []
     private var spaceLaneSegments: [SpaceLaneSegmentLayers] = []
     private var spaceLaneContentWidth: CGFloat = 0
     private var spaceLaneScrollOffset: CGFloat = 0
@@ -40,9 +43,14 @@
     private var hoveredSpaceID: UInt64?
     private var hoveredCloseTarget: CloseTarget?
     private var pendingCloseTarget: CloseTarget?
+    private var pendingCloseRequest: QuickSwitchCloseRequest?
     private var pendingCloseOptOut = false
     private var closeConfirmationArrowEdge: CloseConfirmationArrowEdge?
     private var closeConfirmationRequired = true
+    private var closeFeedback: CloseFeedback?
+    private var closeFeedbackHideTask: Task<Void, Never>?
+    private var hoverTarget: HoverTarget = .none
+    private var hoverTargetSource: HoverTargetSource = .event
     private var hoveredSpaceLaneID: UInt64?
     private var focusedSpaceID: UInt64?
     private var debugHoveredAppGroupIndex: Int?
@@ -108,9 +116,92 @@
         let cancelButtonTextLayer: CATextLayer
     }
 
+    private struct CloseFeedback: Equatable, Sendable {
+        enum Kind: String, Sendable {
+            case progress
+            case failure
+        }
+
+        let kind: Kind
+        let message: String
+        let targetKind: String
+        let appGroupIndex: Int
+        let windowID: UInt32?
+    }
+
     private enum CloseConfirmationArrowEdge: String {
         case left
         case right
+    }
+
+    private enum HoverTarget: Equatable {
+        case none
+        case space(UInt64)
+        case app(Int)
+        case window(appGroupIndex: Int, windowID: UInt32, spaceID: UInt64?)
+
+        var kindDescription: String {
+            switch self {
+            case .none:
+                return "none"
+            case .space:
+                return "space"
+            case .app:
+                return "app"
+            case .window:
+                return "window"
+            }
+        }
+
+        var appGroupIndex: Int? {
+            switch self {
+            case .app(let appGroupIndex):
+                return appGroupIndex
+            case .window(let appGroupIndex, _, _):
+                return appGroupIndex
+            case .none, .space:
+                return nil
+            }
+        }
+
+        var windowID: UInt32? {
+            switch self {
+            case .window(_, let windowID, _):
+                return windowID
+            case .none, .space, .app:
+                return nil
+            }
+        }
+
+        var spaceID: UInt64? {
+            switch self {
+            case .space(let spaceID):
+                return spaceID
+            case .window(_, _, let spaceID):
+                return spaceID
+            case .none, .app:
+                return nil
+            }
+        }
+
+        var spaceLaneID: UInt64? {
+            guard case .space(let spaceID) = self else { return nil }
+            return spaceID
+        }
+
+        var shouldAlignWaterfallWithAppShelf: Bool {
+            switch self {
+            case .app:
+                return true
+            case .none, .space, .window:
+                return false
+            }
+        }
+    }
+
+    private enum HoverTargetSource: String {
+        case event
+        case debug
     }
 
     private struct SpaceLaneSegmentLayers {
@@ -121,6 +212,12 @@
         let appLayer: CATextLayer
         let windowBlocks: [CALayer]
         let fullscreenMarkerLayer: CALayer?
+    }
+
+    private struct SpaceLaneDisplayGroupLayers {
+        let display: QuickSwitchDisplayViewModel
+        let containerLayer: CALayer
+        let segments: [SpaceLaneSegmentLayers]
     }
 
     private struct AppShelfItemLayers {
@@ -184,11 +281,15 @@
         static let columnWidth: CGFloat = 252
         static let columnMinWidth: CGFloat = 220
         static let columnMaxWidth: CGFloat = 292
-        static let columnPadding: CGFloat = 12
+        static let contentHorizontalPadding: CGFloat = 0
+        static let columnHorizontalPadding: CGFloat = 0
+        static let columnVerticalPadding: CGFloat = 12
+        static let columnHeaderTextInset: CGFloat = 12
         static let headerHeight: CGFloat = 34
         static let cardHeight: CGFloat = 138
         static let cardGap: CGFloat = 12
         static let revealPadding: CGFloat = 24
+        static let horizontalRevealPadding: CGFloat = 0
     }
 
     private enum WaterfallHorizontalIntent {
@@ -200,6 +301,16 @@
         static let tolerance: CGFloat = 1
         static let animationDuration: CFTimeInterval = 0.18
         static let animationTimingName = CAMediaTimingFunctionName.easeOut
+    }
+
+    private enum SpaceLaneMetrics {
+        static let horizontalPadding: CGFloat = 12
+        static let groupGap: CGFloat = 30
+        static let groupHorizontalPadding: CGFloat = 12
+        static let groupVerticalPadding: CGFloat = 10
+        static let segmentGap: CGFloat = 10
+        static let segmentMinWidth: CGFloat = 96
+        static let segmentMaxWidth: CGFloat = 150
     }
 
     override init(frame frameRect: NSRect) {
@@ -231,7 +342,7 @@
 
         let trackingArea = NSTrackingArea(
             rect: bounds,
-            options: [.activeInKeyWindow, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect],
+            options: [.activeAlways, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect],
             owner: self,
             userInfo: nil
         )
@@ -268,6 +379,8 @@
     }
 
     override func scrollWheel(with event: NSEvent) {
+        refreshHoverTarget(at: event.locationInWindow)
+
         let dominantDelta = abs(event.scrollingDeltaX) >= abs(event.scrollingDeltaY)
             ? event.scrollingDeltaX
             : event.scrollingDeltaY
@@ -318,43 +431,13 @@
             hoveredCloseTarget = closeTarget
             needsLayout = true
         }
-
-        if let segment = spaceLaneSegment(at: event.locationInWindow) {
-            hoverSpaceLaneSegment(segment)
-            return
-        }
-
-        if let card = waterfallCard(at: event.locationInWindow) {
-            hoveredSpaceLaneID = nil
-            updateHoverState(
-                appGroupIndex: card.item.appGroupIndex,
-                windowID: card.item.window.id,
-                spaceID: card.item.primarySpaceID
-            )
-            return
-        }
-
-        if let item = appShelfItem(at: event.locationInWindow) {
-            hoveredSpaceLaneID = nil
-            updateHoverState(
-                appGroupIndex: item.item.appGroupIndex,
-                windowID: nil,
-                spaceID: nil,
-                alignsWaterfallWithAppShelf: true
-            )
-            return
-        }
-
-        hoveredSpaceLaneID = nil
-        hoveredCloseTarget = nil
-        updateHoverState(appGroupIndex: nil, windowID: nil, spaceID: nil)
+        refreshHoverTarget(at: event.locationInWindow)
     }
 
     override func mouseExited(with event: NSEvent) {
         guard debugHoveredAppGroupIndex == nil else { return }
-        hoveredSpaceLaneID = nil
         hoveredCloseTarget = nil
-        updateHoverState(appGroupIndex: nil, windowID: nil, spaceID: nil)
+        setHoverTarget(.none)
     }
 
     override func mouseDown(with event: NSEvent) {
@@ -392,10 +475,8 @@
 
     func setDebugHoveredAppGroupIndex(_ index: Int?) {
         debugHoveredAppGroupIndex = index
+        setHoverTarget(.none, source: .debug)
         hoveredAppGroupIndex = index
-        hoveredWindowID = nil
-        hoveredSpaceID = nil
-        hoveredSpaceLaneID = nil
         needsLayout = true
     }
 
@@ -457,10 +538,47 @@
         closeConfirmationRequired = required
         if !required {
             pendingCloseTarget = nil
+            pendingCloseRequest = nil
             pendingCloseOptOut = false
             closeConfirmationLayers.containerLayer.isHidden = true
             closeConfirmationArrowEdge = nil
         }
+        needsLayout = true
+    }
+
+    func showCloseProgress(for request: QuickSwitchCloseRequest) {
+        setCloseFeedback(
+            CloseFeedback(
+                kind: .progress,
+                message: "正在关闭...",
+                targetKind: request.kindDescription,
+                appGroupIndex: request.appGroupIndex,
+                windowID: request.windowID
+            ),
+            autoHideAfter: nil
+        )
+    }
+
+    func showCloseFailure(message: String, for request: QuickSwitchCloseRequest) {
+        setCloseFeedback(
+            CloseFeedback(
+                kind: .failure,
+                message: message,
+                targetKind: request.kindDescription,
+                appGroupIndex: request.appGroupIndex,
+                windowID: request.windowID
+            ),
+            autoHideAfter: 2.75
+        )
+    }
+
+    func clearCloseFeedback() {
+        closeFeedbackHideTask?.cancel()
+        closeFeedbackHideTask = nil
+        closeFeedback = nil
+        closeFeedbackLayer.isHidden = true
+        closeFeedbackLayer.opacity = 0
+        closeFeedbackTextLayer.string = ""
         needsLayout = true
     }
 
@@ -471,7 +589,9 @@
         guard let card = waterfallCardsByWindowID()[windowID] else { return }
         card.thumbnailLayer.contents = resolution.image
         if case .realScreenshot = resolution.source {
-            card.shineLayer.opacity = card.item.window.id == effectiveSelection?.windowID ? 1 : 0
+            let isSelected = card.item.window.id == effectiveSelection?.windowID
+            let isHovered = card.item.window.id == hoveredWindowID
+            card.shineLayer.opacity = shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) ? 1 : 0
         }
         needsLayout = true
     }
@@ -507,20 +627,16 @@
         lastSpaceFocusedAppGroupIndex = nil
         lastSpaceLaneClickSpaceID = nil
         lastAppShelfScrollSyncedAppGroupIndex = nil
-        hoveredWindowID = nil
-        hoveredSpaceID = nil
+        focusedSpaceID = nil
         hoveredCloseTarget = nil
         pendingCloseTarget = nil
+        pendingCloseRequest = nil
         pendingCloseOptOut = false
         closeConfirmationLayers.containerLayer.isHidden = true
         closeConfirmationArrowEdge = nil
-        hoveredSpaceLaneID = nil
-        focusedSpaceID = nil
-        if debugHoveredAppGroupIndex == nil {
-            hoveredAppGroupIndex = nil
-        } else {
-            hoveredAppGroupIndex = debugHoveredAppGroupIndex
-        }
+        clearCloseFeedback()
+        setHoverTarget(.none)
+        hoveredAppGroupIndex = debugHoveredAppGroupIndex
         if let currentSelection {
             columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
         }
@@ -557,13 +673,20 @@
             "spaceLaneVisibleWidth": Double(laneLayer.bounds.width),
             "spaceLaneMaxScrollOffset": Double(spaceLaneMaxScrollOffset),
             "spaceLaneScrollable": spaceLaneMaxScrollOffset > 0,
+            "spaceLaneDisplayGroupCount": spaceLaneDisplayGroups.count,
+            "spaceLaneDisplayGroups": spaceLaneDisplayGroupReports(),
             "spaceLaneSegmentCount": spaceLaneSegments.count,
             "spaceLaneLabels": currentViewModel?.displays.flatMap { $0.spaces.map(\.label) } ?? [],
             "spaceLaneSegments": spaceLaneSegmentReports(),
+            "hoverTargetKind": hoverTarget.kindDescription,
+            "hoverTargetSource": hoverTargetSource.rawValue,
             "spaceLaneHoveredSpaceID": hoveredSpaceLaneID ?? NSNull(),
             "spaceLaneFocusedSpaceID": focusedSpaceID ?? NSNull(),
             "activeSpaceFocusID": activeSpaceFocusID ?? NSNull(),
             "spaceFocusAppGroupIndexes": spaceFocusedAppGroupIndexes(),
+            "appHoverAssociatedSpaceIDs": appHoverAssociatedSpaceIDs.sorted(),
+            "appHoverAssociatedSpaceLabels": appHoverAssociatedSpaceLabels,
+            "windowHoverAssociatedSpaceID": windowHoverAssociatedSpaceID ?? NSNull(),
             "lastSpaceFocusedAppGroupIndex": lastSpaceFocusedAppGroupIndex ?? NSNull(),
             "lastSpaceLaneClickSpaceID": lastSpaceLaneClickSpaceID ?? NSNull(),
             "lastAppShelfScrollSyncedAppGroupIndex": lastAppShelfScrollSyncedAppGroupIndex ?? NSNull(),
@@ -627,6 +750,13 @@
             "closeConfirmationVisible": !closeConfirmationLayers.containerLayer.isHidden,
             "closeConfirmationFrame": dictionary(from: closeConfirmationLayers.containerLayer.frame),
             "closeConfirmationArrowEdge": closeConfirmationArrowEdge?.rawValue ?? NSNull(),
+            "closeFeedbackVisible": closeFeedback != nil && !closeFeedbackLayer.isHidden,
+            "closeFeedbackKind": closeFeedback?.kind.rawValue ?? NSNull(),
+            "closeFeedbackMessage": closeFeedback?.message ?? NSNull(),
+            "closeFeedbackTargetKind": closeFeedback?.targetKind ?? NSNull(),
+            "closeFeedbackAppGroupIndex": closeFeedback?.appGroupIndex ?? NSNull(),
+            "closeFeedbackWindowID": closeFeedback?.windowID ?? NSNull(),
+            "closeFeedbackFrame": dictionary(from: closeFeedbackLayer.frame),
             "mouseCommandsApplied": mouseCommandsApplied,
             "lastMouseCommand": lastMouseCommand ?? NSNull(),
             "lastClickedAppGroupIndex": lastClickedAppGroupIndex ?? NSNull(),
@@ -756,7 +886,7 @@
             }
             recordMouseCommand("hover-space:\(spaceID)")
             if let segment = spaceLaneSegment(spaceID: spaceID) {
-                hoverSpaceLaneSegment(segment)
+                hoverSpaceLaneSegment(segment, source: .debug)
             }
         case "click-space" where parts.count == 2:
             guard let spaceID = UInt64(parts[1]) else {
@@ -774,7 +904,7 @@
             }
             recordMouseCommand("hover-card:\(appGroupIndex):\(windowIndex)")
             if let card = waterfallCard(appGroupIndex: appGroupIndex, windowIndex: windowIndex) {
-                hoverWindowCard(card)
+                hoverWindowCard(card, source: .debug)
             }
         case "click-card" where parts.count == 3:
             guard let appGroupIndex = Int(parts[1]), let windowIndex = Int(parts[2]) else {
@@ -826,13 +956,12 @@
     }
 
     private func hoverAppGroup(_ appGroupIndex: Int) {
-        hoveredSpaceLaneID = nil
-        hoveredCloseTarget = nil
         updateHoverState(
             appGroupIndex: appGroupIndex,
             windowID: nil,
             spaceID: nil,
-            alignsWaterfallWithAppShelf: true
+            alignsWaterfallWithAppShelf: true,
+            source: .debug
         )
     }
 
@@ -847,19 +976,18 @@
         beginCloseConfirmation(for: .app(appGroupIndex: appGroupIndex))
     }
 
-    private func hoverWindowCard(_ card: WaterfallCardLayers) {
-        hoveredSpaceLaneID = nil
-        hoveredCloseTarget = nil
+    private func hoverWindowCard(_ card: WaterfallCardLayers, source: HoverTargetSource = .event) {
         updateHoverState(
             appGroupIndex: card.item.appGroupIndex,
             windowID: card.item.window.id,
-            spaceID: card.item.primarySpaceID
+            spaceID: card.item.primarySpaceID,
+            source: source
         )
     }
 
     private func hoverCardCloseButton(appGroupIndex: Int, windowIndex: Int) {
         guard let card = waterfallCard(appGroupIndex: appGroupIndex, windowIndex: windowIndex) else { return }
-        hoverWindowCard(card)
+        hoverWindowCard(card, source: .debug)
         hoveredCloseTarget = .window(
             appGroupIndex: appGroupIndex,
             windowIndex: windowIndex,
@@ -880,8 +1008,8 @@
         )
     }
 
-    private func hoverSpaceLaneSegment(_ segment: SpaceLaneSegmentLayers) {
-        focusSpace(segment.space.id, persistent: false)
+    private func hoverSpaceLaneSegment(_ segment: SpaceLaneSegmentLayers, source: HoverTargetSource = .event) {
+        focusSpace(segment.space.id, persistent: false, source: source)
     }
 
     private func clickSpaceLaneSegment(_ segment: SpaceLaneSegmentLayers) {
@@ -902,11 +1030,8 @@
         commitWindowCard(firstCard, trigger: "spaceLane")
     }
 
-    private func focusSpace(_ spaceID: UInt64, persistent: Bool) {
-        hoveredAppGroupIndex = nil
-        hoveredWindowID = nil
-        hoveredSpaceID = spaceID
-        hoveredSpaceLaneID = spaceID
+    private func focusSpace(_ spaceID: UInt64, persistent: Bool, source: HoverTargetSource = .event) {
+        setHoverTarget(.space(spaceID), source: source)
         if persistent {
             focusedSpaceID = spaceID
             lastSpaceLaneClickSpaceID = spaceID
@@ -1035,24 +1160,67 @@
         appGroupIndex: Int?,
         windowID: UInt32?,
         spaceID: UInt64?,
-        alignsWaterfallWithAppShelf: Bool = false
+        alignsWaterfallWithAppShelf: Bool = false,
+        source: HoverTargetSource = .event
     ) {
-        guard hoveredAppGroupIndex != appGroupIndex
-            || hoveredWindowID != windowID
-            || hoveredSpaceID != spaceID
-        else {
-            if alignsWaterfallWithAppShelf, let appGroupIndex {
-                alignWaterfallColumnWithAppShelfIcon(appGroupIndex: appGroupIndex, animated: true)
-            }
-            return
+        let target: HoverTarget
+        if let appGroupIndex, let windowID {
+            target = .window(appGroupIndex: appGroupIndex, windowID: windowID, spaceID: spaceID)
+        } else if let appGroupIndex {
+            target = .app(appGroupIndex)
+        } else {
+            target = .none
         }
 
-        hoveredAppGroupIndex = appGroupIndex
-        hoveredWindowID = windowID
-        hoveredSpaceID = spaceID
-        if alignsWaterfallWithAppShelf, let appGroupIndex {
+        setHoverTarget(target, source: source, alignsWaterfallWithAppShelf: alignsWaterfallWithAppShelf)
+    }
+
+    private func refreshHoverTarget(at windowLocation: CGPoint) {
+        guard debugHoveredAppGroupIndex == nil else { return }
+        let target = resolveHoverTarget(at: windowLocation)
+        setHoverTarget(target, alignsWaterfallWithAppShelf: target.shouldAlignWaterfallWithAppShelf)
+    }
+
+    private func resolveHoverTarget(at windowLocation: CGPoint) -> HoverTarget {
+        if let segment = spaceLaneSegment(at: windowLocation) {
+            return .space(segment.space.id)
+        }
+
+        if let card = waterfallCard(at: windowLocation) {
+            return .window(
+                appGroupIndex: card.item.appGroupIndex,
+                windowID: card.item.window.id,
+                spaceID: card.item.primarySpaceID
+            )
+        }
+
+        if let item = appShelfItem(at: windowLocation) {
+            return .app(item.item.appGroupIndex)
+        }
+
+        return .none
+    }
+
+    private func setHoverTarget(
+        _ target: HoverTarget,
+        source: HoverTargetSource = .event,
+        alignsWaterfallWithAppShelf: Bool = false
+    ) {
+        let previousTarget = hoverTarget
+        let previousSource = hoverTargetSource
+        hoverTarget = target
+        hoverTargetSource = source
+
+        hoveredAppGroupIndex = target.appGroupIndex
+        hoveredWindowID = target.windowID
+        hoveredSpaceID = target.spaceID
+        hoveredSpaceLaneID = target.spaceLaneID
+
+        if alignsWaterfallWithAppShelf, let appGroupIndex = target.appGroupIndex {
             alignWaterfallColumnWithAppShelfIcon(appGroupIndex: appGroupIndex, animated: true)
         }
+
+        guard previousTarget != target || previousSource != source else { return }
         needsLayout = true
     }
 
@@ -1165,7 +1333,7 @@
             1,
             waterfallLayer.bounds.height
                 - WaterfallMetrics.headerHeight
-                - WaterfallMetrics.columnPadding * 2
+                - WaterfallMetrics.columnVerticalPadding * 2
         )
         let cardStride = WaterfallMetrics.cardHeight + WaterfallMetrics.cardGap
         let cardStart = CGFloat(selection.windowIndex) * cardStride
@@ -1228,7 +1396,7 @@
                 currentOffset: waterfallScrollOffset,
                 visibleWidth: waterfallLayer.bounds.width,
                 maxOffset: waterfallMaxScrollOffset,
-                inset: WaterfallMetrics.revealPadding
+                inset: WaterfallMetrics.horizontalRevealPadding
             )
         )
     }
@@ -1427,6 +1595,24 @@
 
         closeConfirmationLayers.containerLayer.isHidden = true
         glassLayer.addSublayer(closeConfirmationLayers.containerLayer)
+
+        closeFeedbackLayer.isHidden = true
+        closeFeedbackLayer.opacity = 0
+        closeFeedbackLayer.cornerRadius = 11
+        closeFeedbackLayer.borderWidth = 1
+        closeFeedbackLayer.masksToBounds = false
+        closeFeedbackLayer.shadowColor = NSColor.black.cgColor
+        closeFeedbackLayer.shadowOpacity = 0.14
+        closeFeedbackLayer.shadowRadius = 16
+        closeFeedbackLayer.shadowOffset = CGSize(width: 0, height: -5)
+        closeFeedbackLayer.zPosition = 190
+        closeFeedbackTextLayer.contentsScale = backingScaleFactor
+        closeFeedbackTextLayer.font = NSFont.systemFont(ofSize: 13, weight: .medium)
+        closeFeedbackTextLayer.fontSize = 13
+        closeFeedbackTextLayer.alignmentMode = .center
+        closeFeedbackTextLayer.truncationMode = .end
+        closeFeedbackLayer.addSublayer(closeFeedbackTextLayer)
+        glassLayer.addSublayer(closeFeedbackLayer)
     }
 
     private func rebuildAppShelfItems() {
@@ -1434,22 +1620,15 @@
             item.containerLayer.removeFromSuperlayer()
         }
 
-        let selectedAppGroupIndex = effectiveSelection?.appGroupIndex
         appShelfItems = (currentViewModel?.appShelf ?? []).map { item in
             let container = CALayer()
             container.name = item.app.name
 
             let background = CALayer()
             background.cornerRadius = 10
-            background.backgroundColor = (item.appGroupIndex == selectedAppGroupIndex
-                ? NSColor.systemFill.withAlphaComponent(0.16)
-                : NSColor.clear
-            ).cgColor
-            background.borderColor = (item.appGroupIndex == selectedAppGroupIndex
-                ? NSColor.separatorColor.withAlphaComponent(0.36)
-                : NSColor.clear
-            ).cgColor
-            background.borderWidth = item.appGroupIndex == selectedAppGroupIndex ? 1 : 0
+            background.backgroundColor = NSColor.clear.cgColor
+            background.borderColor = NSColor.clear.cgColor
+            background.borderWidth = 0
 
             let icon = CALayer()
             icon.contentsGravity = .resizeAspect
@@ -1523,7 +1702,8 @@
                 string: column.app.name,
                 fontSize: 13,
                 weight: .semibold,
-                color: .labelColor
+                color: .labelColor,
+                alignment: .center
             )
             let count = makeTextLayer(
                 string: "\(column.windows.count)",
@@ -1632,66 +1812,93 @@
     }
 
     private func rebuildSpaceLaneSegments() {
-        for segment in spaceLaneSegments {
-            segment.containerLayer.removeFromSuperlayer()
+        for group in spaceLaneDisplayGroups {
+            group.containerLayer.removeFromSuperlayer()
         }
 
-        let spaces = currentViewModel?.displays.flatMap(\.spaces) ?? []
-        spaceLaneSegments = spaces.map { space in
-            let container = CALayer()
-            container.name = space.label
-            container.cornerRadius = 10
-            container.borderWidth = 1
-            container.borderColor = NSColor.separatorColor.withAlphaComponent(0.34).cgColor
-            container.backgroundColor = spaceColor(for: space).cgColor
-            container.shadowColor = NSColor.black.cgColor
+        var groups: [SpaceLaneDisplayGroupLayers] = []
+        var allSegments: [SpaceLaneSegmentLayers] = []
 
-            let labelLayer = makeTextLayer(
-                string: space.label,
-                fontSize: 15,
-                weight: .semibold,
-                color: .labelColor
-            )
-            let countLayer = makeTextLayer(
-                string: "\(space.windowCount)",
-                fontSize: 12,
-                weight: .medium,
-                color: .secondaryLabelColor,
-                alignment: .center
-            )
-            let appLayer = makeTextLayer(
-                string: appIdentifierText(for: space),
-                fontSize: 12,
-                weight: space.type == .fullscreen ? .medium : .regular,
-                color: space.type == .fullscreen ? .labelColor : .secondaryLabelColor,
-                alignment: space.type == .fullscreen ? .center : .left
-            )
-            let windowBlocks = space.type == .fullscreen
-                ? []
-                : makeWindowBlocks(count: min(space.windowCount, 5), space: space)
-            let fullscreenMarkerLayer = makeFullscreenMarkerLayer(for: space)
+        for display in currentViewModel?.displays ?? [] {
+            let groupContainer = CALayer()
+            groupContainer.name = display.displayLabel
+            groupContainer.cornerRadius = 16
+            groupContainer.masksToBounds = false
+            groupContainer.borderWidth = 1
+            groupContainer.borderColor = NSColor.separatorColor.withAlphaComponent(0.26).cgColor
+            groupContainer.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.28).cgColor
+            groupContainer.shadowColor = NSColor.black.cgColor
+            groupContainer.shadowOpacity = 0
+            groupContainer.shadowRadius = 0
+            groupContainer.shadowOffset = .zero
 
-            container.addSublayer(labelLayer)
-            container.addSublayer(countLayer)
-            container.addSublayer(appLayer)
-            if let fullscreenMarkerLayer {
-                container.addSublayer(fullscreenMarkerLayer)
+            let segments = display.spaces.map { space in
+                let container = CALayer()
+                container.name = space.label
+                container.cornerRadius = 10
+                container.borderWidth = 1
+                container.borderColor = NSColor.separatorColor.withAlphaComponent(0.34).cgColor
+                container.backgroundColor = spaceColor(for: space).cgColor
+                container.shadowColor = NSColor.black.cgColor
+
+                let labelLayer = makeTextLayer(
+                    string: space.label,
+                    fontSize: 15,
+                    weight: .semibold,
+                    color: .labelColor
+                )
+                let countLayer = makeTextLayer(
+                    string: "\(space.windowCount)",
+                    fontSize: 12,
+                    weight: .medium,
+                    color: .secondaryLabelColor,
+                    alignment: .center
+                )
+                let appLayer = makeTextLayer(
+                    string: appIdentifierText(for: space),
+                    fontSize: 12,
+                    weight: space.type == .fullscreen ? .medium : .regular,
+                    color: space.type == .fullscreen ? .labelColor : .secondaryLabelColor,
+                    alignment: space.type == .fullscreen ? .center : .left
+                )
+                let windowBlocks = space.type == .fullscreen
+                    ? []
+                    : makeWindowBlocks(count: min(space.windowCount, 5), space: space)
+                let fullscreenMarkerLayer = makeFullscreenMarkerLayer(for: space)
+
+                container.addSublayer(labelLayer)
+                container.addSublayer(countLayer)
+                container.addSublayer(appLayer)
+                if let fullscreenMarkerLayer {
+                    container.addSublayer(fullscreenMarkerLayer)
+                }
+                for block in windowBlocks {
+                    container.addSublayer(block)
+                }
+                groupContainer.addSublayer(container)
+
+                return SpaceLaneSegmentLayers(
+                    space: space,
+                    containerLayer: container,
+                    labelLayer: labelLayer,
+                    countLayer: countLayer,
+                    appLayer: appLayer,
+                    windowBlocks: windowBlocks,
+                    fullscreenMarkerLayer: fullscreenMarkerLayer
+                )
             }
-            for block in windowBlocks {
-                container.addSublayer(block)
-            }
-            spaceLaneContentLayer.addSublayer(container)
 
-            return SpaceLaneSegmentLayers(
-                space: space,
-                containerLayer: container,
-                labelLayer: labelLayer,
-                countLayer: countLayer,
-                appLayer: appLayer,
-                windowBlocks: windowBlocks,
-                fullscreenMarkerLayer: fullscreenMarkerLayer
-            )
+            spaceLaneContentLayer.addSublayer(groupContainer)
+            groups.append(SpaceLaneDisplayGroupLayers(
+                display: display,
+                containerLayer: groupContainer,
+                segments: segments
+            ))
+            allSegments.append(contentsOf: segments)
         }
+
+        spaceLaneDisplayGroups = groups
+        spaceLaneSegments = allSegments
     }
 
     private func layoutLayers() {
@@ -1740,6 +1947,7 @@
         layoutAppShelfItems()
         layoutWaterfallColumns()
         layoutCloseConfirmation()
+        layoutCloseFeedback()
 
         CATransaction.commit()
     }
@@ -1823,15 +2031,33 @@
     }
 
     private func layoutSpaceLaneSegments() {
-        guard !spaceLaneSegments.isEmpty else { return }
+        guard !spaceLaneDisplayGroups.isEmpty, !spaceLaneSegments.isEmpty else { return }
 
-        let gap: CGFloat = 10
-        let segmentHeight = max(1, laneLayer.bounds.height - 20)
-        let availableWidth = laneLayer.bounds.width - 24 - CGFloat(spaceLaneSegments.count - 1) * gap
-        let segmentWidth = max(96, min(150, availableWidth / CGFloat(spaceLaneSegments.count)))
+        let groupHeight = max(1, laneLayer.bounds.height - 16)
+        let segmentHeight = max(1, groupHeight - SpaceLaneMetrics.groupVerticalPadding * 2)
+        let groupGapWidth = CGFloat(max(0, spaceLaneDisplayGroups.count - 1)) * SpaceLaneMetrics.groupGap
+        let segmentGapWidth = spaceLaneDisplayGroups.reduce(CGFloat(0)) { total, group in
+            total + CGFloat(max(0, group.segments.count - 1)) * SpaceLaneMetrics.segmentGap
+        }
+        let groupPaddingWidth = CGFloat(spaceLaneDisplayGroups.count) * SpaceLaneMetrics.groupHorizontalPadding * 2
+        let availableWidth = laneLayer.bounds.width
+            - SpaceLaneMetrics.horizontalPadding * 2
+            - groupGapWidth
+            - segmentGapWidth
+            - groupPaddingWidth
+        let segmentWidth = max(
+            SpaceLaneMetrics.segmentMinWidth,
+            min(SpaceLaneMetrics.segmentMaxWidth, availableWidth / CGFloat(spaceLaneSegments.count))
+        )
+        let groupWidths = spaceLaneDisplayGroups.map { group in
+            SpaceLaneMetrics.groupHorizontalPadding * 2
+                + CGFloat(group.segments.count) * segmentWidth
+                + CGFloat(max(0, group.segments.count - 1)) * SpaceLaneMetrics.segmentGap
+        }
+        let segmentGroupWidth = groupWidths.reduce(0, +) + groupGapWidth
         spaceLaneContentWidth = max(
             laneLayer.bounds.width,
-            24 + CGFloat(spaceLaneSegments.count) * segmentWidth + CGFloat(spaceLaneSegments.count - 1) * gap
+            SpaceLaneMetrics.horizontalPadding * 2 + segmentGroupWidth
         )
         spaceLaneScrollOffset = clampedSpaceLaneOffset(spaceLaneScrollOffset)
         spaceLaneContentLayer.frame = CGRect(
@@ -1841,13 +2067,35 @@
             height: laneLayer.bounds.height
         )
 
-        var x: CGFloat = 12
+        var x = max(
+            SpaceLaneMetrics.horizontalPadding,
+            (spaceLaneContentWidth - segmentGroupWidth) / 2
+        )
 
-        for segment in spaceLaneSegments {
-            segment.containerLayer.frame = CGRect(x: x, y: 10, width: segmentWidth, height: segmentHeight)
-            applySpaceLaneVisualState(segment)
-            layoutSpaceLaneSegmentContents(segment)
-            x += segmentWidth + gap
+        for (index, group) in spaceLaneDisplayGroups.enumerated() {
+            let groupWidth = groupWidths[index]
+            group.containerLayer.frame = CGRect(
+                x: x,
+                y: 8,
+                width: groupWidth,
+                height: groupHeight
+            )
+            applySpaceLaneDisplayGroupVisualState(group)
+
+            var segmentX = SpaceLaneMetrics.groupHorizontalPadding
+            for segment in group.segments {
+                segment.containerLayer.frame = CGRect(
+                    x: segmentX,
+                    y: SpaceLaneMetrics.groupVerticalPadding,
+                    width: segmentWidth,
+                    height: segmentHeight
+                )
+                applySpaceLaneVisualState(segment)
+                layoutSpaceLaneSegmentContents(segment)
+                segmentX += segmentWidth + SpaceLaneMetrics.segmentGap
+            }
+
+            x += groupWidth + SpaceLaneMetrics.groupGap
         }
     }
 
@@ -1859,29 +2107,74 @@
         min(max(0, offset), spaceLaneMaxScrollOffset)
     }
 
+    private func applySpaceLaneDisplayGroupVisualState(_ group: SpaceLaneDisplayGroupLayers) {
+        let hasActiveSegment = group.segments.contains { segment in
+            segment.space.id == activeSpaceFocusID
+                || appHoverAssociatedSpaceIDs.contains(segment.space.id)
+                || segment.space.id == windowHoverAssociatedSpaceID
+        }
+        let hasCurrentSegment = group.display.spaces.contains { $0.isCurrent }
+        let hasWindows = group.display.spaces.contains { $0.windowCount > 0 }
+        let background = hasActiveSegment
+            ? accentTintedWindowBackground(fraction: 0.035, alpha: 0.62)
+            : NSColor.windowBackgroundColor.withAlphaComponent(hasWindows ? 0.44 : 0.30)
+
+        group.containerLayer.backgroundColor = background.cgColor
+        group.containerLayer.borderColor = (hasActiveSegment
+            ? spaceLaneOccupiedColor.withAlphaComponent(0.36)
+            : hasCurrentSegment
+                ? NSColor.labelColor.withAlphaComponent(0.22)
+                : NSColor.separatorColor.withAlphaComponent(0.24)
+        ).cgColor
+        group.containerLayer.borderWidth = 1
+        group.containerLayer.shadowOpacity = hasActiveSegment ? 0.12 : 0
+        group.containerLayer.shadowRadius = hasActiveSegment ? 14 : 0
+        group.containerLayer.shadowOffset = CGSize(width: 0, height: -3)
+        group.containerLayer.zPosition = hasActiveSegment ? 8 : hasCurrentSegment ? 4 : 0
+        group.containerLayer.shadowPath = CGPath(
+            roundedRect: group.containerLayer.bounds,
+            cornerWidth: group.containerLayer.cornerRadius,
+            cornerHeight: group.containerLayer.cornerRadius,
+            transform: nil
+        )
+    }
+
     private func applySpaceLaneVisualState(_ segment: SpaceLaneSegmentLayers) {
-        let isHovered = segment.space.id == hoveredSpaceID
+        let isHovered = segment.space.id == activeSpaceFocusID
         let isFocused = segment.space.id == activeSpaceFocusID
         let isCurrent = segment.space.isCurrent
+        let hasWindows = segment.space.windowCount > 0
+        let isAppAssociated = appHoverAssociatedSpaceIDs.contains(segment.space.id)
+        let isWindowAssociated = segment.space.id == windowHoverAssociatedSpaceID
         let isActive = isHovered || isFocused
+        let isAssociated = (isAppAssociated || isWindowAssociated) && !isActive
 
-        segment.containerLayer.borderWidth = (isCurrent || isActive) ? 1.5 : 1
+        segment.containerLayer.borderWidth = (hasWindows || isCurrent || isActive || isAssociated) ? 1.5 : 1
         segment.containerLayer.borderColor = (isActive
-            ? NSColor.controlAccentColor.withAlphaComponent(0.54)
-            : isCurrent
-                ? NSColor.labelColor.withAlphaComponent(0.34)
+            ? spaceLaneOccupiedColor.withAlphaComponent(0.62)
+            : isAssociated
+                ? spaceLaneOccupiedColor.withAlphaComponent(0.50)
+                : hasWindows
+                    ? spaceLaneOccupiedColor.withAlphaComponent(isCurrent ? 0.54 : 0.44)
+                : isCurrent
+                    ? NSColor.labelColor.withAlphaComponent(0.34)
                 : NSColor.separatorColor.withAlphaComponent(0.34)
         ).cgColor
         segment.containerLayer.backgroundColor = (isActive
             ? accentTintedWindowBackground(fraction: 0.10, alpha: 0.94)
-            : spaceColor(for: segment.space)
+            : isAssociated
+                ? accentTintedWindowBackground(fraction: 0.07, alpha: 0.90)
+                : spaceColor(for: segment.space)
         ).cgColor
-        segment.containerLayer.shadowOpacity = isActive ? 0.26 : isCurrent ? 0.14 : 0
-        segment.containerLayer.shadowRadius = isActive ? 24 : isCurrent ? 12 : 0
-        segment.containerLayer.shadowOffset = CGSize(width: 0, height: isActive ? -6 : isCurrent ? -2.5 : 0)
-        segment.containerLayer.zPosition = isActive ? 30 : isCurrent ? 10 : 0
-        segment.containerLayer.transform = isActive
-            ? CATransform3DMakeScale(1.03, 1.03, 1)
+        segment.containerLayer.shadowOpacity = isActive ? 0.26 : isAssociated ? 0.18 : isCurrent ? 0.14 : 0
+        segment.containerLayer.shadowRadius = isActive ? 24 : isAssociated ? 18 : isCurrent ? 12 : 0
+        segment.containerLayer.shadowOffset = CGSize(
+            width: 0,
+            height: isActive ? -6 : isAssociated ? -4 : isCurrent ? -2.5 : 0
+        )
+        segment.containerLayer.zPosition = isActive ? 30 : isAssociated ? 18 : isCurrent ? 10 : 0
+        segment.containerLayer.transform = (isActive || isAssociated)
+            ? CATransform3DMakeScale(isActive ? 1.03 : 1.015, isActive ? 1.03 : 1.015, 1)
             : CATransform3DIdentity
         segment.containerLayer.shadowPath = CGPath(
             roundedRect: segment.containerLayer.bounds,
@@ -1944,9 +2237,17 @@
             y: locationInView.y - glassLayer.frame.minY - laneLayer.frame.minY - spaceLaneContentLayer.frame.minY
         )
 
-        return spaceLaneSegments.first { segment in
-            segment.containerLayer.frame.contains(locationInSpaceLaneContent)
+        for group in spaceLaneDisplayGroups where group.containerLayer.frame.contains(locationInSpaceLaneContent) {
+            let locationInGroup = CGPoint(
+                x: locationInSpaceLaneContent.x - group.containerLayer.frame.minX,
+                y: locationInSpaceLaneContent.y - group.containerLayer.frame.minY
+            )
+            if let segment = group.segments.first(where: { $0.containerLayer.frame.contains(locationInGroup) }) {
+                return segment
+            }
         }
+
+        return nil
     }
 
     private func spaceLaneSegment(spaceID: UInt64) -> SpaceLaneSegmentLayers? {
@@ -2069,8 +2370,8 @@
     private func layoutAppShelfItemContents(_ item: AppShelfItemLayers, iconSize: CGFloat) {
         let bounds = item.containerLayer.bounds
         let isSelected = item.item.appGroupIndex == effectiveSelection?.appGroupIndex
-        let showsSelectedVisual = shouldShowSelectedVisual(isSelected)
         let isHovered = item.item.appGroupIndex == effectiveHoveredAppGroupIndex
+        let showsSelectedVisual = shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered)
         let isSpaceFocused = spaceFocusWindowCount(for: item.item.appGroupIndex) > 0
         let visualIconSize = appShelfVisualIconSize(
             forBaseIconSize: iconSize,
@@ -2174,15 +2475,52 @@
     }
 
     private var activeSpaceFocusID: UInt64? {
-        hoveredSpaceLaneID
+        guard case .space(let spaceID) = hoverTarget else { return nil }
+
+        if hoverTargetSource == .debug {
+            return spaceID
+        }
+
+        guard let window else { return nil }
+        let currentMouseLocation = window.mouseLocationOutsideOfEventStream
+        guard let segment = spaceLaneSegment(at: currentMouseLocation),
+              segment.space.id == spaceID
+        else {
+            return nil
+        }
+        return spaceID
+    }
+
+    private var appHoverAssociatedSpaceIDs: Set<UInt64> {
+        guard case .app(let appGroupIndex) = hoverTarget else { return [] }
+        return Set(
+            waterfallColumns
+                .first { $0.column.appGroupIndex == appGroupIndex }?
+                .cards
+                .compactMap(\.item.primarySpaceID) ?? []
+        )
+    }
+
+    private var appHoverAssociatedSpaceLabels: [String] {
+        guard !appHoverAssociatedSpaceIDs.isEmpty else { return [] }
+        return currentViewModel?.displays
+            .flatMap(\.spaces)
+            .filter { appHoverAssociatedSpaceIDs.contains($0.id) }
+            .map(\.label)
+            .sorted() ?? []
+    }
+
+    private var windowHoverAssociatedSpaceID: UInt64? {
+        guard case .window(_, _, let spaceID) = hoverTarget else { return nil }
+        return spaceID
     }
 
     private var isSpaceAssociationActive: Bool {
         activeSpaceFocusID != nil
     }
 
-    private func shouldShowSelectedVisual(_ isSelected: Bool) -> Bool {
-        isSelected && !isSpaceAssociationActive
+    private func shouldShowSelectedVisual(isSelected: Bool, isHovered: Bool) -> Bool {
+        isSelected && isHovered && !isSpaceAssociationActive
     }
 
     private func spaceFocusedAppGroupIndexes() -> [Int] {
@@ -2319,7 +2657,7 @@
         }
 
         let columnWidth = waterfallColumnWidth()
-        let totalWidth = WaterfallMetrics.columnPadding * 2
+        let totalWidth = WaterfallMetrics.contentHorizontalPadding * 2
             + CGFloat(waterfallColumns.count) * columnWidth
             + CGFloat(max(0, waterfallColumns.count - 1)) * WaterfallMetrics.gap
         waterfallContentWidth = max(waterfallLayer.bounds.width, totalWidth)
@@ -2331,7 +2669,7 @@
             height: waterfallLayer.bounds.height
         )
 
-        var x = WaterfallMetrics.columnPadding
+        var x = WaterfallMetrics.contentHorizontalPadding
         for column in waterfallColumns {
             column.containerLayer.frame = CGRect(
                 x: x,
@@ -2347,6 +2685,7 @@
     private func layoutWaterfallColumn(_ column: WaterfallColumnLayers) {
         let bounds = column.containerLayer.bounds
         let isSpaceFocused = spaceFocusWindowCount(for: column.column.appGroupIndex) > 0
+        let isHovered = column.column.appGroupIndex == effectiveHoveredAppGroupIndex
         column.containerLayer.borderColor = (isSpaceFocused
             ? NSColor.separatorColor.withAlphaComponent(0.38)
             : NSColor.separatorColor.withAlphaComponent(0.24)
@@ -2363,11 +2702,15 @@
             : NSColor.controlBackgroundColor.withAlphaComponent(0.52)
         ).cgColor
         column.appNameLayer.frame = CGRect(
-            x: WaterfallMetrics.columnPadding,
+            x: WaterfallMetrics.columnHeaderTextInset,
             y: bounds.height - 24,
-            width: max(1, bounds.width - 64),
+            width: max(1, bounds.width - WaterfallMetrics.columnHeaderTextInset * 2),
             height: 16
         )
+        column.appNameLayer.foregroundColor = (isHovered
+            ? spaceLaneOccupiedColor
+            : NSColor.labelColor
+        ).cgColor
         column.countLayer.frame = CGRect(
             x: bounds.width - 48,
             y: bounds.height - 24,
@@ -2382,10 +2725,10 @@
         )
         waterfallColumnScrollOffsets[appGroupIndex] = verticalOffset
 
-        let cardWidth = max(1, bounds.width - WaterfallMetrics.columnPadding * 2)
+        let cardWidth = max(1, bounds.width - WaterfallMetrics.columnHorizontalPadding * 2)
         let firstCardTop = bounds.height
             - WaterfallMetrics.headerHeight
-            - WaterfallMetrics.columnPadding
+            - WaterfallMetrics.columnVerticalPadding
             + verticalOffset
 
         for card in column.cards {
@@ -2394,7 +2737,7 @@
                 - WaterfallMetrics.cardHeight
                 - CGFloat(cardIndex) * (WaterfallMetrics.cardHeight + WaterfallMetrics.cardGap)
             card.containerLayer.frame = CGRect(
-                x: WaterfallMetrics.columnPadding,
+                x: WaterfallMetrics.columnHorizontalPadding,
                 y: y,
                 width: cardWidth,
                 height: WaterfallMetrics.cardHeight
@@ -2407,9 +2750,13 @@
         let bounds = card.containerLayer.bounds
         let isSelected = card.item.window.id == effectiveSelection?.windowID
         let isHovered = card.item.window.id == hoveredWindowID
-        applyWaterfallCardVisualState(card, selected: shouldShowSelectedVisual(isSelected), hovered: isHovered)
+        applyWaterfallCardVisualState(
+            card,
+            selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered),
+            hovered: isHovered
+        )
 
-        let outerInset: CGFloat = 8
+        let outerInset: CGFloat = 0
         let titleBarHeight: CGFloat = 26
         let titleGap: CGFloat = 6
         let titleBarFrame = CGRect(
@@ -2470,7 +2817,7 @@
     }
 
     private func applyWaterfallCardVisualState(_ card: WaterfallCardLayers, selected: Bool, hovered: Bool) {
-        let isSpaceFocused = card.item.primarySpaceID == activeSpaceFocusID
+        let isSpaceFocused = isWaterfallCardSpaceFocused(card)
         card.containerLayer.backgroundColor = (selected
             ? NSColor.windowBackgroundColor.withAlphaComponent(0.94)
             : hovered
@@ -2527,13 +2874,14 @@
         let size: CGFloat = isButtonHovered ? 24 : 18
         let buttonFill = isButtonHovered
             ? NSColor.systemRed.withAlphaComponent(0.94)
-            : NSColor.labelColor.withAlphaComponent(0.24)
+            : NSColor.white.withAlphaComponent(0.34)
         let buttonStroke = isButtonHovered
             ? NSColor.white.withAlphaComponent(0.76)
-            : NSColor.white.withAlphaComponent(0.52)
+            : NSColor.white.withAlphaComponent(0.46)
         let glyphStroke = isButtonHovered
             ? NSColor.white
-            : NSColor.secondaryLabelColor.withAlphaComponent(0.92)
+            : NSColor.white.withAlphaComponent(0.88)
+
         button.containerLayer.frame = CGRect(
             x: anchorTopRight.x - size / 2,
             y: anchorTopRight.y - size / 2,
@@ -2545,10 +2893,10 @@
         button.containerLayer.borderColor = buttonStroke.cgColor
         button.containerLayer.opacity = visible ? 1 : 0
         button.containerLayer.zPosition = isButtonHovered ? 80 : 60
-        button.containerLayer.shadowOpacity = visible ? (isButtonHovered ? 0.22 : 0.07) : 0
+        button.containerLayer.shadowOpacity = visible ? (isButtonHovered ? 0.22 : 0.04) : 0
         button.containerLayer.shadowRadius = isButtonHovered ? 9 : 5
         button.glyphLayer.frame = CGRect(x: 0, y: 0, width: size, height: size)
-        button.glyphLayer.lineWidth = isButtonHovered ? 2.1 : 1.7
+        button.glyphLayer.lineWidth = isButtonHovered ? 2.1 : 1.45
         button.glyphLayer.strokeColor = glyphStroke.cgColor
         button.glyphLayer.path = closeGlyphPath(size: size, hovered: isButtonHovered)
     }
@@ -2641,6 +2989,80 @@
         layers.confirmButtonTextLayer.frame = layers.confirmButtonLayer.bounds.insetBy(dx: 0, dy: 7)
     }
 
+    private func setCloseFeedback(_ feedback: CloseFeedback, autoHideAfter delay: TimeInterval?) {
+        closeFeedbackHideTask?.cancel()
+        closeFeedback = feedback
+        closeFeedbackLayer.isHidden = false
+        closeFeedbackLayer.opacity = 1
+        closeFeedbackTextLayer.string = feedback.message
+        switch feedback.kind {
+        case .progress:
+            closeFeedbackLayer.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.94).cgColor
+            closeFeedbackLayer.borderColor = NSColor.separatorColor.withAlphaComponent(0.28).cgColor
+            closeFeedbackTextLayer.foregroundColor = NSColor.secondaryLabelColor.withAlphaComponent(0.96).cgColor
+        case .failure:
+            closeFeedbackLayer.backgroundColor = NSColor.systemRed.withAlphaComponent(0.10).cgColor
+            closeFeedbackLayer.borderColor = NSColor.systemRed.withAlphaComponent(0.32).cgColor
+            closeFeedbackTextLayer.foregroundColor = NSColor.systemRed.withAlphaComponent(0.95).cgColor
+        }
+        needsLayout = true
+
+        guard let delay else {
+            closeFeedbackHideTask = nil
+            return
+        }
+
+        closeFeedbackHideTask = Task { @MainActor [weak self] in
+            try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+            guard !Task.isCancelled,
+                  let self,
+                  self.closeFeedback == feedback
+            else {
+                return
+            }
+            self.clearCloseFeedback()
+        }
+    }
+
+    private func layoutCloseFeedback() {
+        guard let feedback = closeFeedback else {
+            closeFeedbackLayer.isHidden = true
+            closeFeedbackLayer.opacity = 0
+            return
+        }
+
+        closeFeedbackLayer.isHidden = false
+        closeFeedbackLayer.opacity = 1
+        let font = NSFont.systemFont(
+            ofSize: 13,
+            weight: feedback.kind == .failure ? .medium : .regular
+        )
+        closeFeedbackTextLayer.font = font
+        closeFeedbackTextLayer.fontSize = 13
+        let textWidth = (feedback.message as NSString).size(
+            withAttributes: [.font: font]
+        ).width
+        let maxWidth = max(180, glassLayer.bounds.width - 48)
+        let width = min(max(180, textWidth + 34), min(460, maxWidth))
+        let height: CGFloat = 34
+        let x = max(24, (glassLayer.bounds.width - width) / 2)
+        let preferredY = waterfallLayer.frame.maxY - height - 14
+        let y = min(
+            max(16, preferredY),
+            max(16, glassLayer.bounds.height - height - 16)
+        )
+
+        closeFeedbackLayer.frame = CGRect(x: x, y: y, width: width, height: height)
+        closeFeedbackLayer.cornerRadius = height / 2
+        closeFeedbackLayer.shadowPath = CGPath(
+            roundedRect: closeFeedbackLayer.bounds,
+            cornerWidth: closeFeedbackLayer.cornerRadius,
+            cornerHeight: closeFeedbackLayer.cornerRadius,
+            transform: nil
+        )
+        closeFeedbackTextLayer.frame = closeFeedbackLayer.bounds.insetBy(dx: 17, dy: 8)
+    }
+
     private func closeConfirmationBubblePath(
         bounds: CGRect,
         contentFrame: CGRect,
@@ -2726,18 +3148,23 @@
         }
 
         pendingCloseTarget = nil
+        pendingCloseRequest = nil
         pendingCloseOptOut = false
         closeConfirmationLayers.containerLayer.isHidden = true
         closeConfirmationArrowEdge = nil
-        if pendingCloseOptOut {
-            onCloseConfirmationDisabled?()
-        }
         onRequestClose?(request)
     }
 
     private func beginCloseConfirmation(for target: CloseTarget) {
         if closeConfirmationRequired {
+            guard let request = closeRequest(for: target) else {
+                DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.missingRequest", [
+                    "targetKind": target.kindDescription
+                ])
+                return
+            }
             pendingCloseTarget = target
+            pendingCloseRequest = request
             pendingCloseOptOut = false
             DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.show", [
                 "targetKind": target.kindDescription
@@ -2750,23 +3177,30 @@
     }
 
     private func confirmPendingClose() {
-        guard let target = pendingCloseTarget else { return }
+        guard let target = pendingCloseTarget,
+              let request = pendingCloseRequest
+        else { return }
         let shouldDisableConfirmation = pendingCloseOptOut
         pendingCloseTarget = nil
+        pendingCloseRequest = nil
         pendingCloseOptOut = false
         closeConfirmationLayers.containerLayer.isHidden = true
         closeConfirmationArrowEdge = nil
-        if shouldDisableConfirmation {
+        if shouldDisableConfirmation && closeConfirmationRequired {
+            closeConfirmationRequired = false
             onCloseConfirmationDisabled?()
         }
-        if let request = closeRequest(for: target) {
-            onRequestClose?(request)
-        }
+        DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.confirm", [
+            "targetKind": target.kindDescription,
+            "disableFutureConfirmation": shouldDisableConfirmation
+        ])
+        onRequestClose?(request)
     }
 
     private func cancelPendingClose() {
         DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.cancel")
         pendingCloseTarget = nil
+        pendingCloseRequest = nil
         pendingCloseOptOut = false
         closeConfirmationLayers.containerLayer.isHidden = true
         closeConfirmationArrowEdge = nil
@@ -2774,7 +3208,15 @@
     }
 
     private func toggleCloseConfirmationOptOut() {
+        if pendingCloseOptOut && !closeConfirmationRequired {
+            return
+        }
         pendingCloseOptOut.toggle()
+        if pendingCloseOptOut {
+            closeConfirmationRequired = false
+            onCloseConfirmationDisabled?()
+            DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.optOutPersisted")
+        }
         needsLayout = true
     }
 
@@ -2941,13 +3383,18 @@
         return color.withAlphaComponent(alpha)
     }
 
+    private func isWaterfallCardSpaceFocused(_ card: WaterfallCardLayers) -> Bool {
+        guard let activeSpaceFocusID else { return false }
+        return card.item.primarySpaceID == activeSpaceFocusID
+    }
+
     private func waterfallColumnWidth() -> CGFloat {
         guard waterfallColumns.count > 0 else { return WaterfallMetrics.columnWidth }
 
         let visibleColumns = min(4, max(1, waterfallColumns.count))
         let fittingWidth = (
             waterfallLayer.bounds.width
-                - WaterfallMetrics.columnPadding * 2
+                - WaterfallMetrics.contentHorizontalPadding * 2
                 - CGFloat(max(0, visibleColumns - 1)) * WaterfallMetrics.gap
         ) / CGFloat(visibleColumns)
         return max(
@@ -2975,7 +3422,7 @@
             1,
             waterfallLayer.bounds.height
                 - WaterfallMetrics.headerHeight
-                - WaterfallMetrics.columnPadding * 2
+                - WaterfallMetrics.columnVerticalPadding * 2
         )
         let contentHeight = CGFloat(column.cards.count) * WaterfallMetrics.cardHeight
             + CGFloat(max(0, column.cards.count - 1)) * WaterfallMetrics.cardGap
@@ -3006,14 +3453,19 @@
     }
 
     private func spaceColor(for space: QuickSwitchSpaceViewModel) -> NSColor {
-        if space.isCurrent {
-            return NSColor.windowBackgroundColor.withAlphaComponent(0.76)
-        }
         if space.windowCount > 0 {
-            return NSColor.controlBackgroundColor.withAlphaComponent(0.56)
+            return NSColor.controlBackgroundColor.withAlphaComponent(0.36)
         }
 
-        return NSColor.controlBackgroundColor.withAlphaComponent(0.26)
+        return NSColor.labelColor.withAlphaComponent(space.isCurrent ? 0.07 : 0.05)
+    }
+
+    private var spaceLaneOccupiedColor: NSColor {
+        if effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
+            return NSColor(calibratedRed: 10 / 255, green: 132 / 255, blue: 1, alpha: 1)
+        }
+
+        return NSColor(calibratedRed: 0, green: 122 / 255, blue: 1, alpha: 1)
     }
 
     private func makeFullscreenMarkerLayer(for space: QuickSwitchSpaceViewModel) -> CALayer? {
@@ -3179,10 +3631,9 @@
         return (0..<count).map { _ in
             let layer = CALayer()
             layer.cornerRadius = 3
-            layer.backgroundColor = (space.type == .fullscreen
-                ? NSColor.labelColor.withAlphaComponent(0.20)
-                : NSColor.labelColor.withAlphaComponent(0.18)
-            ).cgColor
+            layer.backgroundColor = spaceLaneOccupiedColor
+                .withAlphaComponent(space.isCurrent ? 0.72 : 0.58)
+                .cgColor
             return layer
         }
     }
@@ -3238,9 +3689,9 @@
     private func fullscreenMarkerFrame(in segmentBounds: CGRect) -> CGRect {
         CGRect(
             x: 10,
-            y: 30,
+            y: 12,
             width: max(1, segmentBounds.width - 20),
-            height: max(1, segmentBounds.height - 58)
+            height: max(1, segmentBounds.height - 64)
         )
     }
 
@@ -3282,7 +3733,9 @@
                 "closeButtonVisible": item.closeButton.containerLayer.opacity > 0,
                 "closeButtonFrame": dictionary(from: item.closeButton.containerLayer.frame),
                 "closeButtonSize": Double(item.closeButton.containerLayer.bounds.width),
-                "closeButtonFillColor": colorDictionary(from: item.closeButton.containerLayer.backgroundColor)
+                "closeButtonFillColor": colorDictionary(from: item.closeButton.containerLayer.backgroundColor),
+                "closeButtonGlyphColor": colorDictionary(from: item.closeButton.glyphLayer.strokeColor),
+                "closeButtonGlyphLineWidth": Double(item.closeButton.glyphLayer.lineWidth)
             ] as [String: Any]
         }
     }
@@ -3290,12 +3743,13 @@
     private func appShelfVisualStates(for item: AppShelfItemLayers) -> [String] {
         var states: [String] = []
         let isSelected = item.item.appGroupIndex == effectiveSelection?.appGroupIndex
-        if shouldShowSelectedVisual(isSelected) {
+        let isHovered = item.item.appGroupIndex == effectiveHoveredAppGroupIndex
+        if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) {
             states.append("selected")
         } else if isSelected {
             states.append("selectedVisualSuppressed")
         }
-        if item.item.appGroupIndex == effectiveHoveredAppGroupIndex {
+        if isHovered {
             states.append("hover")
         }
         if spaceFocusWindowCount(for: item.item.appGroupIndex) > 0 {
@@ -3308,21 +3762,39 @@
         return states
     }
 
+    private func waterfallColumnVisualStates(for column: WaterfallColumnLayers) -> [String] {
+        var states: [String] = []
+        if column.column.appGroupIndex == effectiveHoveredAppGroupIndex {
+            states.append("hover")
+        }
+        if spaceFocusWindowCount(for: column.column.appGroupIndex) > 0 {
+            states.append("spaceFocused")
+        }
+        return states
+    }
+
     private func waterfallColumnReports() -> [[String: Any]] {
         waterfallColumns.map { column in
             let visibleFrame = column.containerLayer.frame.offsetBy(dx: waterfallContentLayer.frame.minX, dy: 0)
             let appGroupIndex = column.column.appGroupIndex
+            let isHovered = appGroupIndex == effectiveHoveredAppGroupIndex
 
             return [
                 "appGroupIndex": appGroupIndex,
                 "appName": column.column.app.name,
                 "bundleIdentifier": column.column.app.bundleIdentifier,
                 "windowCount": column.column.windows.count,
+                "isHovered": isHovered,
+                "visualStates": waterfallColumnVisualStates(for: column),
                 "spaceFocusWindowCount": spaceFocusWindowCount(for: appGroupIndex),
                 "isSpaceFocused": spaceFocusWindowCount(for: appGroupIndex) > 0,
                 "spaceFocusDirection": spaceFocusDirection(for: appGroupIndex),
                 "frame": dictionary(from: column.containerLayer.frame),
                 "visibleFrame": dictionary(from: visibleFrame),
+                "appNameFrame": dictionary(from: column.appNameLayer.frame),
+                "appNameAlignment": column.appNameLayer.alignmentMode.rawValue,
+                "appNameColor": colorDictionary(from: column.appNameLayer.foregroundColor),
+                "countFrame": dictionary(from: column.countLayer.frame),
                 "verticalScrollOffset": Double(waterfallColumnScrollOffsets[appGroupIndex] ?? 0),
                 "maxVerticalScrollOffset": Double(waterfallMaxVerticalScrollOffset(for: appGroupIndex)),
                 "cards": waterfallCardReports(for: column)
@@ -3418,6 +3890,8 @@
                 "closeButtonFrame": dictionary(from: card.closeButton.containerLayer.frame),
                 "closeButtonSize": Double(card.closeButton.containerLayer.bounds.width),
                 "closeButtonFillColor": colorDictionary(from: card.closeButton.containerLayer.backgroundColor),
+                "closeButtonGlyphColor": colorDictionary(from: card.closeButton.glyphLayer.strokeColor),
+                "closeButtonGlyphLineWidth": Double(card.closeButton.glyphLayer.lineWidth),
                 "shineVisible": card.shineLayer.opacity > 0,
                 "zPosition": Double(card.containerLayer.zPosition),
                 "frame": dictionary(from: card.containerLayer.frame),
@@ -3513,15 +3987,16 @@
     private func waterfallCardVisualStates(for card: WaterfallCardLayers) -> [String] {
         var states: [String] = []
         let isSelected = card.item.window.id == effectiveSelection?.windowID
-        if shouldShowSelectedVisual(isSelected) {
+        let isHovered = card.item.window.id == hoveredWindowID
+        if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) {
             states.append("selected")
         } else if isSelected {
             states.append("selectedVisualSuppressed")
         }
-        if card.item.window.id == hoveredWindowID {
+        if isHovered {
             states.append("hover")
         }
-        if card.item.primarySpaceID == activeSpaceFocusID {
+        if isWaterfallCardSpaceFocused(card) {
             states.append("spaceFocused")
         }
         if card.item.window.isMinimized {
@@ -3537,16 +4012,47 @@
         window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
     }
 
+    private func spaceLaneDisplayGroupReports() -> [[String: Any]] {
+        spaceLaneDisplayGroups.map { group in
+            let visibleFrame = group.containerLayer.frame.offsetBy(dx: spaceLaneContentLayer.frame.minX, dy: 0)
+            return [
+                "displayUUID": group.display.displayUUID,
+                "displayIndex": group.display.displayIndex,
+                "displayLabel": group.display.displayLabel,
+                "physical": group.display.physical,
+                "spaceCount": group.segments.count,
+                "spaceIDs": group.segments.map(\.space.id),
+                "spaceLabels": group.segments.map(\.space.label),
+                "frame": dictionary(from: group.containerLayer.frame),
+                "visibleFrame": dictionary(from: visibleFrame),
+                "backgroundColor": colorDictionary(from: group.containerLayer.backgroundColor),
+                "borderColor": colorDictionary(from: group.containerLayer.borderColor),
+                "borderWidth": Double(group.containerLayer.borderWidth),
+                "shadowOpacity": Double(group.containerLayer.shadowOpacity),
+                "shadowRadius": Double(group.containerLayer.shadowRadius),
+                "groupGap": Double(SpaceLaneMetrics.groupGap),
+                "segmentGap": Double(SpaceLaneMetrics.segmentGap)
+            ] as [String: Any]
+        }
+    }
+
     private func spaceLaneSegmentReports() -> [[String: Any]] {
         currentViewModel?.displays.flatMap { display in
             display.spaces.compactMap { space in
                 guard let segment = spaceLaneSegments.first(where: { $0.space.id == space.id }) else {
                     return nil
                 }
-                let visibleFrame = segment.containerLayer.frame.offsetBy(dx: spaceLaneContentLayer.frame.minX, dy: 0)
+                let isAppAssociated = appHoverAssociatedSpaceIDs.contains(space.id)
+                let isWindowAssociated = space.id == windowHoverAssociatedSpaceID
+                let group = spaceLaneDisplayGroup(containingSpaceID: space.id)
+                let visibleFrame = segment.containerLayer.frame.offsetBy(
+                    dx: (group?.containerLayer.frame.minX ?? 0) + spaceLaneContentLayer.frame.minX,
+                    dy: group?.containerLayer.frame.minY ?? 0
+                )
                 return [
                     "displayUUID": display.displayUUID,
                     "displayLabel": display.displayLabel,
+                    "spaceID": space.id,
                     "label": space.label,
                     "type": spaceTypeName(space.type),
                     "visualStates": visualStates(for: space),
@@ -3554,15 +4060,21 @@
                     "fullscreenMarkerKind": segment.fullscreenMarkerLayer == nil ? "none" : "cornerBracket",
                     "fullscreenMarkerFrame": segment.fullscreenMarkerLayer.map { dictionary(from: $0.frame) } ?? NSNull(),
                     "windowBlockCount": segment.windowBlocks.count,
+                    "windowBlockColors": segment.windowBlocks.map { colorDictionary(from: $0.backgroundColor) },
+                    "windowBlockFrames": segment.windowBlocks.map { dictionary(from: $0.frame) },
                     "backgroundColor": colorDictionary(from: segment.containerLayer.backgroundColor),
+                    "borderColor": colorDictionary(from: segment.containerLayer.borderColor),
+                    "borderWidth": Double(segment.containerLayer.borderWidth),
                     "shadowOpacity": Double(segment.containerLayer.shadowOpacity),
                     "shadowRadius": Double(segment.containerLayer.shadowRadius),
                     "appFrame": dictionary(from: segment.appLayer.frame),
                     "frame": dictionary(from: segment.containerLayer.frame),
                     "visibleFrame": dictionary(from: visibleFrame),
                     "isCurrent": space.isCurrent,
-                    "isHovered": space.id == hoveredSpaceID,
+                    "isHovered": space.id == activeSpaceFocusID,
                     "isFocused": space.id == activeSpaceFocusID,
+                    "isAppAssociated": isAppAssociated,
+                    "isWindowAssociated": isWindowAssociated,
                     "windowCount": space.windowCount,
                     "appCount": space.appCount,
                     "appNames": space.appNames
@@ -3571,12 +4083,24 @@
         } ?? []
     }
 
+    private func spaceLaneDisplayGroup(containingSpaceID spaceID: UInt64) -> SpaceLaneDisplayGroupLayers? {
+        spaceLaneDisplayGroups.first { group in
+            group.segments.contains { $0.space.id == spaceID }
+        }
+    }
+
     private func visualStates(for space: QuickSwitchSpaceViewModel) -> [String] {
         var states: [String] = []
         if space.isCurrent {
             states.append("current")
         }
-        if space.id == hoveredSpaceID {
+        if appHoverAssociatedSpaceIDs.contains(space.id) {
+            states.append("appAssociated")
+        }
+        if space.id == windowHoverAssociatedSpaceID {
+            states.append("windowAssociated")
+        }
+        if space.id == activeSpaceFocusID {
             states.append("hover")
         }
         if space.id == activeSpaceFocusID {
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index fa79190..11aa41d 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -5,13 +5,21 @@
 @MainActor
 final class QuickSwitchSessionController {
     static let overlayTitle = "Aligner Quick Switch"
+    private static let closeRequestStillPresentMessage = "已发送关闭请求,但窗口仍在列表中。"
+    private static let closeTargetNotFoundMessage = "没有找到这个窗口的关闭控件,已刷新列表。"
+    private static let closeUnsupportedMessage = "这个窗口没有可用的关闭按钮。"
+    private static let closeFailedMessage = "关闭请求没有被系统接受。"
+    private static let closeVerificationRetryDelays: [TimeInterval] = [0.35, 0.70, 1.20]
 
     private let view = QuickSwitchRootView()
     private let snapshotLoader: any QuickSwitchSnapshotLoading
     private let screenshotProvider: any ScreenshotProviderProtocol
     private let windowActivationService: any WindowActivationServiceProtocol
+    private let windowCloseService: any WindowCloseServiceProtocol
     private let systemCriticalWindowDetector: any SystemCriticalWindowDetecting
     private let disableScreenshotRefresh: Bool
+    private var closeConfirmationRequired: Bool
+    private let onCloseConfirmationDisabled: (() -> Void)?
     private let debugOverlayWidth: CGFloat?
     private let debugKeySequence: [String]
     private let debugMouseSequence: [String]
@@ -40,6 +48,14 @@
     private var lastActivationWindowID: UInt32?
     private var lastActivationResult: WindowActivationResult?
     private var lastActivationError: String?
+    private var lastCloseTargetKind: String?
+    private var lastCloseAppGroupIndex: Int?
+    private var lastCloseAppName: String?
+    private var lastCloseWindowID: UInt32?
+    private var lastCloseResult: WindowCloseResult?
+    private var lastCloseError: String?
+    private var pendingCloseVerification: PendingCloseVerification?
+    private var suppressedCloseTargets: [SuppressedCloseTarget] = []
     private var lastDismissReason: DismissReason?
     private var lastSystemCriticalAction: String?
     private var lastSystemCriticalMonitorSource: String?
@@ -58,6 +74,26 @@
     private var lifecycleResidualVisibleOverlayWindows = 0
     var onSnapshotUpdated: (() -> Void)?
 
+    private struct PendingCloseVerification {
+        let request: QuickSwitchCloseRequest
+        let generation: Int
+        let attempt: Int
+    }
+
+    private enum SuppressedCloseTarget: Equatable {
+        case app(AlignerApp)
+        case window(UInt32)
+
+        init(_ request: QuickSwitchCloseRequest) {
+            switch request {
+            case .app:
+                self = .app(request.app)
+            case .window:
+                self = .window(request.windowID ?? 0)
+            }
+        }
+    }
+
     init(
         snapshotLoader: any QuickSwitchSnapshotLoading = LiveQuickSwitchSnapshotLoader(),
         screenshotProvider: any ScreenshotProviderProtocol = ScreenCaptureKitScreenshotProvider(
@@ -66,8 +102,13 @@
         windowActivationService: any WindowActivationServiceProtocol = CGWindowAXWindowService(
             spaceIDsByWindowIDProvider: { _ in [:] }
         ),
+        windowCloseService: any WindowCloseServiceProtocol = CGWindowAXWindowService(
+            spaceIDsByWindowIDProvider: { _ in [:] }
+        ),
         systemCriticalWindowDetector: any SystemCriticalWindowDetecting = CGWindowSystemCriticalWindowDetector(),
         disableScreenshotRefresh: Bool = false,
+        closeConfirmationRequired: Bool = true,
+        onCloseConfirmationDisabled: (() -> Void)? = nil,
         debugHoveredAppGroupIndex: Int? = nil,
         debugOverlayWidth: CGFloat? = nil,
         debugKeySequence: [String] = [],
@@ -77,8 +118,11 @@
         self.snapshotLoader = snapshotLoader
         self.screenshotProvider = screenshotProvider
         self.windowActivationService = windowActivationService
+        self.windowCloseService = windowCloseService
         self.systemCriticalWindowDetector = systemCriticalWindowDetector
         self.disableScreenshotRefresh = disableScreenshotRefresh
+        self.closeConfirmationRequired = closeConfirmationRequired
+        self.onCloseConfirmationDisabled = onCloseConfirmationDisabled
         self.debugOverlayWidth = debugOverlayWidth
         self.debugKeySequence = debugKeySequence
         self.debugMouseSequence = debugMouseSequence
@@ -107,6 +151,14 @@
         lastActivationWindowID = nil
         lastActivationResult = nil
         lastActivationError = nil
+        lastCloseTargetKind = nil
+        lastCloseAppGroupIndex = nil
+        lastCloseAppName = nil
+        lastCloseWindowID = nil
+        lastCloseResult = nil
+        lastCloseError = nil
+        pendingCloseVerification = nil
+        suppressedCloseTargets = []
         lastDismissReason = nil
         lastSystemCriticalAction = nil
         lastSystemCriticalMonitorSource = nil
@@ -128,6 +180,15 @@
         view.onCommitSelection = { [weak self] selection, source in
             self?.commitSelection(selection, source: source)
         }
+        view.onRequestClose = { [weak self] request in
+            self?.requestClose(request)
+        }
+        view.onCloseConfirmationDisabled = { [weak self] in
+            guard let self else { return }
+            self.closeConfirmationRequired = false
+            self.onCloseConfirmationDisabled?()
+        }
+        view.setCloseConfirmationRequired(closeConfirmationRequired)
         view.apply(viewModel: nil)
         view.beginPerformanceFirstFrameMeasurement()
         coordinator.openQuickSwitch()
@@ -157,6 +218,8 @@
         snapshotTask = nil
         screenshotTask?.cancel()
         screenshotTask = nil
+        pendingCloseVerification = nil
+        view.clearCloseFeedback()
         invalidateSystemCriticalMonitoring()
         coordinator.closeQuickSwitch(reason: reason)
         lifecycleResidualOverlayWindows = overlayWindowCount()
@@ -212,6 +275,13 @@
             "lastActivationWindowID": lastActivationWindowID ?? NSNull(),
             "lastActivationResult": lastActivationResult.map(Self.activationResultString) ?? NSNull(),
             "lastActivationError": lastActivationError ?? NSNull(),
+            "lastCloseTargetKind": lastCloseTargetKind ?? NSNull(),
+            "lastCloseAppGroupIndex": lastCloseAppGroupIndex ?? NSNull(),
+            "lastCloseAppName": lastCloseAppName ?? NSNull(),
+            "lastCloseWindowID": lastCloseWindowID ?? NSNull(),
+            "lastCloseResult": lastCloseResult.map(Self.closeResultString) ?? NSNull(),
+            "lastCloseError": lastCloseError ?? NSNull(),
+            "suppressedCloseTargetCount": suppressedCloseTargets.count,
             "lastDismissReason": lastDismissReason.map(Self.dismissReasonString) ?? NSNull(),
             "overlayLevel": Int(coordinator.currentLevel),
             "overlayMainMenuLevel": Int(CGWindowLevelForKey(.mainMenuWindow)),
@@ -264,16 +334,26 @@
         updateLifecycleWindowHighWaterMark()
     }
 
-    private func scheduleSnapshotRefresh(for generation: Int) {
+    private func scheduleSnapshotRefresh(
+        for generation: Int,
+        replayDebugCommands: Bool = true,
+        delay: TimeInterval = 0
+    ) {
         DevelopmentDiagnostics.log("quickSwitch.snapshot.schedule", [
             "generation": generation,
-            "visible": isVisible
+            "visible": isVisible,
+            "replayDebugCommands": replayDebugCommands,
+            "delayMilliseconds": Int(delay * 1_000)
         ])
         snapshotTask?.cancel()
         let loader = snapshotLoader
         let showStartTime = showStartTime
         snapshotTask = Task { [weak self] in
             guard let self, !Task.isCancelled else { return }
+            if delay > 0 {
+                try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+                guard !Task.isCancelled else { return }
+            }
 
             let loadTask = Task.detached(priority: .userInitiated) {
                 SnapshotLoadResult.load(using: loader, showStartTime: showStartTime)
@@ -285,7 +365,11 @@
             }
 
             guard !Task.isCancelled else { return }
-            self.applySnapshotResult(result, generation: generation)
+            self.applySnapshotResult(
+                result,
+                generation: generation,
+                replayDebugCommands: replayDebugCommands
+            )
         }
     }
 
@@ -432,6 +516,224 @@
         onSnapshotUpdated?()
     }
 
+    private func requestClose(_ request: QuickSwitchCloseRequest) {
+        DevelopmentDiagnostics.log("quickSwitch.close.request.start", [
+            "targetKind": request.kindDescription,
+            "appGroupIndex": request.appGroupIndex,
+            "appName": request.appName,
+            "windowID": request.windowID,
+            "titleHash": DevelopmentDiagnostics.stableFingerprint(request.title),
+            "titleLength": request.title.count
+        ])
+        lastCloseTargetKind = request.kindDescription
+        lastCloseAppGroupIndex = request.appGroupIndex
+        lastCloseAppName = request.appName
+        lastCloseWindowID = request.windowID
+        lastCloseError = nil
+        pendingCloseVerification = nil
+        view.showCloseProgress(for: request)
+
+        do {
+            let result: WindowCloseResult
+            switch request {
+            case .app:
+                result = try windowCloseService.close(app: request.app)
+            case .window:
+                guard let window = request.window else {
+                    lastCloseResult = .windowNotFound
+                    suppressCloseTarget(request)
+                    view.clearCloseFeedback()
+                    onSnapshotUpdated?()
+                    return
+                }
+                result = try windowCloseService.close(window: window)
+            }
+            lastCloseResult = result
+            DevelopmentDiagnostics.log("quickSwitch.close.request.result", [
+                "targetKind": request.kindDescription,
+                "appGroupIndex": request.appGroupIndex,
+                "appName": request.appName,
+                "windowID": request.windowID,
+                "result": Self.closeResultString(result)
+            ])
+            if result == .requested {
+                suppressCloseTarget(request)
+                DevelopmentDiagnostics.log("quickSwitch.close.request.refreshAfterClose", [
+                    "targetKind": request.kindDescription,
+                    "appGroupIndex": request.appGroupIndex,
+                    "appName": request.appName,
+                    "windowID": request.windowID
+                ])
+                pendingCloseVerification = PendingCloseVerification(
+                    request: request,
+                    generation: sessionGeneration,
+                    attempt: 0
+                )
+                scheduleSnapshotRefresh(
+                    for: sessionGeneration,
+                    replayDebugCommands: false,
+                    delay: Self.closeVerificationRetryDelays[0]
+                )
+            } else if result == .windowNotFound {
+                suppressCloseTarget(request)
+                view.clearCloseFeedback()
+                DevelopmentDiagnostics.log("quickSwitch.close.request.staleTargetRemoved", [
+                    "targetKind": request.kindDescription,
+                    "appGroupIndex": request.appGroupIndex,
+                    "appName": request.appName,
+                    "windowID": request.windowID
+                ])
+                scheduleSnapshotRefresh(
+                    for: sessionGeneration,
+                    replayDebugCommands: false,
+                    delay: 0.10
+                )
+            } else {
+                view.showCloseFailure(message: closeFailureMessage(for: result), for: request)
+            }
+        } catch {
+            let errorSummary = DevelopmentDiagnostics.errorSummaryString(error)
+            lastCloseResult = .failed(errorSummary)
+            lastCloseError = errorSummary
+            pendingCloseVerification = nil
+            view.showCloseFailure(message: Self.closeFailedMessage, for: request)
+            var fields: [String: CustomStringConvertible?] = [
+                "targetKind": request.kindDescription,
+                "appGroupIndex": request.appGroupIndex,
+                "appName": request.appName,
+                "windowID": request.windowID,
+                "titleHash": DevelopmentDiagnostics.stableFingerprint(request.title),
+                "titleLength": request.title.count
+            ]
+            DevelopmentDiagnostics.errorSummaryFields(error).forEach { fields[$0.key] = $0.value }
+            DevelopmentDiagnostics.log("quickSwitch.close.request.error", fields)
+        }
+
+        onSnapshotUpdated?()
+    }
+
+    private func suppressCloseTarget(_ request: QuickSwitchCloseRequest) {
+        let target = SuppressedCloseTarget(request)
+        if !suppressedCloseTargets.contains(target) {
+            suppressedCloseTargets.append(target)
+        }
+
+        guard let currentViewModel else { return }
+        let visibleViewModel = viewModel(currentViewModel, excluding: suppressedCloseTargets)
+        self.currentViewModel = visibleViewModel
+        view.apply(viewModel: visibleViewModel)
+        DevelopmentDiagnostics.log("quickSwitch.close.optimisticRemove", [
+            "targetKind": request.kindDescription,
+            "appGroupIndex": request.appGroupIndex,
+            "appName": request.appName,
+            "windowID": request.windowID,
+            "remainingWindowCount": visibleViewModel.windowCount,
+            "suppressedCloseTargetCount": suppressedCloseTargets.count
+        ])
+    }
+
+    private func viewModel(
+        _ viewModel: QuickSwitchViewModel,
+        excluding suppressedTargets: [SuppressedCloseTarget]
+    ) -> QuickSwitchViewModel {
+        guard !suppressedTargets.isEmpty else { return viewModel }
+
+        var nextGlobalIndex = 0
+        let columns = viewModel.waterfallColumns.compactMap { column -> QuickSwitchWaterfallColumnViewModel? in
+            let windows = column.windows.filter { card in
+                !suppressedTargets.contains { target in
+                    suppressedTarget(target, matches: card)
+                }
+            }
+            guard !windows.isEmpty else { return nil }
+
+            let reindexedWindows = windows.enumerated().map { windowIndex, card in
+                defer { nextGlobalIndex += 1 }
+                return QuickSwitchWindowCardViewModel(
+                    appGroupIndex: column.appGroupIndex,
+                    windowIndex: windowIndex,
+                    globalIndex: nextGlobalIndex,
+                    window: card.window,
+                    primarySpaceID: card.primarySpaceID,
+                    primarySpaceLabel: card.primarySpaceLabel
+                )
+            }
+
+            return QuickSwitchWaterfallColumnViewModel(
+                appGroupIndex: column.appGroupIndex,
+                app: column.app,
+                windows: reindexedWindows
+            )
+        }
+
+        let columnsByAppGroupIndex = Dictionary(uniqueKeysWithValues: columns.map { ($0.appGroupIndex, $0) })
+        let appShelf = viewModel.appShelf.compactMap { item -> QuickSwitchAppShelfItemViewModel? in
+            guard let column = columnsByAppGroupIndex[item.appGroupIndex] else { return nil }
+            return QuickSwitchAppShelfItemViewModel(
+                appGroupIndex: item.appGroupIndex,
+                app: item.app,
+                windowCount: column.windows.count,
+                primarySpaceIDs: orderedPrimarySpaceIDs(in: column.windows),
+                hasMinimizedWindows: column.windows.contains { $0.window.isMinimized },
+                hasFullscreenWindows: column.windows.contains { $0.window.isFullscreen }
+            )
+        }
+
+        return QuickSwitchViewModel(
+            displays: viewModel.displays,
+            appShelf: appShelf,
+            waterfallColumns: columns,
+            initialSelection: initialSelection(in: columns, fallback: viewModel.initialSelection)
+        )
+    }
+
+    private func orderedPrimarySpaceIDs(in cards: [QuickSwitchWindowCardViewModel]) -> [UInt64] {
+        var seen = Set<UInt64>()
+        var result: [UInt64] = []
+        for card in cards {
+            guard let spaceID = card.primarySpaceID,
+                  !seen.contains(spaceID)
+            else {
+                continue
+            }
+            seen.insert(spaceID)
+            result.append(spaceID)
+        }
+        return result
+    }
+
+    private func suppressedTarget(
+        _ target: SuppressedCloseTarget,
+        matches card: QuickSwitchWindowCardViewModel
+    ) -> Bool {
+        switch target {
+        case .window(let windowID):
+            return card.window.id == windowID
+        case .app(let app):
+            return appMatches(card.window.app, app)
+        }
+    }
+
+    private func initialSelection(
+        in columns: [QuickSwitchWaterfallColumnViewModel],
+        fallback: QuickSwitchSelection?
+    ) -> QuickSwitchSelection? {
+        if let fallback,
+           columns.contains(where: { column in
+               column.appGroupIndex == fallback.appGroupIndex
+                   && column.windows.contains { $0.window.id == fallback.windowID }
+           }) {
+            return fallback
+        }
+
+        guard let firstCard = columns.first?.windows.first else { return nil }
+        return QuickSwitchSelection(
+            appGroupIndex: firstCard.appGroupIndex,
+            windowIndex: firstCard.windowIndex,
+            windowID: firstCard.window.id
+        )
+    }
+
     private func shouldCloseAfterActivation(_ result: WindowActivationResult?) -> Bool {
         switch result {
         case .activated, .restored, .appActivatedOnly:
@@ -453,7 +755,11 @@
             .window
     }
 
-    private func applySnapshotResult(_ result: SnapshotLoadResult, generation: Int) {
+    private func applySnapshotResult(
+        _ result: SnapshotLoadResult,
+        generation: Int,
+        replayDebugCommands: Bool
+    ) {
         guard generation == sessionGeneration, isVisible else {
             DevelopmentDiagnostics.log("quickSwitch.snapshot.drop", [
                 "resultGeneration": generation,
@@ -466,15 +772,19 @@
         switch result {
         case .success(let success):
             currentSnapshot = success.snapshot
-            currentViewModel = success.viewModel
+            let visibleViewModel = viewModel(success.viewModel, excluding: suppressedCloseTargets)
+            currentViewModel = visibleViewModel
             lastSnapshotError = nil
             snapshotStartElapsedMilliseconds = success.timing.startElapsedMilliseconds
             snapshotDurationMilliseconds = success.timing.durationMilliseconds
             snapshotRanOnMainThread = success.timing.ranOnMainThread
-            view.apply(viewModel: success.viewModel)
+            view.apply(viewModel: visibleViewModel)
             view.recordPerformanceFirstFrameIfNeeded()
-            view.performDebugKeyboardCommands(debugKeySequence)
-            view.performDebugMouseCommands(debugMouseSequence)
+            if replayDebugCommands {
+                view.performDebugKeyboardCommands(debugKeySequence)
+                view.performDebugMouseCommands(debugMouseSequence)
+            }
+            verifyPendingCloseIfNeeded(in: success.viewModel)
             DevelopmentDiagnostics.log("quickSwitch.snapshot.success", [
                 "generation": generation,
                 "displayCount": success.viewModel.displays.count,
@@ -487,14 +797,14 @@
             ])
             guard isVisible else { return }
             if disableScreenshotRefresh {
-                view.markScreenshotsNotRequested(for: success.viewModel, reason: "disabledByLaunchOption")
+                view.markScreenshotsNotRequested(for: visibleViewModel, reason: "disabledByLaunchOption")
                 screenshotTask?.cancel()
                 screenshotTask = nil
                 DevelopmentDiagnostics.log("quickSwitch.screenshot.disabledByLaunchOption", [
                     "generation": generation
                 ])
             } else {
-                scheduleScreenshotRefresh(for: success.viewModel, generation: generation)
+                scheduleScreenshotRefresh(for: visibleViewModel, generation: generation)
             }
         case .failure(let failure):
             currentSnapshot = nil
@@ -506,6 +816,7 @@
             view.apply(viewModel: nil)
             screenshotTask?.cancel()
             screenshotTask = nil
+            failPendingCloseVerificationIfNeeded(reason: "snapshotFailure")
             DevelopmentDiagnostics.log("quickSwitch.snapshot.failure", [
                 "generation": generation,
                 "message": failure.message,
@@ -515,6 +826,104 @@
         }
 
         onSnapshotUpdated?()
+    }
+
+    private func verifyPendingCloseIfNeeded(in viewModel: QuickSwitchViewModel) {
+        guard let pendingCloseVerification else { return }
+
+        self.pendingCloseVerification = nil
+        let request = pendingCloseVerification.request
+        let stillPresent = closeTargetStillPresent(request, in: viewModel)
+        DevelopmentDiagnostics.log("quickSwitch.close.verify", [
+            "targetKind": request.kindDescription,
+            "appGroupIndex": request.appGroupIndex,
+            "appName": request.appName,
+            "windowID": request.windowID,
+            "requestedGeneration": pendingCloseVerification.generation,
+            "currentGeneration": sessionGeneration,
+            "attempt": pendingCloseVerification.attempt,
+            "stillPresent": stillPresent
+        ])
+
+        if stillPresent {
+            let nextAttempt = pendingCloseVerification.attempt + 1
+            if nextAttempt < Self.closeVerificationRetryDelays.count {
+                self.pendingCloseVerification = PendingCloseVerification(
+                    request: request,
+                    generation: sessionGeneration,
+                    attempt: nextAttempt
+                )
+                scheduleSnapshotRefresh(
+                    for: sessionGeneration,
+                    replayDebugCommands: false,
+                    delay: Self.closeVerificationRetryDelays[nextAttempt]
+                )
+            } else {
+                view.showCloseFailure(message: Self.closeRequestStillPresentMessage, for: request)
+            }
+        } else {
+            view.clearCloseFeedback()
+        }
+    }
+
+    private func failPendingCloseVerificationIfNeeded(reason: String) {
+        guard let pendingCloseVerification else { return }
+
+        self.pendingCloseVerification = nil
+        let request = pendingCloseVerification.request
+        DevelopmentDiagnostics.log("quickSwitch.close.verify.failed", [
+            "targetKind": request.kindDescription,
+            "appGroupIndex": request.appGroupIndex,
+            "appName": request.appName,
+            "windowID": request.windowID,
+            "requestedGeneration": pendingCloseVerification.generation,
+            "currentGeneration": sessionGeneration,
+            "reason": reason
+        ])
+        view.showCloseFailure(message: Self.closeFailedMessage, for: request)
+    }
+
+    private func closeFailureMessage(for result: WindowCloseResult) -> String {
+        switch result {
+        case .requested:
+            return Self.closeRequestStillPresentMessage
+        case .windowNotFound:
+            return Self.closeTargetNotFoundMessage
+        case .unsupported:
+            return Self.closeUnsupportedMessage
+        case .appNotFound, .failed:
+            return Self.closeFailedMessage
+        }
+    }
+
+    private func closeTargetStillPresent(
+        _ request: QuickSwitchCloseRequest,
+        in viewModel: QuickSwitchViewModel
+    ) -> Bool {
+        switch request {
+        case .window:
+            guard let windowID = request.windowID else { return false }
+            return viewModel.waterfallColumns
+                .flatMap(\.windows)
+                .contains { $0.window.id == windowID }
+        case .app:
+            return viewModel.appShelf.contains { appMatches($0.app, request.app) }
+                || viewModel.waterfallColumns.contains { appMatches($0.app, request.app) }
+        }
+    }
+
+    private func appMatches(_ candidate: AlignerApp, _ target: AlignerApp) -> Bool {
+        if let candidatePID = candidate.processIdentifier,
+           let targetPID = target.processIdentifier {
+            return candidatePID == targetPID
+        }
+
+        if !candidate.bundleIdentifier.isEmpty,
+           candidate.bundleIdentifier == target.bundleIdentifier {
+            return true
+        }
+
+        return candidate.name == target.name
     }
 
     private func scheduleScreenshotRefresh(
@@ -650,6 +1059,21 @@
         }
     }
 
+    private static func closeResultString(_ result: WindowCloseResult) -> String {
+        switch result {
+        case .requested:
+            return "requested"
+        case .windowNotFound:
+            return "windowNotFound"
+        case .appNotFound:
+            return "appNotFound"
+        case .unsupported:
+            return "unsupported"
+        case .failed(let message):
+            return "failed(\(message))"
+        }
+    }
+
     private static func screenshotSourceString(_ source: ScreenshotResolutionSource) -> String {
         switch source {
         case .realScreenshot:
@@ -742,6 +1166,7 @@
     let snapshotLoadDelay: TimeInterval?
     let fixtureAppCount: Int?
     let fixtureWindowsPerApp: Int?
+    let fixtureMultiDisplay: Bool
     let fixtureCandidateFiltering: Bool
     let fixtureActivation: Bool
     let debugHoveredAppGroupIndex: Int?
@@ -750,6 +1175,7 @@
     let debugMouseSequence: [String]
     let debugSystemCriticalAfter: TimeInterval?
     let debugWindowActivation: Bool
+    let debugWindowClose: Bool
     let disableScreenshotRefresh: Bool
     let lifecycleCycles: Int?
     let lifecycleInterval: TimeInterval
@@ -766,6 +1192,7 @@
             snapshotLoadDelay: timeInterval(for: "--round01-snapshot-load-delay", in: arguments),
             fixtureAppCount: intValue(for: "--round01-fixture-app-count", in: arguments),
             fixtureWindowsPerApp: intValue(for: "--round01-fixture-windows-per-app", in: arguments),
+            fixtureMultiDisplay: arguments.contains("--round01-fixture-multi-display"),
             fixtureCandidateFiltering: arguments.contains("--round01-fixture-candidate-filtering"),
             fixtureActivation: arguments.contains("--round01-fixture-window-activation"),
             debugHoveredAppGroupIndex: intValue(for: "--round01-debug-hover-app-index", in: arguments),
@@ -774,6 +1201,7 @@
             debugMouseSequence: stringListValue(for: "--round01-debug-mouse-sequence", in: arguments),
             debugSystemCriticalAfter: timeInterval(for: "--round01-debug-system-critical-after", in: arguments),
             debugWindowActivation: arguments.contains("--round01-debug-window-activation"),
+            debugWindowClose: arguments.contains("--round01-debug-window-close"),
             disableScreenshotRefresh: arguments.contains("--round01-disable-screenshot-refresh"),
             lifecycleCycles: intValue(for: "--round01-quick-switch-lifecycle-cycles", in: arguments),
             lifecycleInterval: timeInterval(for: "--round01-quick-switch-lifecycle-interval", in: arguments) ?? 0.02,
diff --git a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift b/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
index 7421c9f..8d45239 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
@@ -62,11 +62,18 @@
     private let appCount: Int
     private let windowsPerApp: Int
     private let mode: FixtureQuickSwitchMode
+    private let usesMultipleDisplays: Bool
 
-    init(appCount: Int, windowsPerApp: Int = 1, mode: FixtureQuickSwitchMode = .layout) {
+    init(
+        appCount: Int,
+        windowsPerApp: Int = 1,
+        mode: FixtureQuickSwitchMode = .layout,
+        usesMultipleDisplays: Bool = false
+    ) {
         self.appCount = max(1, appCount)
         self.windowsPerApp = max(1, windowsPerApp)
         self.mode = mode
+        self.usesMultipleDisplays = usesMultipleDisplays
     }
 
     func snapshot() throws -> QuickSwitchSnapshotLoad {
@@ -81,18 +88,8 @@
     }
 
     private func layoutSnapshot() -> QuickSwitchSnapshotLoad {
-        let displayUUID = "fixture-display-a"
-        let displays = [
-            AlignerDisplay(
-                uuid: displayUUID,
-                physical: true,
-                spaces: [
-                    AlignerSpace(id: 1, type: .user, displayUUID: displayUUID, index: 1),
-                    AlignerSpace(id: 2, type: .fullscreen, displayUUID: displayUUID, index: 2),
-                    AlignerSpace(id: 3, type: .user, displayUUID: displayUUID, index: 3)
-                ]
-            )
-        ]
+        let displays = layoutDisplays()
+        let spaceIDs: [UInt64] = usesMultipleDisplays ? [1, 2, 3, 4, 5] : [1, 2, 3]
         let windows = (0..<appCount).flatMap { index in
             let app = AlignerApp(
                 bundleIdentifier: "com.example.fixture.\(String(format: "%03d", index))",
@@ -103,7 +100,9 @@
 
             return (0..<windowsPerApp).map { windowIndex in
                 let spaceSeed = index + windowIndex
-                let spaceID = spaceSeed % 5 == 0 ? UInt64(2) : UInt64(spaceSeed % 2 == 0 ? 1 : 3)
+                let spaceID = usesMultipleDisplays
+                    ? spaceIDs[spaceSeed % spaceIDs.count]
+                    : UInt64(spaceSeed % 5 == 0 ? 2 : spaceSeed % 2 == 0 ? 1 : 3)
                 return AlignerWindow(
                     id: UInt32(10_000 + index * 100 + windowIndex),
                     app: app,
@@ -116,10 +115,48 @@
 
         return QuickSwitchSnapshotLoad(
             snapshot: QuickSwitchSnapshotBuilder.snapshot(displays: displays, windows: windows),
-            currentSpaceIDs: [2]
+            currentSpaceIDs: usesMultipleDisplays ? [2, 4] : [2]
         )
     }
 
+    private func layoutDisplays() -> [AlignerDisplay] {
+        let displayAUUID = "fixture-display-a"
+        guard usesMultipleDisplays else {
+            return [
+                AlignerDisplay(
+                    uuid: displayAUUID,
+                    physical: true,
+                    spaces: [
+                        AlignerSpace(id: 1, type: .user, displayUUID: displayAUUID, index: 1),
+                        AlignerSpace(id: 2, type: .fullscreen, displayUUID: displayAUUID, index: 2),
+                        AlignerSpace(id: 3, type: .user, displayUUID: displayAUUID, index: 3)
+                    ]
+                )
+            ]
+        }
+
+        let displayBUUID = "fixture-display-b"
+        return [
+            AlignerDisplay(
+                uuid: displayAUUID,
+                physical: true,
+                spaces: [
+                    AlignerSpace(id: 1, type: .user, displayUUID: displayAUUID, index: 1),
+                    AlignerSpace(id: 2, type: .fullscreen, displayUUID: displayAUUID, index: 2),
+                    AlignerSpace(id: 3, type: .user, displayUUID: displayAUUID, index: 3)
+                ]
+            ),
+            AlignerDisplay(
+                uuid: displayBUUID,
+                physical: true,
+                spaces: [
+                    AlignerSpace(id: 4, type: .user, displayUUID: displayBUUID, index: 1),
+                    AlignerSpace(id: 5, type: .user, displayUUID: displayBUUID, index: 2)
+                ]
+            )
+        ]
+    }
+
     private func candidateFilteringSnapshot() -> QuickSwitchSnapshotLoad {
         let displayUUID = "fixture-display-a"
         let displays = [
diff --git a/C1.source/Sources/AlignerCore/Protocols/ServiceProtocols.swift b/C1.source/Sources/AlignerCore/Protocols/ServiceProtocols.swift
index feda3eb..66b5af1 100644
--- a/C1.source/Sources/AlignerCore/Protocols/ServiceProtocols.swift
+++ b/C1.source/Sources/AlignerCore/Protocols/ServiceProtocols.swift
@@ -70,6 +70,19 @@
     func activate(window: AlignerWindow) throws -> WindowActivationResult
 }
 
+public enum WindowCloseResult: Equatable, Sendable {
+    case requested
+    case windowNotFound
+    case appNotFound
+    case unsupported
+    case failed(String)
+}
+
+public protocol WindowCloseServiceProtocol: AnyObject {
+    func close(window: AlignerWindow) throws -> WindowCloseResult
+    func close(app: AlignerApp) throws -> WindowCloseResult
+}
+
 @MainActor
 public protocol ScreenshotProviderProtocol {
     func screenshot(for window: AlignerWindow) async throws -> NSImage
diff --git a/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift b/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
index 6357849..586fc83 100644
--- a/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
+++ b/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
@@ -83,6 +83,7 @@
         guard !record.isDesktopElement else { return false }
         guard !record.isSystemCritical else { return false }
         guard record.isInteractable else { return false }
+        guard !isKnownClosedShottrCGPlaceholder(record) else { return false }
         guard !isCGOnlyOffscreenGhost(record) else { return false }
         guard !isKnownClosedDocumentPlaceholder(record) else { return false }
 
@@ -91,7 +92,7 @@
 
     private static func hasSupportedActivationPolicy(_ record: WindowEnumerationRecord) -> Bool {
         record.activationPolicy == .regular
-            || (record.activationPolicy == .accessory && isKnownCGOnlyOffscreenMainWindow(record))
+            || (record.activationPolicy == .accessory && isKnownAccessoryMainWindow(record))
     }
 
     public static func window(from record: WindowEnumerationRecord) -> AlignerWindow? {
@@ -132,6 +133,10 @@
             return false
         }
 
+        if !record.spaceIDs.isEmpty {
+            return false
+        }
+
         return !isKnownCGOnlyOffscreenMainWindow(record)
     }
 
@@ -141,8 +146,6 @@
         switch record.app.bundleIdentifier {
         case "com.apple.finder":
             return !trimmedTitle.isEmpty && isLargeEnough(record.size)
-        case "cc.ffitch.shottr":
-            return trimmedTitle == "Shottr" && isLargeEnough(record.size)
         case "io.dcloud.HBuilderX":
             return !trimmedTitle.isEmpty && isLargeEnough(record.size)
         default:
@@ -150,6 +153,27 @@
         }
     }
 
+    private static func isKnownAccessoryMainWindow(_ record: WindowEnumerationRecord) -> Bool {
+        let trimmedTitle = record.title.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        return record.app.bundleIdentifier == "cc.ffitch.shottr"
+            && trimmedTitle == "Shottr"
+            && record.isOnscreen
+            && isLargeEnough(record.size)
+    }
+
+    private static func isKnownClosedShottrCGPlaceholder(_ record: WindowEnumerationRecord) -> Bool {
+        let trimmedTitle = record.title.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        return record.app.bundleIdentifier == "cc.ffitch.shottr"
+            && trimmedTitle == "Shottr"
+            && record.identifierSource == .cgWindow
+            && !record.hasAXBacking
+            && !record.isOnscreen
+            && !record.isMinimized
+            && !record.isFullscreen
+    }
+
     private static func isKnownClosedDocumentPlaceholder(_ record: WindowEnumerationRecord) -> Bool {
         let trimmedTitle = record.title.trimmingCharacters(in: .whitespacesAndNewlines)
         return record.identifierSource == .cgWindow
diff --git a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
index 2dcabd3..298f686 100644
--- a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
+++ b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
@@ -212,12 +212,25 @@
         let record = makeWindowRecord(
             title: "Closed Document.md",
             hasAXBacking: false,
-            isOnscreen: false,
-            spaceIDs: [5]
+            isOnscreen: false
         )
 
         XCTAssertFalse(WindowEnumerationPolicy.shouldInclude(record))
         XCTAssertNil(WindowEnumerationPolicy.window(from: record))
+    }
+
+    func testWindowEnumerationPolicyKeepsCGOnlyOffscreenWindowsWithKnownSpaceIDs() {
+        let record = makeWindowRecord(
+            title: "Other Space Document.md",
+            hasAXBacking: false,
+            isOnscreen: false,
+            spaceIDs: [5]
+        )
+
+        let window = WindowEnumerationPolicy.window(from: record)
+
+        XCTAssertEqual(window?.title, "Other Space Document.md")
+        XCTAssertEqual(window?.spaceIDs, [5])
     }
 
     func testWindowEnumerationPolicyKeepsAXBackedOffscreenWindows() {
@@ -283,13 +296,13 @@
         XCTAssertNil(WindowEnumerationPolicy.window(from: tinyRecord))
     }
 
-    func testWindowEnumerationPolicyKeepsShottrCGOnlyAnnotationWindow() {
+    func testWindowEnumerationPolicyExcludesClosedShottrCGPlaceholderButKeepsVisibleWindow() {
         let shottrApp = AlignerApp(
             bundleIdentifier: "cc.ffitch.shottr",
             name: "Shottr",
             category: .generic
         )
-        let annotationRecord = WindowEnumerationRecord(
+        let closedPlaceholderRecord = WindowEnumerationRecord(
             id: 78,
             app: shottrApp,
             activationPolicy: .accessory,
@@ -298,6 +311,16 @@
             subrole: .standard,
             hasAXBacking: false,
             isOnscreen: false
+        )
+        let visibleRecord = WindowEnumerationRecord(
+            id: 79,
+            app: shottrApp,
+            activationPolicy: .accessory,
+            title: "Shottr",
+            size: CGSize(width: 1710, height: 997),
+            subrole: .standard,
+            hasAXBacking: false,
+            isOnscreen: true
         )
         let utilityRecord = WindowEnumerationRecord(
             id: 102,
@@ -310,7 +333,8 @@
             isOnscreen: false
         )
 
-        XCTAssertEqual(WindowEnumerationPolicy.window(from: annotationRecord)?.title, "Shottr")
+        XCTAssertNil(WindowEnumerationPolicy.window(from: closedPlaceholderRecord))
+        XCTAssertEqual(WindowEnumerationPolicy.window(from: visibleRecord)?.title, "Shottr")
         XCTAssertNil(WindowEnumerationPolicy.window(from: utilityRecord))
     }
 
diff --git a/C3.tools/generate-app-icon.py b/C3.tools/generate-app-icon.py
index bb01b28..d75bc57 100755
--- a/C3.tools/generate-app-icon.py
+++ b/C3.tools/generate-app-icon.py
@@ -1,241 +1,82 @@
 #!/usr/bin/env python3
-"""Generate Aligner's macOS app icon assets."""
+"""Sync Aligner's macOS app icon assets from the design handoff."""
 
 from __future__ import annotations
 
 import json
 import shutil
-import subprocess
 from pathlib import Path
 
-from PIL import Image, ImageDraw, ImageFilter
 
-
-ROOT = Path(__file__).resolve().parents[1]
-RESOURCES = ROOT / "C1.source" / "Resources"
+OUTPUT_ROOT = Path(__file__).resolve().parents[1]
+PROJECT_ROOT = OUTPUT_ROOT.parent
+RESOURCES = OUTPUT_ROOT / "C1.source" / "Resources"
 APPICONSET = RESOURCES / "Assets.xcassets" / "AppIcon.appiconset"
 ICNS_PATH = RESOURCES / "AppIcon.icns"
 
-BASE_SIZE = 1024
-SCALE = 4
-CANVAS_SIZE = BASE_SIZE * SCALE
+HANDOFF_DIR = (
+    PROJECT_ROOT
+    / "02-P"
+    / "Round01-QuickSwitch-MVP"
+    / "02.ui设计稿"
+    / "AppIcon-D1-BigBars-Handoff-20260604"
+)
+HANDOFF_ICONSET = HANDOFF_DIR / "AppIcon-D1-BigBars.iconset"
+HANDOFF_ICNS = HANDOFF_DIR / "Aligner-AppIcon-D1-BigBars.icns"
+HANDOFF_PREVIEW = HANDOFF_DIR / "Aligner-AppIcon-D1-BigBars-1024.png"
+
+ICON_FILES = [
+    "icon_16x16.png",
+    "icon_16x16@2x.png",
+    "icon_32x32.png",
+    "icon_32x32@2x.png",
+    "icon_128x128.png",
+    "icon_128x128@2x.png",
+    "icon_256x256.png",
+    "icon_256x256@2x.png",
+    "icon_512x512.png",
+    "icon_512x512@2x.png",
+]
 
 
-def s(value: float) -> int:
-    return round(value * SCALE)
+def require_file(path: Path) -> None:
+    if not path.is_file():
+        raise FileNotFoundError(f"missing app icon source: {path}")
 
 
-def sb(bbox: tuple[float, float, float, float]) -> tuple[int, int, int, int]:
-    return tuple(s(value) for value in bbox)
+def load_contents_json() -> dict:
+    contents_path = HANDOFF_ICONSET / "Contents.json"
+    require_file(contents_path)
+    with contents_path.open("r", encoding="utf-8") as file:
+        return json.load(file)
 
 
-def rounded_mask(size: tuple[int, int], bbox: tuple[int, int, int, int], radius: int) -> Image.Image:
-    mask = Image.new("L", size, 0)
-    ImageDraw.Draw(mask).rounded_rectangle(bbox, radius=radius, fill=255)
-    return mask
-
-
-def composite_masked(base: Image.Image, layer: Image.Image, mask: Image.Image) -> None:
-    base.alpha_composite(Image.composite(layer, Image.new("RGBA", base.size), mask))
-
-
-def vertical_gradient(
-    size: tuple[int, int],
-    top: tuple[int, int, int, int],
-    bottom: tuple[int, int, int, int],
-) -> Image.Image:
-    image = Image.new("RGBA", size)
-    draw = ImageDraw.Draw(image)
-    height = max(size[1] - 1, 1)
-    for y in range(size[1]):
-        t = y / height
-        color = tuple(round(top[i] * (1 - t) + bottom[i] * t) for i in range(4))
-        draw.line((0, y, size[0], y), fill=color)
-    return image
-
-
-def add_shadow(
-    base: Image.Image,
-    mask: Image.Image,
-    color: tuple[int, int, int, int],
-    blur: int,
-    dx: int,
-    dy: int,
-) -> None:
-    shadow_mask = mask.filter(ImageFilter.GaussianBlur(blur))
-    shifted = Image.new("L", base.size, 0)
-    shifted.paste(shadow_mask, (dx, dy))
-    composite_masked(base, Image.new("RGBA", base.size, color), shifted)
-
-
-def draw_outer_shell(canvas: Image.Image) -> None:
-    bbox = sb((78, 58, 946, 934))
-    radius = s(206)
-    mask = rounded_mask(canvas.size, bbox, radius)
-
-    add_shadow(canvas, mask, (55, 72, 98, 22), s(16), 0, s(16))
-    add_shadow(canvas, mask, (255, 255, 255, 135), s(8), 0, s(-5))
-
-    shell_fill = vertical_gradient(
-        canvas.size,
-        (253, 254, 255, 250),
-        (228, 236, 246, 247),
-    )
-    composite_masked(canvas, shell_fill, mask)
-
-    edge = ImageDraw.Draw(canvas)
-    edge.rounded_rectangle(bbox, radius=radius, outline=(255, 255, 255, 226), width=s(7))
-    edge.rounded_rectangle(
-        sb((90, 70, 934, 922)),
-        radius=radius - s(12),
-        outline=(255, 255, 255, 85),
-        width=s(2),
-    )
-    edge.rounded_rectangle(bbox, radius=radius, outline=(168, 184, 206, 32), width=s(2))
-
-
-def draw_capsule(
-    canvas: Image.Image,
-    bbox: tuple[int, int, int, int],
-    top: tuple[int, int, int, int],
-    bottom: tuple[int, int, int, int],
-    outer_edge: tuple[int, int, int, int],
-    shadow_color: tuple[int, int, int, int],
-    shadow_blur: int,
-    is_accent: bool = False,
-) -> None:
-    radius = (bbox[2] - bbox[0]) // 2
-    mask = rounded_mask(canvas.size, bbox, radius)
-
-    add_shadow(canvas, mask, shadow_color, shadow_blur, 0, s(16 if not is_accent else 18))
-    fill = vertical_gradient(canvas.size, top, bottom)
-    composite_masked(canvas, fill, mask)
-
-    draw = ImageDraw.Draw(canvas)
-    draw.rounded_rectangle(bbox, radius=radius, outline=(255, 255, 255, 218), width=s(6))
-    draw.rounded_rectangle(
-        (bbox[0] + s(8), bbox[1] + s(8), bbox[2] - s(8), bbox[3] - s(8)),
-        radius=radius - s(9),
-        outline=(255, 255, 255, 102 if not is_accent else 76),
-        width=s(2),
-    )
-    draw.rounded_rectangle(bbox, radius=radius, outline=outer_edge, width=s(2))
-
-    if not is_accent:
-        shade = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
-        shade_draw = ImageDraw.Draw(shade)
-        shade_draw.rounded_rectangle(
-            (bbox[0] + s(7), bbox[1] + s(12), bbox[0] + s(30), bbox[3] - s(12)),
-            radius=radius // 2,
-            fill=(136, 158, 184, 22),
-        )
-        composite_masked(canvas, shade, mask)
-
-
-def draw_icon() -> Image.Image:
-    canvas = Image.new("RGBA", (CANVAS_SIZE, CANVAS_SIZE), (0, 0, 0, 0))
-    draw_outer_shell(canvas)
-
-    left_bar = sb((300, 262, 420, 742))
-    middle_bar = sb((452, 262, 572, 742))
-    accent_bar = sb((604, 262, 724, 742))
-
-    neutral_edge = (136, 158, 184, 58)
-    draw_capsule(
-        canvas,
-        left_bar,
-        (238, 245, 252, 226),
-        (228, 237, 247, 226),
-        neutral_edge,
-        (136, 158, 184, 58),
-        s(36),
-    )
-    draw_capsule(
-        canvas,
-        middle_bar,
-        (232, 241, 249, 232),
-        (222, 234, 246, 232),
-        neutral_edge,
-        (136, 158, 184, 66),
-        s(38),
-    )
-    draw_capsule(
-        canvas,
-        accent_bar,
-        (75, 183, 255, 238),
-        (20, 145, 255, 238),
-        (28, 133, 255, 72),
-        (25, 118, 210, 62),
-        s(20),
-        is_accent=True,
-    )
-
-    return canvas.resize((BASE_SIZE, BASE_SIZE), Image.Resampling.LANCZOS)
-
-
-def write_contents_json() -> None:
-    entries = [
-        ("16x16", "1x", "icon_16x16.png"),
-        ("16x16", "2x", "icon_16x16@2x.png"),
-        ("32x32", "1x", "icon_32x32.png"),
-        ("32x32", "2x", "icon_32x32@2x.png"),
-        ("128x128", "1x", "icon_128x128.png"),
-        ("128x128", "2x", "icon_128x128@2x.png"),
-        ("256x256", "1x", "icon_256x256.png"),
-        ("256x256", "2x", "icon_256x256@2x.png"),
-        ("512x512", "1x", "icon_512x512.png"),
-        ("512x512", "2x", "icon_512x512@2x.png"),
-    ]
-    payload = {
-        "images": [
-            {"filename": filename, "idiom": "mac", "scale": scale, "size": size}
-            for size, scale, filename in entries
-        ],
-        "info": {"author": "xcode", "version": 1},
-    }
-    (APPICONSET / "Contents.json").write_text(json.dumps(payload, indent=2) + "\n")
-
-
-def write_icon_family(source: Image.Image) -> None:
-    sizes = {
-        "icon_16x16.png": 16,
-        "icon_16x16@2x.png": 32,
-        "icon_32x32.png": 32,
-        "icon_32x32@2x.png": 64,
-        "icon_128x128.png": 128,
-        "icon_128x128@2x.png": 256,
-        "icon_256x256.png": 256,
-        "icon_256x256@2x.png": 512,
-        "icon_512x512.png": 512,
-        "icon_512x512@2x.png": 1024,
-    }
-
+def sync_iconset() -> None:
     APPICONSET.mkdir(parents=True, exist_ok=True)
-    source.save(APPICONSET / "icon_1024_preview.png")
-    for filename, size in sizes.items():
-        source.resize((size, size), Image.Resampling.LANCZOS).save(APPICONSET / filename)
+    contents = load_contents_json()
+
+    for filename in ICON_FILES:
+        source = HANDOFF_ICONSET / filename
+        require_file(source)
+        shutil.copy2(source, APPICONSET / filename)
+
+    require_file(HANDOFF_PREVIEW)
+    shutil.copy2(HANDOFF_PREVIEW, APPICONSET / "icon_1024_preview.png")
+
+    with (APPICONSET / "Contents.json").open("w", encoding="utf-8") as file:
+        json.dump(contents, file, indent=2, ensure_ascii=False)
+        file.write("\n")
 
 
-def write_icns() -> None:
-    iconset = RESOURCES / "AppIcon.iconset"
-    if iconset.exists():
-        shutil.rmtree(iconset)
-    iconset.mkdir()
-
-    for icon in APPICONSET.glob("icon_*.png"):
-        if icon.name == "icon_1024_preview.png":
-            continue
-        shutil.copy2(icon, iconset / icon.name)
-
-    subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(ICNS_PATH)], check=True)
-    shutil.rmtree(iconset)
+def sync_icns() -> None:
+    require_file(HANDOFF_ICNS)
+    shutil.copy2(HANDOFF_ICNS, ICNS_PATH)
 
 
 def main() -> None:
-    icon = draw_icon()
-    write_icon_family(icon)
-    write_contents_json()
-    write_icns()
+    sync_iconset()
+    sync_icns()
+    print(f"Synced AppIcon assets from: {HANDOFF_DIR}")
 
 
 if __name__ == "__main__":
diff --git a/C3.tools/round1-app-column-alignment-fixture-qa.sh b/C3.tools/round1-app-column-alignment-fixture-qa.sh
index d2432dc..9dd6692 100755
--- a/C3.tools/round1-app-column-alignment-fixture-qa.sh
+++ b/C3.tools/round1-app-column-alignment-fixture-qa.sh
@@ -196,7 +196,7 @@
     require(root.get("lastKeyboardCommand") == expected_key_sequence[-1], "last keyboard command must match the alignment target")
     require(root.get("selectedAppGroupIndex") == target_app_index, "keyboard focus must select the target appGroupIndex")
     require(item.get("isSelected") is True, "target App Shelf item must expose selected state")
-    require("selected" in item.get("visualStates", []), "target App Shelf item must expose selected visual state")
+    require("selectedVisualSuppressed" in item.get("visualStates", []), "non-hovered keyboard-selected App Shelf item must suppress selected visual state")
     cards = column.get("cards", [])
     require(cards, "target Waterfall column must include cards")
     require(root.get("selectedWindowID") == cards[0].get("windowID"), "right-arrow focus into target column must select its first window")
diff --git a/C3.tools/round1-app-shelf-fixture-qa.sh b/C3.tools/round1-app-shelf-fixture-qa.sh
index 59151e8..bd4716f 100755
--- a/C3.tools/round1-app-shelf-fixture-qa.sh
+++ b/C3.tools/round1-app-shelf-fixture-qa.sh
@@ -106,6 +106,8 @@
 
 root = report.get("rootView", {})
 items = root.get("appShelfItems", [])
+columns = root.get("waterfallColumns", [])
+segments = root.get("spaceLaneSegments", [])
 
 require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
 require(report.get("appCount") == fixture_app_count, "fixture appCount must match requested count")
@@ -125,14 +127,41 @@
     require(root.get("appShelfContentWidth", 0) <= root.get("appShelfVisibleWidth", 0), "non-scrollable fixture App Shelf content must fit visible width")
     require(root.get("appShelfMaxScrollOffset", 0) == 0, "non-scrollable fixture App Shelf must expose zero max scroll offset")
 require(root.get("appShelfHoveredIndex") == hover_index, "fixture App Shelf must expose requested hovered index")
+require(len(columns) == fixture_app_count, "Waterfall column reports must match fixture count")
 require(sum(1 for item in items if item.get("isSelected") is True) == 1, "App Shelf must expose one selected app")
 require(sum(1 for item in items if item.get("isHovered") is True) == 1, "App Shelf must expose one hovered app")
+require(sum(1 for column in columns if column.get("isHovered") is True) == 1, "Waterfall must expose one hovered column")
 require(items[0].get("isSelected") is True, "fixture first app should be selected")
 require(items[hover_index].get("isHovered") is True, "fixture requested app should be hovered")
-require("selected" in items[0].get("visualStates", []), "selected app must expose selected visual state")
+require(columns[hover_index].get("isHovered") is True, "fixture requested app column should be hovered")
+require("selectedVisualSuppressed" in items[0].get("visualStates", []), "non-hovered selected app must suppress selected visual state")
 require("hover" in items[hover_index].get("visualStates", []), "hovered app must expose hover visual state")
-require(items[0].get("iconFrame", {}).get("width") == 68, "selected app icon must be 68pt")
+require("hover" in columns[hover_index].get("visualStates", []), "hovered column must expose hover visual state")
+require(items[0].get("iconFrame", {}).get("width") == expected_icon_size, "non-hovered selected app icon must match normal fixture icon size")
 require(items[hover_index].get("iconFrame", {}).get("width") == 68, "hovered app icon must be 68pt")
+
+blue_reference = next(
+    (
+        color
+        for segment in segments
+        for color in segment.get("windowBlockColors", [])
+        if color.get("alpha", 0) > 0
+    ),
+    None
+)
+require(blue_reference is not None, "fixture must expose a Space Lane blue reference color")
+hovered_title_color = columns[hover_index].get("appNameColor", {})
+for channel in ("red", "green", "blue"):
+    require(
+        abs(hovered_title_color.get(channel, -1) - blue_reference.get(channel, -2)) <= 0.002,
+        f"hovered column title must use the same blue reference for {channel}"
+    )
+require(hovered_title_color.get("alpha", 0) >= 0.98, "hovered column title color must be opaque")
+for index, column in enumerate(columns):
+    if index == hover_index:
+        continue
+    require(column.get("isHovered") is False, "non-hovered columns must not be marked hovered")
+    require("hover" not in column.get("visualStates", []), "non-hovered columns must not expose hover visual state")
 
 normal_items = [
     item for item in items
@@ -148,7 +177,7 @@
 selected_z = items[0].get("zPosition", 0)
 hover_z = items[hover_index].get("zPosition", 0)
 normal_z = normal_items[0].get("zPosition", 0)
-require(selected_z > hover_z, "selected App Shelf item must stay above non-selected hover item")
+require(selected_z == normal_z, "non-hovered selected App Shelf item must not float above normal items")
 require(hover_z > normal_z, "hover App Shelf item must stay above normal items")
 
 print(json.dumps({
@@ -158,6 +187,8 @@
     "selectedIcon": items[0].get("iconFrame"),
     "hoveredIndex": root.get("appShelfHoveredIndex"),
     "hoveredIcon": items[hover_index].get("iconFrame"),
+    "hoveredColumnTitleColor": hovered_title_color,
+    "blueReference": blue_reference,
     "selectedZ": selected_z,
     "hoverZ": hover_z,
     "scrollable": root.get("appShelfScrollable"),
diff --git a/C3.tools/round1-close-fixture-qa.sh b/C3.tools/round1-close-fixture-qa.sh
new file mode 100755
index 0000000..facf709
--- /dev/null
+++ b/C3.tools/round1-close-fixture-qa.sh
@@ -0,0 +1,547 @@
+#!/bin/bash
+# Round01 close interaction fixture QA. It restores the dedicated coverage for
+# Quick Switch close buttons, confirmation dialog, cancel flow, and App-level close.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+# shellcheck source=build-output-paths.sh
+source "$SCRIPT_DIR/build-output-paths.sh"
+APP="$BUILD_CURRENT_APP"
+BUNDLE_ID="com.ar.Aligner"
+CONFIRM_DEFAULTS_KEY="QuickSwitchConfirmBeforeClose"
+FIXTURE_APP_COUNT="${ALIGNER_ROUND1_CLOSE_FIXTURE_APP_COUNT:-4}"
+FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_CLOSE_FIXTURE_WINDOWS_PER_APP:-3}"
+REPORT_WAIT="${ALIGNER_ROUND1_CLOSE_REPORT_WAIT:-6.0}"
+CARD_DEFAULT_HOVER_REPORT="$BUILD_REPORT_ROOT/round01-close-card-default-hover-report.json"
+CARD_HOVER_REPORT="$BUILD_REPORT_ROOT/round01-close-card-hover-report.json"
+CARD_CONFIRMATION_REPORT="$BUILD_REPORT_ROOT/round01-close-card-confirmation-report.json"
+CARD_CANCEL_REPORT="$BUILD_REPORT_ROOT/round01-close-card-cancel-report.json"
+CARD_CONFIRMED_REPORT="$BUILD_REPORT_ROOT/round01-close-card-confirmed-report.json"
+CARD_WINDOW_NOT_FOUND_REPORT="$BUILD_REPORT_ROOT/round01-close-card-window-not-found-report.json"
+CARD_OPTOUT_TOGGLE_REPORT="$BUILD_REPORT_ROOT/round01-close-card-optout-toggle-report.json"
+CARD_OPTOUT_CONFIRMED_REPORT="$BUILD_REPORT_ROOT/round01-close-card-optout-confirmed-report.json"
+CARD_OPTOUT_SECOND_REPORT="$BUILD_REPORT_ROOT/round01-close-card-optout-second-report.json"
+APP_CONFIRM_REPORT="$BUILD_REPORT_ROOT/round01-close-app-confirm-report.json"
+
+fail() {
+  echo "Round01 close fixture QA failed: $*" >&2
+  exit 1
+}
+
+aligner_pids_for_current_app() {
+  ps -axo pid=,args= | while read -r pid command; do
+    case "$command" in
+      "$APP/Contents/MacOS/Aligner"*) echo "$pid" ;;
+    esac
+  done
+}
+
+stop_current_aligner() {
+  for pid in $(aligner_pids_for_current_app); do
+    kill "$pid" 2>/dev/null || true
+  done
+
+  for _ in {1..30}; do
+    [ -z "$(aligner_pids_for_current_app)" ] && return
+    sleep 0.1
+  done
+
+  fail "current Aligner app did not exit before QA"
+}
+
+reset_close_confirmation_preference() {
+  defaults delete "$BUNDLE_ID" "$CONFIRM_DEFAULTS_KEY" >/dev/null 2>&1 || true
+}
+
+wait_for_report() {
+  local report="$1"
+  local mode="$2"
+  /usr/bin/python3 - "$report" "$REPORT_WAIT" "$mode" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+timeout = float(sys.argv[2])
+mode = sys.argv[3]
+deadline = time.monotonic() + timeout
+last_report = None
+
+def ready(report):
+    root = report.get("rootView", {})
+    if report.get("snapshotLoaded") is not True:
+        return False
+    if mode == "visible":
+        return report.get("quickSwitchVisible") is True
+    if mode == "confirmed":
+        return report.get("quickSwitchVisible") is True and report.get("lastCloseResult") is not None
+    if mode == "verifiedFailure":
+        return (
+            report.get("quickSwitchVisible") is True
+            and report.get("lastCloseResult") is not None
+            and root.get("closeFeedbackKind") == "failure"
+        )
+    return False
+
+while time.monotonic() < deadline:
+    try:
+        with open(path, "r", encoding="utf-8") as file:
+            report = json.load(file)
+        last_report = report
+        if ready(report):
+            sys.exit(0)
+    except FileNotFoundError:
+        pass
+    except json.JSONDecodeError:
+        pass
+    time.sleep(0.2)
+
+if last_report is not None:
+    print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr)
+print(f"close fixture report {path} did not reach mode={mode} within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+run_case() {
+  local report="$1"
+  local sequence="$2"
+  local mode="$3"
+  local preference_mode="${4:-reset}"
+  local close_result="${5:-requested}"
+
+  stop_current_aligner
+  if [ "$preference_mode" = "reset" ]; then
+    reset_close_confirmation_preference
+  fi
+  rm -f "$report"
+
+  ALIGNER_DEBUG_WINDOW_CLOSE_RESULT="$close_result" "$APP/Contents/MacOS/Aligner" \
+    --round0-skip-permissions \
+    --round01-open-quick-switch \
+    --round01-fixture-app-count="$FIXTURE_APP_COUNT" \
+    --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \
+    --round01-disable-screenshot-refresh \
+    --round01-debug-window-close \
+    --round01-debug-mouse-sequence="$sequence" \
+    --round01-quick-switch-report="$report" &
+  APP_PID=$!
+
+  cleanup() {
+    kill "$APP_PID" 2>/dev/null || true
+    wait "$APP_PID" 2>/dev/null || true
+    stop_current_aligner
+  }
+  trap cleanup EXIT
+
+  wait_for_report "$report" "$mode"
+  kill "$APP_PID" 2>/dev/null || true
+  wait "$APP_PID" 2>/dev/null || true
+  trap - EXIT
+  stop_current_aligner
+}
+
+assert_card_default_hover_report() {
+  /usr/bin/python3 - "$CARD_DEFAULT_HOVER_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+columns = root.get("waterfallColumns", [])
+card = columns[0]["cards"][0]
+glyph = card.get("closeButtonGlyphColor", {})
+fill = card.get("closeButtonFillColor", {})
+
+require(report.get("quickSwitchVisible") is True, "card hover must keep Quick Switch visible")
+require(root.get("hoveredCloseTargetKind") is None, "plain card hover must not hover the close target itself")
+require(card.get("closeButtonVisible") is True, "plain hovered card close button must be visible")
+require(card.get("closeButtonSize") == 18, "plain hovered card close button must remain compact")
+require(card.get("closeButtonGlyphLineWidth", 99) <= 1.5, "plain close glyph must use a lighter stroke")
+require(glyph.get("red", 0) >= 0.95 and glyph.get("green", 0) >= 0.95 and glyph.get("blue", 0) >= 0.95, "plain close glyph must be white, not dark")
+require(0.70 <= glyph.get("alpha", 0) <= 0.92, "plain close glyph must be translucent")
+require(fill.get("alpha", 1) <= 0.36, "plain close button fill must be subtle")
+print(json.dumps({
+    "case": "cardDefaultHover",
+    "closeButtonSize": card.get("closeButtonSize"),
+    "glyphColor": glyph,
+    "fillColor": fill,
+    "glyphLineWidth": card.get("closeButtonGlyphLineWidth")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_hover_report() {
+  /usr/bin/python3 - "$CARD_HOVER_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+columns = root.get("waterfallColumns", [])
+card = columns[0]["cards"][0]
+require(report.get("quickSwitchVisible") is True, "card hover must keep Quick Switch visible")
+require(root.get("hoveredCloseTargetKind") == "window", "card close hover must set hoveredCloseTargetKind=window")
+require(root.get("pendingCloseTargetKind") is None, "card close hover must not create pending close target")
+require(card.get("closeButtonVisible") is True, "hovered card close button must be visible")
+require(card.get("closeButtonSize") == 24, "hovered card close button must expand to 24pt")
+require(root.get("closeConfirmationVisible") is False, "card close hover must not show confirmation")
+print(json.dumps({
+    "case": "cardHover",
+    "hoveredCloseTargetKind": root.get("hoveredCloseTargetKind"),
+    "closeButtonSize": card.get("closeButtonSize")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_confirmation_report() {
+  /usr/bin/python3 - "$CARD_CONFIRMATION_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+require(report.get("quickSwitchVisible") is True, "card close confirmation must keep Quick Switch visible before confirm")
+require(root.get("pendingCloseTargetKind") == "window", "card close click must create pending window target")
+require(root.get("closeConfirmationVisible") is True, "card close click must show confirmation")
+require(root.get("closeConfirmationArrowEdge") in ("left", "right"), "confirmation must expose arrow edge")
+require(report.get("lastCloseResult") is None, "card close confirmation must not call service before confirm")
+print(json.dumps({
+    "case": "cardConfirmation",
+    "pendingCloseTargetKind": root.get("pendingCloseTargetKind"),
+    "arrowEdge": root.get("closeConfirmationArrowEdge")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_cancel_report() {
+  /usr/bin/python3 - "$CARD_CANCEL_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+require(report.get("quickSwitchVisible") is True, "cancel must keep Quick Switch visible")
+require(root.get("pendingCloseTargetKind") is None, "cancel must clear pending close target")
+require(root.get("closeConfirmationVisible") is False, "cancel must hide confirmation")
+require(report.get("lastCloseResult") is None, "cancel must not call close service")
+print(json.dumps({
+    "case": "cardCancel",
+    "quickSwitchVisible": report.get("quickSwitchVisible"),
+    "closeConfirmationVisible": root.get("closeConfirmationVisible")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_confirmed_report() {
+  /usr/bin/python3 - "$CARD_CONFIRMED_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+visible_window_ids = [
+    card.get("windowID")
+    for column in root.get("waterfallColumns", [])
+    for card in column.get("cards", [])
+]
+require(report.get("quickSwitchVisible") is True, "confirmed window close must keep Quick Switch visible")
+require(report.get("lastDismissReason") is None, "confirmed window close must not dismiss Quick Switch")
+require(report.get("lastCloseTargetKind") == "window", "confirmed window close must record window target")
+require(report.get("lastCloseWindowID") is not None, "confirmed window close must record target window")
+require(report.get("lastCloseResult") == "requested", "debug window close service must return requested")
+require(report.get("lastCloseWindowID") not in visible_window_ids, "confirmed window close must optimistically remove the stale card from Waterfall")
+require(report.get("suppressedCloseTargetCount") == 1, "confirmed window close must expose one suppressed close target")
+require(root.get("closeConfirmationVisible") is False, "confirmed window close must hide confirmation")
+require(root.get("closeFeedbackVisible") is True, "confirmed window close must show failure feedback when target remains")
+require(root.get("closeFeedbackKind") == "failure", "confirmed window close feedback must be failure")
+require(root.get("closeFeedbackMessage") == "已发送关闭请求,但窗口仍在列表中。", "confirmed window close feedback message must explain verified close miss")
+require(root.get("closeFeedbackTargetKind") == "window", "confirmed window close feedback must keep target kind")
+print(json.dumps({
+    "case": "cardConfirmed",
+    "quickSwitchVisible": report.get("quickSwitchVisible"),
+    "lastCloseTargetKind": report.get("lastCloseTargetKind"),
+    "lastCloseResult": report.get("lastCloseResult"),
+    "lastDismissReason": report.get("lastDismissReason"),
+    "closeFeedbackKind": root.get("closeFeedbackKind"),
+    "closeFeedbackMessage": root.get("closeFeedbackMessage")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_window_not_found_report() {
+  /usr/bin/python3 - "$CARD_WINDOW_NOT_FOUND_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+visible_window_ids = [
+    card.get("windowID")
+    for column in root.get("waterfallColumns", [])
+    for card in column.get("cards", [])
+]
+require(report.get("quickSwitchVisible") is True, "windowNotFound close must keep Quick Switch visible")
+require(report.get("lastCloseTargetKind") == "window", "windowNotFound close must record window target")
+require(report.get("lastCloseResult") == "windowNotFound", "debug close service must return windowNotFound")
+require(report.get("lastCloseWindowID") not in visible_window_ids, "windowNotFound close must remove the stale card from Waterfall")
+require(report.get("suppressedCloseTargetCount") == 1, "windowNotFound close must expose one suppressed close target")
+require(root.get("closeFeedbackVisible") is False, "windowNotFound close must not show technical failure feedback")
+require(root.get("closeFeedbackMessage") is None, "windowNotFound close must not expose a technical message")
+print(json.dumps({
+    "case": "cardWindowNotFound",
+    "lastCloseTargetKind": report.get("lastCloseTargetKind"),
+    "lastCloseResult": report.get("lastCloseResult"),
+    "closeFeedbackVisible": root.get("closeFeedbackVisible"),
+    "suppressedCloseTargetCount": report.get("suppressedCloseTargetCount")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_optout_toggle_report() {
+  /usr/bin/python3 - "$CARD_OPTOUT_TOGGLE_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+require(report.get("quickSwitchVisible") is True, "opt-out toggle must keep Quick Switch visible")
+require(report.get("lastCloseResult") is None, "opt-out toggle alone must not request close")
+require(root.get("pendingCloseTargetKind") == "window", "opt-out toggle must keep pending window close target")
+require(root.get("pendingCloseOptOut") is True, "opt-out toggle must show checked state")
+require(root.get("closeConfirmationVisible") is True, "opt-out toggle must keep confirmation visible")
+print(json.dumps({
+    "case": "cardOptOutToggle",
+    "pendingCloseTargetKind": root.get("pendingCloseTargetKind"),
+    "pendingCloseOptOut": root.get("pendingCloseOptOut"),
+    "closeConfirmationVisible": root.get("closeConfirmationVisible")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_confirmation_preference_disabled() {
+  local value
+  value="$(defaults read "$BUNDLE_ID" "$CONFIRM_DEFAULTS_KEY" 2>/dev/null || true)"
+  [ "$value" = "0" ] || fail "close confirmation preference should be disabled after opt-out, got '$value'"
+  echo "{\"case\":\"closePreferenceDisabled\",\"$CONFIRM_DEFAULTS_KEY\":$value}"
+}
+
+assert_card_optout_confirmed_report() {
+  /usr/bin/python3 - "$CARD_OPTOUT_CONFIRMED_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+visible_window_ids = [
+    card.get("windowID")
+    for column in root.get("waterfallColumns", [])
+    for card in column.get("cards", [])
+]
+require(report.get("quickSwitchVisible") is True, "opt-out confirmed close must keep Quick Switch visible")
+require(report.get("lastCloseTargetKind") == "window", "opt-out confirmed close must record window target")
+require(report.get("lastCloseResult") == "requested", "opt-out confirmed close must request close")
+require(report.get("lastCloseWindowID") not in visible_window_ids, "opt-out confirmed close must optimistically remove the stale card")
+require(report.get("suppressedCloseTargetCount") == 1, "opt-out confirmed close must expose one suppressed close target")
+require(root.get("closeConfirmationVisible") is False, "opt-out confirmed close must hide confirmation")
+require(root.get("pendingCloseOptOut") is False, "opt-out confirmed close must clear temporary checkbox state")
+require(root.get("closeFeedbackKind") == "failure", "opt-out confirmed close must still verify close result")
+print(json.dumps({
+    "case": "cardOptOutConfirmed",
+    "lastCloseTargetKind": report.get("lastCloseTargetKind"),
+    "lastCloseResult": report.get("lastCloseResult"),
+    "pendingCloseOptOut": root.get("pendingCloseOptOut"),
+    "closeConfirmationVisible": root.get("closeConfirmationVisible")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_optout_second_report() {
+  /usr/bin/python3 - "$CARD_OPTOUT_SECOND_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+visible_window_ids = [
+    card.get("windowID")
+    for column in root.get("waterfallColumns", [])
+    for card in column.get("cards", [])
+]
+require(report.get("quickSwitchVisible") is True, "second close after opt-out must keep Quick Switch visible")
+require(report.get("lastCloseTargetKind") == "window", "second close after opt-out must directly request window close")
+require(report.get("lastCloseResult") == "requested", "second close after opt-out must call close service")
+require(report.get("lastCloseWindowID") not in visible_window_ids, "second close after opt-out must optimistically remove the stale card")
+require(report.get("suppressedCloseTargetCount") == 1, "second close after opt-out must expose one suppressed close target")
+require(root.get("pendingCloseTargetKind") is None, "second close after opt-out must not create pending close target")
+require(root.get("closeConfirmationVisible") is False, "second close after opt-out must not show confirmation")
+require(root.get("closeFeedbackKind") == "failure", "second close after opt-out must still verify close result")
+print(json.dumps({
+    "case": "cardOptOutSecondClose",
+    "lastCloseTargetKind": report.get("lastCloseTargetKind"),
+    "lastCloseResult": report.get("lastCloseResult"),
+    "pendingCloseTargetKind": root.get("pendingCloseTargetKind"),
+    "closeConfirmationVisible": root.get("closeConfirmationVisible")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_app_confirm_report() {
+  /usr/bin/python3 - "$APP_CONFIRM_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+require(report.get("quickSwitchVisible") is True, "confirmed App close must keep Quick Switch visible")
+require(report.get("lastDismissReason") is None, "confirmed App close must not dismiss Quick Switch")
+require(report.get("lastCloseTargetKind") == "app", "confirmed App close must record app target")
+require(report.get("lastCloseAppGroupIndex") == 1, "confirmed App close must record target app group")
+require(report.get("lastCloseResult") == "requested", "debug App close service must return requested")
+require(report.get("suppressedCloseTargetCount") == 1, "confirmed App close must expose one suppressed close target")
+require(1 not in [column.get("appGroupIndex") for column in root.get("waterfallColumns", [])], "confirmed App close must optimistically remove the target column")
+require(root.get("closeConfirmationVisible") is False, "confirmed App close must hide confirmation")
+require(root.get("closeFeedbackVisible") is True, "confirmed App close must show failure feedback when target remains")
+require(root.get("closeFeedbackKind") == "failure", "confirmed App close feedback must be failure")
+require(root.get("closeFeedbackMessage") == "已发送关闭请求,但窗口仍在列表中。", "confirmed App close feedback message must explain verified close miss")
+require(root.get("closeFeedbackTargetKind") == "app", "confirmed App close feedback must keep target kind")
+print(json.dumps({
+    "case": "appConfirm",
+    "quickSwitchVisible": report.get("quickSwitchVisible"),
+    "lastCloseTargetKind": report.get("lastCloseTargetKind"),
+    "lastCloseResult": report.get("lastCloseResult"),
+    "lastDismissReason": report.get("lastDismissReason"),
+    "closeFeedbackKind": root.get("closeFeedbackKind"),
+    "closeFeedbackMessage": root.get("closeFeedbackMessage")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+stop_current_aligner
+"$SCRIPT_DIR/package-app.sh" >&2
+
+run_case "$CARD_DEFAULT_HOVER_REPORT" "hover-card:0:0" "visible"
+assert_card_default_hover_report
+
+run_case "$CARD_HOVER_REPORT" "hover-card-close:0:0" "visible"
+assert_card_hover_report
+
+run_case "$CARD_CONFIRMATION_REPORT" "click-card-close:0:0" "visible"
+assert_card_confirmation_report
+
+run_case "$CARD_CANCEL_REPORT" "click-card-close:0:0,cancel-close" "visible"
+assert_card_cancel_report
+
+run_case "$CARD_CONFIRMED_REPORT" "click-card-close:0:0,confirm-close" "verifiedFailure"
+assert_card_confirmed_report
+
+run_case "$CARD_WINDOW_NOT_FOUND_REPORT" "click-card-close:0:0,confirm-close" "confirmed" "reset" "windowNotFound"
+assert_card_window_not_found_report
+
+run_case "$CARD_OPTOUT_TOGGLE_REPORT" "click-card-close:0:0,toggle-close-optout" "visible"
+assert_card_optout_toggle_report
+assert_confirmation_preference_disabled
+
+run_case "$CARD_OPTOUT_CONFIRMED_REPORT" "click-card-close:0:0,toggle-close-optout,confirm-close" "verifiedFailure"
+assert_card_optout_confirmed_report
+assert_confirmation_preference_disabled
+
+run_case "$CARD_OPTOUT_SECOND_REPORT" "click-card-close:0:1" "verifiedFailure" "preserve"
+assert_card_optout_second_report
+
+run_case "$APP_CONFIRM_REPORT" "click-app-close:1,confirm-close" "verifiedFailure"
+assert_app_confirm_report
+
+echo "Round01 close fixture QA passed."
diff --git a/C3.tools/round1-keyboard-navigation-fixture-qa.sh b/C3.tools/round1-keyboard-navigation-fixture-qa.sh
index 3254558..8dc7de2 100755
--- a/C3.tools/round1-keyboard-navigation-fixture-qa.sh
+++ b/C3.tools/round1-keyboard-navigation-fixture-qa.sh
@@ -135,9 +135,9 @@
 selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True]
 require(len(selected_cards) == 1, "Waterfall must expose exactly one selected card after keyboard navigation")
 require(selected_cards[0].get("windowID") == expected_selected_window_id, "selected Waterfall card must match selectedWindowID")
-require("selected" in selected_cards[0].get("visualStates", []), "selected Waterfall card must expose selected visual state")
-require(selected_cards[0].get("shineVisible") is True, "selected Waterfall card must expose shine")
-require(selected_cards[0].get("zPosition", 0) > 0, "selected Waterfall card must float above normal cards")
+require("selectedVisualSuppressed" in selected_cards[0].get("visualStates", []), "non-hovered keyboard-selected Waterfall card must suppress selected visual state")
+require(selected_cards[0].get("shineVisible") is False, "non-hovered keyboard-selected Waterfall card must not expose shine")
+require(selected_cards[0].get("zPosition", 0) == 0, "non-hovered keyboard-selected Waterfall card must not float above normal cards")
 
 selected_items = [item for item in items if item.get("isSelected") is True]
 require(len(selected_items) == 1, "App Shelf must expose exactly one selected App")
diff --git a/C3.tools/round1-mouse-interaction-fixture-qa.sh b/C3.tools/round1-mouse-interaction-fixture-qa.sh
index ce9793e..c8c885f 100755
--- a/C3.tools/round1-mouse-interaction-fixture-qa.sh
+++ b/C3.tools/round1-mouse-interaction-fixture-qa.sh
@@ -18,7 +18,7 @@
 MOUSE_SEQUENCE="${ALIGNER_ROUND1_MOUSE_SEQUENCE:-hover-card:1:7,hover-app:5,hover-card:7:0,click-card:7:0}"
 APP_ONLY_MOUSE_SEQUENCE="${ALIGNER_ROUND1_MOUSE_APP_ONLY_SEQUENCE:-hover-app:5,click-app:7}"
 REPORT_WAIT="${ALIGNER_ROUND1_MOUSE_REPORT_WAIT:-6.0}"
-WINDOW_WAIT="${ALIGNER_ROUND1_MOUSE_WINDOW_WAIT:-3.0}"
+WINDOW_WAIT="${ALIGNER_ROUND1_MOUSE_WINDOW_WAIT:-6.0}"
 
 fail() {
   echo "Round01 mouse interaction fixture QA failed: $*" >&2
@@ -288,9 +288,18 @@
 require(hovered_items[0].get("index") == expected_app_group_index, "hovered window card must link to App Shelf")
 
 hovered_segments = [segment for segment in segments if "hover" in segment.get("visualStates", [])]
-require(len(hovered_segments) == 1, "hovered window card must link to exactly one Space Lane segment")
-require(hovered_segments[0].get("windowCount", 0) > 0, "hovered Space Lane segment must be a real space with windows")
-require(root.get("hoveredSpaceID") == expected_space_id, "root hoveredSpaceID must match clicked card primarySpaceID")
+space_focused_segments = [segment for segment in segments if "spaceFocus" in segment.get("visualStates", [])]
+window_associated_segments = [segment for segment in segments if segment.get("isWindowAssociated") is True]
+require(root.get("hoverTargetKind") == "window", "hovered Waterfall card must report window hover target")
+require(root.get("activeSpaceFocusID") is None, "hovered Waterfall card must not create Space Lane focus")
+require(len(hovered_segments) == 0, "hovered Waterfall card must not mark a Space Lane segment as hovered")
+require(len(space_focused_segments) == 0, "hovered Waterfall card must not mark a Space Lane segment as focused")
+require(root.get("hoveredSpaceID") == expected_space_id, "root hoveredSpaceID may still mirror clicked card primarySpaceID for window context")
+require(root.get("windowHoverAssociatedSpaceID") == expected_space_id, "hovered Waterfall card must expose its Space as windowHoverAssociatedSpaceID")
+require(len(window_associated_segments) == 1, "hovered Waterfall card must highlight exactly one associated Space segment")
+require(window_associated_segments[0].get("spaceID") == expected_space_id, "window-associated Space segment must match hovered card Space")
+require("windowAssociated" in window_associated_segments[0].get("visualStates", []), "window-associated Space segment must expose windowAssociated")
+require("focused" not in window_associated_segments[0].get("visualStates", []), "window-associated Space segment must not expose focused")
 
 frame = selected_cards[0].get("visibleFrame", {})
 require(frame.get("x", -1) >= 0, "clicked Waterfall card must be horizontally visible")
@@ -304,7 +313,9 @@
     "lastActivationResult": report.get("lastActivationResult"),
     "waterfallScrollOffset": root.get("waterfallScrollOffset"),
     "hoveredSpaceID": root.get("hoveredSpaceID"),
-    "hoveredSpaceLabel": hovered_segments[0].get("label")
+    "windowHoverAssociatedSpaceID": root.get("windowHoverAssociatedSpaceID"),
+    "hoverTargetKind": root.get("hoverTargetKind"),
+    "activeSpaceFocusID": root.get("activeSpaceFocusID")
 }, indent=2, ensure_ascii=False))
 PY
 }
diff --git a/C3.tools/round1-session-snapshot-qa.sh b/C3.tools/round1-session-snapshot-qa.sh
index 23be5db..981dd57 100755
--- a/C3.tools/round1-session-snapshot-qa.sh
+++ b/C3.tools/round1-session-snapshot-qa.sh
@@ -58,6 +58,15 @@
 def near(lhs, rhs, tolerance=0.01):
     return abs((lhs or 0) - (rhs or 0)) <= tolerance
 
+def is_blue(color, min_alpha=0.30):
+    return (
+        isinstance(color, dict)
+        and color.get("blue", 0) >= 0.85
+        and color.get("green", 0) >= 0.35
+        and color.get("red", 1) <= 0.18
+        and color.get("alpha", 0) >= min_alpha
+    )
+
 require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
 require(report.get("displayCount", 0) >= 1, "displayCount must be >= 1")
 require(report.get("spaceCount", 0) >= 1, "spaceCount must be >= 1")
@@ -101,6 +110,30 @@
 require(all(isinstance(segment.get("appNames"), list) for segment in lane_segments), "Space Lane segments must expose app names")
 require(all(isinstance(segment.get("frame"), dict) for segment in lane_segments), "Space Lane segments must expose content frames")
 require(all(isinstance(segment.get("visibleFrame"), dict) for segment in lane_segments), "Space Lane segments must expose visible frames")
+require(all(isinstance(segment.get("borderColor"), dict) for segment in lane_segments), "Space Lane segments must expose border colors")
+require(all(isinstance(segment.get("windowBlockColors"), list) for segment in lane_segments), "Space Lane segments must expose window block colors")
+occupied_segments = [segment for segment in lane_segments if segment.get("windowCount", 0) > 0]
+empty_segments = [segment for segment in lane_segments if segment.get("windowCount", 0) == 0]
+require(all(is_blue(segment.get("borderColor"), min_alpha=0.34) for segment in occupied_segments), "occupied Space segments must use blue borders")
+require(all(not is_blue(segment.get("borderColor"), min_alpha=0.34) for segment in empty_segments), "empty Space segments must not use blue borders")
+occupied_floating_segments = [segment for segment in occupied_segments if segment.get("type") != "fullscreen"]
+require(all(segment.get("windowBlockCount", 0) > 0 for segment in occupied_floating_segments), "occupied non-fullscreen Spaces must expose window blocks")
+for segment in occupied_floating_segments:
+    require(all(is_blue(color, min_alpha=0.50) for color in segment.get("windowBlockColors", [])), "occupied Space window blocks must be blue")
+same_display_gaps = []
+cross_display_gaps = []
+for previous, current in zip(lane_segments, lane_segments[1:]):
+    previous_frame = previous.get("visibleFrame", {})
+    current_frame = current.get("visibleFrame", {})
+    gap = current_frame.get("x", 0) - (previous_frame.get("x", 0) + previous_frame.get("width", 0))
+    if previous.get("displayUUID") == current.get("displayUUID"):
+        same_display_gaps.append(gap)
+    else:
+        cross_display_gaps.append(gap)
+if cross_display_gaps:
+    require(all(gap >= 24 for gap in cross_display_gaps), "Space Lane must leave a visible gap between displays")
+    if same_display_gaps:
+        require(min(cross_display_gaps) > max(same_display_gaps) + 8, "display gap must be clearly larger than same-display Space gaps")
 current_labels = root_view.get("spaceLaneCurrentLabels", [])
 fullscreen_labels = root_view.get("spaceLaneFullscreenLabels", [])
 require(len(current_labels) >= 1, "Space Lane must mark at least one current Space")
@@ -110,8 +143,19 @@
 fullscreen_segments = [segment for segment in lane_segments if segment.get("type") == "fullscreen"]
 require([segment.get("label") for segment in fullscreen_segments] == fullscreen_labels, "fullscreen segment reports must match fullscreen labels")
 require(all(segment.get("fullscreenMarkerVisible") is True for segment in fullscreen_segments), "fullscreen segments must expose fullscreen marker")
+require(all(segment.get("fullscreenMarkerKind") == "cornerBracket" for segment in fullscreen_segments), "fullscreen segments must use corner bracket marker")
+require(all(segment.get("windowBlockCount") == 0 for segment in fullscreen_segments), "fullscreen segments must not expose floating-window blocks")
+non_fullscreen_segments = [segment for segment in lane_segments if segment.get("type") != "fullscreen"]
+require(all(segment.get("fullscreenMarkerKind") == "none" for segment in non_fullscreen_segments), "non-fullscreen segments must not expose fullscreen marker")
 for segment in fullscreen_segments:
     require("fullscreen" in segment.get("visualStates", []), "fullscreen segments must expose fullscreen visual state")
+    marker_frame = segment.get("fullscreenMarkerFrame", {})
+    app_frame = segment.get("appFrame", {})
+    require(isinstance(marker_frame, dict), "fullscreen segments must expose marker frame")
+    require(isinstance(app_frame, dict), "fullscreen segments must expose centered App label frame")
+    marker_mid_y = marker_frame.get("y", 0) + marker_frame.get("height", 0) / 2
+    app_mid_y = app_frame.get("y", 0) + app_frame.get("height", 0) / 2
+    require(abs(marker_mid_y - app_mid_y) <= 2, "fullscreen App label must be vertically centered in marker")
 for segment in lane_segments:
     if segment.get("isCurrent") is True:
         require("current" in segment.get("visualStates", []), "current segment must expose current visual state")
@@ -134,6 +178,12 @@
     require(lane_max_scroll_offset > 0, "overflowing Space Lane must expose positive max scroll offset")
 else:
     require(root_view.get("spaceLaneScrollable") is False, "non-overflowing Space Lane should not report scrollable")
+    if lane_segments:
+        first_frame = lane_segments[0].get("visibleFrame", {})
+        last_frame = lane_segments[-1].get("visibleFrame", {})
+        left_blank = first_frame.get("x", 0)
+        right_blank = lane_visible_width - (last_frame.get("x", 0) + last_frame.get("width", 0))
+        require(abs(left_blank - right_blank) <= 2, "non-overflowing Space Lane segments must be centered")
 app_shelf_items = root_view.get("appShelfItems", [])
 app_shelf_names = root_view.get("appShelfNames", [])
 require(root_view.get("appShelfItemCount") == report.get("appCount"), "App Shelf item count must match appCount")
@@ -153,12 +203,12 @@
 require(all(isinstance(item.get("frame"), dict) for item in app_shelf_items), "App Shelf items must expose frames")
 require(all(isinstance(item.get("iconFrame"), dict) for item in app_shelf_items), "App Shelf items must expose icon frames")
 for item in app_shelf_items:
-    if item.get("isSelected") is True:
-        expected_icon_size = root_view.get("appShelfSelectedIconSize")
-    elif item.get("isHovered") is True:
+    if item.get("isHovered") is True:
         expected_icon_size = root_view.get("appShelfHoverIconSize")
     else:
         expected_icon_size = icon_size
+    if item.get("isSelected") is True and item.get("isHovered") is not True:
+        require("selectedVisualSuppressed" in item.get("visualStates", []), "non-hovered selected App Shelf item must suppress selected visual state")
     require(abs(item.get("iconFrame", {}).get("width", -1) - expected_icon_size) < 0.01, "App Shelf icon frame width must match visual icon size")
     require(abs(item.get("iconFrame", {}).get("height", -1) - expected_icon_size) < 0.01, "App Shelf icon frame height must match visual icon size")
 row_y_values = {round(item.get("frame", {}).get("y", 0), 2) for item in app_shelf_items}
@@ -239,6 +289,14 @@
         if card.get("screenshotSource") == "notRequested":
             require(card.get("screenshotNotRequestedReason") is not None, "not-requested screenshot cards must expose reason")
         require(isinstance(card.get("thumbnailFrame"), dict), "Waterfall cards must expose thumbnail frames")
+        require(isinstance(card.get("titleBarFrame"), dict), "Waterfall cards must expose title bar frames")
+        require(card.get("titleBarBorderWidth") == 0, "Waterfall title area must not draw an enclosing rounded rectangle")
+        require(isinstance(card.get("titleFrame"), dict), "Waterfall cards must expose title frames")
+        card_frame = card.get("frame", {})
+        thumbnail_frame = card.get("thumbnailFrame", {})
+        title_bar_frame = card.get("titleBarFrame", {})
+        require(thumbnail_frame.get("width", 0) >= card_frame.get("width", 0) * 0.82, "Waterfall thumbnail must use most of the card width")
+        require(title_bar_frame.get("y", 0) > thumbnail_frame.get("y", 0) + thumbnail_frame.get("height", 0), "Waterfall title bar must sit above the thumbnail")
         require(isinstance(card.get("appIconFrame"), dict), "Waterfall cards must expose App icon frames")
         require(isinstance(card.get("visualStates"), list), "Waterfall cards must expose visual states")
         require(card.get("titleTruncationMode") == "middle", "Waterfall card titles must use middle truncation")
@@ -257,6 +315,11 @@
 require(root_view.get("screenshotResolvedCount") + root_view.get("screenshotPendingCount") == root_view.get("screenshotEligibleCount"), "resolved + pending screenshots must match eligible count")
 require(root_view.get("screenshotEligibleCount") + root_view.get("screenshotNotRequestedCount") == waterfall_card_count, "eligible + not-requested screenshots must cover all cards")
 require(global_indexes == sorted(global_indexes), "Waterfall global indexes must be monotonic across columns")
+if root_view.get("activeSpaceFocusID") is None:
+    require(
+        all("spaceFocused" not in card.get("visualStates", []) for column in waterfall_columns for card in column.get("cards", [])),
+        "Waterfall cards must not expose spaceFocused when no Space Lane focus is active"
+    )
 selected_cards = [
     card
     for column in waterfall_columns
@@ -264,9 +327,9 @@
     if card.get("isSelected") is True
 ]
 require(len(selected_cards) == 1, "Waterfall must expose exactly one selected card")
-require("selected" in selected_cards[0].get("visualStates", []), "selected Waterfall card must expose selected visual state")
-require(selected_cards[0].get("shineVisible") is True, "selected Waterfall card must expose shine layer")
-require(selected_cards[0].get("zPosition", 0) > 0, "selected Waterfall card must be above normal cards")
+require("selectedVisualSuppressed" in selected_cards[0].get("visualStates", []), "non-hovered selected Waterfall card must suppress selected visual state")
+require(selected_cards[0].get("shineVisible") is False, "non-hovered selected Waterfall card must not expose shine layer")
+require(selected_cards[0].get("zPosition", 0) == 0, "non-hovered selected Waterfall card must not float above normal cards")
 require(isinstance(report.get("overlayOpenElapsedMilliseconds"), (int, float)), "overlay open timing must exist")
 require(isinstance(report.get("snapshotStartElapsedMilliseconds"), (int, float)), "snapshot start timing must exist")
 require(report["snapshotStartElapsedMilliseconds"] >= report["overlayOpenElapsedMilliseconds"], "snapshot must start after overlay open path")
diff --git a/C3.tools/round1-three-zone-linkage-fixture-qa.sh b/C3.tools/round1-three-zone-linkage-fixture-qa.sh
index 4fcd850..93a8bc2 100755
--- a/C3.tools/round1-three-zone-linkage-fixture-qa.sh
+++ b/C3.tools/round1-three-zone-linkage-fixture-qa.sh
@@ -122,26 +122,34 @@
   local app_count="${4:-$FIXTURE_APP_COUNT}"
   local windows_per_app="${5:-$FIXTURE_WINDOWS_PER_APP}"
   local quick_switch_expectation="${6:-visible}"
+  local fixture_shape="${7:-single-display}"
+  local args=(
+    --round0-skip-permissions
+    --round01-open-quick-switch
+    --round01-fixture-app-count="$app_count"
+    --round01-fixture-windows-per-app="$windows_per_app"
+    --round01-debug-overlay-width="$OVERLAY_WIDTH"
+    --round01-disable-screenshot-refresh
+    --round01-debug-window-activation
+    --round01-debug-mouse-sequence="$sequence"
+    --round01-quick-switch-report="$report"
+  )
+  if [ "$fixture_shape" = "multi-display" ]; then
+    args+=(--round01-fixture-multi-display)
+  fi
 
   rm -f "$report"
-  "$APP/Contents/MacOS/Aligner" \
-    --round0-skip-permissions \
-    --round01-open-quick-switch \
-    --round01-fixture-app-count="$app_count" \
-    --round01-fixture-windows-per-app="$windows_per_app" \
-    --round01-debug-overlay-width="$OVERLAY_WIDTH" \
-    --round01-disable-screenshot-refresh \
-    --round01-debug-window-activation \
-    --round01-debug-mouse-sequence="$sequence" \
-    --round01-quick-switch-report="$report" &
+  "$APP/Contents/MacOS/Aligner" "${args[@]}" &
 
   APP_PID=$!
 
   wait_for_report "$report" "$expected_last_mouse"
   if [ "$quick_switch_expectation" = "hidden" ]; then
     swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
-  else
+  elif [ "$quick_switch_expectation" = "visible" ]; then
     swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
+  elif [ "$quick_switch_expectation" != "report-only" ]; then
+    fail "unknown quick switch expectation: $quick_switch_expectation"
   fi
 
   kill "$APP_PID" 2>/dev/null || true
@@ -186,10 +194,16 @@
 require(len(focused_segments) == 1, "exactly one Space Lane segment must be focused")
 require(focused_segments[0].get("label") == "01", "focused Space should be 01")
 require("focused" in focused_segments[0].get("visualStates", []), "focused Space segment must expose focused visual state")
+require(focused_segments[0].get("shadowRadius", 0) >= 24, "focused Space segment shadow radius must use the stronger float treatment")
+require(focused_segments[0].get("shadowOpacity", 0) >= 0.25, "focused Space segment shadow opacity must use the stronger float treatment")
 
 require(len(items) == fixture_app_count, "App Shelf fixture item count must match app count")
 require(all(item.get("isSpaceFocused") is True for item in items), "all fixture apps must be highlighted for Space A1")
 require(all("spaceFocused" in item.get("visualStates", []) for item in items), "App Shelf items must expose spaceFocused visual state")
+selected_items = [item for item in items if item.get("isSelected") is True]
+require(len(selected_items) == 1, "App Shelf must keep one functional selection while Space focus is active")
+require("selected" not in selected_items[0].get("visualStates", []), "Space focus must suppress App Shelf selected visual state")
+require("selectedVisualSuppressed" in selected_items[0].get("visualStates", []), "suppressed App Shelf selected visual must be explicit")
 require(all(item.get("spaceFocusWindowCount", 0) > 0 for item in items), "focused Space badge count must be positive for every fixture app")
 require(all(item.get("badgeVisible") is True for item in items), "focused Space badges must be visible")
 require(items[0].get("spaceFocusDirection") == "visible", "first related App must be visible")
@@ -201,10 +215,28 @@
 require(columns[0].get("spaceFocusDirection") == "visible", "first Waterfall column must be visible")
 require(columns[-1].get("isSpaceFocused") is True, "last Waterfall column must be marked focused for Space")
 require(columns[-1].get("spaceFocusDirection") == "right", "last Waterfall column must report right direction before remote click")
+selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True]
+require(len(selected_cards) == 1, "Waterfall must keep one functional selection while Space focus is active")
+require("selected" not in selected_cards[0].get("visualStates", []), "Space focus must suppress Waterfall selected visual state")
+require("selectedVisualSuppressed" in selected_cards[0].get("visualStates", []), "suppressed Waterfall selected visual must be explicit")
+require(selected_cards[0].get("shadowRadius", 0) >= 24, "space-focused Waterfall card shadow radius must use the stronger float treatment")
+require(selected_cards[0].get("shadowOpacity", 0) >= 0.19, "space-focused Waterfall card shadow opacity must use the stronger float treatment")
+
+space_fill = focused_segments[0].get("backgroundColor", {})
+card_fill = selected_cards[0].get("backgroundColor", {})
+for channel in ("red", "green", "blue", "alpha"):
+    require(
+        abs(space_fill.get(channel, -1) - card_fill.get(channel, -2)) <= 0.002,
+        f"focused Space fill must match space-focused card fill for {channel}"
+    )
 
 print(json.dumps({
     "activeSpaceFocusID": root.get("activeSpaceFocusID"),
     "focusedApps": root.get("spaceFocusAppGroupIndexes"),
+    "focusedSpaceFill": space_fill,
+    "spaceFocusedCardFill": card_fill,
+    "focusedSpaceShadowRadius": focused_segments[0].get("shadowRadius"),
+    "spaceFocusedCardShadowRadius": selected_cards[0].get("shadowRadius"),
     "lastRemoteDirection": items[-1].get("spaceFocusDirection"),
     "lastRemoteBadgeText": items[-1].get("badgeText")
 }, indent=2, ensure_ascii=False))
@@ -234,7 +266,8 @@
 last_index = fixture_app_count - 1
 
 require(report.get("quickSwitchVisible") is False, "remote App click must close Quick Switch after committing its first window")
-require(root.get("activeSpaceFocusID") == 1, "Space hover preview should still be reflected in the pre-commit report")
+require(root.get("activeSpaceFocusID") is None, "App click must clear Space hover focus before the pre-commit report")
+require(root.get("hoverTargetKind") == "app", "App click must leave the hover target on the clicked App, not on Space")
 require(root.get("lastClickedAppGroupIndex") == last_index, "remote App click must be recorded")
 require(root.get("lastAppShelfClickChangedSelection") is True, "App Shelf click must move keyboard selection to the remote App column")
 require(root.get("selectedAppGroupIndex") == last_index, "remote App click must focus the remote App column")
@@ -245,12 +278,14 @@
 require(report.get("lastActivationResult") == "activated", "debug activation must activate the first remote window")
 require(report.get("lastCommitSource") == "mouse", "remote App commit source must be mouse")
 require(root.get("waterfallScrollOffset", 0) > 0, "remote App click must scroll Waterfall horizontally")
-require(items[last_index].get("spaceFocusDirection") == "visible", "remote App must become visible after click")
-require(columns[last_index].get("spaceFocusDirection") == "visible", "remote Waterfall column must become visible after click")
-require(items[last_index].get("badgeVisible") is True, "focused Space badge must remain visible after remote click")
+require(items[last_index].get("spaceFocusDirection") == "none", "App click must clear Space focus direction after leaving Space Lane")
+require(columns[last_index].get("spaceFocusDirection") == "none", "Waterfall column must not keep Space focus after App click")
+require(all(item.get("isSpaceFocused") is False for item in items), "App click must clear all App Shelf spaceFocused states")
+require(all(column.get("isSpaceFocused") is False for column in columns), "App click must clear all Waterfall column spaceFocused states")
 
 print(json.dumps({
     "activeSpaceFocusID": root.get("activeSpaceFocusID"),
+    "hoverTargetKind": root.get("hoverTargetKind"),
     "lastClickedAppGroupIndex": root.get("lastClickedAppGroupIndex"),
     "lastCommittedWindowID": root.get("lastCommittedWindowID"),
     "lastActivationResult": report.get("lastActivationResult"),
@@ -307,6 +342,128 @@
 PY
 }
 
+assert_space_focus_clear_report() {
+  /usr/bin/python3 - "$1" "$FIXTURE_APP_COUNT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+fixture_app_count = int(sys.argv[2])
+
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+items = root.get("appShelfItems", [])
+columns = root.get("waterfallColumns", [])
+segments = root.get("spaceLaneSegments", [])
+cards = [card for column in columns for card in column.get("cards", [])]
+hovered_window = next(
+    (
+        card
+        for column in columns
+        for card in column.get("cards", [])
+        if card.get("windowID") == root.get("hoveredWindowID")
+    ),
+    None
+)
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(root.get("hoverTargetKind") == "window", "hover-card after hover-space must set the hover target to window")
+require(root.get("activeSpaceFocusID") is None, "hover-card must clear activeSpaceFocusID after leaving Space Lane")
+require(root.get("spaceLaneHoveredSpaceID") is None, "hover-card must clear Space Lane hovered Space ID")
+require(root.get("spaceFocusAppGroupIndexes") == [], "hover-card must clear all Space focused App indexes")
+require(root.get("hoveredWindowID") is not None, "hover-card must still expose hoveredWindowID")
+require(hovered_window is not None, "hover-card report must contain the hovered window card")
+require(root.get("windowHoverAssociatedSpaceID") == hovered_window.get("primarySpaceID"), "hover-card must expose the hovered window Space ID")
+require(len(items) == fixture_app_count, "App Shelf fixture item count must match app count")
+require(all(item.get("isSpaceFocused") is False for item in items), "hover-card must not leave App Shelf items spaceFocused")
+require(all(column.get("isSpaceFocused") is False for column in columns), "hover-card must not leave Waterfall columns spaceFocused")
+require(all("spaceFocused" not in card.get("visualStates", []) for card in cards), "hover-card must not leave Waterfall cards spaceFocused")
+associated_segments = [segment for segment in segments if segment.get("isWindowAssociated") is True]
+require(len(associated_segments) == 1, "hover-card must highlight exactly one associated Space segment")
+require(associated_segments[0].get("spaceID") == hovered_window.get("primarySpaceID"), "window-associated Space segment must match hovered card Space")
+require("windowAssociated" in associated_segments[0].get("visualStates", []), "window-associated Space segment must expose windowAssociated visual state")
+require("focused" not in associated_segments[0].get("visualStates", []), "window-associated Space segment must not expose focused")
+require("hover" not in associated_segments[0].get("visualStates", []), "window-associated Space segment must not expose Space hover")
+
+print(json.dumps({
+    "hoverTargetKind": root.get("hoverTargetKind"),
+    "activeSpaceFocusID": root.get("activeSpaceFocusID"),
+    "hoveredWindowID": root.get("hoveredWindowID"),
+    "windowHoverAssociatedSpaceID": root.get("windowHoverAssociatedSpaceID"),
+    "spaceFocusAppGroupIndexes": root.get("spaceFocusAppGroupIndexes")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_app_hover_space_lane_report() {
+  /usr/bin/python3 - "$1" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+root = report.get("rootView", {})
+items = root.get("appShelfItems", [])
+columns = root.get("waterfallColumns", [])
+segments = root.get("spaceLaneSegments", [])
+cards = [card for column in columns for card in column.get("cards", [])]
+
+target_column = next((column for column in columns if column.get("appGroupIndex") == 0), None)
+require(target_column is not None, "fixture must include App group 0")
+
+expected_space_ids = sorted({
+    card.get("primarySpaceID")
+    for card in target_column.get("cards", [])
+    if isinstance(card.get("primarySpaceID"), int)
+})
+reported_space_ids = sorted(root.get("appHoverAssociatedSpaceIDs", []))
+associated_segments = [segment for segment in segments if segment.get("isAppAssociated") is True]
+associated_segment_ids = sorted(segment.get("spaceID") for segment in associated_segments)
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(root.get("hoverTargetKind") == "app", "hover-app must set the hover target to app")
+require(root.get("hoveredAppGroupIndex") == 0, "hover-app must expose the hovered App group")
+require(root.get("activeSpaceFocusID") is None, "hover-app must not set activeSpaceFocusID")
+require(root.get("spaceLaneHoveredSpaceID") is None, "hover-app must not masquerade as Space Lane hover")
+require(root.get("spaceFocusAppGroupIndexes") == [], "hover-app must not activate Space focus")
+require(reported_space_ids == expected_space_ids, "hover-app must report only the hovered App's Spaces")
+require(associated_segment_ids == expected_space_ids, "Space Lane appAssociated segments must match hovered App Spaces")
+require(len(associated_segments) > 0, "hover-app must visually associate at least one Space")
+require(all("appAssociated" in segment.get("visualStates", []) for segment in associated_segments), "associated Space segments must expose appAssociated")
+require(all("focused" not in segment.get("visualStates", []) for segment in associated_segments), "app-associated Spaces must not expose focused")
+require(all("hover" not in segment.get("visualStates", []) for segment in associated_segments), "app-associated Spaces must not expose Space hover")
+require(all(segment.get("shadowRadius", 0) >= 18 for segment in associated_segments), "app-associated Spaces must get a visible float shadow")
+require(all(item.get("isSpaceFocused") is False for item in items), "hover-app must not mark App Shelf items spaceFocused")
+require(all(column.get("isSpaceFocused") is False for column in columns), "hover-app must not mark Waterfall columns spaceFocused")
+require(all("spaceFocused" not in card.get("visualStates", []) for card in cards), "hover-app must not mark Waterfall cards spaceFocused")
+
+print(json.dumps({
+    "hoverTargetKind": root.get("hoverTargetKind"),
+    "hoveredAppGroupIndex": root.get("hoveredAppGroupIndex"),
+    "appHoverAssociatedSpaceIDs": reported_space_ids,
+    "associatedSegmentIDs": associated_segment_ids,
+    "activeSpaceFocusID": root.get("activeSpaceFocusID")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
 assert_app_shelf_scroll_report() {
   /usr/bin/python3 - "$1" "$1" <<'PY'
 import json
@@ -347,6 +504,96 @@
 PY
 }
 
+assert_display_group_report() {
+  /usr/bin/python3 - "$1" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+
+with open(path, "r", encoding="utf-8") as file:
+    report = json.load(file)
+
+def require(condition, message):
+    if not condition:
+        print(message, file=sys.stderr)
+        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+        sys.exit(1)
+
+def right_edge(frame):
+    return frame.get("x", 0) + frame.get("width", 0)
+
+def contains(outer, inner, tolerance=1.0):
+    return (
+        inner.get("x", 0) >= outer.get("x", 0) - tolerance
+        and inner.get("y", 0) >= outer.get("y", 0) - tolerance
+        and right_edge(inner) <= right_edge(outer) + tolerance
+        and inner.get("y", 0) + inner.get("height", 0) <= outer.get("y", 0) + outer.get("height", 0) + tolerance
+    )
+
+root = report.get("rootView", {})
+groups = root.get("spaceLaneDisplayGroups", [])
+segments = root.get("spaceLaneSegments", [])
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(report.get("quickSwitchVisible") is True, "multi-display report must keep Quick Switch visible")
+require(root.get("activeSpaceFocusID") == 4, "multi-display hover must focus Space B1")
+require(root.get("spaceLaneDisplayGroupCount") == 2, "multi-display fixture must expose two Display groups")
+require(len(groups) == 2, "Display group reports must contain two groups")
+require(root.get("spaceLaneSegmentCount") == 5, "multi-display fixture must expose five Space segments")
+require(len(segments) == 5, "Space segment reports must contain five segments")
+require(groups[0].get("spaceLabels") == ["A1", "A2", "A3"], "first Display group must contain A spaces")
+require(groups[1].get("spaceLabels") == ["B1", "B2"], "second Display group must contain B spaces")
+
+first_group_frame = groups[0].get("visibleFrame", {})
+second_group_frame = groups[1].get("visibleFrame", {})
+group_gap = groups[0].get("groupGap", 0)
+actual_group_gap = second_group_frame.get("x", 0) - right_edge(first_group_frame)
+require(actual_group_gap >= group_gap - 1.0, "Display group gap must be at least the reported group gap")
+require(actual_group_gap > groups[0].get("segmentGap", 0) * 2, "Display group gap must be visibly larger than inner segment gap")
+
+segments_by_label = {segment.get("label"): segment for segment in segments}
+for group in groups:
+    group_frame = group.get("visibleFrame", {})
+    labels = group.get("spaceLabels", [])
+    for label in labels:
+        require(label in segments_by_label, f"segment report must include {label}")
+        require(contains(group_frame, segments_by_label[label].get("visibleFrame", {})), f"{label} must be inside its Display group")
+
+for group in groups:
+    labels = group.get("spaceLabels", [])
+    if len(labels) < 2:
+        continue
+    previous = segments_by_label[labels[0]].get("visibleFrame", {})
+    for label in labels[1:]:
+        current = segments_by_label[label].get("visibleFrame", {})
+        actual_segment_gap = current.get("x", 0) - right_edge(previous)
+        require(
+            abs(actual_segment_gap - group.get("segmentGap", 0)) <= 1.0,
+            "inner Space segment gap must match reported segment gap"
+        )
+        previous = current
+
+focused = [segment for segment in segments if segment.get("isFocused") is True]
+require(len(focused) == 1, "exactly one Space segment must be focused")
+require(focused[0].get("label") == "B1", "hovered multi-display Space must be B1")
+require("focused" in focused[0].get("visualStates", []), "focused B1 segment must expose focused visual state")
+
+print(json.dumps({
+    "displayGroups": [
+        {
+            "label": group.get("displayLabel"),
+            "spaces": group.get("spaceLabels"),
+            "visibleFrame": group.get("visibleFrame")
+        }
+        for group in groups
+    ],
+    "actualGroupGap": actual_group_gap,
+    "focusedSpace": focused[0].get("label")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
 trap cleanup EXIT
 
 acquire_lock
@@ -354,12 +601,21 @@
 "$SCRIPT_DIR/package-app.sh" >&2
 
 FOCUS_REPORT="$REPORT_DIR/round01-three-zone-linkage-focus-report.json"
+APP_HOVER_REPORT="$REPORT_DIR/round01-three-zone-linkage-app-hover-report.json"
+CLEAR_REPORT="$REPORT_DIR/round01-three-zone-linkage-clear-report.json"
 SPACE_CLICK_REPORT="$REPORT_DIR/round01-three-zone-linkage-space-click-report.json"
 REMOTE_REPORT="$REPORT_DIR/round01-three-zone-linkage-remote-report.json"
 SCROLL_REPORT="$REPORT_DIR/round01-three-zone-linkage-scroll-report.json"
+DISPLAY_GROUP_REPORT="$REPORT_DIR/round01-three-zone-linkage-display-group-report.json"
 
 run_report "$FOCUS_REPORT" "hover-space:1" "hover-space:1"
 assert_focus_report "$FOCUS_REPORT"
+
+run_report "$APP_HOVER_REPORT" "hover-app:0" "hover-app:0"
+assert_app_hover_space_lane_report "$APP_HOVER_REPORT"
+
+run_report "$CLEAR_REPORT" "hover-space:1,hover-card:0:0" "hover-card:0:0"
+assert_space_focus_clear_report "$CLEAR_REPORT"
 
 run_report "$SPACE_CLICK_REPORT" "click-space:1" "click-space:1" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" hidden
 assert_space_click_report "$SPACE_CLICK_REPORT"
@@ -370,6 +626,9 @@
 run_report "$SCROLL_REPORT" "scroll-app-shelf:99999" "scroll-app-shelf:99999" 60 1
 assert_app_shelf_scroll_report "$SCROLL_REPORT"
 
+run_report "$DISPLAY_GROUP_REPORT" "hover-space:4" "hover-space:4" 6 3 report-only multi-display
+assert_display_group_report "$DISPLAY_GROUP_REPORT"
+
 swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
 stop_current_aligner
 release_lock
diff --git a/C3.tools/round1-waterfall-fixture-qa.sh b/C3.tools/round1-waterfall-fixture-qa.sh
index ab6f8c6..444920b 100755
--- a/C3.tools/round1-waterfall-fixture-qa.sh
+++ b/C3.tools/round1-waterfall-fixture-qa.sh
@@ -119,6 +119,18 @@
     require(column.get("appGroupIndex") == expected_index, "Waterfall appGroupIndex must be sequential")
     require(column.get("appName") == app_names[expected_index], "Waterfall column name must match App Shelf name")
     require(column.get("windowCount") == fixture_windows_per_app, "Waterfall column window count must match fixture")
+    require(column.get("appNameAlignment") == "center", "Waterfall column app name must be center aligned")
+    column_frame = column.get("frame", {})
+    app_name_frame = column.get("appNameFrame", {})
+    count_frame = column.get("countFrame", {})
+    require(isinstance(app_name_frame, dict), "Waterfall column must expose appNameFrame")
+    require(isinstance(count_frame, dict), "Waterfall column must expose countFrame")
+    column_width = column_frame.get("width", 0)
+    app_name_center_x = app_name_frame.get("x", 0) + app_name_frame.get("width", 0) / 2
+    require(abs(app_name_center_x - column_width / 2) <= 1.0, "Waterfall column app name frame must be horizontally centered")
+    require(app_name_frame.get("width", 0) >= column_width * 0.88, "Waterfall column app name frame must span most of the title bar")
+    require(count_frame.get("x", 0) + count_frame.get("width", 0) <= column_width + 1.0, "Waterfall count frame must remain inside the title bar")
+    require(count_frame.get("x", 0) >= column_width * 0.72, "Waterfall count frame must stay in the right side of the title bar")
     require(column.get("maxVerticalScrollOffset", 0) > 0, "Long Waterfall column must expose vertical scroll overflow")
     require(column.get("verticalScrollOffset") == 0, "Waterfall column should start at top")
 
@@ -131,6 +143,10 @@
     require(all(card.get("screenshotSource") == "notRequested" for card in cards), "Waterfall fixture cards must not start screenshot refresh")
     require(all(card.get("screenshotNotRequestedReason") == "disabledByLaunchOption" for card in cards), "Waterfall fixture screenshot refresh must be disabled explicitly")
     require(all(isinstance(card.get("thumbnailFrame"), dict) for card in cards), "Waterfall cards must expose thumbnail frames")
+    require(all(isinstance(card.get("titleBarFrame"), dict) for card in cards), "Waterfall cards must expose title bar frames")
+    require(all(card.get("titleBarBorderWidth") == 0 for card in cards), "Waterfall title area must not draw an enclosing rounded rectangle")
+    require(all(card.get("thumbnailFrame", {}).get("width", 0) >= card.get("frame", {}).get("width", 0) * 0.82 for card in cards), "Waterfall thumbnails must use most of the card width")
+    require(all(card.get("titleBarFrame", {}).get("y", 0) > card.get("thumbnailFrame", {}).get("y", 0) + card.get("thumbnailFrame", {}).get("height", 0) for card in cards), "Waterfall title bars must sit above thumbnails")
     require(all(isinstance(card.get("appIconFrame"), dict) for card in cards), "Waterfall cards must expose App icon frames")
     require(any(card.get("frame", {}).get("y", 0) < 0 for card in cards), "Long Waterfall column must have below-viewport cards at offset zero")
 
@@ -142,9 +158,9 @@
 selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True]
 require(len(selected_cards) == 1, "Waterfall must expose exactly one selected card")
 selected = selected_cards[0]
-require("selected" in selected.get("visualStates", []), "selected Waterfall card must expose selected visual state")
-require(selected.get("shineVisible") is True, "selected Waterfall card must expose shine layer")
-require(selected.get("zPosition", 0) > 0, "selected Waterfall card must be above normal cards")
+require("selectedVisualSuppressed" in selected.get("visualStates", []), "non-hovered selected Waterfall card must suppress selected visual state")
+require(selected.get("shineVisible") is False, "non-hovered selected Waterfall card must not expose shine layer")
+require(selected.get("zPosition", 0) == 0, "non-hovered selected Waterfall card must not float above normal cards")
 normal_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is not True]
 require(normal_cards, "Waterfall fixture must include normal cards")
 require(all(card.get("shineVisible") is False for card in normal_cards), "non-selected Waterfall cards must not show shine")
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\345\274\200\345\217\221\345\267\245\344\275\234-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\345\274\200\345\217\221\345\267\245\344\275\234-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md"
new file mode 100644
index 0000000..b492741
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\345\274\200\345\217\221\345\267\245\344\275\234-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md"
@@ -0,0 +1,11 @@
+# 开发工作经验与教训
+
+> 用途:记录代码、架构、构建、发布、技术实现方面的可复用教训。
+
+## 2026-06-07|已确认功能线不得当作无价值代码清理
+
+- 触发:用户指出 Quick Switch 的关闭按钮、关闭确认弹窗、关闭服务和专门 close QA 脚本是已确认并做出的功能线,被误删了。
+- 失误:AI 把未完全理解的 close 功能线当成可清理代码删除,导致交互和 QA 覆盖一起丢失。
+- 原因:清理未确认代码前没有先用 git 历史、QA 报告、用户决策记录核对功能归属。
+- 改进:删除任何已出现过 QA 报告、脚本、用户验收截图或专门服务协议的功能线前,必须先列出影响面并取得用户明确同意;恢复时必须同步恢复对应 QA 脚本。
+- 关联:`C1.source/Sources/Aligner/QuickSwitchRootView.swift`、`QuickSwitchCloseRequest.swift`、`DebugWindowCloseService.swift`、`C3.tools/round1-close-fixture-qa.sh`。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\351\241\271\347\233\256\347\273\217\347\220\206-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\351\241\271\347\233\256\347\273\217\347\220\206-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md"
index c74b436..05e2369 100644
--- "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\351\241\271\347\233\256\347\273\217\347\220\206-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md"
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\351\241\271\347\233\256\347\273\217\347\220\206-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md"
@@ -10,3 +10,11 @@
 - 原因:把“完成技术调研”等同于“建立可复用排查入口”,遗漏了后续研发排障场景的检索需求。
 - 改进:以后对成熟竞品 / 参考源码做预研时,除专题结论外,必须同步产出源码索引;后续遇到同类问题时,先查索引,再定位源码,再记录 Aligner 是否采用。
 - 关联:`01-I/04.coding/tech-pre-study/AltTab源码索引.md`、`AGENTS.md` 的“参考源码排查规则”。
+
+## 2026-06-04|不把待验证假设包装成执行方案
+
+- 触发:用户指出 Space Lane 优化中,AI 提出“真实窗口截图拼图”替代真实 Space 截图,但没有明确该方案可能造成陌生画面,也没有说明是否在等待用户决策。
+- 失误:把未经视觉和技术验证的合成截图方案当成可执行方向,弱化了“用户必须瞬间识别真实 Space”的产品目标。
+- 原因:过早从实现便利性出发收敛方案,遗漏了等待决策需显式说明的协作规则和设计中“降级缩略图不能伪装成真实截图”的约束。
+- 改进:以后涉及用户识别、原生感、真实截图等高感知 UI 方案时,先定义可验收的真实性标准;未验证前只能作为技术探索,不得直接进入实现计划。若需要用户裁决,必须明确写出正在等待什么决策。
+- 关联:`/Users/ar/.codex/AGENTS.md` 的 Waiting For User 规则、`DESIGN.md` 的缩略图规则。

--
Gitblit v1.9.3