From 0045898e8cd7b2d8972bb24c8ac27e514bdd8c70 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 11 Jun 2026 16:46:31 +0800
Subject: [PATCH] Implement Quick Switch Space filtering

---
 C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift        |   99 ++++
 C3.tools/round1-space-filter-fixture-qa.sh                       |  436 ++++++++++++++++++
 C1.source/Sources/Aligner/QuickSwitchRootView.swift              |  388 ++++++++++++++-
 C1.source/Resources/Aligner-Info.plist                           |    4 
 C1.source/Sources/Aligner/AlignerApplicationDelegate.swift       |    4 
 C1.source/Sources/Aligner/QuickSwitchSessionController.swift     |  130 +++++
 C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift |  153 ++++++
 C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift          |  157 ++++++
 C3.tools/round1-three-zone-linkage-fixture-qa.sh                 |   45 +
 9 files changed, 1,341 insertions(+), 75 deletions(-)

diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index cbb85aa..19cd854 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.52</string>
+    <string>0.0.53</string>
 	<key>CFBundleVersion</key>
-    <string>20260610.1717</string>
+    <string>20260611.1607</string>
 	<key>LSMinimumSystemVersion</key>
 	<string>26.0</string>
 	<key>NSHighResolutionCapable</key>
diff --git a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
index 3b968f2..b2d0fa9 100644
--- a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
+++ b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -517,6 +517,10 @@
             return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .candidateFiltering)
         }
 
+        if round1QuickSwitchOptions.fixtureSpaceFilter {
+            return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .spaceFilter)
+        }
+
         if let fixtureAppCount = round1QuickSwitchOptions.fixtureAppCount {
             return FixtureQuickSwitchSnapshotLoader(
                 appCount: fixtureAppCount,
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 0d3590c..dba70d2 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -78,8 +78,13 @@
     private var keyboardResponseLatencyMilliseconds: [Double] = []
     private var mouseResponseLatencyMilliseconds: [Double] = []
     private var hoverResponseLatencyMilliseconds: [Double] = []
+    private var lastProjectionTransitionMovedLayerCount = 0
+    private var lastProjectionTransitionFadeInLayerCount = 0
+    private var lastProjectionTransitionFadeOutLayerCount = 0
+    private var lastProjectionTransitionDurationMilliseconds: Double = 0
     var onEscape: (() -> Void)?
     var onCommitSelection: ((QuickSwitchSelection, QuickSwitchCommitSource) -> Void)?
+    var onSpaceLaneClick: ((UInt64) -> Void)?
     var onRequestClose: ((QuickSwitchCloseRequest) -> Void)?
     var onCloseConfirmationDisabled: (() -> Void)?
 
@@ -256,6 +261,22 @@
         let stateLayer: CATextLayer
         let shineLayer: CAGradientLayer
         let closeButton: CloseButtonLayers
+    }
+
+    private struct ProjectionLayerSnapshot {
+        let layer: CALayer
+        let frame: CGRect
+    }
+
+    private struct ProjectionColumnSnapshot {
+        let layer: CALayer
+        let frame: CGRect
+        let cardsByWindowID: [UInt32: ProjectionLayerSnapshot]
+    }
+
+    private struct ProjectionTransitionSnapshot {
+        let appShelfItemsByAppGroupIndex: [Int: ProjectionLayerSnapshot]
+        let waterfallColumnsByAppGroupIndex: [Int: ProjectionColumnSnapshot]
     }
 
     private enum AppShelfMetrics {
@@ -608,35 +629,73 @@
         }
     }
 
+    func effectiveSelectionForProjection() -> QuickSwitchSelection? {
+        effectiveSelection
+    }
+
     func apply(viewModel: QuickSwitchViewModel?) {
+        apply(viewModel: viewModel, preservingInteractionState: false, animatedProjection: false)
+    }
+
+    func applyProjected(viewModel: QuickSwitchViewModel?) {
+        apply(viewModel: viewModel, preservingInteractionState: true, animatedProjection: true)
+    }
+
+    private func apply(
+        viewModel: QuickSwitchViewModel?,
+        preservingInteractionState: Bool,
+        animatedProjection: Bool
+    ) {
+        let transitionSnapshot = animatedProjection ? captureProjectionTransitionSnapshot() : nil
+        if !animatedProjection {
+            resetProjectionTransitionReport()
+        }
         currentViewModel = viewModel
-        screenshotSourcesByWindowID = [:]
-        screenshotSkippedReasonsByWindowID = [:]
-        currentSelection = viewModel?.initialSelection
-        columnSelectionHistory = [:]
-        keyboardCommandsApplied = []
-        lastKeyboardCommand = nil
-        tabIgnoredCount = 0
-        lastCommittedSelection = nil
-        lastCommitSource = nil
-        mouseCommandsApplied = []
-        lastMouseCommand = nil
-        lastClickedAppGroupIndex = nil
-        lastAppShelfClickChangedSelection = nil
-        lastClickedWindowID = nil
-        lastSpaceFocusedAppGroupIndex = nil
-        lastSpaceLaneClickSpaceID = nil
-        lastAppShelfScrollSyncedAppGroupIndex = nil
-        focusedSpaceID = nil
-        hoveredCloseTarget = nil
-        pendingCloseTarget = nil
-        pendingCloseRequest = nil
-        pendingCloseOptOut = false
-        closeConfirmationLayers.containerLayer.isHidden = true
-        closeConfirmationArrowEdge = nil
-        clearCloseFeedback()
-        setHoverTarget(.none)
-        hoveredAppGroupIndex = debugHoveredAppGroupIndex
+
+        if preservingInteractionState {
+            currentSelection = validSelection(currentSelection, in: viewModel) ?? viewModel?.initialSelection
+            columnSelectionHistory = columnSelectionHistory.filter { appGroupIndex, _ in
+                viewModel?.waterfallColumns.contains { $0.appGroupIndex == appGroupIndex } == true
+            }
+            if let currentSelection {
+                columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
+            }
+            hoveredCloseTarget = nil
+            pendingCloseTarget = nil
+            pendingCloseRequest = nil
+            pendingCloseOptOut = false
+            closeConfirmationLayers.containerLayer.isHidden = true
+            closeConfirmationArrowEdge = nil
+        } else {
+            screenshotSourcesByWindowID = [:]
+            screenshotSkippedReasonsByWindowID = [:]
+            currentSelection = viewModel?.initialSelection
+            columnSelectionHistory = [:]
+            keyboardCommandsApplied = []
+            lastKeyboardCommand = nil
+            tabIgnoredCount = 0
+            lastCommittedSelection = nil
+            lastCommitSource = nil
+            mouseCommandsApplied = []
+            lastMouseCommand = nil
+            lastClickedAppGroupIndex = nil
+            lastAppShelfClickChangedSelection = nil
+            lastClickedWindowID = nil
+            lastSpaceFocusedAppGroupIndex = nil
+            lastSpaceLaneClickSpaceID = nil
+            lastAppShelfScrollSyncedAppGroupIndex = nil
+            focusedSpaceID = nil
+            hoveredCloseTarget = nil
+            pendingCloseTarget = nil
+            pendingCloseRequest = nil
+            pendingCloseOptOut = false
+            closeConfirmationLayers.containerLayer.isHidden = true
+            closeConfirmationArrowEdge = nil
+            clearCloseFeedback()
+            setHoverTarget(.none)
+            hoveredAppGroupIndex = debugHoveredAppGroupIndex
+        }
+
         if let currentSelection {
             columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
         }
@@ -646,6 +705,250 @@
         needsLayout = true
         ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: false))
         needsLayout = true
+        if animatedProjection, let transitionSnapshot {
+            layoutSubtreeIfNeeded()
+            applyProjectionTransitionAnimations(from: transitionSnapshot)
+        }
+    }
+
+    private func validSelection(
+        _ selection: QuickSwitchSelection?,
+        in viewModel: QuickSwitchViewModel?
+    ) -> QuickSwitchSelection? {
+        guard let selection, let viewModel else { return nil }
+
+        for column in viewModel.waterfallColumns {
+            if let card = column.windows.first(where: { $0.window.id == selection.windowID }) {
+                return QuickSwitchSelection(
+                    appGroupIndex: card.appGroupIndex,
+                    windowIndex: card.windowIndex,
+                    windowID: card.window.id
+                )
+            }
+        }
+
+        return nil
+    }
+
+    private func captureProjectionTransitionSnapshot() -> ProjectionTransitionSnapshot {
+        ProjectionTransitionSnapshot(
+            appShelfItemsByAppGroupIndex: Dictionary(
+                uniqueKeysWithValues: appShelfItems.map { item in
+                    (
+                        item.item.appGroupIndex,
+                        ProjectionLayerSnapshot(
+                            layer: item.containerLayer,
+                            frame: item.containerLayer.frame
+                        )
+                    )
+                }
+            ),
+            waterfallColumnsByAppGroupIndex: Dictionary(
+                uniqueKeysWithValues: waterfallColumns.map { column in
+                    (
+                        column.column.appGroupIndex,
+                        ProjectionColumnSnapshot(
+                            layer: column.containerLayer,
+                            frame: column.containerLayer.frame,
+                            cardsByWindowID: Dictionary(
+                                uniqueKeysWithValues: column.cards.map { card in
+                                    (
+                                        card.item.window.id,
+                                        ProjectionLayerSnapshot(
+                                            layer: card.containerLayer,
+                                            frame: card.containerLayer.frame
+                                        )
+                                    )
+                                }
+                            )
+                        )
+                    )
+                }
+            )
+        )
+    }
+
+    private func applyProjectionTransitionAnimations(from snapshot: ProjectionTransitionSnapshot) {
+        let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
+        let duration: CFTimeInterval = reduceMotion ? 0.12 : 0.21
+        let timing = CAMediaTimingFunction(name: reduceMotion ? .linear : .easeOut)
+        var movedLayerCount = 0
+        var fadeInLayerCount = 0
+        var fadeOutLayerCount = 0
+
+        for item in appShelfItems {
+            guard let oldSnapshot = snapshot.appShelfItemsByAppGroupIndex[item.item.appGroupIndex] else {
+                if addFadeInAnimation(to: item.containerLayer, duration: duration, timing: timing) {
+                    fadeInLayerCount += 1
+                }
+                continue
+            }
+            if addPositionAnimation(to: item.containerLayer, from: oldSnapshot.frame, duration: duration, timing: timing) {
+                movedLayerCount += 1
+            }
+        }
+
+        let visibleAppGroupIndexes = Set(appShelfItems.map(\.item.appGroupIndex))
+        for (appGroupIndex, oldSnapshot) in snapshot.appShelfItemsByAppGroupIndex
+            where !visibleAppGroupIndexes.contains(appGroupIndex) {
+            if addFadeOutSnapshot(
+                oldSnapshot,
+                to: appShelfContentLayer,
+                frame: oldSnapshot.frame,
+                duration: duration,
+                timing: timing
+            ) {
+                fadeOutLayerCount += 1
+            }
+        }
+
+        for column in waterfallColumns {
+            guard let oldSnapshot = snapshot.waterfallColumnsByAppGroupIndex[column.column.appGroupIndex] else {
+                if addFadeInAnimation(to: column.containerLayer, duration: duration, timing: timing) {
+                    fadeInLayerCount += 1
+                }
+                continue
+            }
+            if addPositionAnimation(to: column.containerLayer, from: oldSnapshot.frame, duration: duration, timing: timing) {
+                movedLayerCount += 1
+            }
+
+            for card in column.cards {
+                guard let oldCardSnapshot = oldSnapshot.cardsByWindowID[card.item.window.id] else {
+                    if addFadeInAnimation(to: card.containerLayer, duration: duration, timing: timing) {
+                        fadeInLayerCount += 1
+                    }
+                    continue
+                }
+                if addPositionAnimation(to: card.containerLayer, from: oldCardSnapshot.frame, duration: duration, timing: timing) {
+                    movedLayerCount += 1
+                }
+            }
+        }
+
+        let visibleColumnsByAppGroupIndex = Dictionary(
+            uniqueKeysWithValues: waterfallColumns.map { ($0.column.appGroupIndex, $0) }
+        )
+        for (appGroupIndex, oldSnapshot) in snapshot.waterfallColumnsByAppGroupIndex {
+            guard let visibleColumn = visibleColumnsByAppGroupIndex[appGroupIndex] else {
+                if addFadeOutSnapshot(
+                    ProjectionLayerSnapshot(layer: oldSnapshot.layer, frame: oldSnapshot.frame),
+                    to: waterfallContentLayer,
+                    frame: oldSnapshot.frame,
+                    duration: duration,
+                    timing: timing
+                ) {
+                    fadeOutLayerCount += 1
+                }
+                continue
+            }
+
+            let visibleWindowIDs = Set(visibleColumn.cards.map(\.item.window.id))
+            for (windowID, oldCardSnapshot) in oldSnapshot.cardsByWindowID
+                where !visibleWindowIDs.contains(windowID) {
+                let absoluteFrame = oldCardSnapshot.frame.offsetBy(
+                    dx: oldSnapshot.frame.minX,
+                    dy: oldSnapshot.frame.minY
+                )
+                if addFadeOutSnapshot(
+                    oldCardSnapshot,
+                    to: waterfallContentLayer,
+                    frame: absoluteFrame,
+                    duration: duration,
+                    timing: timing
+                ) {
+                    fadeOutLayerCount += 1
+                }
+            }
+        }
+
+        lastProjectionTransitionMovedLayerCount = movedLayerCount
+        lastProjectionTransitionFadeInLayerCount = fadeInLayerCount
+        lastProjectionTransitionFadeOutLayerCount = fadeOutLayerCount
+        lastProjectionTransitionDurationMilliseconds = duration * 1_000
+    }
+
+    private func resetProjectionTransitionReport() {
+        lastProjectionTransitionMovedLayerCount = 0
+        lastProjectionTransitionFadeInLayerCount = 0
+        lastProjectionTransitionFadeOutLayerCount = 0
+        lastProjectionTransitionDurationMilliseconds = 0
+    }
+
+    @discardableResult
+    private func addPositionAnimation(
+        to layer: CALayer,
+        from oldFrame: CGRect,
+        duration: CFTimeInterval,
+        timing: CAMediaTimingFunction
+    ) -> Bool {
+        let oldPosition = CGPoint(x: oldFrame.midX, y: oldFrame.midY)
+        let newPosition = layer.position
+        guard abs(oldPosition.x - newPosition.x) > 0.5 || abs(oldPosition.y - newPosition.y) > 0.5 else {
+            return false
+        }
+
+        let animation = CABasicAnimation(keyPath: "position")
+        animation.fromValue = oldPosition
+        animation.toValue = newPosition
+        animation.duration = duration
+        animation.timingFunction = timing
+        animation.isRemovedOnCompletion = true
+        layer.add(animation, forKey: "quickSwitchProjectionPosition")
+        return true
+    }
+
+    @discardableResult
+    private func addFadeInAnimation(
+        to layer: CALayer,
+        duration: CFTimeInterval,
+        timing: CAMediaTimingFunction
+    ) -> Bool {
+        let animation = CABasicAnimation(keyPath: "opacity")
+        animation.fromValue = 0
+        animation.toValue = layer.opacity
+        animation.duration = duration
+        animation.timingFunction = timing
+        animation.isRemovedOnCompletion = true
+        layer.add(animation, forKey: "quickSwitchProjectionFadeIn")
+        return true
+    }
+
+    @discardableResult
+    private func addFadeOutSnapshot(
+        _ snapshot: ProjectionLayerSnapshot,
+        to parentLayer: CALayer,
+        frame: CGRect,
+        duration: CFTimeInterval,
+        timing: CAMediaTimingFunction
+    ) -> Bool {
+        let layer = snapshot.layer
+        layer.removeAllAnimations()
+        layer.removeFromSuperlayer()
+        CATransaction.begin()
+        CATransaction.setDisableActions(true)
+        layer.frame = frame
+        layer.opacity = max(layer.opacity, 0.01)
+        layer.zPosition = max(layer.zPosition, 240)
+        parentLayer.addSublayer(layer)
+        CATransaction.commit()
+
+        let animation = CABasicAnimation(keyPath: "opacity")
+        animation.fromValue = layer.opacity
+        animation.toValue = 0
+        animation.duration = duration
+        animation.timingFunction = timing
+        animation.isRemovedOnCompletion = true
+        CATransaction.begin()
+        CATransaction.setDisableActions(true)
+        layer.opacity = 0
+        CATransaction.commit()
+        layer.add(animation, forKey: "quickSwitchProjectionFadeOut")
+
+        DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.05) { [weak layer] in
+            layer?.removeFromSuperlayer()
+        }
+        return true
     }
 
     func reportDictionary() -> [String: Any] {
@@ -685,6 +988,8 @@
             "spaceLaneHoveredSpaceID": hoveredSpaceLaneID ?? NSNull(),
             "spaceLaneFocusedSpaceID": focusedSpaceID ?? NSNull(),
             "activeSpaceFocusID": activeSpaceFocusID ?? NSNull(),
+            "spaceFilterLockedSpaceID": currentViewModel?.lockedSpaceID ?? NSNull(),
+            "spaceFilterActive": currentViewModel?.lockedSpaceID != nil,
             "spaceFocusAppGroupIndexes": spaceFocusedAppGroupIndexes(),
             "appHoverAssociatedSpaceIDs": appHoverAssociatedSpaceIDs.sorted(),
             "appHoverAssociatedSpaceLabels": appHoverAssociatedSpaceLabels,
@@ -732,6 +1037,10 @@
             "waterfallAlignmentTolerance": Double(WaterfallAlignmentMetrics.tolerance),
             "waterfallAlignmentAnimationDurationMilliseconds": Double(WaterfallAlignmentMetrics.animationDuration * 1_000),
             "waterfallAlignmentAnimationTimingName": "easeOut",
+            "projectionTransitionMovedLayerCount": lastProjectionTransitionMovedLayerCount,
+            "projectionTransitionFadeInLayerCount": lastProjectionTransitionFadeInLayerCount,
+            "projectionTransitionFadeOutLayerCount": lastProjectionTransitionFadeOutLayerCount,
+            "projectionTransitionDurationMilliseconds": lastProjectionTransitionDurationMilliseconds,
             "reduceMotionEffective": NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
             "selectedAppGroupIndex": effectiveSelection?.appGroupIndex ?? NSNull(),
             "selectedWindowIndex": effectiveSelection?.windowIndex ?? NSNull(),
@@ -1021,16 +1330,8 @@
             "label": segment.space.label
         ])
         lastSpaceLaneClickSpaceID = segment.space.id
-
-        guard let firstCard = firstWaterfallCard(inSpaceID: segment.space.id) else {
-            DevelopmentDiagnostics.log("quickSwitch.view.clickSpaceLane.emptySpaceUnsupported", [
-                "spaceID": segment.space.id,
-                "label": segment.space.label
-            ])
-            return
-        }
-
-        commitWindowCard(firstCard, trigger: "spaceLane")
+        onSpaceLaneClick?(segment.space.id)
+        lastSpaceLaneClickSpaceID = segment.space.id
     }
 
     private func focusSpace(_ spaceID: UInt64, persistent: Bool, source: HoverTargetSource = .event) {
@@ -2118,6 +2419,7 @@
             segment.space.id == activeSpaceFocusID
                 || appHoverAssociatedSpaceIDs.contains(segment.space.id)
                 || segment.space.id == windowHoverAssociatedSpaceID
+                || segment.space.id == currentViewModel?.lockedSpaceID
         }
         let hasCurrentSegment = group.display.spaces.contains { $0.isCurrent }
         group.containerLayer.backgroundColor = NSColor.windowBackgroundColor.cgColor
@@ -2133,11 +2435,12 @@
     private func applySpaceLaneVisualState(_ segment: SpaceLaneSegmentLayers) {
         let isHovered = segment.space.id == activeSpaceFocusID
         let isFocused = segment.space.id == activeSpaceFocusID
+        let isLocked = segment.space.id == currentViewModel?.lockedSpaceID
         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 isActive = isHovered || isFocused || isLocked
         let isAssociated = (isAppAssociated || isWindowAssociated) && !isActive
 
         segment.containerLayer.borderWidth = (hasWindows || isCurrent || isActive || isAssociated) ? 1.5 : 1
@@ -2169,15 +2472,15 @@
                 colors: spaceLaneIdleOccupiedGradientColors()
             )
         }
-        segment.containerLayer.shadowOpacity = hasWindows
+        segment.containerLayer.shadowOpacity = (hasWindows || isLocked)
             ? (isActive ? 0.26 : isAssociated ? 0.18 : isCurrent ? 0.14 : 0)
             : 0
-        segment.containerLayer.shadowRadius = hasWindows
+        segment.containerLayer.shadowRadius = (hasWindows || isLocked)
             ? (isActive ? 24 : isAssociated ? 18 : isCurrent ? 12 : 0)
             : 0
         segment.containerLayer.shadowOffset = CGSize(
             width: 0,
-            height: hasWindows ? (isActive ? -6 : isAssociated ? -4 : isCurrent ? -2.5 : 0) : 0
+            height: (hasWindows || isLocked) ? (isActive ? -6 : isAssociated ? -4 : isCurrent ? -2.5 : 0) : 0
         )
         segment.containerLayer.zPosition = isActive ? 30 : isAssociated ? 18 : isCurrent ? 10 : 0
         segment.containerLayer.transform = (isActive || isAssociated)
@@ -4164,6 +4467,9 @@
         if space.id == activeSpaceFocusID {
             states.append("focused")
         }
+        if space.id == currentViewModel?.lockedSpaceID {
+            states.append("locked")
+        }
         if space.type == .fullscreen {
             states.append("fullscreen")
         }
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index 11aa41d..5a36765 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -30,7 +30,11 @@
         debugOverlayWidth: debugOverlayWidth
     )
     private var currentSnapshot: QuickSwitchSnapshot?
+    private var sourceViewModel: QuickSwitchViewModel?
     private var currentViewModel: QuickSwitchViewModel?
+    private var lockedSpaceFilterID: UInt64?
+    private var selectionBeforeSpaceFilter: QuickSwitchSelection?
+    private var lastSpaceFilterAction: String?
     private var lastSnapshotError: String?
     private var snapshotTask: Task<Void, Never>?
     private var screenshotTask: Task<Void, Never>?
@@ -159,6 +163,9 @@
         lastCloseError = nil
         pendingCloseVerification = nil
         suppressedCloseTargets = []
+        lockedSpaceFilterID = nil
+        selectionBeforeSpaceFilter = nil
+        lastSpaceFilterAction = nil
         lastDismissReason = nil
         lastSystemCriticalAction = nil
         lastSystemCriticalMonitorSource = nil
@@ -179,6 +186,9 @@
         }
         view.onCommitSelection = { [weak self] selection, source in
             self?.commitSelection(selection, source: source)
+        }
+        view.onSpaceLaneClick = { [weak self] spaceID in
+            self?.handleSpaceLaneClick(spaceID)
         }
         view.onRequestClose = { [weak self] request in
             self?.requestClose(request)
@@ -264,6 +274,9 @@
             "initialSelectionWindowID": currentViewModel?.initialSelection?.windowID ?? NSNull(),
             "appShelfNames": currentViewModel?.appShelf.map(\.app.name) ?? [],
             "spaceLabels": currentViewModel?.displays.flatMap { $0.spaces.map(\.label) } ?? [],
+            "spaceFilterLockedSpaceID": lockedSpaceFilterID ?? NSNull(),
+            "spaceFilterActive": lockedSpaceFilterID != nil,
+            "lastSpaceFilterAction": lastSpaceFilterAction ?? NSNull(),
             "overlayOpenElapsedMilliseconds": overlayOpenElapsedMilliseconds ?? NSNull(),
             "snapshotStartElapsedMilliseconds": snapshotStartElapsedMilliseconds ?? NSNull(),
             "snapshotDurationMilliseconds": snapshotDurationMilliseconds ?? NSNull(),
@@ -375,6 +388,7 @@
 
     private func clearSnapshotState() {
         currentSnapshot = nil
+        sourceViewModel = nil
         currentViewModel = nil
         lastSnapshotError = nil
     }
@@ -446,7 +460,11 @@
         onSnapshotUpdated?()
     }
 
-    private func commitSelection(_ selection: QuickSwitchSelection, source: QuickSwitchCommitSource) {
+    private func commitSelection(
+        _ selection: QuickSwitchSelection,
+        source: QuickSwitchCommitSource,
+        in viewModel: QuickSwitchViewModel? = nil
+    ) {
         DevelopmentDiagnostics.log("quickSwitch.activation.commit.start", [
             "source": source.rawValue,
             "appGroupIndex": selection.appGroupIndex,
@@ -458,7 +476,7 @@
         lastActivationWindowID = selection.windowID
         lastActivationError = nil
 
-        guard let window = window(for: selection) else {
+        guard let window = window(for: selection, in: viewModel ?? currentViewModel) else {
             lastActivationResult = .windowNotFound
             DevelopmentDiagnostics.log("quickSwitch.activation.commit.windowNotFound", [
                 "source": source.rawValue,
@@ -513,6 +531,77 @@
                 "result": lastActivationResult.map(Self.activationResultString)
             ])
         }
+        onSnapshotUpdated?()
+    }
+
+    private func handleSpaceLaneClick(_ spaceID: UInt64) {
+        guard let sourceViewModel else {
+            lastSpaceFilterAction = "blockedNoSourceViewModel"
+            DevelopmentDiagnostics.log("quickSwitch.spaceFilter.click.blocked", [
+                "spaceID": spaceID,
+                "reason": "noSourceViewModel"
+            ])
+            onSnapshotUpdated?()
+            return
+        }
+
+        if let fullscreenSelection = QuickSwitchSpaceFilterPolicy.singleFullscreenSelection(
+            inSpaceID: spaceID,
+            viewModel: sourceViewModel
+        ) {
+            lockedSpaceFilterID = nil
+            selectionBeforeSpaceFilter = nil
+            lastSpaceFilterAction = "activateSingleFullscreen"
+            let visibleViewModel = projectedViewModel(from: sourceViewModel)
+            currentViewModel = visibleViewModel
+            view.applyProjected(viewModel: visibleViewModel)
+            DevelopmentDiagnostics.log("quickSwitch.spaceFilter.singleFullscreenActivate", [
+                "spaceID": spaceID,
+                "windowID": fullscreenSelection.windowID
+            ])
+            commitSelection(fullscreenSelection, source: .mouse, in: sourceViewModel)
+            return
+        }
+
+        let preferredSelection: QuickSwitchSelection?
+        if lockedSpaceFilterID == spaceID {
+            lockedSpaceFilterID = nil
+            preferredSelection = selectionBeforeSpaceFilter
+            selectionBeforeSpaceFilter = nil
+            lastSpaceFilterAction = "unlock"
+        } else {
+            if lockedSpaceFilterID == nil {
+                selectionBeforeSpaceFilter = view.effectiveSelectionForProjection()
+                lastSpaceFilterAction = "lock"
+            } else {
+                lastSpaceFilterAction = "switch"
+            }
+            lockedSpaceFilterID = spaceID
+            preferredSelection = nil
+        }
+
+        let visibleViewModel = projectedViewModel(
+            from: sourceViewModel,
+            preferredSelection: preferredSelection
+        )
+        currentViewModel = visibleViewModel
+        view.applyProjected(viewModel: visibleViewModel)
+        DevelopmentDiagnostics.log("quickSwitch.spaceFilter.apply", [
+            "spaceID": spaceID,
+            "lockedSpaceID": lockedSpaceFilterID,
+            "action": lastSpaceFilterAction,
+            "visibleAppCount": visibleViewModel.appShelf.count,
+            "visibleWindowCount": visibleViewModel.windowCount
+        ])
+
+        if disableScreenshotRefresh {
+            view.markScreenshotsNotRequested(for: visibleViewModel, reason: "disabledByLaunchOption")
+            screenshotTask?.cancel()
+            screenshotTask = nil
+        } else {
+            scheduleScreenshotRefresh(for: visibleViewModel, generation: sessionGeneration)
+        }
+
         onSnapshotUpdated?()
     }
 
@@ -618,10 +707,10 @@
             suppressedCloseTargets.append(target)
         }
 
-        guard let currentViewModel else { return }
-        let visibleViewModel = viewModel(currentViewModel, excluding: suppressedCloseTargets)
+        guard let sourceViewModel else { return }
+        let visibleViewModel = projectedViewModel(from: sourceViewModel, preferredSelection: view.effectiveSelectionForProjection())
         self.currentViewModel = visibleViewModel
-        view.apply(viewModel: visibleViewModel)
+        view.applyProjected(viewModel: visibleViewModel)
         DevelopmentDiagnostics.log("quickSwitch.close.optimisticRemove", [
             "targetKind": request.kindDescription,
             "appGroupIndex": request.appGroupIndex,
@@ -683,7 +772,20 @@
             displays: viewModel.displays,
             appShelf: appShelf,
             waterfallColumns: columns,
-            initialSelection: initialSelection(in: columns, fallback: viewModel.initialSelection)
+            initialSelection: initialSelection(in: columns, fallback: viewModel.initialSelection),
+            lockedSpaceID: viewModel.lockedSpaceID
+        )
+    }
+
+    private func projectedViewModel(
+        from sourceViewModel: QuickSwitchViewModel,
+        preferredSelection: QuickSwitchSelection? = nil
+    ) -> QuickSwitchViewModel {
+        let closeProjectedViewModel = viewModel(sourceViewModel, excluding: suppressedCloseTargets)
+        return QuickSwitchSpaceFilterPolicy.projectedViewModel(
+            from: closeProjectedViewModel,
+            lockedSpaceID: lockedSpaceFilterID,
+            preferredSelection: preferredSelection
         )
     }
 
@@ -743,8 +845,11 @@
         }
     }
 
-    private func window(for selection: QuickSwitchSelection) -> AlignerWindow? {
-        currentViewModel?
+    private func window(
+        for selection: QuickSwitchSelection,
+        in viewModel: QuickSwitchViewModel?
+    ) -> AlignerWindow? {
+        viewModel?
             .waterfallColumns
             .flatMap(\.windows)
             .first { card in
@@ -772,7 +877,11 @@
         switch result {
         case .success(let success):
             currentSnapshot = success.snapshot
-            let visibleViewModel = viewModel(success.viewModel, excluding: suppressedCloseTargets)
+            sourceViewModel = success.viewModel
+            let visibleViewModel = projectedViewModel(
+                from: success.viewModel,
+                preferredSelection: view.effectiveSelectionForProjection()
+            )
             currentViewModel = visibleViewModel
             lastSnapshotError = nil
             snapshotStartElapsedMilliseconds = success.timing.startElapsedMilliseconds
@@ -808,6 +917,7 @@
             }
         case .failure(let failure):
             currentSnapshot = nil
+            sourceViewModel = nil
             currentViewModel = nil
             lastSnapshotError = failure.message
             snapshotStartElapsedMilliseconds = failure.timing.startElapsedMilliseconds
@@ -1169,6 +1279,7 @@
     let fixtureMultiDisplay: Bool
     let fixtureCandidateFiltering: Bool
     let fixtureActivation: Bool
+    let fixtureSpaceFilter: Bool
     let debugHoveredAppGroupIndex: Int?
     let debugOverlayWidth: CGFloat?
     let debugKeySequence: [String]
@@ -1195,6 +1306,7 @@
             fixtureMultiDisplay: arguments.contains("--round01-fixture-multi-display"),
             fixtureCandidateFiltering: arguments.contains("--round01-fixture-candidate-filtering"),
             fixtureActivation: arguments.contains("--round01-fixture-window-activation"),
+            fixtureSpaceFilter: arguments.contains("--round01-fixture-space-filter"),
             debugHoveredAppGroupIndex: intValue(for: "--round01-debug-hover-app-index", in: arguments),
             debugOverlayWidth: cgFloatValue(for: "--round01-debug-overlay-width", in: arguments),
             debugKeySequence: stringListValue(for: "--round01-debug-key-sequence", in: arguments),
diff --git a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift b/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
index 8d45239..fa8d37a 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
@@ -14,6 +14,7 @@
     case layout
     case candidateFiltering
     case activation
+    case spaceFilter
 }
 
 final class LiveQuickSwitchSnapshotLoader: QuickSwitchSnapshotLoading {
@@ -82,6 +83,9 @@
         }
         if mode == .activation {
             return activationSnapshot()
+        }
+        if mode == .spaceFilter {
+            return spaceFilterSnapshot()
         }
 
         return layoutSnapshot()
@@ -395,4 +399,99 @@
             currentSpaceIDs: [1]
         )
     }
+
+    private func spaceFilterSnapshot() -> QuickSwitchSnapshotLoad {
+        let displayAUUID = "fixture-display-a"
+        let displayBUUID = "fixture-display-b"
+        let displays = [
+            AlignerDisplay(
+                uuid: displayAUUID,
+                physical: true,
+                spaces: [
+                    AlignerSpace(id: 1, type: .user, displayUUID: displayAUUID, index: 1),
+                    AlignerSpace(id: 2, type: .user, 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: .fullscreen, displayUUID: displayBUUID, index: 2)
+                ]
+            )
+        ]
+
+        let alphaApp = AlignerApp(
+            bundleIdentifier: "com.example.spacefilter.alpha",
+            name: "Alpha Space App",
+            category: .finder,
+            processIdentifier: 50_001
+        )
+        let betaApp = AlignerApp(
+            bundleIdentifier: "com.example.spacefilter.beta",
+            name: "Beta Space App",
+            category: .documentNotes,
+            processIdentifier: 50_002
+        )
+        let fullscreenApp = AlignerApp(
+            bundleIdentifier: "com.example.spacefilter.fullscreen",
+            name: "Fullscreen Solo App",
+            category: .generic,
+            processIdentifier: 50_003
+        )
+        let noSpaceApp = AlignerApp(
+            bundleIdentifier: "com.example.spacefilter.nospace",
+            name: "No Space App",
+            category: .generic,
+            processIdentifier: 50_004
+        )
+
+        let windows = [
+            AlignerWindow(
+                id: 50_101,
+                app: alphaApp,
+                title: "Alpha A1 Primary",
+                spaceIDs: [1]
+            ),
+            AlignerWindow(
+                id: 50_102,
+                app: alphaApp,
+                title: "Alpha A1 Secondary",
+                isMinimized: true,
+                spaceIDs: [1]
+            ),
+            AlignerWindow(
+                id: 50_201,
+                app: betaApp,
+                title: "Beta A1 Shared",
+                spaceIDs: [1]
+            ),
+            AlignerWindow(
+                id: 50_202,
+                app: betaApp,
+                title: "Beta A2 Only",
+                spaceIDs: [2]
+            ),
+            AlignerWindow(
+                id: 50_501,
+                app: fullscreenApp,
+                title: "Fullscreen B2 Solo",
+                isFullscreen: true,
+                spaceIDs: [5]
+            ),
+            AlignerWindow(
+                id: 50_901,
+                app: noSpaceApp,
+                title: "No Space Diagnostic",
+                spaceIDs: []
+            )
+        ]
+
+        return QuickSwitchSnapshotLoad(
+            snapshot: QuickSwitchSnapshotBuilder.snapshot(displays: displays, windows: windows),
+            currentSpaceIDs: [1, 4]
+        )
+    }
 }
diff --git a/C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift b/C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift
index 1686294..1bec9df 100644
--- a/C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift
+++ b/C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift
@@ -5,17 +5,20 @@
     public let appShelf: [QuickSwitchAppShelfItemViewModel]
     public let waterfallColumns: [QuickSwitchWaterfallColumnViewModel]
     public let initialSelection: QuickSwitchSelection?
+    public let lockedSpaceID: UInt64?
 
     public init(
         displays: [QuickSwitchDisplayViewModel],
         appShelf: [QuickSwitchAppShelfItemViewModel],
         waterfallColumns: [QuickSwitchWaterfallColumnViewModel],
-        initialSelection: QuickSwitchSelection?
+        initialSelection: QuickSwitchSelection?,
+        lockedSpaceID: UInt64? = nil
     ) {
         self.displays = displays
         self.appShelf = appShelf
         self.waterfallColumns = waterfallColumns
         self.initialSelection = initialSelection
+        self.lockedSpaceID = lockedSpaceID
     }
 
     public var spaceCount: Int {
@@ -197,7 +200,8 @@
             displays: displays,
             appShelf: appShelf,
             waterfallColumns: columns,
-            initialSelection: QuickSwitchFocusPolicy.initialSelection(in: snapshot)
+            initialSelection: QuickSwitchFocusPolicy.initialSelection(in: snapshot),
+            lockedSpaceID: nil
         )
     }
 
@@ -339,3 +343,148 @@
         return "\(displayLabel(for: displayIndex))\(space.index)"
     }
 }
+
+public enum QuickSwitchSpaceFilterPolicy {
+    public static func projectedViewModel(
+        from viewModel: QuickSwitchViewModel,
+        lockedSpaceID: UInt64?,
+        preferredSelection: QuickSwitchSelection? = nil
+    ) -> QuickSwitchViewModel {
+        guard let lockedSpaceID else {
+            return replacingInitialSelection(
+                in: viewModel,
+                lockedSpaceID: nil,
+                preferredSelection: preferredSelection
+            )
+        }
+
+        var nextGlobalIndex = 0
+        let columns = viewModel.waterfallColumns.compactMap { column -> QuickSwitchWaterfallColumnViewModel? in
+            let windows = column.windows.filter { $0.primarySpaceID == lockedSpaceID }
+            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, preferredSelection: preferredSelection),
+            lockedSpaceID: lockedSpaceID
+        )
+    }
+
+    public static func singleFullscreenSelection(
+        inSpaceID spaceID: UInt64,
+        viewModel: QuickSwitchViewModel
+    ) -> QuickSwitchSelection? {
+        guard viewModel.displays
+            .flatMap(\.spaces)
+            .contains(where: { $0.id == spaceID && $0.type == .fullscreen })
+        else {
+            return nil
+        }
+
+        let cards = viewModel.waterfallColumns
+            .flatMap(\.windows)
+            .filter { $0.primarySpaceID == spaceID }
+
+        guard cards.count == 1,
+              let card = cards.first,
+              card.window.isFullscreen
+        else {
+            return nil
+        }
+
+        return QuickSwitchSelection(
+            appGroupIndex: card.appGroupIndex,
+            windowIndex: card.windowIndex,
+            windowID: card.window.id
+        )
+    }
+
+    private static func replacingInitialSelection(
+        in viewModel: QuickSwitchViewModel,
+        lockedSpaceID: UInt64?,
+        preferredSelection: QuickSwitchSelection?
+    ) -> QuickSwitchViewModel {
+        QuickSwitchViewModel(
+            displays: viewModel.displays,
+            appShelf: viewModel.appShelf,
+            waterfallColumns: viewModel.waterfallColumns,
+            initialSelection: initialSelection(in: viewModel.waterfallColumns, preferredSelection: preferredSelection)
+                ?? viewModel.initialSelection,
+            lockedSpaceID: lockedSpaceID
+        )
+    }
+
+    private static func initialSelection(
+        in columns: [QuickSwitchWaterfallColumnViewModel],
+        preferredSelection: QuickSwitchSelection?
+    ) -> QuickSwitchSelection? {
+        if let preferredSelection,
+           let matchingCard = columns
+            .flatMap(\.windows)
+            .first(where: { $0.window.id == preferredSelection.windowID }) {
+            return QuickSwitchSelection(
+                appGroupIndex: matchingCard.appGroupIndex,
+                windowIndex: matchingCard.windowIndex,
+                windowID: matchingCard.window.id
+            )
+        }
+
+        guard let firstCard = columns.first?.windows.first else { return nil }
+        return QuickSwitchSelection(
+            appGroupIndex: firstCard.appGroupIndex,
+            windowIndex: firstCard.windowIndex,
+            windowID: firstCard.window.id
+        )
+    }
+
+    private static 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
+    }
+}
diff --git a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
index a6c3447..e583820 100644
--- a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
+++ b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
@@ -1279,6 +1279,163 @@
         XCTAssertEqual(viewModel.waterfallColumns.flatMap { $0.windows.map(\.primarySpaceLabel) }, ["01", "02"])
     }
 
+    func testQuickSwitchSpaceFilterProjectsAppShelfAndWaterfallWithoutChangingSpaceLane() {
+        let alpha = AlignerApp(bundleIdentifier: "com.example.Alpha", name: "Alpha", category: .generic)
+        let beta = AlignerApp(bundleIdentifier: "com.example.Beta", name: "Beta", category: .generic)
+        let snapshot = QuickSwitchSnapshotBuilder.snapshot(
+            displays: [makeDisplay(spaceIDs: [10, 11])],
+            windows: [
+                AlignerWindow(id: 1, app: alpha, title: "A1", spaceIDs: [10]),
+                AlignerWindow(id: 2, app: alpha, title: "A2", spaceIDs: [11]),
+                AlignerWindow(id: 3, app: beta, title: "B1", spaceIDs: [11]),
+                AlignerWindow(id: 4, app: beta, title: "No Space")
+            ]
+        )
+        let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot)
+
+        let filtered = QuickSwitchSpaceFilterPolicy.projectedViewModel(
+            from: viewModel,
+            lockedSpaceID: 11
+        )
+
+        XCTAssertEqual(filtered.lockedSpaceID, 11)
+        XCTAssertEqual(filtered.displays.flatMap { $0.spaces.map(\.id) }, [10, 11])
+        XCTAssertEqual(filtered.displays.flatMap { $0.spaces.map(\.windowCount) }, [1, 2])
+        XCTAssertEqual(filtered.appShelf.map(\.app.name), ["Alpha", "Beta"])
+        XCTAssertEqual(filtered.appShelf.map(\.windowCount), [1, 1])
+        XCTAssertEqual(filtered.appShelf.flatMap(\.primarySpaceIDs), [11, 11])
+        XCTAssertEqual(filtered.waterfallColumns.flatMap { $0.windows.map(\.window.id) }, [2, 3])
+        XCTAssertEqual(filtered.waterfallColumns.flatMap { $0.windows.map(\.windowIndex) }, [0, 0])
+        XCTAssertEqual(filtered.waterfallColumns.flatMap { $0.windows.map(\.globalIndex) }, [0, 1])
+        XCTAssertEqual(filtered.initialSelection, QuickSwitchSelection(appGroupIndex: 0, windowIndex: 0, windowID: 2))
+    }
+
+    func testQuickSwitchSpaceFilterKeyboardNavigationStaysInsideFilteredCollection() {
+        let alpha = AlignerApp(bundleIdentifier: "com.example.Alpha", name: "Alpha", category: .generic)
+        let beta = AlignerApp(bundleIdentifier: "com.example.Beta", name: "Beta", category: .generic)
+        let snapshot = QuickSwitchSnapshotBuilder.snapshot(
+            displays: [makeDisplay(spaceIDs: [10, 11])],
+            windows: [
+                AlignerWindow(id: 1, app: alpha, title: "Alpha A1", spaceIDs: [10]),
+                AlignerWindow(id: 2, app: alpha, title: "Alpha A2", spaceIDs: [11]),
+                AlignerWindow(id: 3, app: beta, title: "Beta A2", spaceIDs: [11]),
+                AlignerWindow(id: 4, app: beta, title: "Beta No Space")
+            ]
+        )
+        let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot)
+        let filtered = QuickSwitchSpaceFilterPolicy.projectedViewModel(
+            from: viewModel,
+            lockedSpaceID: 11
+        )
+        let filteredSnapshot = QuickSwitchSnapshot(
+            displays: snapshot.displays,
+            appGroups: filtered.waterfallColumns.map { column in
+                QuickSwitchAppGroup(
+                    app: column.app,
+                    windows: column.windows.map {
+                        QuickSwitchWindowItem(window: $0.window, primarySpaceID: $0.primarySpaceID)
+                    }
+                )
+            }
+        )
+
+        XCTAssertEqual(filtered.initialSelection, QuickSwitchSelection(appGroupIndex: 0, windowIndex: 0, windowID: 2))
+        XCTAssertEqual(
+            QuickSwitchFocusPolicy.nextSelection(
+                from: filtered.initialSelection!,
+                in: filteredSnapshot,
+                direction: .right
+            ),
+            QuickSwitchSelection(appGroupIndex: 1, windowIndex: 0, windowID: 3)
+        )
+        XCTAssertEqual(
+            QuickSwitchFocusPolicy.nextSelection(
+                from: QuickSwitchSelection(appGroupIndex: 1, windowIndex: 0, windowID: 3),
+                in: filteredSnapshot,
+                direction: .right
+            ),
+            QuickSwitchSelection(appGroupIndex: 1, windowIndex: 0, windowID: 3)
+        )
+        XCTAssertFalse(filteredSnapshot.appGroups.flatMap(\.windows).contains { $0.window.id == 1 || $0.window.id == 4 })
+    }
+
+    func testQuickSwitchSpaceFilterCanLockEmptySpaceWithoutChangingSpaceLane() {
+        let app = AlignerApp(bundleIdentifier: "com.example.Alpha", name: "Alpha", category: .generic)
+        let snapshot = QuickSwitchSnapshotBuilder.snapshot(
+            displays: [makeDisplay(spaceIDs: [10, 11])],
+            windows: [
+                AlignerWindow(id: 1, app: app, title: "A1", spaceIDs: [10])
+            ]
+        )
+        let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot)
+
+        let filtered = QuickSwitchSpaceFilterPolicy.projectedViewModel(
+            from: viewModel,
+            lockedSpaceID: 11
+        )
+
+        XCTAssertEqual(filtered.lockedSpaceID, 11)
+        XCTAssertEqual(filtered.displays.flatMap { $0.spaces.map(\.id) }, [10, 11])
+        XCTAssertTrue(filtered.appShelf.isEmpty)
+        XCTAssertTrue(filtered.waterfallColumns.isEmpty)
+        XCTAssertNil(filtered.initialSelection)
+    }
+
+    func testQuickSwitchSpaceFilterRestoresPreferredSelectionWhenUnlocking() {
+        let app = AlignerApp(bundleIdentifier: "com.example.Alpha", name: "Alpha", category: .generic)
+        let snapshot = QuickSwitchSnapshotBuilder.snapshot(
+            displays: [makeDisplay(spaceIDs: [10, 11])],
+            windows: [
+                AlignerWindow(id: 1, app: app, title: "A1", spaceIDs: [10]),
+                AlignerWindow(id: 2, app: app, title: "A2", spaceIDs: [11])
+            ]
+        )
+        let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot)
+        let preferred = QuickSwitchSelection(appGroupIndex: 0, windowIndex: 1, windowID: 2)
+
+        let unlocked = QuickSwitchSpaceFilterPolicy.projectedViewModel(
+            from: viewModel,
+            lockedSpaceID: nil,
+            preferredSelection: preferred
+        )
+
+        XCTAssertNil(unlocked.lockedSpaceID)
+        XCTAssertEqual(unlocked.initialSelection, preferred)
+    }
+
+    func testQuickSwitchSpaceFilterDetectsOnlySingleFullscreenWindowSpacesAsDirectActivation() {
+        let alpha = AlignerApp(bundleIdentifier: "com.example.Alpha", name: "Alpha", category: .generic)
+        let beta = AlignerApp(bundleIdentifier: "com.example.Beta", name: "Beta", category: .generic)
+        let displayUUID = "display-a"
+        let snapshot = QuickSwitchSnapshotBuilder.snapshot(
+            displays: [
+                AlignerDisplay(
+                    uuid: displayUUID,
+                    physical: true,
+                    spaces: [
+                        AlignerSpace(id: 10, type: .user, displayUUID: displayUUID, index: 1),
+                        AlignerSpace(id: 11, type: .fullscreen, displayUUID: displayUUID, index: 2),
+                        AlignerSpace(id: 12, type: .fullscreen, displayUUID: displayUUID, index: 3)
+                    ]
+                )
+            ],
+            windows: [
+                AlignerWindow(id: 1, app: alpha, title: "Normal", spaceIDs: [10]),
+                AlignerWindow(id: 2, app: alpha, title: "Fullscreen", isFullscreen: true, spaceIDs: [11]),
+                AlignerWindow(id: 3, app: beta, title: "Split A", isFullscreen: true, spaceIDs: [12]),
+                AlignerWindow(id: 4, app: beta, title: "Split B", isFullscreen: true, spaceIDs: [12])
+            ]
+        )
+        let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot)
+
+        XCTAssertEqual(
+            QuickSwitchSpaceFilterPolicy.singleFullscreenSelection(inSpaceID: 11, viewModel: viewModel),
+            QuickSwitchSelection(appGroupIndex: 0, windowIndex: 1, windowID: 2)
+        )
+        XCTAssertNil(QuickSwitchSpaceFilterPolicy.singleFullscreenSelection(inSpaceID: 10, viewModel: viewModel))
+        XCTAssertNil(QuickSwitchSpaceFilterPolicy.singleFullscreenSelection(inSpaceID: 12, viewModel: viewModel))
+    }
+
     func testQuickSwitchFocusPolicySelectsFirstStableWindowInitially() {
         let app = makeApp()
         let snapshot = QuickSwitchSnapshotBuilder.snapshot(
diff --git a/C3.tools/round1-space-filter-fixture-qa.sh b/C3.tools/round1-space-filter-fixture-qa.sh
new file mode 100755
index 0000000..cc1b1a2
--- /dev/null
+++ b/C3.tools/round1-space-filter-fixture-qa.sh
@@ -0,0 +1,436 @@
+#!/bin/bash
+# Round01.1 Space filter fixture QA. It verifies click-to-lock filtering,
+# unlock, switching locked Spaces, empty Space state, and single-fullscreen
+# Space direct activation.
+
+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"
+REPORT_WAIT="${ALIGNER_ROUND1_SPACE_FILTER_REPORT_WAIT:-6.0}"
+REPORT_PREFIX="$BUILD_REPORT_ROOT/round01-space-filter"
+LOCK_DIR="$BUILD_LOCK_ROOT/round1-space-filter-fixture-qa.lock"
+APP_PID=""
+LOCK_HELD=0
+
+fail() {
+  echo "Round01.1 Space filter fixture QA failed: $*" >&2
+  exit 1
+}
+
+acquire_lock() {
+  if ! mkdir "$LOCK_DIR" 2>/dev/null; then
+    fail "another round1-space-filter-fixture-qa.sh appears to be running; run packaging QA scripts serially"
+  fi
+  LOCK_HELD=1
+}
+
+release_lock() {
+  if [ "$LOCK_HELD" -eq 1 ]; then
+    rmdir "$LOCK_DIR" 2>/dev/null || true
+    LOCK_HELD=0
+  fi
+}
+
+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"
+}
+
+cleanup() {
+  if [ -n "${APP_PID:-}" ]; then
+    kill "$APP_PID" 2>/dev/null || true
+    wait "$APP_PID" 2>/dev/null || true
+    APP_PID=""
+  fi
+  stop_current_aligner
+  release_lock
+}
+
+wait_for_visible_report() {
+  local report="$1"
+  local expected_last_mouse="$2"
+
+  /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_last_mouse" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+timeout = float(sys.argv[2])
+expected_last_mouse = sys.argv[3]
+deadline = time.monotonic() + timeout
+last_report = None
+
+while time.monotonic() < deadline:
+    try:
+        with open(path, "r", encoding="utf-8") as file:
+            report = json.load(file)
+        last_report = report
+        root = report.get("rootView", {})
+        if (
+            report.get("snapshotLoaded") is True
+            and report.get("quickSwitchVisible") is True
+            and root.get("lastMouseCommand") == expected_last_mouse
+        ):
+            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"visible report did not reach mouse command {expected_last_mouse!r} within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+wait_for_activation_report() {
+  local report="$1"
+  local expected_last_mouse="$2"
+
+  /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_last_mouse" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+timeout = float(sys.argv[2])
+expected_last_mouse = sys.argv[3]
+deadline = time.monotonic() + timeout
+last_report = None
+
+while time.monotonic() < deadline:
+    try:
+        with open(path, "r", encoding="utf-8") as file:
+            report = json.load(file)
+        last_report = report
+        root = report.get("rootView", {})
+        if (
+            report.get("snapshotLoaded") is True
+            and report.get("quickSwitchVisible") is False
+            and report.get("lastSpaceFilterAction") == "activateSingleFullscreen"
+            and report.get("lastActivationResult") is not None
+            and root.get("lastMouseCommand") == expected_last_mouse
+        ):
+            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"activation report did not reach mouse command {expected_last_mouse!r} within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+run_report() {
+  local report="$1"
+  local sequence="$2"
+  local expected_last_mouse="$3"
+  local quick_switch_expectation="${4:-visible}"
+
+  rm -f "$report"
+  "$APP/Contents/MacOS/Aligner" \
+    --round0-skip-permissions \
+    --round01-open-quick-switch \
+    --round01-fixture-space-filter \
+    --round01-disable-screenshot-refresh \
+    --round01-debug-window-activation \
+    --round01-debug-mouse-sequence="$sequence" \
+    --round01-quick-switch-report="$report" &
+
+  APP_PID=$!
+
+  if [ "$quick_switch_expectation" = "hidden" ]; then
+    wait_for_activation_report "$report" "$expected_last_mouse"
+    swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
+  elif [ "$quick_switch_expectation" = "visible" ]; then
+    wait_for_visible_report "$report" "$expected_last_mouse"
+    swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
+  else
+    fail "unknown quick switch expectation: $quick_switch_expectation"
+  fi
+
+  kill "$APP_PID" 2>/dev/null || true
+  wait "$APP_PID" 2>/dev/null || true
+  APP_PID=""
+  sleep 0.3
+  stop_current_aligner
+}
+
+assert_lock_a1_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", [])]
+segment_by_id = {segment.get("spaceID"): segment for segment in segments}
+
+require(report.get("snapshotLoaded") is True, "snapshot must load")
+require(report.get("quickSwitchVisible") is True, "Quick Switch must stay visible after filtering")
+require(report.get("spaceFilterActive") is True, "session filter must be active")
+require(report.get("spaceFilterLockedSpaceID") == 1, "session locked Space must be A1")
+require(report.get("lastSpaceFilterAction") == "lock", "first click must lock")
+require(root.get("spaceFilterActive") is True, "root filter must be active")
+require(root.get("spaceFilterLockedSpaceID") == 1, "root locked Space must be A1")
+require(report.get("appShelfNames") == ["Alpha Space App", "Beta Space App"], "App Shelf must only include A1 apps")
+require(root.get("appShelfNames") == ["Alpha Space App", "Beta Space App"], "root App Shelf must only include A1 apps")
+require(root.get("waterfallColumnNames") == ["Alpha Space App", "Beta Space App"], "Waterfall must only include A1 columns")
+require(report.get("windowCount") == 3, "A1 must expose exactly three windows")
+require(len(cards) == 3, "A1 card count must be three")
+require(all(card.get("primarySpaceID") == 1 for card in cards), "every visible card must belong to A1")
+require(50901 not in [card.get("windowID") for card in cards], "no-space window must not enter A1 filter")
+require(root.get("selectedWindowID") == 50101, "lock should select first filtered window")
+require(root.get("spaceLaneLabels") == ["A1", "A2", "A3", "B1", "B2"], "Space Lane must keep all Spaces")
+require(len(segments) == 5, "Space Lane must keep all five fixture Spaces")
+require(segment_by_id[1].get("windowCount") == 3, "A1 Space Lane global count must remain three")
+require(segment_by_id[2].get("windowCount") == 1, "A2 Space Lane global count must remain one")
+require(segment_by_id[3].get("windowCount") == 0, "A3 must remain visible as empty Space")
+require(segment_by_id[4].get("windowCount") == 0, "B1 must remain visible as empty Space")
+require(segment_by_id[5].get("windowCount") == 1, "B2 fullscreen Space must remain visible")
+require("locked" in segment_by_id[1].get("visualStates", []), "locked Space must expose locked visual state")
+require("locked" not in segment_by_id[5].get("visualStates", []), "single fullscreen Space must not be marked locked")
+require(root.get("projectionTransitionFadeOutLayerCount", 0) >= 1, "filter transition must fade out removed visual layers")
+require(1 <= root.get("projectionTransitionDurationMilliseconds", 0) <= 220, "filter transition duration must stay within P0 native-feel budget")
+
+print(json.dumps({
+    "case": "lock-a1",
+    "apps": root.get("appShelfNames"),
+    "columns": root.get("waterfallColumnNames"),
+    "cardWindowIDs": [card.get("windowID") for card in cards],
+    "lockedSpace": root.get("spaceFilterLockedSpaceID"),
+    "fadeOutLayerCount": root.get("projectionTransitionFadeOutLayerCount"),
+    "transitionDurationMilliseconds": root.get("projectionTransitionDurationMilliseconds")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_switch_a2_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", {})
+columns = root.get("waterfallColumns", [])
+cards = [card for column in columns for card in column.get("cards", [])]
+
+require(report.get("spaceFilterActive") is True, "filter must remain active after switching Space")
+require(report.get("spaceFilterLockedSpaceID") == 2, "locked Space must switch to A2")
+require(report.get("lastSpaceFilterAction") == "switch", "second different Space click must switch")
+require(root.get("appShelfNames") == ["Beta Space App"], "A2 should only show Beta app")
+require(root.get("waterfallColumnNames") == ["Beta Space App"], "A2 should only show Beta column")
+require(len(cards) == 1 and cards[0].get("windowID") == 50202, "A2 should only show the Beta A2 window")
+require(cards[0].get("primarySpaceID") == 2, "A2 card must belong to Space 2")
+require(root.get("selectedWindowID") == 50202, "switching lock should select first A2 window")
+
+print(json.dumps({
+    "case": "switch-a2",
+    "apps": root.get("appShelfNames"),
+    "cardWindowIDs": [card.get("windowID") for card in cards],
+    "lockedSpace": root.get("spaceFilterLockedSpaceID")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_unlock_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", {})
+cards = [card for column in root.get("waterfallColumns", []) for card in column.get("cards", [])]
+
+require(report.get("spaceFilterActive") is False, "same Space click must unlock session filter")
+require(root.get("spaceFilterActive") is False, "same Space click must unlock root filter")
+require(report.get("lastSpaceFilterAction") == "unlock", "same Space click must record unlock")
+require(report.get("appShelfNames") == ["Alpha Space App", "Beta Space App", "Fullscreen Solo App", "No Space App"], "unlock must restore all fixture apps")
+require(report.get("windowCount") == 6, "unlock must restore all six fixture windows")
+require(50901 in [card.get("windowID") for card in cards], "no-space window should be visible again after unlock")
+require(root.get("selectedWindowID") == 50101, "unlock should restore pre-filter selected window")
+
+print(json.dumps({
+    "case": "unlock-a1",
+    "apps": report.get("appShelfNames"),
+    "windowCount": report.get("windowCount"),
+    "selectedWindowID": root.get("selectedWindowID")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_empty_a3_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", {})
+segments = root.get("spaceLaneSegments", [])
+segment_by_id = {segment.get("spaceID"): segment for segment in segments}
+
+require(report.get("spaceFilterActive") is True, "empty Space click must still lock filter")
+require(report.get("spaceFilterLockedSpaceID") == 3, "empty Space lock must target A3")
+require(report.get("lastSpaceFilterAction") == "lock", "empty Space first click must record lock")
+require(report.get("appCount") == 0, "empty Space must show no App Shelf items")
+require(report.get("columnCount") == 0, "empty Space must show no Waterfall columns")
+require(report.get("windowCount") == 0, "empty Space must show no windows")
+require(root.get("appShelfItemCount") == 0, "root App Shelf must be empty")
+require(root.get("waterfallColumnCount") == 0, "root Waterfall must be empty")
+require(root.get("spaceLaneLabels") == ["A1", "A2", "A3", "B1", "B2"], "Space Lane must stay global while empty Space is locked")
+require("locked" in segment_by_id[3].get("visualStates", []), "empty locked Space must expose locked visual state")
+require(segment_by_id[3].get("backgroundKind") == "clear", "empty locked Space must not use gray/gradient fill")
+require(segment_by_id[4].get("backgroundKind") == "clear", "empty sibling Space must not inherit gray/gradient fill")
+
+print(json.dumps({
+    "case": "empty-a3",
+    "lockedSpace": report.get("spaceFilterLockedSpaceID"),
+    "appCount": report.get("appCount"),
+    "columnCount": report.get("columnCount"),
+    "a3BackgroundKind": segment_by_id[3].get("backgroundKind"),
+    "b1BackgroundKind": segment_by_id[4].get("backgroundKind")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_single_fullscreen_report() {
+  local report="$1"
+  local expected_previous_action="$2"
+
+  /usr/bin/python3 - "$report" "$expected_previous_action" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+expected_previous_action = 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", {})
+
+require(report.get("quickSwitchVisible") is False, "single fullscreen Space click must close Quick Switch")
+require(report.get("spaceFilterActive") is False, "single fullscreen Space must not keep filter active")
+require(root.get("spaceFilterActive") is False, "root must show no filter after direct activation")
+require(report.get("lastSpaceFilterAction") == "activateSingleFullscreen", "single fullscreen Space must use direct activation action")
+require(report.get("lastCommittedWindowID") == 50501, "direct activation must commit fullscreen window")
+require(report.get("lastActivationWindowID") == 50501, "direct activation target must be fullscreen window")
+require(report.get("lastActivationResult") == "activated", "debug activation should activate fullscreen window")
+require(report.get("lastCommitSource") == "mouse", "direct fullscreen activation must be mouse sourced")
+require(root.get("lastMouseCommand") == "click-space:5", "last mouse command must target B2")
+
+print(json.dumps({
+    "case": expected_previous_action,
+    "quickSwitchVisible": report.get("quickSwitchVisible"),
+    "lastSpaceFilterAction": report.get("lastSpaceFilterAction"),
+    "lastCommittedWindowID": report.get("lastCommittedWindowID"),
+    "lastActivationResult": report.get("lastActivationResult")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+acquire_lock
+trap cleanup EXIT
+stop_current_aligner
+"$SCRIPT_DIR/package-app.sh" >&2
+
+LOCK_A1_REPORT="$REPORT_PREFIX-lock-a1-report.json"
+SWITCH_A2_REPORT="$REPORT_PREFIX-switch-a2-report.json"
+UNLOCK_REPORT="$REPORT_PREFIX-unlock-report.json"
+EMPTY_A3_REPORT="$REPORT_PREFIX-empty-a3-report.json"
+FULLSCREEN_REPORT="$REPORT_PREFIX-fullscreen-report.json"
+LOCK_THEN_FULLSCREEN_REPORT="$REPORT_PREFIX-lock-then-fullscreen-report.json"
+
+run_report "$LOCK_A1_REPORT" "click-space:1" "click-space:1" "visible"
+assert_lock_a1_report "$LOCK_A1_REPORT"
+
+run_report "$SWITCH_A2_REPORT" "click-space:1,click-space:2" "click-space:2" "visible"
+assert_switch_a2_report "$SWITCH_A2_REPORT"
+
+run_report "$UNLOCK_REPORT" "click-space:1,click-space:1" "click-space:1" "visible"
+assert_unlock_report "$UNLOCK_REPORT"
+
+run_report "$EMPTY_A3_REPORT" "click-space:3" "click-space:3" "visible"
+assert_empty_a3_report "$EMPTY_A3_REPORT"
+
+run_report "$FULLSCREEN_REPORT" "click-space:5" "click-space:5" "hidden"
+assert_single_fullscreen_report "$FULLSCREEN_REPORT" "single-fullscreen"
+
+run_report "$LOCK_THEN_FULLSCREEN_REPORT" "click-space:1,click-space:5" "click-space:5" "hidden"
+assert_single_fullscreen_report "$LOCK_THEN_FULLSCREEN_REPORT" "lock-then-single-fullscreen"
+
+trap - EXIT
+cleanup
+
+echo "Round01.1 Space filter fixture QA passed"
diff --git a/C3.tools/round1-three-zone-linkage-fixture-qa.sh b/C3.tools/round1-three-zone-linkage-fixture-qa.sh
index b32d5b8..8486406 100755
--- a/C3.tools/round1-three-zone-linkage-fixture-qa.sh
+++ b/C3.tools/round1-three-zone-linkage-fixture-qa.sh
@@ -295,7 +295,7 @@
 PY
 }
 
-assert_space_click_report() {
+assert_space_click_filter_report() {
   /usr/bin/python3 - "$1" <<'PY'
 import json
 import sys
@@ -314,30 +314,33 @@
 root = report.get("rootView", {})
 columns = sorted(root.get("waterfallColumns", []), key=lambda column: column.get("appGroupIndex", 0))
 target_space_id = 1
-first_space_card = None
-for column in columns:
-    cards = sorted(column.get("cards", []), key=lambda card: card.get("windowIndex", 0))
-    first_space_card = next((card for card in cards if card.get("primarySpaceID") == target_space_id), None)
-    if first_space_card is not None:
-        break
-
-require(first_space_card is not None, "fixture must include at least one window in Space A1")
-expected_window_id = first_space_card.get("windowID")
+cards = [card for column in columns for card in column.get("cards", [])]
+segments = root.get("spaceLaneSegments", [])
+locked_segments = [segment for segment in segments if "locked" in segment.get("visualStates", [])]
 
 require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
-require(report.get("quickSwitchVisible") is False, "click-space must close Quick Switch after committing a window in that Space")
+require(report.get("quickSwitchVisible") is True, "click-space must keep Quick Switch visible after locking Space filter")
+require(report.get("spaceFilterActive") is True, "click-space must enable session Space filter")
+require(report.get("spaceFilterLockedSpaceID") == target_space_id, "click-space must lock the clicked Space")
+require(report.get("lastSpaceFilterAction") == "lock", "click-space must record a lock action")
+require(root.get("spaceFilterActive") is True, "root report must expose active Space filter")
+require(root.get("spaceFilterLockedSpaceID") == target_space_id, "root report must expose the locked Space")
 require(root.get("lastSpaceLaneClickSpaceID") == target_space_id, "click-space must record the clicked Space")
-require(root.get("spaceLaneFocusedSpaceID") is None, "click-space must not reuse persistent focus as its behavior")
-require(root.get("lastCommittedWindowID") == expected_window_id, "click-space must commit the first window in the clicked Space")
-require(report.get("lastCommittedWindowID") == expected_window_id, "session must surface the Space click commit")
-require(report.get("lastActivationWindowID") == expected_window_id, "activation window must match the first window in clicked Space")
-require(report.get("lastActivationResult") == "activated", "debug activation must activate the clicked Space window")
-require(report.get("lastCommitSource") == "mouse", "click-space commit source must be mouse")
+require(root.get("spaceLaneFocusedSpaceID") is None, "click-space must not reuse persistent hover focus as its behavior")
+require(report.get("lastCommittedWindowID") is None, "ordinary click-space must not commit a window")
+require(report.get("lastActivationWindowID") is None, "ordinary click-space must not activate a window")
+require(report.get("lastActivationResult") is None, "ordinary click-space must not report activation")
+require(report.get("lastCommitSource") is None, "ordinary click-space must not report a commit source")
+require(cards, "fixture must include filtered cards for Space A1")
+require(all(card.get("primarySpaceID") == target_space_id for card in cards), "click-space filter must only keep windows in clicked Space")
+require(len(locked_segments) == 1, "exactly one Space Lane segment must expose locked")
+require(locked_segments[0].get("spaceID") == target_space_id, "locked segment must be the clicked Space")
 
 print(json.dumps({
     "clickedSpaceID": root.get("lastSpaceLaneClickSpaceID"),
-    "lastCommittedWindowID": root.get("lastCommittedWindowID"),
-    "lastActivationResult": report.get("lastActivationResult")
+    "lockedSpaceID": root.get("spaceFilterLockedSpaceID"),
+    "visibleCardSpaceIDs": sorted({card.get("primarySpaceID") for card in cards}),
+    "quickSwitchVisible": report.get("quickSwitchVisible")
 }, indent=2, ensure_ascii=False))
 PY
 }
@@ -702,8 +705,8 @@
 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"
+run_report "$SPACE_CLICK_REPORT" "click-space:1" "click-space:1" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" visible
+assert_space_click_filter_report "$SPACE_CLICK_REPORT"
 
 run_report "$REMOTE_REPORT" "hover-space:1,click-app:$REMOTE_APP_INDEX" "click-app:$REMOTE_APP_INDEX" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" hidden
 assert_remote_click_report "$REMOTE_REPORT"

--
Gitblit v1.9.3