From cb67d8b2fa9b9c9d30cd81d72b8737ee2baa44b6 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 19 Jun 2026 14:09:49 +0800
Subject: [PATCH] Fix vertical keyboard window focus

---
 C3.tools/round1-keyboard-navigation-fixture-qa.sh         |   20 ++++-
 C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh |  111 +++++++++++++++++++--------
 C3.tools/round1-main-ui-qa.sh                             |    1 
 C1.source/Sources/Aligner/QuickSwitchRootView.swift       |   94 +++++++++++++++++++----
 C1.source/Resources/Aligner-Info.plist                    |    4 
 5 files changed, 174 insertions(+), 56 deletions(-)

diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index 7f859ba..a097353 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.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>
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 9fb63d3..f7cbfa2 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/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")
         }
diff --git a/C3.tools/round1-keyboard-navigation-fixture-qa.sh b/C3.tools/round1-keyboard-navigation-fixture-qa.sh
index fa10a3e..380e376 100755
--- a/C3.tools/round1-keyboard-navigation-fixture-qa.sh
+++ b/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"),
diff --git a/C3.tools/round1-main-ui-qa.sh b/C3.tools/round1-main-ui-qa.sh
index aa5c4d2..9c29bf2 100755
--- a/C3.tools/round1-main-ui-qa.sh
+++ b/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"
diff --git a/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh b/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
index 1e9250e..c25cc9a 100755
--- a/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
+++ b/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

--
Gitblit v1.9.3