Ariver
2026-06-13 6827b8fc6ac32217cc27e5095422e9496739643f
Fix app grid usage tips interactions
4 files modified
856 ■■■■ changed files
src/Apptag/AppGridCollectionView.swift 591 ●●●● patch | view | raw | blame | history
src/Apptag/ApptagApp.swift 32 ●●●●● patch | view | raw | blame | history
src/Apptag/OverlayWindowController.swift 6 ●●●●● patch | view | raw | blame | history
src/Scripts/usage_tips_qa.sh 227 ●●●●● patch | view | raw | blame | history
src/Apptag/AppGridCollectionView.swift
@@ -1,5 +1,6 @@
import SwiftUI
import AppKit
import Carbon.HIToolbox
fileprivate enum AppGridCollectionDisplayMode: Equatable {
    case flat
@@ -37,10 +38,11 @@
}
enum AppGridUsageTipsMetrics {
    static let barHeight: CGFloat = 68
    static let reservedHeight: CGFloat = 104
    static let bottomMargin: CGFloat = 18
    static let horizontalInset: CGFloat = AppGridCollectionMetrics.outerPadding
    static let barHeight: CGFloat = 84
    static let reservedHeight: CGFloat = 128
    static let bottomMargin: CGFloat = 20
    static let horizontalInset: CGFloat = 24
    static let minWidth: CGFloat = 640
}
struct AppGridCollectionView: NSViewRepresentable {
@@ -432,6 +434,7 @@
    private let scrollView = AppGridScrollView()
    private let collectionView = NSCollectionView()
    private let gridLayout = AppGridContainerCollectionLayout()
    private let usageTipsShieldView = AppGridUsageTipsShieldView()
    private let usageTipsView = AppGridUsageTipsNSView()
    private weak var coordinator: AppGridCollectionView.Coordinator?
    private var scrollObserver: NSObjectProtocol?
@@ -439,6 +442,8 @@
    private var lastReportedBoundsOrigin: NSPoint?
    private var scrollUnfreezeWorkItem: DispatchWorkItem?
    private var scrollActivityIsActive = false
    private var usageTipsEventRegionIsClaimed = false
    private var usageTipsMouseMonitor: Any?
    override var isFlipped: Bool { true }
@@ -456,6 +461,7 @@
        if let scrollObserver {
            NotificationCenter.default.removeObserver(scrollObserver)
        }
        removeUsageTipsMouseMonitor()
        scrollUnfreezeWorkItem?.cancel()
        coordinator?.cancelAppIconDrag()
        AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
@@ -508,12 +514,23 @@
    override func viewDidMoveToWindow() {
        super.viewDidMoveToWindow()
        if window == nil {
            removeUsageTipsMouseMonitor()
            AppDragCoordinator.shared.cancelDrag()
            coordinator?.cancelAppIconDrag()
            AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
        } else {
            installUsageTipsMouseMonitor()
            AppDragCoordinator.shared.registerEmptyDropTarget(id: emptyDropTargetID, view: self)
        }
    }
    override func hitTest(_ point: NSPoint) -> NSView? {
        if !usageTipsView.isHidden,
           usageTipsView.frame.contains(point) {
            let tipsPoint = usageTipsView.convert(point, from: self)
            return usageTipsView.hitTest(tipsPoint) ?? usageTipsView
        }
        return super.hitTest(point)
    }
    func performEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) {
@@ -552,8 +569,16 @@
        scrollView.borderType = .noBorder
        scrollView.documentView = collectionView
        addSubview(scrollView)
        usageTipsShieldView.isHidden = true
        usageTipsShieldView.onClaim = { [weak self] in
            self?.claimUsageTipsEventRegion()
        }
        usageTipsShieldView.onRelease = { [weak self] in
            self?.releaseUsageTipsEventRegion()
        }
        usageTipsView.isHidden = true
        addSubview(usageTipsView)
        addSubview(usageTipsShieldView, positioned: .above, relativeTo: scrollView)
        addSubview(usageTipsView, positioned: .above, relativeTo: usageTipsShieldView)
        scrollView.contentView.postsBoundsChangedNotifications = true
        scrollObserver = NotificationCenter.default.addObserver(
@@ -573,17 +598,63 @@
              coordinator.usageTipsVisible
        else {
            usageTipsView.isHidden = true
            usageTipsShieldView.isHidden = true
            usageTipsView.clearHoverState()
            releaseUsageTipsEventRegion()
            usageTipsView.frame = .zero
            usageTipsShieldView.frame = .zero
            return
        }
        let inset = min(AppGridUsageTipsMetrics.horizontalInset, max(0, bounds.width / 4))
        let width = max(1, bounds.width - inset * 2)
        let availableWidth = max(1, bounds.width - inset * 2)
        let width = usageTipsView.preferredWidth(maxAvailableWidth: availableWidth)
        let height = AppGridUsageTipsMetrics.barHeight
        let y = max(0, bounds.height - AppGridUsageTipsMetrics.bottomMargin - height)
        usageTipsShieldView.isHidden = false
        usageTipsShieldView.frame = usageTipsEventRegion()
        usageTipsView.configureVisualLayout(
            width: width,
            height: height,
            bottomMargin: AppGridUsageTipsMetrics.bottomMargin
        )
        usageTipsView.isHidden = false
        usageTipsView.frame = NSRect(x: inset, y: y, width: width, height: height)
        usageTipsView.frame = usageTipsEventRegion()
    }
    fileprivate func relayoutUsageTipsAfterContentChange() {
        positionUsageTipsView()
        usageTipsView.needsLayout = true
    }
    private func usageTipsEventRegion() -> NSRect {
        guard let coordinator,
              coordinator.usageTipsVisible
        else { return .zero }
        let height = min(bounds.height, AppGridUsageTipsMetrics.reservedHeight)
        return NSRect(
            x: 0,
            y: max(0, bounds.height - height),
            width: bounds.width,
            height: height
        )
    }
    private func claimUsageTipsEventRegion() {
        usageTipsEventRegionIsClaimed = true
        window?.makeFirstResponder(usageTipsView)
        if coordinator?.setUsageTipsBubbleDisabled(true) == true {
            refreshVisibleRuntimeState()
        }
        coordinator?.onUsageTipsHoverChange(true)
    }
    private func releaseUsageTipsEventRegion() {
        guard usageTipsEventRegionIsClaimed else { return }
        usageTipsEventRegionIsClaimed = false
        if coordinator?.setUsageTipsBubbleDisabled(false) == true {
            refreshVisibleRuntimeState()
        }
        coordinator?.onUsageTipsHoverChange(false)
    }
    private func handleScrollActivity() {
@@ -623,6 +694,58 @@
        refreshVisibleRuntimeState()
    }
    func handleUsageTipsMouseDown(_ event: NSEvent) -> Bool {
        handleUsageTipsMouseEvent(event, triggerButtons: true)
    }
    private func handleUsageTipsMouseEvent(_ event: NSEvent, triggerButtons: Bool) -> Bool {
        let point = usageTipsHostPoint(for: event)
        guard !usageTipsView.isHidden,
              usageTipsView.frame.contains(point)
        else { return false }
        return usageTipsView.handleMouseEventFromHost(event, triggerButtons: triggerButtons)
    }
    private func installUsageTipsMouseMonitor() {
        guard usageTipsMouseMonitor == nil else { return }
        usageTipsMouseMonitor = NSEvent.addLocalMonitorForEvents(
            matching: [
                .leftMouseDown,
                .leftMouseUp,
                .rightMouseDown,
                .rightMouseUp,
                .otherMouseDown,
                .otherMouseUp
            ]
        ) { [weak self] event in
            guard let self,
                  self.window != nil,
                  self.handleUsageTipsMouseEvent(
                    event,
                    triggerButtons: event.type == .leftMouseDown
                  )
            else { return event }
            return nil
        }
    }
    private func usageTipsHostPoint(for event: NSEvent) -> NSPoint {
        if event.window === window {
            return convert(event.locationInWindow, from: nil)
        }
        guard let window else { return .zero }
        let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
        return convert(windowPoint, from: nil)
    }
    private func removeUsageTipsMouseMonitor() {
        if let usageTipsMouseMonitor {
            NSEvent.removeMonitor(usageTipsMouseMonitor)
            self.usageTipsMouseMonitor = nil
        }
    }
    private func replayPointerHover() {
        guard let window else { return }
        let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
@@ -645,22 +768,80 @@
    }
}
private final class AppGridUsageTipsShieldView: NSView {
    var onClaim: (() -> Void)?
    var onRelease: (() -> Void)?
    private var trackingAreaRef: NSTrackingArea?
    override var isFlipped: Bool { true }
    override var acceptsFirstResponder: Bool { false }
    override var mouseDownCanMoveWindow: Bool { false }
    override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
        true
    }
    override func updateTrackingAreas() {
        super.updateTrackingAreas()
        if let trackingAreaRef {
            removeTrackingArea(trackingAreaRef)
        }
        let area = NSTrackingArea(
            rect: .zero,
            options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect],
            owner: self,
            userInfo: nil
        )
        addTrackingArea(area)
        trackingAreaRef = area
    }
    override func hitTest(_ point: NSPoint) -> NSView? {
        guard !isHidden, bounds.contains(point) else { return nil }
        onClaim?()
        return self
    }
    override func mouseEntered(with event: NSEvent) { onClaim?() }
    override func mouseMoved(with event: NSEvent) { onClaim?() }
    override func mouseDragged(with event: NSEvent) { onClaim?() }
    override func mouseExited(with event: NSEvent) { onRelease?() }
    override func mouseDown(with event: NSEvent) {
        Diagnostics.log("usageTips.shield.mouseDown")
        onClaim?()
    }
    override func mouseUp(with event: NSEvent) {}
    override func rightMouseDown(with event: NSEvent) { onClaim?() }
    override func rightMouseUp(with event: NSEvent) {}
    override func otherMouseDown(with event: NSEvent) { onClaim?() }
    override func otherMouseUp(with event: NSEvent) {}
    override func scrollWheel(with event: NSEvent) { onClaim?() }
}
private final class AppGridUsageTipsNSView: NSView {
    weak var coordinator: AppGridCollectionView.Coordinator?
    private let titleBackgroundView = NSView()
    private let backgroundView = NSVisualEffectView()
    private let iconView = AppGridDecorativeImageView()
    private let titleLabel = NSTextField(labelWithString: "")
    private let detailScrollView = NSScrollView()
    private let detailLabel = NSTextField(labelWithString: "")
    private let controlsView = NSView()
    private let previousButton = AppGridUsageTipIconButton(systemImage: "chevron.left")
    private let nextButton = AppGridUsageTipIconButton(systemImage: "chevron.right")
    private let dotsView = AppGridUsageTipDotsView()
    private var trackingAreaRef: NSTrackingArea?
    private var isPointerInside = false
    private var visualWidth: CGFloat = AppGridUsageTipsMetrics.minWidth
    private var visualHeight: CGFloat = AppGridUsageTipsMetrics.barHeight
    private var visualBottomMargin: CGFloat = AppGridUsageTipsMetrics.bottomMargin
    private let titleFont = NSFont.systemFont(ofSize: 24, weight: .semibold)
    private let detailFont = NSFont.systemFont(ofSize: 24, weight: .regular)
    override var isFlipped: Bool { true }
    override var acceptsFirstResponder: Bool { false }
    override var acceptsFirstResponder: Bool { true }
    override var mouseDownCanMoveWindow: Bool { false }
    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
@@ -670,6 +851,10 @@
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
        true
    }
    override func viewDidMoveToWindow() {
@@ -686,7 +871,7 @@
        }
        let area = NSTrackingArea(
            rect: .zero,
            options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
            options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect],
            owner: self,
            userInfo: nil
        )
@@ -696,11 +881,20 @@
    override func hitTest(_ point: NSPoint) -> NSView? {
        guard !isHidden, bounds.contains(point) else { return nil }
        return super.hitTest(point) ?? self
        setPointerInside(true)
        let hitView = super.hitTest(point)
        if hitView == nil || hitView === backgroundView || hitView?.isDescendant(of: backgroundView) == true {
            return self
        }
        return hitView
    }
    override func mouseEntered(with event: NSEvent) {
        setPointerInside(true)
        claimInteractionFocus()
    }
    override func mouseMoved(with event: NSEvent) {
        claimInteractionFocus()
    }
    override func mouseExited(with event: NSEvent) {
@@ -708,12 +902,33 @@
    }
    override func mouseDown(with event: NSEvent) {
        window?.makeKeyAndOrderFront(nil)
        Diagnostics.log("usageTips.hud.mouseDown")
        if routeButtonClickIfNeeded(event) {
            return
        }
        claimInteractionFocus()
    }
    override func mouseDragged(with event: NSEvent) {
        claimInteractionFocus()
    }
    override func mouseUp(with event: NSEvent) {}
    override func rightMouseDown(with event: NSEvent) {}
    override func rightMouseDown(with event: NSEvent) { claimInteractionFocus() }
    override func rightMouseUp(with event: NSEvent) {}
    override func otherMouseDown(with event: NSEvent) { claimInteractionFocus() }
    override func otherMouseUp(with event: NSEvent) {}
    override func keyDown(with event: NSEvent) {
        switch Int(event.keyCode) {
        case kVK_LeftArrow:
            selectUsageTip(offset: -1)
        case kVK_RightArrow:
            selectUsageTip(offset: 1)
        default:
            super.keyDown(with: event)
        }
    }
    override func scrollWheel(with event: NSEvent) {
        detailScrollView.scrollWheel(with: event)
@@ -721,21 +936,78 @@
    override func layout() {
        super.layout()
        let titleWidth = min(320, max(240, bounds.width * 0.24))
        let controlsWidth: CGFloat = 120
        let detailWidth = max(1, bounds.width - titleWidth - controlsWidth)
        let visualFrame = currentVisualFrame()
        backgroundView.frame = visualFrame
        titleBackgroundView.frame = NSRect(x: 0, y: 0, width: titleWidth, height: bounds.height)
        titleLabel.frame = titleBackgroundView.bounds.insetBy(dx: 18, dy: 0)
        detailScrollView.frame = NSRect(x: titleWidth, y: 0, width: detailWidth, height: bounds.height)
        controlsView.frame = NSRect(x: titleWidth + detailWidth, y: 0, width: controlsWidth, height: bounds.height)
        let paddingLeft: CGFloat = 22
        let paddingRight: CGFloat = 16
        let iconSize: CGFloat = 24
        let iconTextGap: CGFloat = 10
        let titleDetailGap: CGFloat = 22
        let detailDotsGap: CGFloat = 24
        let dotsButtonGap: CGFloat = 16
        let buttonGap: CGFloat = 6
        let buttonSize: CGFloat = 48
        let dotsWidth = dotsView.preferredWidth
        let fullTitleWidth = ceil(titleLabel.attributedStringValue.size().width)
        let buttonSize: CGFloat = 34
        let buttonY: CGFloat = 9
        previousButton.frame = NSRect(x: 22, y: buttonY, width: buttonSize, height: buttonSize)
        nextButton.frame = NSRect(x: 64, y: buttonY, width: buttonSize, height: buttonSize)
        dotsView.frame = NSRect(x: 18, y: 48, width: controlsWidth - 36, height: 10)
        let centerY = visualFrame.midY
        iconView.frame = NSRect(
            x: visualFrame.minX + paddingLeft,
            y: centerY - iconSize / 2,
            width: iconSize,
            height: iconSize
        )
        let titleX = iconView.frame.maxX + iconTextGap
        let buttonsWidth = buttonSize * 2 + buttonGap
        let trailingWidth = paddingRight + buttonsWidth + dotsButtonGap + dotsWidth + detailDotsGap
        let availableTextWidth = max(1, visualFrame.maxX - titleX - titleDetailGap - trailingWidth)
        let minimumReadableDetailWidth: CGFloat = 220
        let titleWidth: CGFloat
        if availableTextWidth >= fullTitleWidth + minimumReadableDetailWidth {
            titleWidth = fullTitleWidth
        } else if availableTextWidth >= fullTitleWidth {
            titleWidth = fullTitleWidth
        } else {
            titleWidth = max(1, availableTextWidth)
        }
        titleLabel.frame = NSRect(
            x: titleX,
            y: visualFrame.minY,
            width: titleWidth,
            height: visualFrame.height
        )
        let buttonY = centerY - buttonSize / 2
        nextButton.frame = NSRect(
            x: visualFrame.maxX - paddingRight - buttonSize,
            y: buttonY,
            width: buttonSize,
            height: buttonSize
        )
        previousButton.frame = NSRect(
            x: nextButton.frame.minX - buttonGap - buttonSize,
            y: buttonY,
            width: buttonSize,
            height: buttonSize
        )
        dotsView.frame = NSRect(
            x: previousButton.frame.minX - dotsButtonGap - dotsWidth,
            y: centerY - 5,
            width: dotsWidth,
            height: 10
        )
        let detailX = titleLabel.frame.maxX + titleDetailGap
        let detailRight = dotsView.frame.minX - detailDotsGap
        detailScrollView.frame = NSRect(
            x: detailX,
            y: visualFrame.minY,
            width: max(1, detailRight - detailX),
            height: visualFrame.height
        )
        layoutDetailLabel()
    }
@@ -753,12 +1025,13 @@
        let safeIndex = min(max(0, coordinator.selectedUsageTipIndex), coordinator.usageTips.count - 1)
        let tip = coordinator.usageTips[safeIndex]
        titleLabel.stringValue = tr(tip.titleKey)
        detailLabel.stringValue = tr(tip.detailKey)
        previousButton.accessibilityLabel = tr("usageTips.previous")
        nextButton.accessibilityLabel = tr("usageTips.next")
        detailLabel.stringValue = formattedTipDetail(tr(tip.detailKey))
        previousButton.buttonAccessibilityLabel = tr("usageTips.previous")
        nextButton.buttonAccessibilityLabel = tr("usageTips.next")
        dotsView.configure(count: coordinator.usageTips.count, selectedIndex: safeIndex)
        updateColors()
        layoutDetailLabel()
        needsLayout = true
        enclosingHostView?.relayoutUsageTipsAfterContentChange()
    }
    func clearHoverState() {
@@ -766,29 +1039,50 @@
        setPointerInside(false)
    }
    func configureVisualLayout(width: CGFloat, height: CGFloat, bottomMargin: CGFloat) {
        visualWidth = max(1, width)
        visualHeight = max(1, height)
        visualBottomMargin = max(0, bottomMargin)
        needsLayout = true
    }
    func preferredWidth(maxAvailableWidth: CGFloat) -> CGFloat {
        let padding: CGFloat = 22 + 16
        let fixedWidth: CGFloat = 24 + 10 + 22 + 24 + 16 + 48 + 6 + 48
        let dotsWidth = dotsView.preferredWidth
        let titleWidth = ceil(titleLabel.attributedStringValue.size().width)
        let detailWidth = ceil(detailLabel.attributedStringValue.size().width)
        let contentWidth = padding + fixedWidth + dotsWidth + titleWidth + detailWidth
        let preferredWidth = max(AppGridUsageTipsMetrics.minWidth, contentWidth)
        return min(maxAvailableWidth, preferredWidth)
    }
    private func setup() {
        wantsLayer = true
        layer?.cornerRadius = 8
        layer?.masksToBounds = true
        layer?.shadowColor = NSColor.black.cgColor
        layer?.shadowOpacity = 0.14
        layer?.shadowRadius = 12
        layer?.shadowOffset = NSSize(width: 0, height: -2)
        titleBackgroundView.wantsLayer = true
        controlsView.wantsLayer = true
        backgroundView.material = .popover
        backgroundView.blendingMode = .withinWindow
        backgroundView.state = .active
        backgroundView.wantsLayer = true
        backgroundView.layer?.cornerRadius = 10
        backgroundView.layer?.masksToBounds = true
        backgroundView.layer?.borderWidth = 1
        addSubview(backgroundView)
        titleLabel.isEditable = false
        titleLabel.isSelectable = false
        titleLabel.drawsBackground = false
        titleLabel.isBordered = false
        titleLabel.lineBreakMode = .byTruncatingTail
        titleLabel.maximumNumberOfLines = 1
        titleLabel.font = NSFont.systemFont(ofSize: 24, weight: .bold)
        let iconConfig = NSImage.SymbolConfiguration(pointSize: 24, weight: .semibold)
        iconView.image = NSImage(
            systemSymbolName: "lightbulb.fill",
            accessibilityDescription: nil
        )?.withSymbolConfiguration(iconConfig)
        iconView.imageScaling = .scaleProportionallyDown
        addSubview(iconView)
        detailLabel.isEditable = false
        detailLabel.isSelectable = false
        detailLabel.drawsBackground = false
        detailLabel.isBordered = false
        detailLabel.lineBreakMode = .byClipping
        detailLabel.maximumNumberOfLines = 1
        detailLabel.font = NSFont.systemFont(ofSize: 24, weight: .bold)
        configureLabel(titleLabel, font: titleFont, lineBreakMode: .byClipping)
        configureLabel(detailLabel, font: detailFont, lineBreakMode: .byClipping)
        detailScrollView.drawsBackground = false
        detailScrollView.hasVerticalScroller = false
@@ -800,21 +1094,70 @@
        previousButton.action = { [weak self] in self?.selectUsageTip(offset: -1) }
        nextButton.action = { [weak self] in self?.selectUsageTip(offset: 1) }
        addSubview(titleBackgroundView)
        titleBackgroundView.addSubview(titleLabel)
        addSubview(titleLabel)
        addSubview(detailScrollView)
        addSubview(controlsView)
        controlsView.addSubview(previousButton)
        controlsView.addSubview(nextButton)
        controlsView.addSubview(dotsView)
        addSubview(dotsView)
        addSubview(previousButton)
        addSubview(nextButton)
        setAccessibilityRole(.group)
        updateColors()
    }
    private func configureLabel(_ label: NSTextField, font: NSFont, lineBreakMode: NSLineBreakMode) {
        label.cell = AppGridCenteredTextFieldCell(textCell: "")
        label.isEditable = false
        label.isSelectable = false
        label.drawsBackground = false
        label.isBordered = false
        label.lineBreakMode = lineBreakMode
        label.maximumNumberOfLines = 1
        label.font = font
    }
    private func claimInteractionFocus() {
        setPointerInside(true)
        window?.makeFirstResponder(self)
    }
    func handleMouseEventFromHost(_ event: NSEvent, triggerButtons: Bool) -> Bool {
        let point = usageTipsPoint(for: event)
        guard bounds.contains(point) else { return false }
        if triggerButtons, routeButtonClickIfNeeded(event) {
            return true
        }
        claimInteractionFocus()
        return true
    }
    private func selectUsageTip(offset: Int) {
        coordinator?.selectUsageTip(offset: offset)
        applyCoordinatorState()
    }
    private func routeButtonClickIfNeeded(_ event: NSEvent) -> Bool {
        let point = usageTipsPoint(for: event)
        let hitOutset: CGFloat = 24
        if nextButton.frame.insetBy(dx: -hitOutset, dy: -hitOutset).contains(point) {
            Diagnostics.log("usageTips.hud.routedNext")
            selectUsageTip(offset: 1)
            return true
        }
        if previousButton.frame.insetBy(dx: -hitOutset, dy: -hitOutset).contains(point) {
            Diagnostics.log("usageTips.hud.routedPrevious")
            selectUsageTip(offset: -1)
            return true
        }
        return false
    }
    private func usageTipsPoint(for event: NSEvent) -> NSPoint {
        if event.window === window {
            return convert(event.locationInWindow, from: nil)
        }
        guard let window else { return .zero }
        let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
        return convert(windowPoint, from: nil)
    }
    private func setPointerInside(_ inside: Bool) {
@@ -839,35 +1182,79 @@
    private func layoutDetailLabel() {
        let textWidth = detailLabel.attributedStringValue.size().width
        let width = max(detailScrollView.contentView.bounds.width, ceil(textWidth) + 36)
        detailLabel.frame = NSRect(x: 18, y: 0, width: width, height: bounds.height)
        let width = max(detailScrollView.contentView.bounds.width, ceil(textWidth) + 18)
        detailLabel.frame = NSRect(x: 0, y: 0, width: width, height: detailScrollView.bounds.height)
    }
    private func currentVisualFrame() -> NSRect {
        let width = min(bounds.width, visualWidth)
        let height = min(bounds.height, visualHeight)
        return NSRect(
            x: max(0, (bounds.width - width) / 2),
            y: max(0, bounds.height - visualBottomMargin - height),
            width: width,
            height: height
        )
    }
    private func formattedTipDetail(_ text: String) -> String {
        text
            .replacingOccurrences(of: " > ", with: " -> ")
            .replacingOccurrences(of: ">", with: "->")
    }
    private func updateColors() {
        layer?.backgroundColor = NSColor.black.withAlphaComponent(0.88).cgColor
        layer?.borderColor = NSColor.labelColor.withAlphaComponent(0.18).cgColor
        layer?.borderWidth = 1
        titleBackgroundView.layer?.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.96).cgColor
        controlsView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.88).cgColor
        titleLabel.textColor = .labelColor
        detailLabel.textColor = NSColor.white.withAlphaComponent(0.96)
        backgroundView.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.35).cgColor
        iconView.contentTintColor = .systemYellow
        titleLabel.textColor = .secondaryLabelColor
        detailLabel.textColor = .labelColor
        previousButton.tintColor = .labelColor
        nextButton.tintColor = .labelColor
        dotsView.needsDisplay = true
    }
}
private final class AppGridCenteredTextFieldCell: NSTextFieldCell {
    override func drawingRect(forBounds rect: NSRect) -> NSRect {
        var drawingRect = super.drawingRect(forBounds: rect)
        let textHeight = cellSize(forBounds: rect).height
        drawingRect.origin.y = rect.origin.y + max(0, (rect.height - textHeight) / 2)
        drawingRect.size.height = min(rect.height, textHeight + 2)
        return drawingRect
    }
}
private final class AppGridDecorativeImageView: NSImageView {
    override func hitTest(_ point: NSPoint) -> NSView? {
        nil
    }
    override func isAccessibilityElement() -> Bool {
        false
    }
}
private final class AppGridUsageTipIconButton: NSView {
    var action: (() -> Void)?
    var accessibilityLabel: String = "" {
    var buttonAccessibilityLabel: String = "" {
        didSet {
            setAccessibilityLabel(accessibilityLabel)
            setAccessibilityLabel(buttonAccessibilityLabel)
        }
    }
    var tintColor: NSColor = .labelColor {
        didSet {
            imageView.contentTintColor = tintColor
        }
    }
    private let imageView = NSImageView()
    private let imageView = AppGridDecorativeImageView()
    private var trackingAreaRef: NSTrackingArea?
    private var isHovered = false
    private var isPressed = false
    override var isFlipped: Bool { true }
    override var acceptsFirstResponder: Bool { false }
    override var mouseDownCanMoveWindow: Bool { false }
    init(systemImage: String) {
        super.init(frame: .zero)
@@ -876,9 +1263,30 @@
    required init?(coder: NSCoder) { fatalError() }
    override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
        true
    }
    override func isAccessibilityElement() -> Bool {
        true
    }
    override func accessibilityRole() -> NSAccessibility.Role? {
        .button
    }
    override func accessibilityLabel() -> String? {
        buttonAccessibilityLabel
    }
    override func accessibilityPerformPress() -> Bool {
        action?()
        return true
    }
    override func layout() {
        super.layout()
        let size: CGFloat = 24
        let size: CGFloat = 26
        imageView.frame = NSRect(
            x: (bounds.width - size) / 2,
            y: (bounds.height - size) / 2,
@@ -913,28 +1321,38 @@
    override func mouseExited(with event: NSEvent) {
        isHovered = false
        isPressed = false
        needsDisplay = true
    }
    override func mouseDown(with event: NSEvent) {
        Diagnostics.log("usageTips.button.mouseDown", [
            "label": buttonAccessibilityLabel
        ])
        isPressed = true
        needsDisplay = true
        action?()
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak self] in
            self?.isPressed = false
            self?.needsDisplay = true
        }
    }
    override func draw(_ dirtyRect: NSRect) {
        guard isHovered else { return }
        NSColor.white.withAlphaComponent(0.12).setFill()
        NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 7, yRadius: 7).fill()
        guard isHovered || isPressed else { return }
        tintColor.withAlphaComponent(isPressed ? 0.14 : 0.08).setFill()
        NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 6, yRadius: 6).fill()
    }
    private func setup(systemImage: String) {
        wantsLayer = true
        let config = NSImage.SymbolConfiguration(pointSize: 24, weight: .bold)
        let config = NSImage.SymbolConfiguration(pointSize: 26, weight: .semibold)
        imageView.image = NSImage(
            systemSymbolName: systemImage,
            accessibilityDescription: nil
        )?.withSymbolConfiguration(config)
        imageView.imageScaling = .scaleProportionallyDown
        imageView.contentTintColor = .white
        imageView.contentTintColor = tintColor
        addSubview(imageView)
        setAccessibilityRole(.button)
    }
@@ -946,6 +1364,11 @@
    override var isFlipped: Bool { true }
    var preferredWidth: CGFloat {
        guard count > 0 else { return 0 }
        return CGFloat(count) * 5 + CGFloat(max(0, count - 1)) * 5 + 2
    }
    func configure(count: Int, selectedIndex: Int) {
        self.count = count
        self.selectedIndex = selectedIndex
@@ -954,21 +1377,25 @@
    override func draw(_ dirtyRect: NSRect) {
        guard count > 0 else { return }
        let dotSize: CGFloat = 5
        let spacing: CGFloat = 5
        let totalWidth = CGFloat(count) * dotSize + CGFloat(max(0, count - 1)) * spacing
        let totalWidth = preferredWidth
        var x = (bounds.width - totalWidth) / 2
        let y = (bounds.height - dotSize) / 2
        let centerY = bounds.midY
        for index in 0..<count {
            let color = index == selectedIndex
                ? NSColor.controlAccentColor
                : NSColor.white.withAlphaComponent(0.58)
            let selected = index == selectedIndex
            let dotSize: CGFloat = selected ? 6 : 5
            let color = NSColor.labelColor.withAlphaComponent(selected ? 0.85 : 0.28)
            color.setFill()
            NSBezierPath(
                ovalIn: NSRect(x: x, y: y, width: dotSize, height: dotSize)
                ovalIn: NSRect(
                    x: x,
                    y: centerY - dotSize / 2,
                    width: dotSize,
                    height: dotSize
                )
            ).fill()
            x += dotSize + spacing
            x += 5 + spacing
        }
    }
}
src/Apptag/ApptagApp.swift
@@ -1751,7 +1751,13 @@
            super.mouseDown(with: event)
            return
        }
        if routeUsageTipsMouseDownIfNeeded(event) {
            return
        }
        if hit == self {
            if shouldSwallowUsageTipsBackdropClick(at: location) {
                return
            }
            if quickSearchSuppressesBackdropDismiss {
                NotificationCenter.default.post(
                    name: .tagLauncherQuickSearchDismissRequested,
@@ -1786,6 +1792,32 @@
        super.mouseDown(with: event)
    }
    private func routeUsageTipsMouseDownIfNeeded(_ event: NSEvent) -> Bool {
        guard let appGridHost = findAppGridCollectionHost(in: self) else { return false }
        return appGridHost.handleUsageTipsMouseDown(event)
    }
    private func findAppGridCollectionHost(in view: NSView) -> AppGridCollectionHostView? {
        if let host = view as? AppGridCollectionHostView {
            return host
        }
        for subview in view.subviews {
            if let host = findAppGridCollectionHost(in: subview) {
                return host
            }
        }
        return nil
    }
    private func shouldSwallowUsageTipsBackdropClick(at location: NSPoint) -> Bool {
        guard !UserDefaults.standard.bool(forKey: "hideUsageTips") else { return false }
        let height = min(bounds.height, AppGridUsageTipsMetrics.reservedHeight)
        guard height > 0 else { return false }
        let y = isFlipped ? max(0, bounds.height - height) : 0
        let region = NSRect(x: 0, y: y, width: bounds.width, height: height)
        return region.contains(location)
    }
    private func observeBackdropDismissSuppressionChanges() {
        modalInteractionObserver = NotificationCenter.default.addObserver(
            forName: .tagLauncherModalInteractionChanged,
src/Apptag/OverlayWindowController.swift
@@ -180,6 +180,12 @@
    func hide(force: Bool = false, discardWindow: Bool = false) {
        guard force || dependencies.canHideOverlay() else { return }
        Diagnostics.log("overlay.hide", [
            "force": force,
            "discardWindow": discardWindow,
            "hasWindow": window != nil,
            "isVisible": window?.isVisible == true
        ])
        generation &+= 1
        dependencies.onWillHide()
        if let settingsWindow = dependencies.settingsWindow(), settingsWindow.parent == window {
src/Scripts/usage_tips_qa.sh
@@ -4,6 +4,7 @@
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONTENT_VIEW_SWIFT="$ROOT_DIR/Apptag/ContentView.swift"
APP_GRID_SWIFT="$ROOT_DIR/Apptag/AppGridCollectionView.swift"
APPTAG_APP_SWIFT="$ROOT_DIR/Apptag/ApptagApp.swift"
PREFERENCES_VIEW_SWIFT="$ROOT_DIR/Apptag/PreferencesView.swift"
APP_DEFAULTS_SWIFT="$ROOT_DIR/Apptag/AppDefaults.swift"
LOCALIZATION_DIR="$ROOT_DIR/Apptag/Localization"
@@ -15,11 +16,12 @@
[[ -f "$CONTENT_VIEW_SWIFT" ]] || fail "missing ContentView.swift"
[[ -f "$APP_GRID_SWIFT" ]] || fail "missing AppGridCollectionView.swift"
[[ -f "$APPTAG_APP_SWIFT" ]] || fail "missing ApptagApp.swift"
[[ -f "$PREFERENCES_VIEW_SWIFT" ]] || fail "missing PreferencesView.swift"
[[ -f "$APP_DEFAULTS_SWIFT" ]] || fail "missing AppDefaults.swift"
[[ -d "$LOCALIZATION_DIR" ]] || fail "missing Localization directory"
python3 - "$CONTENT_VIEW_SWIFT" "$APP_GRID_SWIFT" "$PREFERENCES_VIEW_SWIFT" "$APP_DEFAULTS_SWIFT" "$LOCALIZATION_DIR" <<'PY'
python3 - "$CONTENT_VIEW_SWIFT" "$APP_GRID_SWIFT" "$APPTAG_APP_SWIFT" "$PREFERENCES_VIEW_SWIFT" "$APP_DEFAULTS_SWIFT" "$LOCALIZATION_DIR" <<'PY'
import json
import pathlib
import re
@@ -27,9 +29,10 @@
content_view = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
app_grid = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")
preferences = pathlib.Path(sys.argv[3]).read_text(encoding="utf-8")
defaults = pathlib.Path(sys.argv[4]).read_text(encoding="utf-8")
localization_dir = pathlib.Path(sys.argv[5])
apptag_app = pathlib.Path(sys.argv[3]).read_text(encoding="utf-8")
preferences = pathlib.Path(sys.argv[4]).read_text(encoding="utf-8")
defaults = pathlib.Path(sys.argv[5]).read_text(encoding="utf-8")
localization_dir = pathlib.Path(sys.argv[6])
required_keys = [
    "settings.hideUsageTips",
@@ -83,17 +86,61 @@
    "Usage tips must be implemented as a native AppKit NSView",
)
require(
    r"enum\s+AppGridUsageTipsMetrics\s*\{(?P<body>.*?)static\s+let\s+reservedHeight\s*:\s*CGFloat\s*=\s*104",
    r"enum\s+AppGridUsageTipsMetrics\s*\{(?P<body>.*?)static\s+let\s+reservedHeight\s*:\s*CGFloat\s*=\s*128",
    app_grid,
    "Usage tips overlay must reserve stable bottom space for the larger native bar",
    "Usage tips overlay must reserve stable bottom space for the native HUD bar",
)
require(
    r"NSVisualEffectView\s*\(",
    app_grid,
    "Usage tips must use a native macOS visual effect surface instead of split black/white blocks",
)
require(
    r'systemSymbolName\s*:\s*"lightbulb\.fill"',
    app_grid,
    "Usage tips HUD must include a compact native guidance icon",
)
require(
    r"preferredWidth\s*\(\s*maxAvailableWidth\s*:\s*CGFloat\s*\)",
    app_grid,
    "Usage tips HUD must be content-adaptive instead of stretched across the full AppGrid",
)
if re.search(r"static\s+let\s+maxWidth\s*:\s*CGFloat", app_grid):
    fail("Usage tips HUD must not use a hard maximum width that truncates localized titles")
require(
    r"fullTitleWidth\s*=\s*ceil\s*\(\s*titleLabel\.attributedStringValue\.size\(\)\.width\s*\)",
    app_grid,
    "Usage tips layout must measure the localized title width instead of using a fixed title column",
)
require(
    r"configureLabel\s*\(\s*titleLabel\s*,\s*font\s*:\s*titleFont\s*,\s*lineBreakMode\s*:\s*\.byClipping\s*\)",
    app_grid,
    "Usage tip titles must not use tail truncation ellipses",
)
if re.search(r"configureLabel\s*\(\s*titleLabel\s*,\s*font\s*:\s*titleFont\s*,\s*lineBreakMode\s*:\s*\.byTruncatingTail\s*\)", app_grid):
    fail("Usage tip titles must not be configured with byTruncatingTail")
require(
    r"relayoutUsageTipsAfterContentChange\s*\(\s*\)",
    app_grid,
    "Usage tips must ask the host to recompute width after localized text changes",
)
require(
    r"availableTextWidth\s*=\s*max\s*\(\s*1\s*,\s*visualFrame\.maxX\s*-\s*titleX\s*-\s*titleDetailGap\s*-\s*trailingWidth\s*\)",
    app_grid,
    "Usage tips layout must allocate text from actual available AppGrid width",
)
require(
    r"return\s+min\s*\(\s*maxAvailableWidth\s*,\s*preferredWidth\s*\)",
    app_grid,
    "Usage tips preferred width must expand up to available AppGrid width for long localizations",
)
require(
    r"detailScrollView\.hasHorizontalScroller\s*=\s*true",
    app_grid,
    "Long localized tip text must be horizontally scrollable in the native AppKit bar",
)
if len(re.findall(r"NSFont\.systemFont\s*\(\s*ofSize\s*:\s*24\s*,\s*weight\s*:\s*\.bold\s*\)", app_grid)) < 2:
    fail("Usage tip title/detail fonts must use the larger 24pt native AppKit style")
if len(re.findall(r"NSFont\.systemFont\s*\(\s*ofSize\s*:\s*24\s*,\s*weight\s*:\s*\.(?:semibold|regular)\s*\)", app_grid)) < 2:
    fail("Usage tip title/detail fonts must use readable 24pt native HUD typography")
require(
    r"override\s+func\s+scrollWheel\s*\(\s*with\s+event\s*:\s*NSEvent\s*\)\s*\{\s*detailScrollView\.scrollWheel\s*\(\s*with\s*:\s*event\s*\)",
    app_grid,
@@ -120,6 +167,103 @@
    "Native usage tips bar must own hit testing so clicks do not fall through to AppGrid",
)
require(
    r"override\s+func\s+hitTest\s*\(\s*_\s+point\s*:\s*NSPoint\s*\)\s*->\s*NSView\?\s*\{(?P<body>.*?)usageTipsView\.frame\.contains\s*\(\s*point\s*\)",
    app_grid,
    "AppGrid host must prioritize the native usage tips hit-test region above the collection view",
)
require(
    r"usageTipsEventRegion\s*\(\s*\)\s*->\s*NSRect",
    app_grid,
    "AppGrid host must reserve a full bottom event region for tips so clicks cannot pass through",
)
require(
    r"private\s+final\s+class\s+AppGridUsageTipsShieldView\s*:\s*NSView\b",
    app_grid,
    "AppGrid must use a native transparent shield view for the full bottom tips event region",
)
require(
    r"usageTipsShieldView\.frame\s*=\s*usageTipsEventRegion\s*\(\s*\)",
    app_grid,
    "Usage tips shield must cover the full bottom event region, not just the visible HUD",
)
require(
    r"usageTipsView\.frame\s*=\s*usageTipsEventRegion\s*\(\s*\)",
    app_grid,
    "Usage tips HUD hit-test view itself must cover the full bottom event region",
)
require(
    r"configureVisualLayout\s*\(\s*width\s*:\s*CGFloat\s*,\s*height\s*:\s*CGFloat\s*,\s*bottomMargin\s*:\s*CGFloat\s*\)",
    app_grid,
    "Usage tips must separate full hit-test coverage from the centered visual HUD frame",
)
require(
    r"currentVisualFrame\s*\(\s*\)\s*->\s*NSRect",
    app_grid,
    "Usage tips must compute a centered visual frame inside the full bottom hit-test region",
)
require(
    r"addSubview\s*\(\s*usageTipsShieldView\s*,\s*positioned\s*:\s*\.above\s*,\s*relativeTo\s*:\s*scrollView\s*\)",
    app_grid,
    "Usage tips shield must sit above the AppGrid collection scroll view",
)
require(
    r"addSubview\s*\(\s*usageTipsView\s*,\s*positioned\s*:\s*\.above\s*,\s*relativeTo\s*:\s*usageTipsShieldView\s*\)",
    app_grid,
    "Visible usage tips HUD must sit above the transparent event shield",
)
require(
    r"claimUsageTipsEventRegion\s*\(\s*\)",
    app_grid,
    "AppGrid host must claim bottom tips events and suppress lower bubbles",
)
require(
    r"handleUsageTipsMouseDown\s*\(\s*_\s+event\s*:\s*NSEvent\s*\)\s*->\s*Bool",
    app_grid,
    "AppGrid host must expose a native AppKit event router for usage tips clicks",
)
require(
    r"addLocalMonitorForEvents\s*\(\s*matching\s*:\s*\[(?P<body>.*?)\.leftMouseDown(?P<body2>.*?)\.leftMouseUp(?P<body3>.*?)\.rightMouseDown(?P<body4>.*?)\.otherMouseUp",
    app_grid,
    "AppGrid host must intercept usage tips mouseDown/mouseUp events before NSCollectionView app items receive them",
)
require(
    r"\.leftMouseUp(?P<body>.*?)\.rightMouseUp(?P<body2>.*?)\.otherMouseUp",
    app_grid,
    "AppGrid host must also swallow mouseUp events in the usage tips region so app icons below cannot open on release",
)
require(
    r"triggerButtons\s*:\s*event\.type\s*==\s*\.leftMouseDown",
    app_grid,
    "Usage tips monitor must only trigger paging on left mouseDown, not on mouseUp or right/other clicks",
)
require(
    r"usageTipsHostPoint\s*\(\s*for\s+event\s*:\s*NSEvent\s*\)\s*->\s*NSPoint",
    app_grid,
    "Usage tips mouse monitor must resolve the event position through the AppGrid host",
)
require(
    r"window\.convertPoint\s*\(\s*fromScreen\s*:\s*NSEvent\.mouseLocation\s*\)",
    app_grid,
    "Usage tips hit testing must fall back to current screen mouse location when event.window is unreliable",
)
require(
    r"self\.handleUsageTipsMouseEvent\s*\(\s*event\s*,(?P<body>.*?)triggerButtons\s*:\s*event\.type\s*==\s*\.leftMouseDown(?P<body2>.*?)return\s+nil",
    app_grid,
    "Usage tips mouse monitor must swallow handled bottom-tip events so app icons below cannot open",
)
require(
    r"removeUsageTipsMouseMonitor\s*\(\s*\)",
    app_grid,
    "Usage tips mouse monitor must be removed when the AppGrid host leaves its window",
)
require(
    r"func\s+handleMouseEventFromHost\s*\(\s*_\s+event\s*:\s*NSEvent\s*,\s*triggerButtons\s*:\s*Bool\s*\)\s*->\s*Bool\s*\{(?P<body>.*?)if\s+triggerButtons\s*,\s*routeButtonClickIfNeeded\s*\(\s*event\s*\)(?P<body2>.*?)claimInteractionFocus\s*\(\s*\)(?P<body3>.*?)return\s+true",
    app_grid,
    "Usage tips host-routed clicks must only trigger page changes on arrow hit regions and otherwise only consume the event",
)
if re.search(r"handleMouseEventFromHost\s*\(\s*_\s+event\s*:\s*NSEvent\s*,\s*triggerButtons\s*:\s*Bool\s*\)\s*->\s*Bool\s*\{(?P<body>.*?)target\.mouseDown", app_grid, re.S):
    fail("Usage tips host-routed clicks must not redispatch arbitrary mouseDown events that can pierce to app icons")
require(
    r"override\s+func\s+mouseDown\s*\(\s*with\s+event\s*:\s*NSEvent\s*\)",
    app_grid,
    "Native usage tips bar must swallow mouseDown events instead of letting clicks close the grid",
@@ -128,6 +272,36 @@
    r"private\s+final\s+class\s+AppGridUsageTipIconButton\s*:\s*NSView\b",
    app_grid,
    "Usage tip navigation controls must be native AppKit hit-testable views",
)
require(
    r"private\s+final\s+class\s+AppGridDecorativeImageView\s*:\s*NSImageView\b(?P<body>.*?)override\s+func\s+hitTest\s*\(\s*_\s+point\s*:\s*NSPoint\s*\)\s*->\s*NSView\?\s*\{\s*nil\s*\}",
    app_grid,
    "Usage tip icon images must not steal hit testing from their parent AppKit controls",
)
require(
    r"private\s+final\s+class\s+AppGridUsageTipIconButton\s*:\s*NSView\b(?P<body>.*?)acceptsFirstMouse\s*\(\s*for\s+event\s*:\s*NSEvent\?\s*\)\s*->\s*Bool\s*\{\s*true\s*\}",
    app_grid,
    "Usage tip navigation controls must accept first mouse clicks in the floating overlay",
)
require(
    r"override\s+func\s+isAccessibilityElement\s*\(\s*\)\s*->\s*Bool\s*\{\s*true\s*\}",
    app_grid,
    "Usage tip navigation controls must be exposed as accessibility elements",
)
require(
    r"override\s+func\s+accessibilityRole\s*\(\s*\)\s*->\s*NSAccessibility\.Role\?\s*\{\s*\.button\s*\}",
    app_grid,
    "Usage tip navigation controls must expose the AX button role",
)
require(
    r"override\s+func\s+accessibilityPerformPress\s*\(\s*\)\s*->\s*Bool\s*\{(?P<body>.*?)action\?\(\)(?P<body2>.*?)return\s+true",
    app_grid,
    "Usage tip navigation controls must support AX press actions",
)
require(
    r"override\s+var\s+mouseDownCanMoveWindow\s*:\s*Bool\s*\{\s*false\s*\}",
    app_grid,
    "Usage tips hit-test views must not let mouseDown move or dismiss the overlay window",
)
require(
    r"func\s+selectUsageTip\s*\(\s*offset\s*:\s*Int\s*\)",
@@ -145,7 +319,7 @@
    "Next usage tip control must call the native selection handler",
)
require(
    r"override\s+func\s+mouseDown\s*\(\s*with\s+event\s*:\s*NSEvent\s*\)\s*\{\s*action\?\(\)\s*\}",
    r"override\s+func\s+mouseDown\s*\(\s*with\s+event\s*:\s*NSEvent\s*\)\s*\{(?P<body>.*?)action\?\(\)",
    app_grid,
    "Native usage tip icon buttons must invoke their action directly on mouseDown",
)
@@ -153,6 +327,16 @@
    r"onUsageTipIndexChange\s*\(\s*nextIndex\s*\)",
    app_grid,
    "Native usage tip selection must publish the new index back to SwiftUI state",
)
require(
    r"override\s+func\s+keyDown\s*\(\s*with\s+event\s*:\s*NSEvent\s*\)\s*\{(?P<body>.*?)kVK_LeftArrow(?P<body2>.*?)kVK_RightArrow",
    app_grid,
    "Focused native usage tips HUD must support left/right arrow key switching",
)
require(
    r"formattedTipDetail\s*\(_\s+text\s*:\s*String\s*\)",
    app_grid,
    "Usage tips detail text must normalize > separators to -> for display",
)
require(
    r"setUsageTipsBubbleDisabled\s*\(\s*inside\s*\)",
@@ -165,6 +349,31 @@
    "Hovering usage tips must notify ContentView to clear already visible SwiftUI bubbles",
)
require(
    r"shouldSwallowUsageTipsBackdropClick\s*\(\s*at\s+location\s*:\s*NSPoint\s*\)\s*->\s*Bool",
    apptag_app,
    "DismissibleHostingView must not treat bottom usage tips clicks as backdrop taps",
)
require(
    r"routeUsageTipsMouseDownIfNeeded\s*\(\s*event\s*\)",
    apptag_app,
    "DismissibleHostingView must forward bottom usage tips clicks to native AppKit controls before swallowing",
)
require(
    r"findAppGridCollectionHost\s*\(\s*in\s+view\s*:\s*NSView\s*\)\s*->\s*AppGridCollectionHostView\?",
    apptag_app,
    "DismissibleHostingView must locate the native AppGrid host for usage tips event forwarding",
)
require(
    r"AppGridUsageTipsMetrics\.reservedHeight",
    apptag_app,
    "Backdrop usage tips guard must match the native AppGrid reserved height",
)
require(
    r'UserDefaults\.standard\.bool\s*\(\s*forKey\s*:\s*"hideUsageTips"\s*\)',
    apptag_app,
    "Backdrop usage tips guard must respect the hide usage tips setting",
)
require(
    r"private\s+func\s+handleUsageTipsHoverChange\s*\(\s*_\s+hovering\s*:\s*Bool\s*\)",
    content_view,
    "ContentView must clear existing app bubbles while the native usage tips bar is hovered",