Ariver
2026-06-12 63128a870cbedb841cb19cdfbf2bf54e3dfbc25c
Implement horizontal Quick Switch waterfall
7 files modified
1 files added
885 ■■■■■ changed files
C1.source/Resources/Aligner-Info.plist 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift 7 ●●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchRootView.swift 500 ●●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchSessionController.swift 22 ●●●●● patch | view | raw | blame | history
C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift 21 ●●●●● patch | view | raw | blame | history
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift 7 ●●●● patch | view | raw | blame | history
C3.tools/round1-horizontal-waterfall-fixture-qa.sh 323 ●●●●● patch | view | raw | blame | history
C3.tools/round1-main-ui-qa.sh 1 ●●●● patch | view | raw | blame | history
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>
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()
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 {
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)
    }
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)
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
C3.tools/round1-horizontal-waterfall-fixture-qa.sh
New file
@@ -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"
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