From 63128a870cbedb841cb19cdfbf2bf54e3dfbc25c Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 12 Jun 2026 11:55:58 +0800
Subject: [PATCH] Implement horizontal Quick Switch waterfall

---
 C3.tools/round1-main-ui-qa.sh                                      |    1 
 C3.tools/round1-horizontal-waterfall-fixture-qa.sh                 |  323 +++++++++++++++++++++
 C1.source/Sources/Aligner/QuickSwitchRootView.swift                |  500 +++++++++++++++++++++++++++++++++
 C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift |   21 +
 C1.source/Resources/Aligner-Info.plist                             |    4 
 C1.source/Sources/Aligner/AlignerApplicationDelegate.swift         |    7 
 C1.source/Sources/Aligner/QuickSwitchSessionController.swift       |   22 +
 C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift            |    7 
 8 files changed, 878 insertions(+), 7 deletions(-)

diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index 6026e5d..ce77d23 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.56</string>
+    <string>0.0.57</string>
 	<key>CFBundleVersion</key>
-    <string>20260611.2211</string>
+    <string>20260612.1151</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 4fcad5f..f021a10 100644
--- a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
+++ b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -357,6 +357,7 @@
                 spaceActivationService: quickSwitchSpaceActivationService(),
                 windowCloseService: quickSwitchWindowCloseService(),
                 disableScreenshotRefresh: round1QuickSwitchOptions.disableScreenshotRefresh,
+                waterfallViewMode: quickSwitchWaterfallViewMode(),
                 closeConfirmationRequired: quickSwitchConfirmBeforeClose,
                 onCloseConfirmationDisabled: { [weak self] in
                     self?.setQuickSwitchConfirmBeforeClose(false)
@@ -432,6 +433,7 @@
                 spaceActivationService: quickSwitchSpaceActivationService(),
                 windowCloseService: quickSwitchWindowCloseService(),
                 disableScreenshotRefresh: round1QuickSwitchOptions.disableScreenshotRefresh,
+                waterfallViewMode: quickSwitchWaterfallViewMode(),
                 closeConfirmationRequired: quickSwitchConfirmBeforeClose,
                 onCloseConfirmationDisabled: { [weak self] in
                     self?.setQuickSwitchConfirmBeforeClose(false)
@@ -548,6 +550,11 @@
         return CGWindowAXWindowService(spaceIDsByWindowIDProvider: { _ in [:] })
     }
 
+    private func quickSwitchWaterfallViewMode() -> QuickSwitchWaterfallViewMode {
+        round1QuickSwitchOptions.waterfallViewMode
+            ?? UserDefaultsPreferenceStore().read().quickSwitchWaterfallViewMode
+    }
+
     private func quickSwitchSpaceActivationService() -> any SpaceActivationServiceProtocol {
         if round1QuickSwitchOptions.debugWindowActivation {
             return DebugSpaceActivationService()
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 4f8f371..e001bc4 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -55,9 +55,12 @@
     private var focusedSpaceID: UInt64?
     private var debugHoveredAppGroupIndex: Int?
     private var waterfallColumns: [WaterfallColumnLayers] = []
+    private var waterfallViewMode: QuickSwitchWaterfallViewMode = .verticalColumns
     private var waterfallContentWidth: CGFloat = 0
     private var waterfallScrollOffset: CGFloat = 0
     private var waterfallColumnScrollOffsets: [Int: CGFloat] = [:]
+    private var horizontalMasonryContentHeight: CGFloat = 0
+    private var horizontalMasonryScrollOffset: CGFloat = 0
     private var currentSelection: QuickSwitchSelection?
     private var columnSelectionHistory: [Int: Int] = [:]
     private var keyboardCommandsApplied: [String] = []
@@ -316,6 +319,18 @@
         static let horizontalRevealPadding: CGFloat = 0
     }
 
+    private enum HorizontalMasonryMetrics {
+        static let horizontalPadding: CGFloat = 12
+        static let verticalPadding: CGFloat = 0
+        static let cardGap: CGFloat = 16
+        static let cardMinWidth: CGFloat = 220
+        static let cardMaxWidth: CGFloat = 320
+        static let titleBarHeight: CGFloat = 26
+        static let titleGap: CGFloat = 6
+        static let minThumbnailHeight: CGFloat = 92
+        static let maxThumbnailHeightFraction: CGFloat = 0.40
+    }
+
     private enum WaterfallHorizontalIntent {
         case reveal
         case alignWithAppShelf(animated: Bool)
@@ -431,6 +446,15 @@
         }
 
         if shouldScrollWaterfall(for: event) {
+            if isHorizontalMasonryMode {
+                let currentOffset = horizontalMasonryScrollOffset
+                horizontalMasonryScrollOffset = clampedHorizontalMasonryOffset(
+                    currentOffset - event.scrollingDeltaY
+                )
+                needsLayout = true
+                return
+            }
+
             if abs(event.scrollingDeltaX) >= abs(event.scrollingDeltaY), waterfallAlignmentScrollable {
                 waterfallScrollOffset = clampedWaterfallAlignmentOffset(waterfallScrollOffset - event.scrollingDeltaX)
                 needsLayout = true
@@ -598,6 +622,17 @@
         needsLayout = true
     }
 
+    func setWaterfallViewMode(_ mode: QuickSwitchWaterfallViewMode) {
+        guard waterfallViewMode != mode else { return }
+
+        waterfallViewMode = mode
+        waterfallScrollOffset = 0
+        waterfallColumnScrollOffsets = [:]
+        horizontalMasonryScrollOffset = 0
+        horizontalMasonryContentHeight = 0
+        needsLayout = true
+    }
+
     func showCloseProgress(for request: QuickSwitchCloseRequest) {
         setCloseFeedback(
             CloseFeedback(
@@ -715,6 +750,10 @@
             lastSpaceFocusedAppGroupIndex = nil
             lastSpaceLaneClickSpaceID = nil
             lastAppShelfScrollSyncedAppGroupIndex = nil
+            waterfallScrollOffset = 0
+            waterfallColumnScrollOffsets = [:]
+            horizontalMasonryScrollOffset = 0
+            horizontalMasonryContentHeight = 0
             focusedSpaceID = nil
             hoveredCloseTarget = nil
             pendingCloseTarget = nil
@@ -1052,10 +1091,15 @@
             "appShelfMinIconSize": Double(AppShelfMetrics.minIconSize),
             "appShelfItems": appShelfItemReports(),
             "waterfallFrame": dictionary(from: waterfallLayer.frame),
+            "waterfallViewMode": waterfallViewMode.rawValue,
             "waterfallContentWidth": Double(waterfallContentWidth),
             "waterfallVisibleWidth": Double(waterfallLayer.bounds.width),
             "waterfallScrollOffset": Double(waterfallScrollOffset),
             "waterfallMaxScrollOffset": Double(waterfallMaxScrollOffset),
+            "horizontalMasonryContentHeight": Double(horizontalMasonryContentHeight),
+            "horizontalMasonryScrollOffset": Double(horizontalMasonryScrollOffset),
+            "horizontalMasonryMaxScrollOffset": Double(horizontalMasonryMaxScrollOffset),
+            "horizontalMasonryScrollable": horizontalMasonryMaxScrollOffset > 0,
             "waterfallAlignmentMinScrollOffset": Double(waterfallAlignmentScrollRange.lowerBound),
             "waterfallAlignmentMaxScrollOffset": Double(waterfallAlignmentScrollRange.upperBound),
             "waterfallScrollable": waterfallMaxScrollOffset > 0,
@@ -1167,8 +1211,13 @@
             recordKeyboardCommand("enter")
             commitCurrentSelection()
         case "tab":
-            tabIgnoredCount += 1
-            recordKeyboardCommand("tabIgnored")
+            if isHorizontalMasonryMode {
+                recordKeyboardCommand("tab")
+                moveSelection(.right)
+            } else {
+                tabIgnoredCount += 1
+                recordKeyboardCommand("tabIgnored")
+            }
         default:
             recordKeyboardCommand("unknown:\(command)")
         }
@@ -1574,6 +1623,11 @@
     }
 
     private func moveSelection(_ direction: QuickSwitchKeyboardDirection) {
+        if isHorizontalMasonryMode {
+            moveHorizontalMasonrySelection(direction)
+            return
+        }
+
         guard let currentViewModel,
               let snapshot = snapshotForFocus(from: currentViewModel),
               let current = currentSelection ?? currentViewModel.initialSelection,
@@ -1603,6 +1657,113 @@
             "windowID": next.windowID
         ])
         needsLayout = true
+    }
+
+    private func moveHorizontalMasonrySelection(_ direction: QuickSwitchKeyboardDirection) {
+        guard let current = currentSelection ?? currentViewModel?.initialSelection,
+              let next = nextHorizontalMasonrySelection(from: current, direction: direction)
+        else {
+            DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection.blocked", [
+                "direction": String(describing: direction),
+                "hasSelection": (currentSelection ?? currentViewModel?.initialSelection) != nil
+            ])
+            return
+        }
+
+        columnSelectionHistory[current.appGroupIndex] = current.windowIndex
+        currentSelection = next
+        columnSelectionHistory[next.appGroupIndex] = next.windowIndex
+        ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true))
+        DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection", [
+            "direction": String(describing: direction),
+            "appGroupIndex": next.appGroupIndex,
+            "windowIndex": next.windowIndex,
+            "windowID": next.windowID
+        ])
+        needsLayout = true
+    }
+
+    private func nextHorizontalMasonrySelection(
+        from current: QuickSwitchSelection,
+        direction: QuickSwitchKeyboardDirection
+    ) -> QuickSwitchSelection? {
+        layoutSubtreeIfNeeded()
+
+        let cards = horizontalMasonryCardsInPlacementOrder()
+        guard let currentIndex = cards.firstIndex(where: { $0.item.window.id == current.windowID }) else {
+            return cards.first.map(selectionForCard)
+        }
+
+        switch direction {
+        case .right:
+            return selectionForCard(cards[(currentIndex + 1) % cards.count])
+        case .left:
+            return selectionForCard(cards[(currentIndex - 1 + cards.count) % cards.count])
+        case .up:
+            return nearestHorizontalMasonrySelection(from: cards[currentIndex], direction: .up)
+                ?? selectionForCard(cards.first ?? cards[currentIndex])
+        case .down:
+            return nearestHorizontalMasonrySelection(from: cards[currentIndex], direction: .down)
+                ?? selectionForCard(cards.last ?? cards[currentIndex])
+        }
+    }
+
+    private func nearestHorizontalMasonrySelection(
+        from currentCard: WaterfallCardLayers,
+        direction: QuickSwitchKeyboardDirection
+    ) -> QuickSwitchSelection? {
+        let currentFrame = currentCard.containerLayer.frame
+        let candidates = horizontalMasonryCardsInPlacementOrder().filter { card in
+            guard card.item.window.id != currentCard.item.window.id else { return false }
+            switch direction {
+            case .up:
+                return card.containerLayer.frame.minY >= currentFrame.maxY - 0.5
+            case .down:
+                return card.containerLayer.frame.maxY <= currentFrame.minY + 0.5
+            case .left, .right:
+                return false
+            }
+        }
+
+        let best = candidates.min { lhs, rhs in
+            let lhsFrame = lhs.containerLayer.frame
+            let rhsFrame = rhs.containerLayer.frame
+            let lhsPrimaryDistance = direction == .up
+                ? lhsFrame.minY - currentFrame.maxY
+                : currentFrame.minY - lhsFrame.maxY
+            let rhsPrimaryDistance = direction == .up
+                ? rhsFrame.minY - currentFrame.maxY
+                : currentFrame.minY - rhsFrame.maxY
+            if abs(lhsPrimaryDistance - rhsPrimaryDistance) > 0.5 {
+                return lhsPrimaryDistance < rhsPrimaryDistance
+            }
+
+            let lhsHorizontalDistance = abs(lhsFrame.midX - currentFrame.midX)
+            let rhsHorizontalDistance = abs(rhsFrame.midX - currentFrame.midX)
+            if abs(lhsHorizontalDistance - rhsHorizontalDistance) > 0.5 {
+                return lhsHorizontalDistance < rhsHorizontalDistance
+            }
+
+            return lhs.item.globalIndex < rhs.item.globalIndex
+        }
+
+        return best.map(selectionForCard)
+    }
+
+    private func horizontalMasonryCardsInPlacementOrder() -> [WaterfallCardLayers] {
+        waterfallColumns
+            .sorted { $0.column.appGroupIndex < $1.column.appGroupIndex }
+            .flatMap { column in
+                column.cards.sorted { $0.item.windowIndex < $1.item.windowIndex }
+            }
+    }
+
+    private func selectionForCard(_ card: WaterfallCardLayers) -> QuickSwitchSelection {
+        QuickSwitchSelection(
+            appGroupIndex: card.item.appGroupIndex,
+            windowIndex: card.item.windowIndex,
+            windowID: card.item.window.id
+        )
     }
 
     private func commitCurrentSelection() {
@@ -1662,6 +1823,16 @@
         _ selection: QuickSwitchSelection,
         horizontalIntent: WaterfallHorizontalIntent = .reveal
     ) {
+        if isHorizontalMasonryMode {
+            switch horizontalIntent {
+            case .reveal:
+                ensureHorizontalMasonrySelectionVisible(selection, animated: false)
+            case .alignWithAppShelf(let animated):
+                ensureHorizontalMasonrySelectionVisible(selection, animated: animated)
+            }
+            return
+        }
+
         guard
             let column = waterfallColumns.first(where: { $0.column.appGroupIndex == selection.appGroupIndex })
         else {
@@ -1683,6 +1854,11 @@
     }
 
     private func ensureWaterfallCardVisibleVertically(_ selection: QuickSwitchSelection) {
+        guard !isHorizontalMasonryMode else {
+            ensureHorizontalMasonrySelectionVisible(selection, animated: false)
+            return
+        }
+
         let visibleHeight = max(
             1,
             waterfallLayer.bounds.height
@@ -1710,6 +1886,11 @@
     }
 
     private func alignWaterfallColumnWithAppShelfIcon(appGroupIndex: Int, animated: Bool) {
+        if isHorizontalMasonryMode {
+            ensureHorizontalMasonryAppVisible(appGroupIndex, animated: animated)
+            return
+        }
+
         guard
             let item = appShelfItems.first(where: { $0.item.appGroupIndex == appGroupIndex }),
             let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex })
@@ -1736,6 +1917,11 @@
     }
 
     private func ensureWaterfallColumnVisible(_ appGroupIndex: Int) {
+        if isHorizontalMasonryMode {
+            ensureHorizontalMasonryAppVisible(appGroupIndex, animated: false)
+            return
+        }
+
         guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex }) else {
             return
         }
@@ -1744,6 +1930,11 @@
     }
 
     private func ensureWaterfallColumnVisible(_ column: WaterfallColumnLayers) {
+        if isHorizontalMasonryMode {
+            ensureHorizontalMasonryAppVisible(column.column.appGroupIndex, animated: false)
+            return
+        }
+
         waterfallScrollOffset = clampedWaterfallOffset(
             scrollOffset(
                 makingVisible: column.containerLayer.frame,
@@ -1756,6 +1947,10 @@
     }
 
     private var waterfallAlignmentScrollRange: ClosedRange<CGFloat> {
+        if isHorizontalMasonryMode {
+            return 0...0
+        }
+
         if waterfallShouldKeepFilteredColumnsCentered {
             return 0...0
         }
@@ -1822,6 +2017,80 @@
         animation.timingFunction = CAMediaTimingFunction(name: WaterfallAlignmentMetrics.animationTimingName)
         animation.isRemovedOnCompletion = true
         waterfallContentLayer.add(animation, forKey: "waterfallColumnAlignment")
+    }
+
+    private func ensureHorizontalMasonrySelectionVisible(
+        _ selection: QuickSwitchSelection,
+        animated: Bool
+    ) {
+        guard let card = waterfallCard(
+            appGroupIndex: selection.appGroupIndex,
+            windowIndex: selection.windowIndex
+        ) else {
+            return
+        }
+
+        let visibleFrame = card.containerLayer.frame.offsetBy(
+            dx: 0,
+            dy: waterfallContentLayer.frame.minY
+        )
+        let visibleHeight = waterfallLayer.bounds.height
+        let padding = WaterfallMetrics.revealPadding
+        var nextOffset = horizontalMasonryScrollOffset
+
+        if visibleFrame.maxY > visibleHeight - padding {
+            nextOffset -= visibleFrame.maxY - (visibleHeight - padding)
+        } else if visibleFrame.minY < padding {
+            nextOffset += padding - visibleFrame.minY
+        }
+
+        setHorizontalMasonryScrollOffset(nextOffset, animated: animated)
+    }
+
+    private func ensureHorizontalMasonryAppVisible(_ appGroupIndex: Int, animated: Bool) {
+        guard let firstCard = waterfallColumns
+            .first(where: { $0.column.appGroupIndex == appGroupIndex })?
+            .cards
+            .sorted(by: { $0.item.windowIndex < $1.item.windowIndex })
+            .first
+        else {
+            return
+        }
+
+        let cardTopDistance = horizontalMasonryContentHeight - firstCard.containerLayer.frame.maxY
+        setHorizontalMasonryScrollOffset(
+            cardTopDistance - WaterfallMetrics.revealPadding,
+            animated: animated
+        )
+    }
+
+    private func setHorizontalMasonryScrollOffset(_ offset: CGFloat, animated: Bool) {
+        let nextOffset = clampedHorizontalMasonryOffset(offset)
+        let previousPositionY = waterfallContentLayer.presentation()?.position.y
+            ?? waterfallContentLayer.position.y
+
+        horizontalMasonryScrollOffset = nextOffset
+        CATransaction.begin()
+        CATransaction.setDisableActions(true)
+        waterfallContentLayer.frame = horizontalMasonryContentFrame()
+        CATransaction.commit()
+
+        let nextPositionY = waterfallContentLayer.position.y
+        guard animated,
+              !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
+              abs(previousPositionY - nextPositionY) > 0.5
+        else {
+            waterfallContentLayer.removeAnimation(forKey: "horizontalMasonryAlignment")
+            return
+        }
+
+        let animation = CABasicAnimation(keyPath: "position.y")
+        animation.fromValue = previousPositionY
+        animation.toValue = nextPositionY
+        animation.duration = WaterfallAlignmentMetrics.animationDuration
+        animation.timingFunction = CAMediaTimingFunction(name: WaterfallAlignmentMetrics.animationTimingName)
+        animation.isRemovedOnCompletion = true
+        waterfallContentLayer.add(animation, forKey: "horizontalMasonryAlignment")
     }
 
     private func scrollOffset(
@@ -2942,6 +3211,10 @@
             return "none"
         }
 
+        guard !isHorizontalMasonryMode else {
+            return "visible"
+        }
+
         let visibleMinX = waterfallScrollOffset
         let visibleMaxX = waterfallScrollOffset + waterfallLayer.bounds.width
         if column.containerLayer.frame.maxX < visibleMinX + WaterfallMetrics.gap {
@@ -3024,9 +3297,16 @@
     }
 
     private func layoutWaterfallColumns() {
+        if isHorizontalMasonryMode {
+            layoutHorizontalMasonryWaterfall()
+            return
+        }
+
         guard !waterfallColumns.isEmpty else {
             waterfallContentWidth = waterfallLayer.bounds.width
             waterfallScrollOffset = 0
+            horizontalMasonryContentHeight = waterfallLayer.bounds.height
+            horizontalMasonryScrollOffset = 0
             waterfallContentLayer.frame = waterfallLayer.bounds
             return
         }
@@ -3057,6 +3337,195 @@
         }
     }
 
+    private func layoutHorizontalMasonryWaterfall() {
+        guard !waterfallColumns.isEmpty else {
+            waterfallContentWidth = waterfallLayer.bounds.width
+            waterfallScrollOffset = 0
+            horizontalMasonryContentHeight = waterfallLayer.bounds.height
+            horizontalMasonryScrollOffset = 0
+            waterfallContentLayer.frame = waterfallLayer.bounds
+            return
+        }
+
+        let layout = horizontalMasonryLayout()
+        waterfallContentWidth = waterfallLayer.bounds.width
+        waterfallScrollOffset = 0
+        horizontalMasonryContentHeight = layout.contentHeight
+        horizontalMasonryScrollOffset = clampedHorizontalMasonryOffset(horizontalMasonryScrollOffset)
+        waterfallContentLayer.frame = horizontalMasonryContentFrame()
+
+        for column in waterfallColumns {
+            column.containerLayer.frame = CGRect(
+                x: 0,
+                y: 0,
+                width: waterfallContentWidth,
+                height: horizontalMasonryContentHeight
+            )
+            column.containerLayer.masksToBounds = false
+            column.containerLayer.backgroundColor = NSColor.clear.cgColor
+            column.containerLayer.borderColor = NSColor.clear.cgColor
+            column.containerLayer.borderWidth = 0
+            column.containerLayer.shadowOpacity = 0
+            column.containerLayer.shadowRadius = 0
+            column.containerLayer.shadowOffset = .zero
+            column.headerLayer.isHidden = true
+            column.appNameLayer.isHidden = true
+            column.countLayer.isHidden = true
+
+            for card in column.cards {
+                if let frame = layout.cardFramesByWindowID[card.item.window.id] {
+                    card.containerLayer.frame = frame
+                } else {
+                    card.containerLayer.frame = .zero
+                }
+                layoutWaterfallCard(card)
+            }
+        }
+    }
+
+    private struct HorizontalMasonryLayout {
+        let contentHeight: CGFloat
+        let cardFramesByWindowID: [UInt32: CGRect]
+    }
+
+    private func horizontalMasonryLayout() -> HorizontalMasonryLayout {
+        let cards = horizontalMasonryCardsInPlacementOrder()
+        guard !cards.isEmpty else {
+            return HorizontalMasonryLayout(
+                contentHeight: waterfallLayer.bounds.height,
+                cardFramesByWindowID: [:]
+            )
+        }
+
+        let viewportWidth = max(1, waterfallLayer.bounds.width)
+        let availableWidth = max(
+            HorizontalMasonryMetrics.cardMinWidth,
+            viewportWidth - HorizontalMasonryMetrics.horizontalPadding * 2
+        )
+        let maximumLaneCount = max(
+            1,
+            Int(
+                floor(
+                    (availableWidth + HorizontalMasonryMetrics.cardGap)
+                        / (HorizontalMasonryMetrics.cardMinWidth + HorizontalMasonryMetrics.cardGap)
+                )
+            )
+        )
+        let laneCount = min(maximumLaneCount, max(1, cards.count))
+        let fittingWidth = (
+            availableWidth - CGFloat(max(0, laneCount - 1)) * HorizontalMasonryMetrics.cardGap
+        ) / CGFloat(laneCount)
+        let cardWidth = floor(
+            min(
+                HorizontalMasonryMetrics.cardMaxWidth,
+                max(HorizontalMasonryMetrics.cardMinWidth, fittingWidth)
+            )
+        )
+        let usedWidth = CGFloat(laneCount) * cardWidth
+            + CGFloat(max(0, laneCount - 1)) * HorizontalMasonryMetrics.cardGap
+        let startX = max(
+            HorizontalMasonryMetrics.horizontalPadding,
+            (viewportWidth - usedWidth) / 2
+        )
+
+        var laneHeights = Array(repeating: CGFloat(0), count: laneCount)
+        var plannedFrames: [(windowID: UInt32, x: CGFloat, top: CGFloat, width: CGFloat, height: CGFloat)] = []
+
+        for card in cards {
+            let laneIndex = laneHeights.enumerated().min { lhs, rhs in
+                if abs(lhs.element - rhs.element) > 0.5 {
+                    return lhs.element < rhs.element
+                }
+                return lhs.offset < rhs.offset
+            }?.offset ?? 0
+            let cardHeight = horizontalMasonryCardHeight(
+                for: card,
+                width: cardWidth,
+                viewportHeight: waterfallLayer.bounds.height
+            )
+            let x = startX + CGFloat(laneIndex) * (cardWidth + HorizontalMasonryMetrics.cardGap)
+            let top = HorizontalMasonryMetrics.verticalPadding + laneHeights[laneIndex]
+            plannedFrames.append((
+                windowID: card.item.window.id,
+                x: x,
+                top: top,
+                width: cardWidth,
+                height: cardHeight
+            ))
+            laneHeights[laneIndex] += cardHeight + HorizontalMasonryMetrics.cardGap
+        }
+
+        let tallestLane = laneHeights.max().map {
+            max(0, $0 - HorizontalMasonryMetrics.cardGap)
+        } ?? 0
+        let contentHeight = max(
+            waterfallLayer.bounds.height,
+            HorizontalMasonryMetrics.verticalPadding * 2 + tallestLane
+        )
+        let frames = Dictionary(
+            uniqueKeysWithValues: plannedFrames.map { planned in
+                (
+                    planned.windowID,
+                    CGRect(
+                        x: planned.x,
+                        y: contentHeight - planned.top - planned.height,
+                        width: planned.width,
+                        height: planned.height
+                    )
+                )
+            }
+        )
+
+        return HorizontalMasonryLayout(contentHeight: contentHeight, cardFramesByWindowID: frames)
+    }
+
+    private func horizontalMasonryCardHeight(
+        for card: WaterfallCardLayers,
+        width: CGFloat,
+        viewportHeight: CGFloat
+    ) -> CGFloat {
+        let thumbnailHeight = min(
+            max(
+                HorizontalMasonryMetrics.minThumbnailHeight,
+                width / horizontalMasonryThumbnailAspectRatio(for: card)
+            ),
+            max(1, viewportHeight * HorizontalMasonryMetrics.maxThumbnailHeightFraction)
+        )
+        return HorizontalMasonryMetrics.titleBarHeight
+            + HorizontalMasonryMetrics.titleGap
+            + thumbnailHeight
+    }
+
+    private func horizontalMasonryThumbnailAspectRatio(for card: WaterfallCardLayers) -> CGFloat {
+        if card.item.window.isFullscreen {
+            return 16.0 / 9.0
+        }
+        if card.item.window.isMinimized {
+            return 4.0 / 3.0
+        }
+
+        switch Int(card.item.window.id % 4) {
+        case 0:
+            return 16.0 / 9.0
+        case 1:
+            return 3.0 / 2.0
+        case 2:
+            return 4.0 / 3.0
+        default:
+            return 5.0 / 4.0
+        }
+    }
+
+    private func horizontalMasonryContentFrame() -> CGRect {
+        let contentHeight = max(waterfallLayer.bounds.height, horizontalMasonryContentHeight)
+        return CGRect(
+            x: 0,
+            y: waterfallLayer.bounds.height - contentHeight + horizontalMasonryScrollOffset,
+            width: waterfallLayer.bounds.width,
+            height: contentHeight
+        )
+    }
+
     private func waterfallColumnStartX(columnsWidth: CGFloat) -> CGFloat {
         guard currentViewModel?.lockedSpaceID != nil else {
             return WaterfallMetrics.contentHorizontalPadding
@@ -3067,6 +3536,16 @@
     }
 
     private func layoutWaterfallColumn(_ column: WaterfallColumnLayers) {
+        column.containerLayer.cornerRadius = 14
+        column.containerLayer.masksToBounds = true
+        column.containerLayer.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.56).cgColor
+        column.containerLayer.shadowOpacity = 0
+        column.containerLayer.shadowRadius = 0
+        column.containerLayer.shadowOffset = .zero
+        column.headerLayer.isHidden = false
+        column.appNameLayer.isHidden = false
+        column.countLayer.isHidden = false
+
         let bounds = column.containerLayer.bounds
         let isSpaceFocused = spaceFocusWindowCount(for: column.column.appGroupIndex) > 0
         let isHovered = column.column.appGroupIndex == effectiveHoveredAppGroupIndex
@@ -3787,15 +4266,30 @@
         )
     }
 
+    private var isHorizontalMasonryMode: Bool {
+        waterfallViewMode == .horizontalMasonry
+    }
+
     private var waterfallMaxScrollOffset: CGFloat {
-        max(0, waterfallContentWidth - waterfallLayer.bounds.width)
+        guard !isHorizontalMasonryMode else { return 0 }
+        return max(0, waterfallContentWidth - waterfallLayer.bounds.width)
     }
 
     private func clampedWaterfallOffset(_ offset: CGFloat) -> CGFloat {
         min(max(0, offset), waterfallMaxScrollOffset)
     }
 
+    private var horizontalMasonryMaxScrollOffset: CGFloat {
+        max(0, horizontalMasonryContentHeight - waterfallLayer.bounds.height)
+    }
+
+    private func clampedHorizontalMasonryOffset(_ offset: CGFloat) -> CGFloat {
+        min(max(0, offset), horizontalMasonryMaxScrollOffset)
+    }
+
     private func waterfallMaxVerticalScrollOffset(for appGroupIndex: Int) -> CGFloat {
+        guard !isHorizontalMasonryMode else { return 0 }
+
         guard
             let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex })
         else {
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index 03e9501..51ff663 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -19,6 +19,7 @@
     private let windowCloseService: any WindowCloseServiceProtocol
     private let systemCriticalWindowDetector: any SystemCriticalWindowDetecting
     private let disableScreenshotRefresh: Bool
+    private let waterfallViewMode: QuickSwitchWaterfallViewMode
     private var closeConfirmationRequired: Bool
     private let onCloseConfirmationDisabled: (() -> Void)?
     private let debugOverlayWidth: CGFloat?
@@ -120,6 +121,7 @@
         ),
         systemCriticalWindowDetector: any SystemCriticalWindowDetecting = CGWindowSystemCriticalWindowDetector(),
         disableScreenshotRefresh: Bool = false,
+        waterfallViewMode: QuickSwitchWaterfallViewMode = .verticalColumns,
         closeConfirmationRequired: Bool = true,
         onCloseConfirmationDisabled: (() -> Void)? = nil,
         debugHoveredAppGroupIndex: Int? = nil,
@@ -135,6 +137,7 @@
         self.windowCloseService = windowCloseService
         self.systemCriticalWindowDetector = systemCriticalWindowDetector
         self.disableScreenshotRefresh = disableScreenshotRefresh
+        self.waterfallViewMode = waterfallViewMode
         self.closeConfirmationRequired = closeConfirmationRequired
         self.onCloseConfirmationDisabled = onCloseConfirmationDisabled
         self.debugOverlayWidth = debugOverlayWidth
@@ -219,6 +222,7 @@
             self.onCloseConfirmationDisabled?()
         }
         view.setCloseConfirmationRequired(closeConfirmationRequired)
+        view.setWaterfallViewMode(waterfallViewMode)
         view.apply(viewModel: nil)
         view.beginPerformanceFirstFrameMeasurement()
         coordinator.openQuickSwitch()
@@ -1461,6 +1465,7 @@
     let debugWindowActivation: Bool
     let debugWindowClose: Bool
     let disableScreenshotRefresh: Bool
+    let waterfallViewMode: QuickSwitchWaterfallViewMode?
     let lifecycleCycles: Int?
     let lifecycleInterval: TimeInterval
     let lifecycleVisibleDuration: TimeInterval
@@ -1488,6 +1493,7 @@
             debugWindowActivation: arguments.contains("--round01-debug-window-activation"),
             debugWindowClose: arguments.contains("--round01-debug-window-close"),
             disableScreenshotRefresh: arguments.contains("--round01-disable-screenshot-refresh"),
+            waterfallViewMode: waterfallViewModeValue(for: "--round01-waterfall-view-mode", in: arguments),
             lifecycleCycles: intValue(for: "--round01-quick-switch-lifecycle-cycles", in: arguments),
             lifecycleInterval: timeInterval(for: "--round01-quick-switch-lifecycle-interval", in: arguments) ?? 0.02,
             lifecycleVisibleDuration: timeInterval(for: "--round01-quick-switch-lifecycle-visible-duration", in: arguments) ?? 0.16
@@ -1512,6 +1518,22 @@
         return String(argument.dropFirst(prefix.count))
     }
 
+    private static func waterfallViewModeValue(
+        for key: String,
+        in arguments: [String]
+    ) -> QuickSwitchWaterfallViewMode? {
+        guard let value = stringValue(for: key, in: arguments) else { return nil }
+
+        switch value {
+        case "vertical", "vertical-columns", "verticalColumns":
+            return .verticalColumns
+        case "horizontal", "horizontal-masonry", "horizontalMasonry":
+            return .horizontalMasonry
+        default:
+            return nil
+        }
+    }
+
     private static func intValue(for key: String, in arguments: [String]) -> Int? {
         stringValue(for: key, in: arguments).flatMap(Int.init)
     }
diff --git a/C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift b/C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift
index 264f57b..716112b 100644
--- a/C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift
+++ b/C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift
@@ -4,6 +4,7 @@
     case schemaVersion = "schemaVersion"
     case showMinimizedWindows = "quickSwitch.showMinimizedWindows"
     case showFullscreenWindows = "quickSwitch.showFullscreenWindows"
+    case quickSwitchWaterfallViewMode = "quickSwitch.waterfallViewMode"
     case theme = "appearance.theme"
     case language = "appearance.language"
     case spaceDisplayStrategy = "spaces.displayStrategy"
@@ -30,9 +31,15 @@
     case currentSpaceOnly
 }
 
+public enum QuickSwitchWaterfallViewMode: String, CaseIterable, Equatable, Sendable {
+    case verticalColumns
+    case horizontalMasonry
+}
+
 public struct AlignerPreferences: Equatable, Sendable {
     public let showMinimizedWindows: Bool
     public let showFullscreenWindows: Bool
+    public let quickSwitchWaterfallViewMode: QuickSwitchWaterfallViewMode
     public let theme: ThemePreference
     public let language: LanguagePreference
     public let spaceDisplayStrategy: SpaceDisplayStrategy
@@ -40,12 +47,14 @@
     public init(
         showMinimizedWindows: Bool,
         showFullscreenWindows: Bool,
+        quickSwitchWaterfallViewMode: QuickSwitchWaterfallViewMode,
         theme: ThemePreference,
         language: LanguagePreference,
         spaceDisplayStrategy: SpaceDisplayStrategy
     ) {
         self.showMinimizedWindows = showMinimizedWindows
         self.showFullscreenWindows = showFullscreenWindows
+        self.quickSwitchWaterfallViewMode = quickSwitchWaterfallViewMode
         self.theme = theme
         self.language = language
         self.spaceDisplayStrategy = spaceDisplayStrategy
@@ -53,11 +62,12 @@
 }
 
 public enum PreferenceSchema {
-    public static let version = 1
+    public static let version = 2
 
     public static let defaults = AlignerPreferences(
         showMinimizedWindows: true,
         showFullscreenWindows: true,
+        quickSwitchWaterfallViewMode: .verticalColumns,
         theme: .system,
         language: .system,
         spaceDisplayStrategy: .includeVisibleAndFullscreen
@@ -68,6 +78,7 @@
             PreferenceKey.schemaVersion.storageKey: version,
             PreferenceKey.showMinimizedWindows.storageKey: defaults.showMinimizedWindows,
             PreferenceKey.showFullscreenWindows.storageKey: defaults.showFullscreenWindows,
+            PreferenceKey.quickSwitchWaterfallViewMode.storageKey: defaults.quickSwitchWaterfallViewMode.rawValue,
             PreferenceKey.theme.storageKey: defaults.theme.rawValue,
             PreferenceKey.language.storageKey: defaults.language.rawValue,
             PreferenceKey.spaceDisplayStrategy.storageKey: defaults.spaceDisplayStrategy.rawValue
@@ -110,6 +121,10 @@
         return AlignerPreferences(
             showMinimizedWindows: userDefaults.bool(forKey: PreferenceKey.showMinimizedWindows.storageKey),
             showFullscreenWindows: userDefaults.bool(forKey: PreferenceKey.showFullscreenWindows.storageKey),
+            quickSwitchWaterfallViewMode: rawValue(
+                for: .quickSwitchWaterfallViewMode,
+                default: PreferenceSchema.defaults.quickSwitchWaterfallViewMode
+            ),
             theme: rawValue(for: .theme, default: PreferenceSchema.defaults.theme),
             language: rawValue(for: .language, default: PreferenceSchema.defaults.language),
             spaceDisplayStrategy: rawValue(
@@ -123,6 +138,10 @@
         userDefaults.set(PreferenceSchema.version, forKey: PreferenceKey.schemaVersion.storageKey)
         userDefaults.set(preferences.showMinimizedWindows, forKey: PreferenceKey.showMinimizedWindows.storageKey)
         userDefaults.set(preferences.showFullscreenWindows, forKey: PreferenceKey.showFullscreenWindows.storageKey)
+        userDefaults.set(
+            preferences.quickSwitchWaterfallViewMode.rawValue,
+            forKey: PreferenceKey.quickSwitchWaterfallViewMode.storageKey
+        )
         userDefaults.set(preferences.theme.rawValue, forKey: PreferenceKey.theme.storageKey)
         userDefaults.set(preferences.language.rawValue, forKey: PreferenceKey.language.storageKey)
         userDefaults.set(preferences.spaceDisplayStrategy.rawValue, forKey: PreferenceKey.spaceDisplayStrategy.storageKey)
diff --git a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
index e583820..3c1be22 100644
--- a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
+++ b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
@@ -873,6 +873,7 @@
                 "schemaVersion",
                 "quickSwitch.showMinimizedWindows",
                 "quickSwitch.showFullscreenWindows",
+                "quickSwitch.waterfallViewMode",
                 "appearance.theme",
                 "appearance.language",
                 "spaces.displayStrategy"
@@ -885,9 +886,10 @@
     }
 
     func testDefaultPreferencesMatchRound0Decisions() {
-        XCTAssertEqual(PreferenceSchema.version, 1)
+        XCTAssertEqual(PreferenceSchema.version, 2)
         XCTAssertEqual(PreferenceSchema.defaults.showMinimizedWindows, true)
         XCTAssertEqual(PreferenceSchema.defaults.showFullscreenWindows, true)
+        XCTAssertEqual(PreferenceSchema.defaults.quickSwitchWaterfallViewMode, .verticalColumns)
         XCTAssertEqual(PreferenceSchema.defaults.theme, .system)
         XCTAssertEqual(PreferenceSchema.defaults.language, .system)
         XCTAssertEqual(PreferenceSchema.defaults.spaceDisplayStrategy, .includeVisibleAndFullscreen)
@@ -908,6 +910,7 @@
         let preferences = AlignerPreferences(
             showMinimizedWindows: false,
             showFullscreenWindows: false,
+            quickSwitchWaterfallViewMode: .horizontalMasonry,
             theme: .dark,
             language: .simplifiedChinese,
             spaceDisplayStrategy: .currentSpaceOnly
@@ -945,12 +948,14 @@
         userDefaults.set("not-a-theme", forKey: PreferenceKey.theme.storageKey)
         userDefaults.set("not-a-language", forKey: PreferenceKey.language.storageKey)
         userDefaults.set("not-a-space-strategy", forKey: PreferenceKey.spaceDisplayStrategy.storageKey)
+        userDefaults.set("not-a-waterfall-mode", forKey: PreferenceKey.quickSwitchWaterfallViewMode.storageKey)
 
         let preferences = UserDefaultsPreferenceStore(userDefaults: userDefaults).read()
 
         XCTAssertEqual(preferences.theme, PreferenceSchema.defaults.theme)
         XCTAssertEqual(preferences.language, PreferenceSchema.defaults.language)
         XCTAssertEqual(preferences.spaceDisplayStrategy, PreferenceSchema.defaults.spaceDisplayStrategy)
+        XCTAssertEqual(preferences.quickSwitchWaterfallViewMode, PreferenceSchema.defaults.quickSwitchWaterfallViewMode)
     }
 
     @MainActor
diff --git a/C3.tools/round1-horizontal-waterfall-fixture-qa.sh b/C3.tools/round1-horizontal-waterfall-fixture-qa.sh
new file mode 100755
index 0000000..712b8af
--- /dev/null
+++ b/C3.tools/round1-horizontal-waterfall-fixture-qa.sh
@@ -0,0 +1,323 @@
+#!/bin/bash
+# Round01.1 horizontal Waterfall fixture QA. It verifies the P1 masonry view:
+# no horizontal scroll, centered equal-width cards, variable heights, thumbnail
+# 40% max-height, App Shelf anchoring, and horizontal-mode Tab navigation.
+
+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"
+LAYOUT_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-layout-report.json"
+KEYBOARD_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-keyboard-report.json"
+FILTER_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-filter-report.json"
+FIXTURE_APP_COUNT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_FIXTURE_APP_COUNT:-8}"
+FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_FIXTURE_WINDOWS_PER_APP:-4}"
+HOVER_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_HOVER_APP_INDEX:-7}"
+KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_KEY_SEQUENCE:-tab}"
+REPORT_WAIT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_REPORT_WAIT:-6.0}"
+
+fail() {
+  echo "Round01.1 horizontal Waterfall 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"
+}
+
+wait_for_loaded_report() {
+  local report="$1"
+  /usr/bin/python3 - "$report" "$REPORT_WAIT" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+timeout = float(sys.argv[2])
+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 root.get("waterfallViewMode") == "horizontalMasonry":
+            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"horizontal Waterfall report did not become loaded within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+assert_layout_report() {
+  /usr/bin/python3 - "$LAYOUT_REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$HOVER_APP_INDEX" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+fixture_app_count = int(sys.argv[2])
+fixture_windows_per_app = int(sys.argv[3])
+hover_app_index = int(sys.argv[4])
+
+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", [])
+items = root.get("appShelfItems", [])
+waterfall_frame = root.get("waterfallFrame", {})
+waterfall_height = waterfall_frame.get("height", 0)
+waterfall_width = root.get("waterfallVisibleWidth", 0)
+cards = [card for column in columns for card in column.get("cards", [])]
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(report.get("quickSwitchVisible") is True, "layout run must keep Quick Switch visible")
+require(root.get("waterfallViewMode") == "horizontalMasonry", "Waterfall view mode must be horizontalMasonry")
+require(report.get("appCount") == fixture_app_count, "fixture appCount must match requested count")
+require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match requested shape")
+require(root.get("waterfallColumnCount") == fixture_app_count, "Waterfall app section count must match app count")
+require(len(columns) == fixture_app_count, "Waterfall column reports must match app count")
+require(len(items) == fixture_app_count, "App Shelf item reports must match app count")
+require(root.get("waterfallColumnNames") == root.get("appShelfNames"), "Waterfall app order must match App Shelf order")
+
+require(root.get("waterfallScrollable") is False, "horizontal masonry must not expose horizontal scroll")
+require(root.get("waterfallMaxScrollOffset") == 0, "horizontal masonry horizontal max scroll offset must be zero")
+require(abs(root.get("waterfallContentWidth", 0) - waterfall_width) <= 1.0, "horizontal masonry content width must equal visible width")
+require(root.get("horizontalMasonryScrollable") is True, "fixture must make horizontal masonry vertically scrollable")
+require(root.get("horizontalMasonryMaxScrollOffset", 0) > 0, "horizontal masonry must expose positive vertical max scroll offset")
+require(root.get("horizontalMasonryScrollOffset", 0) > 0, "hovering a later App must anchor masonry vertically")
+require(root.get("appShelfHoveredIndex") == hover_app_index, "debug hover must mark the requested App Shelf item")
+
+require(cards, "horizontal masonry fixture must expose cards")
+widths = [round(card.get("frame", {}).get("width", 0), 1) for card in cards]
+require(len(set(widths)) == 1, "horizontal masonry cards must be equal width")
+heights = [round(card.get("frame", {}).get("height", 0), 1) for card in cards]
+require(len(set(heights)) >= 2, "horizontal masonry cards must expose variable heights")
+require(all(card.get("frame", {}).get("x", -1) >= -1 for card in cards), "cards must not overflow left")
+require(all(card.get("frame", {}).get("x", 0) + card.get("frame", {}).get("width", 0) <= waterfall_width + 1 for card in cards), "cards must not overflow right")
+require(all(card.get("thumbnailFrame", {}).get("height", 0) <= waterfall_height * 0.40 + 1.0 for card in cards), "thumbnail height must be capped to 40% of Waterfall height")
+require(all(card.get("titleBarFrame", {}).get("y", 0) >= card.get("thumbnailFrame", {}).get("y", 0) + card.get("thumbnailFrame", {}).get("height", 0) for card in cards), "title bars must remain above thumbnails")
+
+for expected_index, column in enumerate(columns):
+    require(column.get("appGroupIndex") == expected_index, "appGroupIndex must remain sequential")
+    require(column.get("windowCount") == fixture_windows_per_app, "each fixture App must keep its windows")
+    column_cards = column.get("cards", [])
+    require([card.get("windowIndex") for card in column_cards] == list(range(fixture_windows_per_app)), "window order must remain stable within each App")
+
+hover_column = columns[hover_app_index]
+hover_first_card = hover_column.get("cards", [])[0]
+hover_visible = hover_first_card.get("visibleFrame", {})
+require(hover_visible.get("y", -9999) < waterfall_height + 1, "hovered App first card should be brought into vertical viewport")
+require(hover_visible.get("y", 0) + hover_visible.get("height", 0) > -1, "hovered App first card should not be fully below viewport")
+
+print(json.dumps({
+    "mode": root.get("waterfallViewMode"),
+    "appCount": report.get("appCount"),
+    "windowCount": report.get("windowCount"),
+    "cardWidth": widths[0],
+    "distinctHeights": sorted(set(heights))[:6],
+    "horizontalMasonryScrollOffset": root.get("horizontalMasonryScrollOffset"),
+    "horizontalMasonryMaxScrollOffset": root.get("horizontalMasonryMaxScrollOffset")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_keyboard_report() {
+  /usr/bin/python3 - "$KEYBOARD_REPORT" "$KEY_SEQUENCE" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+key_sequence = [part for part in sys.argv[2].split(",") if part]
+
+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", [])
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(root.get("waterfallViewMode") == "horizontalMasonry", "keyboard run must use horizontalMasonry")
+require(root.get("keyboardCommandsApplied") == key_sequence, "horizontal keyboard commands must be applied")
+require(root.get("tabIgnoredCount") == 0, "Tab must navigate in horizontal masonry mode")
+require(root.get("selectedAppGroupIndex") == 0, "single Tab from first card must stay in first App")
+require(root.get("selectedWindowIndex") == 1, "single Tab from first card must select the second laid card")
+selected = columns[0].get("cards", [])[1]
+require(root.get("selectedWindowID") == selected.get("windowID"), "selectedWindowID must match the second laid card")
+
+print(json.dumps({
+    "commands": root.get("keyboardCommandsApplied"),
+    "tabIgnoredCount": root.get("tabIgnoredCount"),
+    "selectedAppGroupIndex": root.get("selectedAppGroupIndex"),
+    "selectedWindowIndex": root.get("selectedWindowIndex")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_filter_report() {
+  /usr/bin/python3 - "$FILTER_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", [])
+cards = [card for column in columns for card in column.get("cards", [])]
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(report.get("quickSwitchVisible") is True, "Space filter run must keep Quick Switch visible")
+require(root.get("waterfallViewMode") == "horizontalMasonry", "Space filter run must use horizontalMasonry")
+require(root.get("spaceFilterActive") is True, "clicking Space A1 must lock the filter")
+require(root.get("spaceFilterLockedSpaceID") == 1, "Space A1 must be the locked Space")
+require(root.get("appShelfNames") == ["Alpha Space App", "Beta Space App"], "horizontal Space filter must filter App Shelf")
+require(root.get("waterfallColumnNames") == ["Alpha Space App", "Beta Space App"], "horizontal Space filter must filter Waterfall sections")
+require([card.get("windowID") for card in cards] == [50101, 50102, 50201], "horizontal Space filter must expose only Space A1 windows")
+require(all(card.get("primarySpaceID") == 1 for card in cards), "all filtered horizontal cards must belong to Space A1")
+require(root.get("waterfallScrollable") is False, "horizontal Space filter must not expose horizontal scroll")
+require(root.get("projectionTransitionFadeOutLayerCount", 0) > 0, "horizontal Space filter must animate removed cards/apps")
+require(root.get("projectionTransitionDurationMilliseconds", 0) >= 120, "horizontal Space filter must report projection duration")
+
+widths = [round(card.get("frame", {}).get("width", 0), 1) for card in cards]
+require(len(set(widths)) == 1, "filtered horizontal cards must remain equal width")
+visible_width = root.get("waterfallVisibleWidth", 0)
+require(all(card.get("frame", {}).get("x", -1) >= -1 for card in cards), "filtered cards must not overflow left")
+require(all(card.get("frame", {}).get("x", 0) + card.get("frame", {}).get("width", 0) <= visible_width + 1 for card in cards), "filtered cards must not overflow right")
+
+print(json.dumps({
+    "mode": root.get("waterfallViewMode"),
+    "lockedSpaceID": root.get("spaceFilterLockedSpaceID"),
+    "apps": root.get("appShelfNames"),
+    "windowIDs": [card.get("windowID") for card in cards],
+    "fadeOutLayerCount": root.get("projectionTransitionFadeOutLayerCount"),
+    "transitionDurationMilliseconds": root.get("projectionTransitionDurationMilliseconds")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+run_fixture() {
+  local report="$1"
+  shift
+
+  rm -f "$report"
+  "$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-waterfall-view-mode=horizontal \
+    --round01-quick-switch-report="$report" \
+    "$@" &
+
+  APP_PID=$!
+}
+
+run_space_filter_fixture() {
+  local report="$1"
+
+  rm -f "$report"
+  "$APP/Contents/MacOS/Aligner" \
+    --round0-skip-permissions \
+    --round01-open-quick-switch \
+    --round01-fixture-space-filter \
+    --round01-disable-screenshot-refresh \
+    --round01-waterfall-view-mode=horizontal \
+    --round01-debug-mouse-sequence="click-space:1" \
+    --round01-quick-switch-report="$report" &
+
+  APP_PID=$!
+}
+
+if [ "$FIXTURE_APP_COUNT" -le "$HOVER_APP_INDEX" ]; then
+  fail "hover App index must be inside fixture app count"
+fi
+if [ "$FIXTURE_WINDOWS_PER_APP" -lt 2 ]; then
+  fail "fixture must include at least two windows per App for Tab navigation"
+fi
+
+stop_current_aligner
+"$SCRIPT_DIR/package-app.sh" >&2
+
+APP_PID=""
+cleanup() {
+  if [ -n "${APP_PID:-}" ]; then
+    kill "$APP_PID" 2>/dev/null || true
+    wait "$APP_PID" 2>/dev/null || true
+  fi
+  stop_current_aligner
+}
+trap cleanup EXIT
+
+run_fixture "$LAYOUT_REPORT" --round01-debug-mouse-sequence="hover-app:$HOVER_APP_INDEX"
+wait_for_loaded_report "$LAYOUT_REPORT"
+assert_layout_report
+kill "$APP_PID" 2>/dev/null || true
+wait "$APP_PID" 2>/dev/null || true
+stop_current_aligner
+
+run_fixture "$KEYBOARD_REPORT" --round01-debug-key-sequence="$KEY_SEQUENCE"
+wait_for_loaded_report "$KEYBOARD_REPORT"
+assert_keyboard_report
+kill "$APP_PID" 2>/dev/null || true
+wait "$APP_PID" 2>/dev/null || true
+stop_current_aligner
+
+run_space_filter_fixture "$FILTER_REPORT"
+wait_for_loaded_report "$FILTER_REPORT"
+assert_filter_report
+kill "$APP_PID" 2>/dev/null || true
+wait "$APP_PID" 2>/dev/null || true
+
+trap - EXIT
+stop_current_aligner
+echo "Round01.1 horizontal Waterfall fixture QA passed"
diff --git a/C3.tools/round1-main-ui-qa.sh b/C3.tools/round1-main-ui-qa.sh
index f12f98a..2652fab 100755
--- a/C3.tools/round1-main-ui-qa.sh
+++ b/C3.tools/round1-main-ui-qa.sh
@@ -108,6 +108,7 @@
 run_step window-activation "$SCRIPT_DIR/round1-window-activation-fixture-qa.sh"
 run_step mouse-interaction "$SCRIPT_DIR/round1-mouse-interaction-fixture-qa.sh"
 run_step app-column-alignment "$SCRIPT_DIR/round1-app-column-alignment-fixture-qa.sh"
+run_step horizontal-waterfall "$SCRIPT_DIR/round1-horizontal-waterfall-fixture-qa.sh"
 run_step no-quick-switch-residual swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch
 
 assert_no_current_aligner

--
Gitblit v1.9.3