Ariver
2026-06-19 cb67d8b2fa9b9c9d30cd81d72b8737ee2baa44b6
Fix vertical keyboard window focus
5 files modified
230 ■■■■ changed files
C1.source/Resources/Aligner-Info.plist 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchRootView.swift 94 ●●●● patch | view | raw | blame | history
C3.tools/round1-keyboard-navigation-fixture-qa.sh 20 ●●●● patch | view | raw | blame | history
C3.tools/round1-main-ui-qa.sh 1 ●●●● patch | view | raw | blame | history
C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh 111 ●●●●● 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.72</string>
    <string>0.0.73</string>
    <key>CFBundleVersion</key>
    <string>20260618.2320</string>
    <string>20260619.1408</string>
    <key>LSMinimumSystemVersion</key>
    <string>26.0</string>
    <key>NSHighResolutionCapable</key>
C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -70,6 +70,7 @@
    private var currentSelection: QuickSwitchSelection?
    private var columnSelectionHistory: [Int: Int] = [:]
    private var keyboardFocusedAppGroupIndex: Int?
    private var keyboardFocusedWindowID: UInt32?
    private var keyboardCommandsApplied: [String] = []
    private var lastKeyboardCommand: String?
    private var lastKeyboardAppIndexCommand: String?
@@ -266,6 +267,7 @@
    private struct WaterfallColumnLayers {
        let column: QuickSwitchWaterfallColumnViewModel
        let containerLayer: CALayer
        let cardsClipLayer: CALayer
        let headerLayer: CALayer
        let appNameLayer: CATextLayer
        let countLayer: CATextLayer
@@ -355,6 +357,8 @@
        static let cardGap: CGFloat = 12
        static let revealPadding: CGFloat = 24
        static let horizontalRevealPadding: CGFloat = 0
        static let selectedCardScale: CGFloat = 1.015
        static let selectedCardScaleOverflow: CGFloat = cardHeight * (selectedCardScale - 1) / 2
    }
    private enum HorizontalMasonryMetrics {
@@ -739,7 +743,8 @@
        if case .realScreenshot = resolution.source {
            let isSelected = card.item.window.id == effectiveSelection?.windowID
            let isHovered = card.item.window.id == hoveredWindowID
            card.shineLayer.opacity = shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) ? 1 : 0
            let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID
            card.shineLayer.opacity = (shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused) ? 1 : 0
        }
        needsLayout = true
    }
@@ -815,6 +820,12 @@
               viewModel?.waterfallColumns.contains(where: { $0.appGroupIndex == keyboardFocusedAppGroupIndex }) != true {
                self.keyboardFocusedAppGroupIndex = nil
            }
            if let keyboardFocusedWindowID,
               viewModel?.waterfallColumns.contains(where: { column in
                   column.windows.contains { $0.window.id == keyboardFocusedWindowID }
               }) != true {
                self.keyboardFocusedWindowID = nil
            }
            if let currentSelection {
                columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
            }
@@ -830,6 +841,7 @@
            currentSelection = viewModel?.initialSelection
            columnSelectionHistory = [:]
            keyboardFocusedAppGroupIndex = nil
            keyboardFocusedWindowID = nil
            keyboardCommandsApplied = []
            lastKeyboardCommand = nil
            lastKeyboardAppIndexCommand = nil
@@ -1256,6 +1268,7 @@
            "selectedWindowIndex": effectiveSelection?.windowIndex ?? NSNull(),
            "selectedWindowID": effectiveSelection?.windowID ?? NSNull(),
            "keyboardFocusedAppGroupIndex": keyboardFocusedAppGroupIndex ?? NSNull(),
            "keyboardFocusedWindowID": keyboardFocusedWindowID ?? NSNull(),
            "keyboardCommandsApplied": keyboardCommandsApplied,
            "lastKeyboardCommand": lastKeyboardCommand ?? NSNull(),
            "lastKeyboardAppIndexCommand": lastKeyboardAppIndexCommand ?? NSNull(),
@@ -1450,6 +1463,7 @@
        }
        keyboardFocusedAppGroupIndex = appGroupIndex
        keyboardFocusedWindowID = nil
        lastKeyboardAppIndexCommand = appIndexCommand
        selectionChangedByLastCommand = false
        clearKeyboardBoundaryFeedback()
@@ -1533,6 +1547,7 @@
        keyboardFocusedAppGroupIndex = targetAppGroupIndex
        currentSelection = nextSelection
        keyboardFocusedWindowID = nextSelection.windowID
        columnSelectionHistory[nextSelection.appGroupIndex] = nextSelection.windowIndex
        selectionChangedByLastCommand = previousSelection != nextSelection
        clearKeyboardBoundaryFeedback()
@@ -2767,8 +2782,13 @@
            container.borderColor = NSColor.separatorColor.withAlphaComponent(0.34).cgColor
            container.borderWidth = 1
            let cardsClip = CALayer()
            cardsClip.masksToBounds = true
            cardsClip.backgroundColor = NSColor.clear.cgColor
            let header = CALayer()
            header.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.68).cgColor
            header.zPosition = 40
            let appName = makeTextLayer(
                string: column.app.name,
@@ -2785,6 +2805,10 @@
                alignment: .right
            )
            appName.zPosition = 42
            count.zPosition = 42
            container.addSublayer(cardsClip)
            container.addSublayer(header)
            container.addSublayer(appName)
            container.addSublayer(count)
@@ -2851,7 +2875,7 @@
                card.addSublayer(state)
                card.addSublayer(shine)
                card.addSublayer(closeButton.containerLayer)
                container.addSublayer(card)
                cardsClip.addSublayer(card)
                return WaterfallCardLayers(
                    item: window,
@@ -2872,6 +2896,7 @@
            return WaterfallColumnLayers(
                column: column,
                containerLayer: container,
                cardsClipLayer: cardsClip,
                headerLayer: header,
                appNameLayer: appName,
                countLayer: count,
@@ -3591,7 +3616,14 @@
                x: locationInWaterfallContent.x - column.containerLayer.frame.minX,
                y: locationInWaterfallContent.y - column.containerLayer.frame.minY
            )
            if let card = column.cards.first(where: { $0.containerLayer.frame.contains(locationInColumn) }) {
            guard column.cardsClipLayer.frame.contains(locationInColumn) else {
                continue
            }
            let locationInCardsClip = CGPoint(
                x: locationInColumn.x - column.cardsClipLayer.frame.minX,
                y: locationInColumn.y - column.cardsClipLayer.frame.minY
            )
            if let card = column.cards.first(where: { $0.containerLayer.frame.contains(locationInCardsClip) }) {
                return card
            }
        }
@@ -4154,6 +4186,8 @@
            column.containerLayer.shadowOpacity = 0
            column.containerLayer.shadowRadius = 0
            column.containerLayer.shadowOffset = .zero
            column.cardsClipLayer.frame = column.containerLayer.bounds
            column.cardsClipLayer.masksToBounds = false
            column.headerLayer.isHidden = true
            column.appNameLayer.isHidden = true
            column.countLayer.isHidden = true
@@ -4293,6 +4327,14 @@
        column.countLayer.isHidden = false
        let bounds = column.containerLayer.bounds
        let cardsClipFrame = CGRect(
            x: 0,
            y: 0,
            width: bounds.width,
            height: max(1, bounds.height - WaterfallMetrics.headerHeight)
        )
        column.cardsClipLayer.frame = cardsClipFrame
        column.cardsClipLayer.masksToBounds = true
        let isSpaceFocused = spaceFocusWindowCount(for: column.column.appGroupIndex) > 0
        let isHovered = column.column.appGroupIndex == effectiveHoveredAppGroupIndex
        column.containerLayer.borderColor = (isSpaceFocused
@@ -4335,8 +4377,8 @@
        waterfallColumnScrollOffsets[appGroupIndex] = verticalOffset
        let cardWidth = max(1, bounds.width - WaterfallMetrics.columnHorizontalPadding * 2)
        let firstCardTop = bounds.height
            - WaterfallMetrics.headerHeight
        let firstCardTop = cardsClipFrame.height
            - WaterfallMetrics.selectedCardScaleOverflow
            - WaterfallMetrics.columnVerticalPadding
            + verticalOffset
@@ -4360,10 +4402,11 @@
        let bounds = card.containerLayer.bounds
        let isSelected = card.item.window.id == effectiveSelection?.windowID
        let isHovered = card.item.window.id == hoveredWindowID
        let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID
        let isAppLinked = isWaterfallCardAppLinked(card)
        applyWaterfallCardVisualState(
            card,
            selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered),
            selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused,
            hovered: isHovered,
            appLinked: isAppLinked
        )
@@ -4469,7 +4512,7 @@
                    ? CGSize(width: 0, height: -5)
                    : .zero
        card.containerLayer.zPosition = selected ? 20 : hovered ? 12 : appLinked ? 10 : isSpaceFocused ? 8 : 0
        let focusedScale: CGFloat = selected ? 1.015 : isSpaceFocused ? 1.012 : 1.008
        let focusedScale: CGFloat = selected ? WaterfallMetrics.selectedCardScale : isSpaceFocused ? 1.012 : 1.008
        card.containerLayer.transform = (selected || hovered || isSpaceFocused)
            ? CATransform3DMakeScale(focusedScale, focusedScale, 1)
            : CATransform3DIdentity
@@ -4945,11 +4988,18 @@
                x: locationInWaterfallContent.x - column.containerLayer.frame.minX,
                y: locationInWaterfallContent.y - column.containerLayer.frame.minY
            )
            for card in column.cards where card.containerLayer.frame.contains(locationInColumn) {
            guard column.cardsClipLayer.frame.contains(locationInColumn) else {
                continue
            }
            let locationInCardsClip = CGPoint(
                x: locationInColumn.x - column.cardsClipLayer.frame.minX,
                y: locationInColumn.y - column.cardsClipLayer.frame.minY
            )
            for card in column.cards where card.containerLayer.frame.contains(locationInCardsClip) {
                guard card.closeButton.containerLayer.opacity > 0 else { continue }
                let locationInCard = CGPoint(
                    x: locationInColumn.x - card.containerLayer.frame.minX,
                    y: locationInColumn.y - card.containerLayer.frame.minY
                    x: locationInCardsClip.x - card.containerLayer.frame.minX,
                    y: locationInCardsClip.y - card.containerLayer.frame.minY
                )
                if card.closeButton.containerLayer.frame.insetBy(dx: -8, dy: -8).contains(locationInCard) {
                    return .window(
@@ -4991,11 +5041,13 @@
                x: waterfallLayer.frame.minX
                    + waterfallContentLayer.frame.minX
                    + column.containerLayer.frame.minX
                    + column.cardsClipLayer.frame.minX
                    + card.containerLayer.frame.minX
                    + card.closeButton.containerLayer.frame.midX,
                y: waterfallLayer.frame.minY
                    + waterfallContentLayer.frame.minY
                    + column.containerLayer.frame.minY
                    + column.cardsClipLayer.frame.minY
                    + card.containerLayer.frame.minY
                    + card.closeButton.containerLayer.frame.midY
            )
@@ -5123,7 +5175,7 @@
        )
        let contentHeight = CGFloat(column.cards.count) * WaterfallMetrics.cardHeight
            + CGFloat(max(0, column.cards.count - 1)) * WaterfallMetrics.cardGap
        return max(0, contentHeight - visibleHeight)
        return max(0, contentHeight - visibleHeight + WaterfallMetrics.selectedCardScaleOverflow * 2)
    }
    private func clampedWaterfallColumnOffset(_ offset: CGFloat, appGroupIndex: Int) -> CGFloat {
@@ -5546,10 +5598,13 @@
            let appGroupIndex = column.column.appGroupIndex
            let isHovered = appGroupIndex == effectiveHoveredAppGroupIndex
            let firstCardFrame = column.cards.first?.containerLayer.frame
            let headerToFirstCardGap = firstCardFrame.map {
            let visibleFirstCardFrame = firstCardFrame.map {
                $0.offsetBy(dx: column.cardsClipLayer.frame.minX, dy: column.cardsClipLayer.frame.minY)
            }
            let headerToFirstCardGap = visibleFirstCardFrame.map {
                column.headerLayer.frame.minY - $0.maxY
            }
            let topToFirstCardGap = firstCardFrame.map {
            let topToFirstCardGap = visibleFirstCardFrame.map {
                column.containerLayer.bounds.height - $0.maxY
            }
@@ -5567,6 +5622,8 @@
                "visibleFrame": dictionary(from: visibleFrame),
                "headerFrame": dictionary(from: column.headerLayer.frame),
                "headerBackgroundColor": colorDictionary(from: column.headerLayer.backgroundColor),
                "cardsClipFrame": dictionary(from: column.cardsClipLayer.frame),
                "cardsClipMasksToBounds": column.cardsClipLayer.masksToBounds,
                "headerToFirstCardGap": headerToFirstCardGap.map(Double.init) ?? NSNull(),
                "topToFirstCardGap": topToFirstCardGap.map(Double.init) ?? NSNull(),
                "appNameFrame": dictionary(from: column.appNameLayer.frame),
@@ -5631,8 +5688,8 @@
    private func waterfallCardReports(for column: WaterfallColumnLayers) -> [[String: Any]] {
        column.cards.map { card in
            let visibleFrame = card.containerLayer.frame.offsetBy(
                dx: column.containerLayer.frame.minX + waterfallContentLayer.frame.minX,
                dy: column.containerLayer.frame.minY
                dx: column.containerLayer.frame.minX + column.cardsClipLayer.frame.minX + waterfallContentLayer.frame.minX,
                dy: column.containerLayer.frame.minY + column.cardsClipLayer.frame.minY
            )
            let screenshotSource = screenshotSourcesByWindowID[card.item.window.id]
@@ -5650,6 +5707,7 @@
                "isFullscreen": card.item.window.isFullscreen,
                "identifierSource": "\(card.item.window.identifierSource)",
                "isSelected": card.item.window.id == effectiveSelection?.windowID,
                "isKeyboardFocused": card.item.window.id == keyboardFocusedWindowID,
                "visualStates": waterfallCardVisualStates(for: card),
                "thumbnailStrategy": thumbnailStrategyString(for: card.item.window),
                "screenshotSource": screenshotSourceString(screenshotSource, for: card.item.window),
@@ -5768,12 +5826,16 @@
        var states: [String] = []
        let isSelected = card.item.window.id == effectiveSelection?.windowID
        let isHovered = card.item.window.id == hoveredWindowID
        let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID
        let isAppLinked = isWaterfallCardAppLinked(card)
        if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) {
        if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused {
            states.append("selected")
        } else if isSelected {
            states.append("selectedVisualSuppressed")
        }
        if isKeyboardFocused {
            states.append("keyboardFocused")
        }
        if isHovered {
            states.append("hover")
        }
C3.tools/round1-keyboard-navigation-fixture-qa.sh
@@ -154,9 +154,12 @@
selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True]
require(len(selected_cards) == 1, "Waterfall must expose exactly one selected card after keyboard navigation")
require(selected_cards[0].get("windowID") == expected_selected_window_id, "selected Waterfall card must match selectedWindowID")
require("selectedVisualSuppressed" in selected_cards[0].get("visualStates", []), "non-hovered keyboard-selected Waterfall card must suppress selected visual state")
require(selected_cards[0].get("shineVisible") is False, "non-hovered keyboard-selected Waterfall card must not expose shine")
require(selected_cards[0].get("zPosition", 0) == 0, "non-hovered keyboard-selected Waterfall card must not float above normal cards")
require(selected_cards[0].get("isKeyboardFocused") is True, "keyboard-selected Waterfall card must expose keyboard focus")
require("selected" in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must expose selected visual state")
require("keyboardFocused" in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must expose keyboardFocused visual state")
require("selectedVisualSuppressed" not in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must not suppress selected visual state")
require(selected_cards[0].get("shineVisible") is True, "keyboard-selected Waterfall card must expose shine")
require(selected_cards[0].get("zPosition", 0) > 0, "keyboard-selected Waterfall card must float above normal cards")
require("appLinked" in selected_column.get("cards", [])[0].get("visualStates", []), "App-focused vertical column must keep its first card linked to App hover")
selected_items = [item for item in items if item.get("isSelected") is True]
@@ -179,10 +182,19 @@
require(selected_column.get("verticalScrollOffset", 0) > 0, "down-arrow navigation must auto-scroll the selected window into view")
frame = selected_cards[0].get("visibleFrame", {})
column_frame = selected_column.get("visibleFrame", {})
header_frame = selected_column.get("headerFrame", {})
cards_clip_frame = selected_column.get("cardsClipFrame", {})
visibility_tolerance = 1
require(frame.get("x", -1) >= 0, "selected Waterfall card must be horizontally visible")
require(frame.get("x", 0) + frame.get("width", 0) <= root.get("waterfallVisibleWidth", 0) + 1, "selected Waterfall card must fit in visible Waterfall width")
require(frame.get("y", -1) >= 0, "selected Waterfall card must be vertically visible")
require(frame.get("y", -1) >= -visibility_tolerance, "selected Waterfall card must be vertically visible")
require(frame.get("y", 0) + frame.get("height", 0) <= root.get("waterfallFrame", {}).get("height", 0) + 1, "selected Waterfall card must fit in visible Waterfall height")
require(selected_column.get("cardsClipMasksToBounds") is True, "vertical Waterfall column must clip cards below the pinned header")
require(cards_clip_frame.get("y", -1) == 0, "cards clip layer must start below the column bottom edge")
require(cards_clip_frame.get("height", 0) <= header_frame.get("y", 0) + 1, "cards clip layer must stop before the pinned header")
header_min_y = column_frame.get("y", 0) + header_frame.get("y", 0)
require(frame.get("y", 0) + frame.get("height", 0) <= header_min_y + 1, "selected Waterfall card must not overlap the pinned column header")
print(json.dumps({
    "selectedAppGroupIndex": root.get("selectedAppGroupIndex"),
C3.tools/round1-main-ui-qa.sh
@@ -109,6 +109,7 @@
run_step candidate-filter "$SCRIPT_DIR/round1-candidate-filter-fixture-qa.sh"
run_step screenshot-session "$SCRIPT_DIR/round1-screenshot-session-qa.sh"
run_step keyboard-navigation "$SCRIPT_DIR/round1-keyboard-navigation-fixture-qa.sh"
run_step vertical-keyboard-app-focus "$SCRIPT_DIR/round1-vertical-keyboard-app-focus-fixture-qa.sh"
run_step window-activation "$SCRIPT_DIR/round1-window-activation-fixture-qa.sh"
run_step multi-page-identity "$SCRIPT_DIR/round1-multi-page-identity-fixture-qa.sh"
run_step mouse-interaction "$SCRIPT_DIR/round1-mouse-interaction-fixture-qa.sh"
C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
@@ -121,20 +121,24 @@
assert_report() {
  local report="$1"
  local expected_sequence="$2"
  local expected_app_index="$3"
  local expected_window_index="$4"
  local expected_boundary_count="$5"
  local expected_boundary_direction="$6"
  local expect_selection_changed="$7"
  local expected_focus_app_index="$3"
  local expected_selected_app_index="$4"
  local expected_window_index="$5"
  local expected_boundary_count="$6"
  local expected_boundary_direction="$7"
  local expect_selection_changed="$8"
  local expect_keyboard_window_focus="$9"
  /usr/bin/python3 - \
    "$report" \
    "$expected_sequence" \
    "$expected_app_index" \
    "$expected_focus_app_index" \
    "$expected_selected_app_index" \
    "$expected_window_index" \
    "$expected_boundary_count" \
    "$expected_boundary_direction" \
    "$expect_selection_changed" \
    "$expect_keyboard_window_focus" \
    "$FIXTURE_APP_COUNT" \
    "$FIXTURE_WINDOWS_PER_APP" <<'PY'
import json
@@ -142,13 +146,15 @@
path = sys.argv[1]
expected_sequence = [part for part in sys.argv[2].split(",") if part]
expected_app_index = int(sys.argv[3])
expected_window_index = int(sys.argv[4])
expected_boundary_count = int(sys.argv[5])
expected_boundary_direction = None if sys.argv[6] == "none" else sys.argv[6]
expect_selection_changed = sys.argv[7] == "true"
fixture_app_count = int(sys.argv[8])
fixture_windows_per_app = int(sys.argv[9])
expected_focus_app_index = int(sys.argv[3])
expected_selected_app_index = int(sys.argv[4])
expected_window_index = int(sys.argv[5])
expected_boundary_count = int(sys.argv[6])
expected_boundary_direction = None if sys.argv[7] == "none" else sys.argv[7]
expect_selection_changed = sys.argv[8] == "true"
expect_keyboard_window_focus = sys.argv[9] == "true"
fixture_app_count = int(sys.argv[10])
fixture_windows_per_app = int(sys.argv[11])
with open(path, "r", encoding="utf-8") as file:
    report = json.load(file)
@@ -162,8 +168,9 @@
root = report.get("rootView", {})
items = root.get("appShelfItems", [])
columns = root.get("waterfallColumns", [])
target_item = next((item for item in items if item.get("index") == expected_app_index), None)
target_column = next((column for column in columns if column.get("appGroupIndex") == expected_app_index), None)
focus_item = next((item for item in items if item.get("index") == expected_focus_app_index), None)
focus_column = next((column for column in columns if column.get("appGroupIndex") == expected_focus_app_index), None)
selected_column = next((column for column in columns if column.get("appGroupIndex") == expected_selected_app_index), None)
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(report.get("quickSwitchVisible") is True, "fixture must keep Quick Switch visible")
@@ -171,22 +178,43 @@
require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match")
require(root.get("waterfallViewMode") == "verticalColumns", "fixture must run in vertical Waterfall")
require(root.get("keyboardCommandsApplied") == expected_sequence, "keyboard commands must match normalized sequence")
require(root.get("keyboardFocusedAppGroupIndex") == expected_app_index, "keyboard focus must land on expected App")
require(root.get("hoveredAppGroupIndex") == expected_app_index, "keyboard focus must expose hovered App")
require(root.get("keyboardFocusedAppGroupIndex") == expected_focus_app_index, "keyboard focus must land on expected App")
require(root.get("hoveredAppGroupIndex") == expected_focus_app_index, "keyboard focus must expose hovered App")
require(root.get("hoverTargetKind") == "app", "keyboard focus must use App hover target")
require(root.get("hoverTargetSource") == "keyboard", "keyboard focus must report keyboard source")
require(root.get("lastCommittedWindowID") is None, "no-Enter keyboard focus runs must not commit")
require(root.get("lastCommitSource") is None, "no-Enter keyboard focus runs must not report commit source")
require(target_item is not None, "target App Shelf item must exist")
require(target_column is not None, "target Waterfall column must exist")
require(target_item.get("isHovered") is True, "target App Shelf item must expose hover")
require("hover" in target_item.get("visualStates", []), "target App Shelf item must expose hover visual")
require(target_column.get("isHovered") is True, "target Waterfall column must expose hover")
require("hover" in target_column.get("visualStates", []), "target Waterfall column must expose hover visual")
require(root.get("selectedAppGroupIndex") == expected_app_index, "selected App must match expected App after column entry")
require(focus_item is not None, "focused App Shelf item must exist")
require(focus_column is not None, "focused Waterfall column must exist")
require(selected_column is not None, "selected Waterfall column must exist")
selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True]
selected_card = selected_column.get("cards", [])[expected_window_index]
require(focus_item.get("isHovered") is True, "focused App Shelf item must expose hover")
require("hover" in focus_item.get("visualStates", []), "focused App Shelf item must expose hover visual")
require(focus_column.get("isHovered") is True, "focused Waterfall column must expose hover")
require("hover" in focus_column.get("visualStates", []), "focused Waterfall column must expose hover visual")
require(root.get("selectedAppGroupIndex") == expected_selected_app_index, "selected App must match expected selected App")
require(root.get("selectedWindowIndex") == expected_window_index, "selected window index must match expected column movement")
require(root.get("selectionChangedByLastCommand") is expect_selection_changed, "selectionChangedByLastCommand must match final command")
require(root.get("boundaryBounceCount") == expected_boundary_count, "boundary bounce count must match")
if expect_keyboard_window_focus:
    require(len(selected_cards) == 1, "keyboard window focus run must expose exactly one selected Waterfall card")
    require(selected_card.get("isSelected") is True, "expected selected card must expose selection")
    require(selected_card.get("isKeyboardFocused") is True, "expected selected card must expose keyboard focus")
    require(root.get("keyboardFocusedWindowID") == selected_card.get("windowID"), "keyboardFocusedWindowID must match selected card")
    require("selected" in selected_card.get("visualStates", []), "keyboard-focused selected card must expose selected visual")
    require("keyboardFocused" in selected_card.get("visualStates", []), "keyboard-focused selected card must expose keyboardFocused state")
    require(selected_card.get("shineVisible") is True, "keyboard-focused selected card must expose shine")
    header_frame = selected_column.get("headerFrame", {})
    clip_frame = selected_column.get("cardsClipFrame", {})
    column_frame = selected_column.get("visibleFrame", {})
    card_frame = selected_card.get("visibleFrame", {})
    require(selected_column.get("cardsClipMasksToBounds") is True, "vertical cards must be clipped below pinned header")
    require(clip_frame.get("height", 0) <= header_frame.get("y", 0) + 1, "cards clip must stop before pinned header")
    header_min_y = column_frame.get("y", 0) + header_frame.get("y", 0)
    require(card_frame.get("y", 0) + card_frame.get("height", 0) <= header_min_y + 1, "keyboard-focused card must not overlap pinned header")
else:
    require(root.get("keyboardFocusedWindowID") is None, "App-only keyboard focus must not expose keyboardFocusedWindowID")
if expected_boundary_direction is None:
    require(root.get("lastBoundaryBounceAxis") is None, "no-boundary run must not report boundary axis")
    require(root.get("lastBoundaryBounceDirection") is None, "no-boundary run must not report boundary direction")
@@ -197,7 +225,9 @@
print(json.dumps({
    "commands": root.get("keyboardCommandsApplied"),
    "keyboardFocusedAppGroupIndex": root.get("keyboardFocusedAppGroupIndex"),
    "selectedAppGroupIndex": root.get("selectedAppGroupIndex"),
    "selectedWindowIndex": root.get("selectedWindowIndex"),
    "keyboardFocusedWindowID": root.get("keyboardFocusedWindowID"),
    "boundaryBounceCount": root.get("boundaryBounceCount"),
    "lastBoundaryBounceDirection": root.get("lastBoundaryBounceDirection")
}, indent=2, ensure_ascii=False))
@@ -207,11 +237,13 @@
run_case() {
  local name="$1"
  local sequence="$2"
  local expected_app_index="$3"
  local expected_window_index="$4"
  local expected_boundary_count="$5"
  local expected_boundary_direction="$6"
  local expect_selection_changed="$7"
  local expected_focus_app_index="$3"
  local expected_selected_app_index="$4"
  local expected_window_index="$5"
  local expected_boundary_count="$6"
  local expected_boundary_direction="$7"
  local expect_selection_changed="$8"
  local expect_keyboard_window_focus="$9"
  local report="$REPORT_DIR/round01-vertical-keyboard-$name-report.json"
  local expected_sequence
  expected_sequence="$(normalize_keyboard_sequence "$sequence")"
@@ -235,11 +267,13 @@
  assert_report \
    "$report" \
    "$expected_sequence" \
    "$expected_app_index" \
    "$expected_focus_app_index" \
    "$expected_selected_app_index" \
    "$expected_window_index" \
    "$expected_boundary_count" \
    "$expected_boundary_direction" \
    "$expect_selection_changed"
    "$expect_selection_changed" \
    "$expect_keyboard_window_focus"
  kill "$APP_PID" 2>/dev/null || true
  wait "$APP_PID" 2>/dev/null || true
@@ -254,14 +288,23 @@
if [ "$FIXTURE_WINDOWS_PER_APP" -lt 8 ]; then
  fail "fixture windows per app must be at least 8"
fi
symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if [ "$FIXTURE_APP_COUNT" -gt "${#symbols}" ]; then
  fail "fixture app count cannot exceed supported App Shelf index symbols"
fi
LAST_APP_INDEX=$((FIXTURE_APP_COUNT - 1))
LAST_APP_SYMBOL="${symbols:LAST_APP_INDEX:1}"
trap cleanup EXIT
stop_current_aligner
"$SCRIPT_DIR/package-app.sh" >&2
run_case "wrap" "1,left,right" 0 0 0 "none" "false"
run_case "bottom-boundary" "3,down,down,down,down,down,down,down,down,down" 2 7 1 "down" "false"
run_case "top-boundary" "4,down,up" 3 0 1 "up" "false"
run_case "index-hover-only" "A" 10 0 0 0 "none" "false" "false"
run_case "left-wrap-to-last" "1,left" "$LAST_APP_INDEX" 0 0 0 "none" "false" "false"
run_case "right-wrap-to-first" "$LAST_APP_SYMBOL,right" 0 0 0 0 "none" "false" "false"
run_case "bottom-boundary" "3,down,down,down,down,down,down,down,down,down" 2 2 7 1 "down" "false" "true"
run_case "top-boundary" "4,down,up" 3 3 0 1 "up" "false" "true"
trap - EXIT
cleanup