Ariver
2026-06-18 b6e11990cc9bd3c838ad6cdd9e53caafff0fd7cb
Add vertical App Shelf keyboard focus
6 files modified
1 files added
707 ■■■■■ changed files
C1.source/Resources/Aligner-Info.plist 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchRootView.swift 309 ●●●●● patch | view | raw | blame | history
C3.tools/round1-app-column-alignment-fixture-qa.sh 55 ●●●● patch | view | raw | blame | history
C3.tools/round1-keyboard-navigation-fixture-qa.sh 63 ●●●●● patch | view | raw | blame | history
C3.tools/round1-mouse-interaction-fixture-qa.sh 7 ●●●● patch | view | raw | blame | history
C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh 267 ●●●●● patch | view | raw | blame | history
C3.tools/round1-window-activation-fixture-qa.sh 2 ●●● 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.71</string>
    <string>0.0.72</string>
    <key>CFBundleVersion</key>
    <string>20260618.2149</string>
    <string>20260618.2320</string>
    <key>LSMinimumSystemVersion</key>
    <string>26.0</string>
    <key>NSHighResolutionCapable</key>
C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -10,6 +10,8 @@
@MainActor
final class QuickSwitchRootView: NSView {
    private static let appShelfIndexSymbols = Array("1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ").map(String.init)
    private let backdropBlurView = NSVisualEffectView()
    private let layerHostView = NSView()
    private let backgroundLayer = CAGradientLayer()
@@ -67,8 +69,14 @@
    private var horizontalMasonryScrollOffset: CGFloat = 0
    private var currentSelection: QuickSwitchSelection?
    private var columnSelectionHistory: [Int: Int] = [:]
    private var keyboardFocusedAppGroupIndex: Int?
    private var keyboardCommandsApplied: [String] = []
    private var lastKeyboardCommand: String?
    private var lastKeyboardAppIndexCommand: String?
    private var lastBoundaryBounceAxis: String?
    private var lastBoundaryBounceDirection: String?
    private var boundaryBounceCount = 0
    private var selectionChangedByLastCommand: Bool?
    private var tabIgnoredCount = 0
    private var lastCommittedSelection: QuickSwitchSelection?
    private var lastCommitSource: QuickSwitchCommitSource?
@@ -218,6 +226,7 @@
    private enum HoverTargetSource: String {
        case event
        case debug
        case keyboard
    }
    private struct SpaceLaneSegmentLayers {
@@ -447,6 +456,9 @@
        case TriggerKeyCode.tab:
            performKeyboardCommand("tab")
        default:
            if handleAppShelfIndexKey(event) {
                return
            }
            super.keyDown(with: event)
        }
    }
@@ -799,6 +811,10 @@
            columnSelectionHistory = columnSelectionHistory.filter { appGroupIndex, _ in
                viewModel?.waterfallColumns.contains { $0.appGroupIndex == appGroupIndex } == true
            }
            if let keyboardFocusedAppGroupIndex,
               viewModel?.waterfallColumns.contains(where: { $0.appGroupIndex == keyboardFocusedAppGroupIndex }) != true {
                self.keyboardFocusedAppGroupIndex = nil
            }
            if let currentSelection {
                columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
            }
@@ -813,8 +829,14 @@
            screenshotSkippedReasonsByWindowID = [:]
            currentSelection = viewModel?.initialSelection
            columnSelectionHistory = [:]
            keyboardFocusedAppGroupIndex = nil
            keyboardCommandsApplied = []
            lastKeyboardCommand = nil
            lastKeyboardAppIndexCommand = nil
            lastBoundaryBounceAxis = nil
            lastBoundaryBounceDirection = nil
            boundaryBounceCount = 0
            selectionChangedByLastCommand = nil
            tabIgnoredCount = 0
            lastCommittedSelection = nil
            lastCommitSource = nil
@@ -1233,8 +1255,14 @@
            "selectedAppGroupIndex": effectiveSelection?.appGroupIndex ?? NSNull(),
            "selectedWindowIndex": effectiveSelection?.windowIndex ?? NSNull(),
            "selectedWindowID": effectiveSelection?.windowID ?? NSNull(),
            "keyboardFocusedAppGroupIndex": keyboardFocusedAppGroupIndex ?? NSNull(),
            "keyboardCommandsApplied": keyboardCommandsApplied,
            "lastKeyboardCommand": lastKeyboardCommand ?? NSNull(),
            "lastKeyboardAppIndexCommand": lastKeyboardAppIndexCommand ?? NSNull(),
            "lastBoundaryBounceAxis": lastBoundaryBounceAxis ?? NSNull(),
            "lastBoundaryBounceDirection": lastBoundaryBounceDirection ?? NSNull(),
            "boundaryBounceCount": boundaryBounceCount,
            "selectionChangedByLastCommand": selectionChangedByLastCommand ?? NSNull(),
            "tabIgnoredCount": tabIgnoredCount,
            "lastCommittedAppGroupIndex": lastCommittedSelection?.appGroupIndex ?? NSNull(),
            "lastCommittedWindowIndex": lastCommittedSelection?.windowIndex ?? NSNull(),
@@ -1307,21 +1335,47 @@
            .lowercased()
        guard !command.isEmpty else { return }
        if let indexSymbol = appShelfIndexSymbol(fromKeyboardCommand: command) {
            let recordedCommand = "app:\(indexSymbol)"
            recordKeyboardCommand(recordedCommand)
            if !focusAppShelfItemForKeyboard(indexSymbol: indexSymbol, commandLabel: recordedCommand) {
                selectionChangedByLastCommand = false
            }
            return
        }
        switch command {
        case "left":
            recordKeyboardCommand("left")
            moveSelection(.left)
            if isHorizontalMasonryMode {
                moveSelection(.left)
            } else if !moveKeyboardAppFocus(cycleForward: false) {
                selectionChangedByLastCommand = false
            }
        case "right":
            recordKeyboardCommand("right")
            moveSelection(.right)
            if isHorizontalMasonryMode {
                moveSelection(.right)
            } else if !moveKeyboardAppFocus(cycleForward: true) {
                selectionChangedByLastCommand = false
            }
        case "up":
            recordKeyboardCommand("up")
            moveSelection(.up)
            if isHorizontalMasonryMode {
                moveSelection(.up)
            } else {
                moveVerticalSelectionWithinFocusedColumn(.up)
            }
        case "down":
            recordKeyboardCommand("down")
            moveSelection(.down)
            if isHorizontalMasonryMode {
                moveSelection(.down)
            } else {
                moveVerticalSelectionWithinFocusedColumn(.down)
            }
        case "enter", "return", "commit":
            recordKeyboardCommand("enter")
            selectionChangedByLastCommand = false
            commitCurrentSelection()
        case "tab":
            if isHorizontalMasonryMode {
@@ -1330,15 +1384,241 @@
            } else {
                tabIgnoredCount += 1
                recordKeyboardCommand("tabIgnored")
                selectionChangedByLastCommand = false
            }
        default:
            recordKeyboardCommand("unknown:\(command)")
            selectionChangedByLastCommand = false
        }
    }
    private func recordKeyboardCommand(_ command: String) {
        lastKeyboardCommand = command
        keyboardCommandsApplied.append(command)
    }
    private func handleAppShelfIndexKey(_ event: NSEvent) -> Bool {
        guard !isHorizontalMasonryMode else { return false }
        let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
        guard flags.isDisjoint(with: [.command, .control]) else { return false }
        guard let characters = event.charactersIgnoringModifiers,
              let symbol = appShelfIndexSymbol(fromKeyboardCommand: characters)
        else {
            return false
        }
        performKeyboardCommand(symbol)
        return true
    }
    private func appShelfIndexSymbol(fromKeyboardCommand command: String) -> String? {
        var candidate = command.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
        for prefix in ["APP:", "APP-", "INDEX:", "INDEX-"] where candidate.hasPrefix(prefix) {
            candidate.removeFirst(prefix.count)
            break
        }
        guard candidate.count == 1,
              Self.appShelfIndexSymbols.contains(candidate)
        else {
            return nil
        }
        return candidate
    }
    private func focusAppShelfItemForKeyboard(indexSymbol: String, commandLabel: String) -> Bool {
        guard let ordinal = Self.appShelfIndexSymbols.firstIndex(of: indexSymbol),
              ordinal < appShelfItems.count
        else {
            return false
        }
        return focusAppShelfItemForKeyboard(
            appGroupIndex: appShelfItems[ordinal].item.appGroupIndex,
            appIndexCommand: commandLabel
        )
    }
    @discardableResult
    private func focusAppShelfItemForKeyboard(
        appGroupIndex: Int,
        appIndexCommand: String? = nil
    ) -> Bool {
        guard appShelfItems.contains(where: { $0.item.appGroupIndex == appGroupIndex }),
              waterfallColumns.contains(where: { $0.column.appGroupIndex == appGroupIndex })
        else {
            return false
        }
        keyboardFocusedAppGroupIndex = appGroupIndex
        lastKeyboardAppIndexCommand = appIndexCommand
        selectionChangedByLastCommand = false
        clearKeyboardBoundaryFeedback()
        ensureAppShelfItemVisible(appGroupIndex)
        layoutSubtreeIfNeeded()
        hoverAppGroup(appGroupIndex, source: .keyboard)
        needsLayout = true
        return true
    }
    @discardableResult
    private func moveKeyboardAppFocus(cycleForward: Bool) -> Bool {
        let orderedAppGroupIndexes = appShelfItems.map(\.item.appGroupIndex)
        guard !orderedAppGroupIndexes.isEmpty else { return false }
        let currentAppGroupIndex = keyboardFocusedAppGroupIndex
            ?? appHoverTargetAppGroupIndex
            ?? effectiveSelection?.appGroupIndex
        let currentPosition = currentAppGroupIndex.flatMap { orderedAppGroupIndexes.firstIndex(of: $0) }
        let nextPosition = currentPosition.map { current in
            if cycleForward {
                return (current + 1) % orderedAppGroupIndexes.count
            }
            return (current - 1 + orderedAppGroupIndexes.count) % orderedAppGroupIndexes.count
        } ?? (cycleForward ? 0 : orderedAppGroupIndexes.count - 1)
        return focusAppShelfItemForKeyboard(appGroupIndex: orderedAppGroupIndexes[nextPosition])
    }
    private var appHoverTargetAppGroupIndex: Int? {
        guard case .app(let appGroupIndex) = hoverTarget else { return nil }
        return appGroupIndex
    }
    private func moveVerticalSelectionWithinFocusedColumn(_ direction: QuickSwitchKeyboardDirection) {
        guard direction == .up || direction == .down,
              let targetAppGroupIndex = verticalKeyboardNavigationAppGroupIndex(),
              let column = waterfallColumns.first(where: { $0.column.appGroupIndex == targetAppGroupIndex })
        else {
            DevelopmentDiagnostics.log("quickSwitch.view.moveVerticalSelection.blocked", [
                "direction": keyboardDirectionName(direction),
                "hasFocusedApp": keyboardFocusedAppGroupIndex != nil,
                "hasSelection": effectiveSelection != nil
            ])
            selectionChangedByLastCommand = false
            return
        }
        let cards = column.cards.sorted { $0.item.windowIndex < $1.item.windowIndex }
        guard !cards.isEmpty else {
            selectionChangedByLastCommand = false
            return
        }
        let previousSelection = effectiveSelection
        let nextSelection: QuickSwitchSelection
        if let current = previousSelection,
           current.appGroupIndex == targetAppGroupIndex,
           let currentPosition = cards.firstIndex(where: { $0.item.window.id == current.windowID }) {
            let nextPosition = currentPosition + (direction == .down ? 1 : -1)
            guard cards.indices.contains(nextPosition) else {
                keyboardFocusedAppGroupIndex = targetAppGroupIndex
                hoverAppGroup(targetAppGroupIndex, source: .keyboard)
                triggerKeyboardBoundaryFeedback(appGroupIndex: targetAppGroupIndex, direction: direction)
                DevelopmentDiagnostics.log("quickSwitch.view.moveVerticalSelection.boundary", [
                    "direction": keyboardDirectionName(direction),
                    "appGroupIndex": targetAppGroupIndex,
                    "windowIndex": current.windowIndex
                ])
                needsLayout = true
                return
            }
            nextSelection = selectionForCard(cards[nextPosition])
        } else {
            let rememberedWindowIndex = columnSelectionHistory[targetAppGroupIndex]
            let rememberedCard = rememberedWindowIndex.flatMap { windowIndex in
                cards.first { $0.item.windowIndex == windowIndex }
            }
            nextSelection = selectionForCard(rememberedCard ?? cards[0])
        }
        keyboardFocusedAppGroupIndex = targetAppGroupIndex
        currentSelection = nextSelection
        columnSelectionHistory[nextSelection.appGroupIndex] = nextSelection.windowIndex
        selectionChangedByLastCommand = previousSelection != nextSelection
        clearKeyboardBoundaryFeedback()
        hoverAppGroup(targetAppGroupIndex, source: .keyboard)
        ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true))
        DevelopmentDiagnostics.log("quickSwitch.view.moveVerticalSelection", [
            "direction": keyboardDirectionName(direction),
            "appGroupIndex": nextSelection.appGroupIndex,
            "windowIndex": nextSelection.windowIndex,
            "windowID": nextSelection.windowID
        ])
        needsLayout = true
    }
    private func verticalKeyboardNavigationAppGroupIndex() -> Int? {
        if let keyboardFocusedAppGroupIndex,
           waterfallColumns.contains(where: { $0.column.appGroupIndex == keyboardFocusedAppGroupIndex }) {
            return keyboardFocusedAppGroupIndex
        }
        if let appHoverTargetAppGroupIndex,
           waterfallColumns.contains(where: { $0.column.appGroupIndex == appHoverTargetAppGroupIndex }) {
            return appHoverTargetAppGroupIndex
        }
        if let appGroupIndex = effectiveSelection?.appGroupIndex,
           waterfallColumns.contains(where: { $0.column.appGroupIndex == appGroupIndex }) {
            return appGroupIndex
        }
        return waterfallColumns.first?.column.appGroupIndex
    }
    private func clearKeyboardBoundaryFeedback() {
        lastBoundaryBounceAxis = nil
        lastBoundaryBounceDirection = nil
    }
    private func triggerKeyboardBoundaryFeedback(
        appGroupIndex: Int,
        direction: QuickSwitchKeyboardDirection
    ) {
        selectionChangedByLastCommand = false
        lastBoundaryBounceAxis = "vertical"
        lastBoundaryBounceDirection = keyboardDirectionName(direction)
        boundaryBounceCount += 1
        layoutSubtreeIfNeeded()
        guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex }) else { return }
        let animationKey = "keyboardBoundaryBounce"
        column.containerLayer.removeAnimation(forKey: animationKey)
        if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
            let pulse = CABasicAnimation(keyPath: "opacity")
            pulse.fromValue = 1
            pulse.toValue = 0.9
            pulse.duration = 0.12
            pulse.autoreverses = true
            pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
            column.containerLayer.add(pulse, forKey: animationKey)
            return
        }
        let currentY = column.containerLayer.presentation()?.position.y
            ?? column.containerLayer.position.y
        let delta: CGFloat = direction == .up ? 5 : -5
        let bounce = CAKeyframeAnimation(keyPath: "position.y")
        bounce.values = [currentY, currentY + delta, currentY]
        bounce.keyTimes = [0, 0.38, 1]
        bounce.timingFunctions = [
            CAMediaTimingFunction(name: .easeOut),
            CAMediaTimingFunction(name: .easeInEaseOut)
        ]
        bounce.duration = 0.23
        bounce.isRemovedOnCompletion = true
        column.containerLayer.add(bounce, forKey: animationKey)
    }
    private func keyboardDirectionName(_ direction: QuickSwitchKeyboardDirection) -> String {
        switch direction {
        case .left:
            return "left"
        case .right:
            return "right"
        case .up:
            return "up"
        case .down:
            return "down"
        }
    }
    private func performMouseCommand(_ rawCommand: String) {
@@ -1491,13 +1771,13 @@
        return command.hasPrefix("hover-")
    }
    private func hoverAppGroup(_ appGroupIndex: Int) {
    private func hoverAppGroup(_ appGroupIndex: Int, source: HoverTargetSource = .debug) {
        updateHoverState(
            appGroupIndex: appGroupIndex,
            windowID: nil,
            spaceID: nil,
            alignsWaterfallWithAppShelf: true,
            source: .debug
            source: source
        )
    }
@@ -1778,8 +2058,11 @@
        }
        columnSelectionHistory[current.appGroupIndex] = current.windowIndex
        let previousSelection = effectiveSelection
        currentSelection = next
        columnSelectionHistory[next.appGroupIndex] = next.windowIndex
        selectionChangedByLastCommand = previousSelection != next
        clearKeyboardBoundaryFeedback()
        ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true))
        DevelopmentDiagnostics.log("quickSwitch.view.moveSelection", [
            "direction": String(describing: direction),
@@ -1802,8 +2085,11 @@
        }
        columnSelectionHistory[current.appGroupIndex] = current.windowIndex
        let previousSelection = effectiveSelection
        currentSelection = next
        columnSelectionHistory[next.appGroupIndex] = next.windowIndex
        selectionChangedByLastCommand = previousSelection != next
        clearKeyboardBoundaryFeedback()
        ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true))
        DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection", [
            "direction": String(describing: direction),
@@ -3543,15 +3829,14 @@
    }
    private func appShelfIndexText(forOrdinal ordinal: Int) -> String {
        let symbols = Array("1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ")
        guard ordinal >= 0 else { return "" }
        if ordinal < symbols.count {
            return String(symbols[ordinal])
        if ordinal < Self.appShelfIndexSymbols.count {
            return Self.appShelfIndexSymbols[ordinal]
        }
        let overflowIndex = ordinal - symbols.count
        let prefix = symbols[10 + (overflowIndex / 26) % 26]
        let suffix = symbols[10 + overflowIndex % 26]
        let overflowIndex = ordinal - Self.appShelfIndexSymbols.count
        let prefix = Self.appShelfIndexSymbols[10 + (overflowIndex / 26) % 26]
        let suffix = Self.appShelfIndexSymbols[10 + overflowIndex % 26]
        return "\(prefix)\(suffix)"
    }
C3.tools/round1-app-column-alignment-fixture-qa.sh
@@ -58,19 +58,40 @@
  stop_current_aligner
}
keyboard_right_sequence() {
keyboard_index_sequence() {
  local target_index="$1"
  local sequence=""
  local symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
  for ((index = 0; index < target_index; index++)); do
    if [ -n "$sequence" ]; then
      sequence="$sequence,right"
  if [ "$target_index" -ge "${#symbols}" ]; then
    fail "keyboard index sequence does not support target index $target_index"
  fi
  echo "${symbols:$target_index:1}"
}
normalize_keyboard_sequence() {
  local sequence="$1"
  local symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
  local normalized=""
  local IFS=,
  for raw_part in $sequence; do
    local part
    part="$(printf "%s" "$raw_part" | tr '[:lower:]' '[:upper:]')"
    if [ "${#part}" -eq 1 ] && [[ "$symbols" == *"$part"* ]]; then
      part="app:$part"
    else
      sequence="right"
      part="$raw_part"
    fi
    if [ -n "$normalized" ]; then
      normalized="$normalized,$part"
    else
      normalized="$part"
    fi
  done
  echo "$sequence"
  echo "$normalized"
}
wait_for_report() {
@@ -196,12 +217,14 @@
elif mode == "keyboard":
    require(root.get("keyboardCommandsApplied") == expected_key_sequence, "debug key sequence must be applied in order")
    require(root.get("lastKeyboardCommand") == expected_key_sequence[-1], "last keyboard command must match the alignment target")
    require(root.get("selectedAppGroupIndex") == target_app_index, "keyboard focus must select the target appGroupIndex")
    require(item.get("isSelected") is True, "target App Shelf item must expose selected state")
    require("selectedVisualSuppressed" in item.get("visualStates", []), "non-hovered keyboard-selected App Shelf item must suppress selected visual state")
    cards = column.get("cards", [])
    require(cards, "target Waterfall column must include cards")
    require(root.get("selectedWindowID") == cards[0].get("windowID"), "right-arrow focus into target column must select its first window")
    require(root.get("keyboardFocusedAppGroupIndex") == target_app_index, "keyboard App focus must target the requested App")
    require(root.get("hoveredAppGroupIndex") == target_app_index, "keyboard App focus must become hovered appGroupIndex")
    require(root.get("hoverTargetKind") == "app", "keyboard App focus must expose an App hover target")
    require(root.get("hoverTargetSource") == "keyboard", "keyboard App focus must be reported as keyboard sourced")
    require(item.get("isHovered") is True, "target App Shelf item must expose hover state")
    require("hover" in item.get("visualStates", []), "target App Shelf item must expose hover visual state")
    require(item.get("isSelected") is False, "keyboard App focus must not immediately move selected window")
    require(root.get("selectionChangedByLastCommand") is False, "keyboard App focus must not change selected window")
    require(root.get("lastCommittedWindowID") is None, "keyboard alignment without Enter must not commit a window")
else:
    require(False, f"unknown alignment mode {mode!r}")
@@ -268,7 +291,7 @@
    expected_mouse_sequence="$sequence"
    args+=(--round01-debug-mouse-sequence="$sequence")
  elif [ "$mode" = "keyboard" ]; then
    expected_key_sequence="$sequence"
    expected_key_sequence="$(normalize_keyboard_sequence "$sequence")"
    args+=(--round01-debug-key-sequence="$sequence")
  else
    fail "unknown fixture mode: $mode"
@@ -308,8 +331,8 @@
REGULAR_RIGHT_INDEX=$((FIXTURE_APP_COUNT - 1))
NARROW_MIDDLE_INDEX=$((NARROW_FIXTURE_APP_COUNT / 2))
NARROW_RIGHT_INDEX=$((NARROW_FIXTURE_APP_COUNT - 1))
REGULAR_KEY_SEQUENCE="$(keyboard_right_sequence "$REGULAR_RIGHT_INDEX")"
NARROW_KEY_SEQUENCE="$(keyboard_right_sequence "$NARROW_RIGHT_INDEX")"
REGULAR_KEY_SEQUENCE="$(keyboard_index_sequence "$REGULAR_RIGHT_INDEX")"
NARROW_KEY_SEQUENCE="$(keyboard_index_sequence "$NARROW_RIGHT_INDEX")"
trap cleanup EXIT
stop_current_aligner
C3.tools/round1-keyboard-navigation-fixture-qa.sh
@@ -1,7 +1,8 @@
#!/bin/bash
# Round01 keyboard navigation fixture QA. It drives deterministic key commands
# through the Quick Switch debug hook and verifies stable selection, no wrapping,
# visible scrolling, Tab ignore, Enter activation reporting, and clean close.
# through the Quick Switch debug hook and verifies vertical App-index focus,
# column-local movement, visible scrolling, Tab ignore, Enter activation reporting,
# and clean close.
set -euo pipefail
@@ -11,9 +12,11 @@
source "$SCRIPT_DIR/build-output-paths.sh"
APP="$BUILD_CURRENT_APP"
REPORT="$BUILD_REPORT_ROOT/round01-keyboard-navigation-fixture-report.json"
FIXTURE_APP_COUNT="${ALIGNER_ROUND1_KEYBOARD_FIXTURE_APP_COUNT:-8}"
FIXTURE_APP_COUNT="${ALIGNER_ROUND1_KEYBOARD_FIXTURE_APP_COUNT:-12}"
FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_KEYBOARD_FIXTURE_WINDOWS_PER_APP:-8}"
KEY_SEQUENCE="${ALIGNER_ROUND1_KEYBOARD_SEQUENCE:-right,down,down,down,down,down,down,down,left,right,right,right,right,right,right,right,right,tab,enter}"
KEY_SEQUENCE="${ALIGNER_ROUND1_KEYBOARD_SEQUENCE:-A,down,down,down,down,down,down,down,down,tab,enter}"
EXPECTED_APP_INDEX="${ALIGNER_ROUND1_KEYBOARD_EXPECTED_APP_INDEX:-10}"
EXPECTED_WINDOW_INDEX="${ALIGNER_ROUND1_KEYBOARD_EXPECTED_WINDOW_INDEX:-$((FIXTURE_WINDOWS_PER_APP - 1))}"
REPORT_WAIT="${ALIGNER_ROUND1_KEYBOARD_REPORT_WAIT:-6.0}"
fail() {
@@ -80,7 +83,7 @@
}
assert_report() {
  /usr/bin/python3 - "$REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$KEY_SEQUENCE" <<'PY'
  /usr/bin/python3 - "$REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$KEY_SEQUENCE" "$EXPECTED_APP_INDEX" "$EXPECTED_WINDOW_INDEX" <<'PY'
import json
import sys
@@ -88,6 +91,8 @@
fixture_app_count = int(sys.argv[2])
fixture_windows_per_app = int(sys.argv[3])
key_sequence = [part for part in sys.argv[4].split(",") if part]
expected_selected_app_group_index = int(sys.argv[5])
expected_selected_window_index = int(sys.argv[6])
with open(path, "r", encoding="utf-8") as file:
    report = json.load(file)
@@ -101,6 +106,15 @@
root = report.get("rootView", {})
columns = root.get("waterfallColumns", [])
items = root.get("appShelfItems", [])
index_symbols = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
def normalized_key_command(command):
    upper = command.upper()
    if len(upper) == 1 and upper in index_symbols:
        return f"app:{upper}"
    if command == "tab":
        return "tabIgnored"
    return command
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(report.get("quickSwitchVisible") is False, "Enter activation must close Quick Switch")
@@ -110,15 +124,19 @@
require(len(columns) == fixture_app_count, "Waterfall column reports must match fixture count")
require(len(items) == fixture_app_count, "App Shelf item reports must match fixture count")
expected_selected_app_group_index = fixture_app_count - 1
expected_selected_window_index = 0
selected_column = columns[expected_selected_app_group_index]
selected_card = selected_column.get("cards", [])[expected_selected_window_index]
expected_selected_window_id = selected_card.get("windowID")
require(root.get("selectedAppGroupIndex") == expected_selected_app_group_index, "right-arrow navigation must stop on the final App column")
require(root.get("selectedWindowIndex") == expected_selected_window_index, "right-arrow into new columns must fall back to window index zero")
require(root.get("selectedAppGroupIndex") == expected_selected_app_group_index, "index-key App focus plus down-arrow navigation must select the expected App column")
require(root.get("selectedWindowIndex") == expected_selected_window_index, "down-arrow navigation must move within the focused App column")
require(root.get("selectedWindowID") == expected_selected_window_id, "selectedWindowID must match selected card")
require(root.get("keyboardFocusedAppGroupIndex") == expected_selected_app_group_index, "keyboard App focus must stay on the target App")
require(root.get("hoveredAppGroupIndex") == expected_selected_app_group_index, "keyboard App focus must expose hoveredAppGroupIndex")
require(root.get("hoverTargetKind") == "app", "keyboard App focus must use an App hover target")
require(root.get("hoverTargetSource") == "keyboard", "keyboard App focus must report keyboard source")
expected_app_index_command = next((normalized_key_command(command) for command in key_sequence if normalized_key_command(command).startswith("app:")), None)
require(root.get("lastKeyboardAppIndexCommand") == expected_app_index_command, "App index command must be reported")
require(report.get("lastCommittedWindowID") == expected_selected_window_id, "Enter commit must be surfaced at session level")
require(report.get("lastCommitSource") == "keyboard", "Enter commit source must be keyboard")
require(report.get("lastActivationWindowID") == expected_selected_window_id, "Enter activation window must match selected card")
@@ -128,9 +146,10 @@
require(root.get("lastCommittedWindowIndex") == expected_selected_window_index, "Enter commit window index must match selected card")
commands = root.get("keyboardCommandsApplied", [])
require(commands == ["tabIgnored" if command == "tab" else command for command in key_sequence], "debug keyboard sequence must be applied in order with Tab ignored")
require(commands == [normalized_key_command(command) for command in key_sequence], "debug keyboard sequence must be applied in order with App index and Tab normalized")
require(root.get("tabIgnoredCount") == 1, "Tab must be ignored exactly once")
require(root.get("lastKeyboardCommand") == "enter", "Enter must be the final keyboard command")
require(root.get("boundaryBounceCount") == 0, "in-range down-arrow movement must not report a boundary bounce")
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")
@@ -138,23 +157,26 @@
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("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]
require(len(selected_items) == 1, "App Shelf must expose exactly one selected App")
require(selected_items[0].get("index") == expected_selected_app_group_index, "App Shelf selection must follow keyboard-selected Waterfall column")
require(selected_items[0].get("isHovered") is True, "App Shelf selected App must also expose keyboard hover")
require("selected" in selected_items[0].get("visualStates", []), "keyboard-focused selected App Shelf item must expose selected visual")
require("hover" in selected_items[0].get("visualStates", []), "keyboard-focused selected App Shelf item must expose hover visual")
history = {
    entry.get("appGroupIndex"): entry.get("windowIndex")
    for entry in root.get("columnSelectionHistory", [])
}
require(history.get(1) == fixture_windows_per_app - 1, "left/right navigation must restore the remembered window index for column 1")
require(history.get(expected_selected_app_group_index) == 0, "final column history must remember selected window zero")
require(history.get(expected_selected_app_group_index) == expected_selected_window_index, "final column history must remember selected window")
require(root.get("waterfallScrollable") is True, "fixture must make Waterfall horizontally scrollable")
require(root.get("waterfallMaxScrollOffset", 0) > 0, "Waterfall must expose horizontal overflow")
require(root.get("waterfallScrollOffset", 0) > 0, "selected far-right App column must auto-scroll into view")
require(columns[1].get("maxVerticalScrollOffset", 0) > 0, "fixture column 1 must expose vertical overflow")
require(columns[1].get("verticalScrollOffset", 0) > 0, "down-arrow navigation must auto-scroll the selected window into view")
require(selected_column.get("maxVerticalScrollOffset", 0) > 0, "selected fixture column must expose vertical overflow")
require(selected_column.get("verticalScrollOffset", 0) > 0, "down-arrow navigation must auto-scroll the selected window into view")
frame = selected_cards[0].get("visibleFrame", {})
require(frame.get("x", -1) >= 0, "selected Waterfall card must be horizontally visible")
@@ -167,12 +189,13 @@
    "selectedWindowIndex": root.get("selectedWindowIndex"),
    "selectedWindowID": root.get("selectedWindowID"),
    "lastCommittedWindowID": root.get("lastCommittedWindowID"),
    "lastActivationResult": report.get("lastActivationResult"),
    "quickSwitchVisible": report.get("quickSwitchVisible"),
    "waterfallScrollOffset": root.get("waterfallScrollOffset"),
    "column1VerticalScrollOffset": columns[1].get("verticalScrollOffset"),
    "tabIgnoredCount": root.get("tabIgnoredCount")
}, indent=2, ensure_ascii=False))
        "lastActivationResult": report.get("lastActivationResult"),
        "quickSwitchVisible": report.get("quickSwitchVisible"),
        "waterfallScrollOffset": root.get("waterfallScrollOffset"),
        "selectedColumnVerticalScrollOffset": selected_column.get("verticalScrollOffset"),
        "keyboardFocusedAppGroupIndex": root.get("keyboardFocusedAppGroupIndex"),
        "tabIgnoredCount": root.get("tabIgnoredCount")
    }, indent=2, ensure_ascii=False))
PY
}
C3.tools/round1-mouse-interaction-fixture-qa.sh
@@ -302,8 +302,13 @@
require("focused" not in window_associated_segments[0].get("visualStates", []), "window-associated Space segment must not expose focused")
frame = selected_cards[0].get("visibleFrame", {})
selected_card_visual_overflow_tolerance = 2.5
require(frame.get("x", -1) >= 0, "clicked Waterfall card must be horizontally visible")
require(frame.get("x", 0) + frame.get("width", 0) <= root.get("waterfallVisibleWidth", 0) + 1, "clicked card must fit in visible Waterfall width")
require(
    frame.get("x", 0) + frame.get("width", 0)
    <= root.get("waterfallVisibleWidth", 0) + selected_card_visual_overflow_tolerance,
    "clicked card must fit in visible Waterfall width"
)
print(json.dumps({
    "quickSwitchVisible": report.get("quickSwitchVisible"),
C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
New file
@@ -0,0 +1,267 @@
#!/bin/bash
# Round01 vertical keyboard App focus fixture QA. It verifies that App Shelf
# index keys and left/right arrows behave like App icon hover in vertical
# Waterfall, while up/down move only inside the focused column and report
# boundary bounce without wrapping.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# shellcheck source=build-output-paths.sh
source "$SCRIPT_DIR/build-output-paths.sh"
APP="$BUILD_CURRENT_APP"
REPORT_DIR="$BUILD_REPORT_ROOT"
FIXTURE_APP_COUNT="${ALIGNER_ROUND1_VERTICAL_KEYBOARD_APP_COUNT:-12}"
FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_VERTICAL_KEYBOARD_WINDOWS_PER_APP:-8}"
REPORT_WAIT="${ALIGNER_ROUND1_VERTICAL_KEYBOARD_REPORT_WAIT:-6.0}"
APP_PID=""
fail() {
  echo "Round01 vertical keyboard App focus fixture QA failed: $*" >&2
  exit 1
}
aligner_pids_for_current_app() {
  ps -axo pid=,args= | while read -r pid command; do
    if [[ "$command" == "$APP/Contents/MacOS/Aligner"* ]] \
      || [[ "$command" == "/Applications/Aligner.app/Contents/MacOS/Aligner"* ]] \
      || [[ "$command" == "$HOME/Applications/Aligner.app/Contents/MacOS/Aligner"* ]]; then
      echo "$pid"
    fi
  done
}
stop_current_aligner() {
  for pid in $(aligner_pids_for_current_app); do
    kill "$pid" 2>/dev/null || true
  done
  for _ in {1..30}; do
    [ -z "$(aligner_pids_for_current_app)" ] && return
    sleep 0.1
  done
  fail "current Aligner app did not exit before QA"
}
cleanup() {
  if [ -n "${APP_PID:-}" ]; then
    kill "$APP_PID" 2>/dev/null || true
    wait "$APP_PID" 2>/dev/null || true
    APP_PID=""
  fi
  stop_current_aligner
}
normalize_keyboard_sequence() {
  local sequence="$1"
  local symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
  local normalized=""
  local IFS=,
  for raw_part in $sequence; do
    local upper
    upper="$(printf "%s" "$raw_part" | tr '[:lower:]' '[:upper:]')"
    local part="$raw_part"
    if [ "${#upper}" -eq 1 ] && [[ "$symbols" == *"$upper"* ]]; then
      part="app:$upper"
    fi
    if [ -n "$normalized" ]; then
      normalized="$normalized,$part"
    else
      normalized="$part"
    fi
  done
  echo "$normalized"
}
wait_for_report() {
  local report="$1"
  local expected_sequence="$2"
  /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_sequence" <<'PY'
import json
import sys
import time
path = sys.argv[1]
timeout = float(sys.argv[2])
expected_sequence = [part for part in sys.argv[3].split(",") if part]
deadline = time.monotonic() + timeout
last_report = None
while time.monotonic() < deadline:
    try:
        with open(path, "r", encoding="utf-8") as file:
            report = json.load(file)
        last_report = report
        root = report.get("rootView", {})
        if (
            report.get("snapshotLoaded") is True
            and report.get("quickSwitchVisible") is True
            and root.get("keyboardCommandsApplied") == expected_sequence
        ):
            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"vertical keyboard report did not reach expected commands within {timeout:.1f}s", file=sys.stderr)
sys.exit(1)
PY
}
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"
  /usr/bin/python3 - \
    "$report" \
    "$expected_sequence" \
    "$expected_app_index" \
    "$expected_window_index" \
    "$expected_boundary_count" \
    "$expected_boundary_direction" \
    "$expect_selection_changed" \
    "$FIXTURE_APP_COUNT" \
    "$FIXTURE_WINDOWS_PER_APP" <<'PY'
import json
import sys
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])
with open(path, "r", encoding="utf-8") as file:
    report = json.load(file)
def require(condition, message):
    if not condition:
        print(message, file=sys.stderr)
        print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
        sys.exit(1)
root = report.get("rootView", {})
items = root.get("appShelfItems", [])
columns = root.get("waterfallColumns", [])
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)
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(report.get("quickSwitchVisible") is True, "fixture must keep Quick Switch visible")
require(report.get("appCount") == fixture_app_count, "fixture appCount must match")
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("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(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 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")
else:
    require(root.get("lastBoundaryBounceAxis") == "vertical", "boundary run must report vertical axis")
    require(root.get("lastBoundaryBounceDirection") == expected_boundary_direction, "boundary direction must match")
print(json.dumps({
    "commands": root.get("keyboardCommandsApplied"),
    "keyboardFocusedAppGroupIndex": root.get("keyboardFocusedAppGroupIndex"),
    "selectedWindowIndex": root.get("selectedWindowIndex"),
    "boundaryBounceCount": root.get("boundaryBounceCount"),
    "lastBoundaryBounceDirection": root.get("lastBoundaryBounceDirection")
}, indent=2, ensure_ascii=False))
PY
}
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 report="$REPORT_DIR/round01-vertical-keyboard-$name-report.json"
  local expected_sequence
  expected_sequence="$(normalize_keyboard_sequence "$sequence")"
  stop_current_aligner
  rm -f "$report"
  "$APP/Contents/MacOS/Aligner" \
    --round0-skip-permissions \
    --round01-open-quick-switch \
    --round01-waterfall-view-mode=vertical \
    --round01-fixture-app-count="$FIXTURE_APP_COUNT" \
    --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \
    --round01-disable-screenshot-refresh \
    --round01-debug-key-sequence="$sequence" \
    --round01-quick-switch-report="$report" &
  APP_PID=$!
  wait_for_report "$report" "$expected_sequence"
  swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
  assert_report \
    "$report" \
    "$expected_sequence" \
    "$expected_app_index" \
    "$expected_window_index" \
    "$expected_boundary_count" \
    "$expected_boundary_direction" \
    "$expect_selection_changed"
  kill "$APP_PID" 2>/dev/null || true
  wait "$APP_PID" 2>/dev/null || true
  APP_PID=""
  sleep 0.3
  swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
}
if [ "$FIXTURE_APP_COUNT" -lt 12 ]; then
  fail "fixture app count must be at least 12"
fi
if [ "$FIXTURE_WINDOWS_PER_APP" -lt 8 ]; then
  fail "fixture windows per app must be at least 8"
fi
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"
trap - EXIT
cleanup
C3.tools/round1-window-activation-fixture-qa.sh
@@ -149,7 +149,7 @@
  --round01-fixture-window-activation \
  --round01-disable-screenshot-refresh \
  --round01-debug-window-activation \
  --round01-debug-key-sequence="right,enter" \
  --round01-debug-key-sequence="2,down,enter" \
  --round01-quick-switch-report="$MINIMIZED_REPORT" &
APP_PID=$!