Ariver
2026-07-01 0ae3a9359e4cdf6ae5b669c4affd91662463f370
src/Apptag/ContentView.swift
@@ -8,9 +8,19 @@
    static let tagLauncherAppNoteEditingChanged = Notification.Name("TagLauncherAppNoteEditingChanged")
    static let tagLauncherDataDidChange = Notification.Name("TagLauncherDataDidChange")
    static let tagLauncherOpenPreferencesRequested = Notification.Name("TagLauncherOpenPreferencesRequested")
    static let tagLauncherPreferencesTabRequested = Notification.Name("TagLauncherPreferencesTabRequested")
    static let tagLauncherOverlayDidShow = Notification.Name("TagLauncherOverlayDidShow")
    static let tagLauncherOverlayDidHide = Notification.Name("TagLauncherOverlayDidHide")
    static let tagLauncherModalInteractionChanged = Notification.Name("TagLauncherModalInteractionChanged")
}
enum SettingsTabTarget {
    static let userInfoKey = "tab"
    static let theme = "theme"
    static let tags = "tags"
    static let data = "data"
    static let pro = "pro"
    static let about = "about"
}
// MARK: - Edit Phase
@@ -28,8 +38,8 @@
// MARK: - Native NSTextField (avoids SwiftUI TextField event issues)
/// Custom container that wraps NSTextField so that hitTest returns the container
/// and mouseDown can reliably make the text field first responder.
/// Custom container that wraps NSTextField so first focus is reliable while
/// subsequent clicks keep native caret placement and selection behavior.
final class TextFieldContainer: NSView {
    let textField: NSTextField
@@ -48,14 +58,33 @@
        textField.frame = bounds
    }
    private var isEditing: Bool {
        guard let window else { return false }
        return window.firstResponder === textField || textField.currentEditor() != nil
    }
    override func hitTest(_ point: NSPoint) -> NSView? {
        if bounds.contains(point) { return self }
        return nil
        guard bounds.contains(point) else { return nil }
        if isEditing {
            let fieldPoint = textField.convert(point, from: self)
            return textField.hitTest(fieldPoint) ?? textField
        }
        return self
    }
    func focusTextField() {
        guard let w = window else { return }
        let shouldSelectAll = !isEditing
        w.makeFirstResponder(textField)
        if shouldSelectAll {
            textField.selectText(nil)
        }
    }
    override func mouseDown(with event: NSEvent) {
        if let w = window { w.makeFirstResponder(textField) }
        textField.mouseDown(with: event)
        // Do not forward this same NSEvent to NSTextField.mouseDown. AppKit can
        // forward it back to this container during control tracking and recurse.
        focusTextField()
    }
}
@@ -82,6 +111,10 @@
    }
    func updateNSView(_ container: TextFieldContainer, context: Context) {
        if let editor = container.textField.currentEditor() as? NSTextView,
           editor.hasMarkedText() {
            return
        }
        if container.textField.stringValue != text {
            container.textField.stringValue = text
        }
@@ -106,6 +139,9 @@
        }
        func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
            if textView.hasMarkedText() {
                return false
            }
            if commandSelector == #selector(NSResponder.insertNewline(_:)) {
                onSubmit?()
                return true
@@ -198,19 +234,21 @@
struct TagPill: View {
    let name: String
    let colorIndex: Int
    var customColor: TagCustomColor? = nil
    var dragModeActive: Bool = false
    var isDragging: Bool = false
    let action: () -> Void
    private var resolvedNSColor: NSColor {
        TagColor.nsColor(for: colorIndex, customColor: customColor)
    }
    private var bgColor: Color {
        Color(nsColor: TagColor.nsColor(for: colorIndex))
        Color(nsColor: resolvedNSColor)
    }
    private var textColor: Color {
        if colorIndex == 0 || colorIndex == 5 {
            return .primary
        }
        return .white
        TagColor.prefersDarkText(for: resolvedNSColor) ? .primary : .white
    }
    var body: some View {
@@ -237,15 +275,21 @@
struct SideTagPill: View {
    let name: String
    let colorIndex: Int
    var customColor: TagCustomColor? = nil
    var dragModeActive: Bool = false
    var isDragging: Bool = false
    let action: () -> Void
    private var bgColor: Color {
        Color(nsColor: TagColor.nsColor(for: colorIndex))
    private var resolvedNSColor: NSColor {
        TagColor.nsColor(for: colorIndex, customColor: customColor)
    }
    private var bgColor: Color {
        Color(nsColor: resolvedNSColor)
    }
    private var textColor: Color {
        colorIndex == 0 || colorIndex == 5 ? .primary : .white
        TagColor.prefersDarkText(for: resolvedNSColor) ? .primary : .white
    }
    var body: some View {
@@ -313,8 +357,11 @@
    var editingBubble: AppBubbleContext? = nil
    var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
    var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
    var proPrompt: PendingProPrompt? = nil
    var uncategorizedDropSuppressFuturePrompt = false
    var tagRemovalDropSuppressFuturePrompt = false
    var usageTipsCloseReminderVisible = false
    var usageTipsCloseSuppressFuturePrompt = false
    var appDragResetToken = 0
    var bubbleDraftNote = ""
    var tagNavigationHoveredGroupName: String? = nil
@@ -342,17 +389,31 @@
    let tagName: String
}
private struct PendingProPrompt: Identifiable {
    let id = UUID()
    let feature: ProFeature
    let noteQuotaStatus: ProNoteQuotaStatus?
    let customContainerQuotaStatus: ProCustomContainerQuotaStatus?
}
struct ContentView: View {
    let hideOverlay: () -> Void
    private let initialQuickSearchSource: String?
    @Environment(\.colorScheme) private var colorScheme
    @ObservedObject private var proEntitlement = ProEntitlementCenter.shared
    @State private var allApps: [AppInfo] = []
    @State private var displayGroups: [TagGroup] = []
    @State private var tagColors: [String: Int] = [:]
    @State private var tagCustomColors: [String: TagCustomColor] = [:]
    @State private var tagDefinitions: [String: TagDatabase.TagDef] = [:]
    @State private var customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus()
    @State private var containerAppOrder: [String: [String]] = [:]
    @State private var groupLayoutVersion = 0
    @State private var appGridScrollTargetID: String? = nil
    @State private var appGridScrollRequestToken = 0
    @State private var selectedUsageTipIndex = 0
    @State private var usageTipsHovered = false
    // Edit mode
    @State private var editPhase: EditPhase = .none
@@ -374,6 +435,8 @@
    @State private var smartStartNotice: SmartStartNotice? = nil
    @State private var pendingSmartStartDraft: SmartCategorizationDraft? = nil
    @State private var refreshInProgress = false
    @State private var loadingSpinnerVisible = false
    @State private var loadingSpinnerToken = 0
    @State private var refreshAgainAfterCurrent = false
    @State private var refreshAgainForceLayout = false
    @State private var refreshAgainUseCache = true
@@ -414,30 +477,72 @@
    @AppStorage("displayMode") private var displayMode = AppDefaults.displayMode
    @AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames
    @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
    @AppStorage("hideUsageTips") private var hideUsageTips = AppDefaults.hideUsageTips
    @AppStorage(AppGridTheme.storageKey) private var appGridThemeID = AppDefaults.appGridThemeID
    @AppStorage(AppGridTheme.deepBlueTuningStorageKey) private var deepBlueThemeTuning = AppGridTheme.deepBlue.defaultColorTuning
    @AppStorage(AppGridTheme.colorfulTuningStorageKey) private var colorfulThemeTuning = AppGridTheme.colorful.defaultColorTuning
    @AppStorage("useAppKitTagNavigation") private var useAppKitTagNavigation = AppDefaults.useAppKitTagNavigation
    @AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false
    @AppStorage("skipUncategorizedDropConfirm") private var skipUncategorizedDropConfirm = false
    @AppStorage("skipUsageTipsCloseReminder") private var skipUsageTipsCloseReminder = false
    private let editSidebarWidth: CGFloat = 188
    private let editSidebarHorizontalInset: CGFloat = 12
    private let floatingControlsTrailingInset: CGFloat = 20
    private let floatingControlsReservedWidth: CGFloat = 120
    private let loadingSpinnerDelay: TimeInterval = 0.25
    private var appBubbleDisabled: Bool {
        appGridInteraction.appDragModeActive
        usageTipsHovered
            || appGridInteraction.appDragModeActive
            || appGridInteraction.pendingUncategorizedDrop != nil
            || appGridInteraction.pendingTagRemovalDrop != nil
            || appGridInteraction.usageTipsCloseReminderVisible
            || appGridInteraction.proPrompt != nil
    }
    private var appGridHighlightedGroupName: String? {
        appGridInteraction.tagNavigationHoveredGroupName
            ?? (isColorlessContainerMode ? filledColorlessContainer : nil)
    }
    private let rightSidebarFloatingClearance: CGFloat = 44
    private let tagNavigationHoverScrollDelay: TimeInterval = 0.14
    private let tagNavigationHoverScrollInterval: TimeInterval = 0.22
    private var floatingButtonSurfaceColor: Color {
        colorScheme == .dark
        return colorScheme == .dark
            ? Color.white.opacity(0.10)
            : Color.white.opacity(0.78)
    }
    private var appGridTheme: AppGridTheme {
        AppGridTheme(storedID: appGridThemeID)
    }
    private var renderedAppGridTheme: AppGridTheme {
        editPhase == .none
            ? ProEntitlementPolicy.effectiveTheme(for: appGridTheme)
            : .defaultLight
    }
    private var renderedAppGridThemeTuning: Double? {
        guard editPhase == .none else { return nil }
        return ProEntitlementPolicy.effectiveThemeTuning(
            for: appGridTheme,
            storedTuning: storedThemeTuning(for: appGridTheme)
        )
    }
    private var renderedDisplayMode: String {
        ProEntitlementPolicy.effectiveDisplayMode(for: displayMode)
    }
    private func storedThemeTuning(for theme: AppGridTheme) -> Double? {
        switch theme {
        case .deepBlue:
            return deepBlueThemeTuning
        case .colorful:
            return colorfulThemeTuning
        case .defaultLight, .black, .pink, .purple, .green, .blue:
            return nil
        }
    }
    private var isSideLayout: Bool {
@@ -449,7 +554,24 @@
    }
    private var isColorlessContainerMode: Bool {
        displayMode == "container" || displayMode == "gridContainer"
        renderedDisplayMode == "container" || renderedDisplayMode == "gridContainer"
    }
    private var shouldShowUsageTips: Bool {
        !hideUsageTips && !allApps.isEmpty && !quickSearchOnlySession
    }
    private var appGridUsageTips: [AppGridUsageTip] {
        [
            AppGridUsageTip(id: 1, titleKey: "usageTips.tip1.title", detailKey: "usageTips.tip1.detail"),
            AppGridUsageTip(id: 2, titleKey: "usageTips.tip2.title", detailKey: "usageTips.tip2.detail"),
            AppGridUsageTip(id: 3, titleKey: "usageTips.tip3.title", detailKey: "usageTips.tip3.detail"),
            AppGridUsageTip(id: 4, titleKey: "usageTips.tip4.title", detailKey: "usageTips.tip4.detail"),
            AppGridUsageTip(id: 5, titleKey: "usageTips.tip5.title", detailKey: "usageTips.tip5.detail"),
            AppGridUsageTip(id: 6, titleKey: "usageTips.tip6.title", detailKey: "usageTips.tip6.detail"),
            AppGridUsageTip(id: 7, titleKey: "usageTips.tip7.title", detailKey: "usageTips.tip7.detail"),
            AppGridUsageTip(id: 8, titleKey: "usageTips.tip8.title", detailKey: "usageTips.tip8.detail"),
        ]
    }
    private var shouldRenderAppGridBehindQuickSearch: Bool {
@@ -460,175 +582,174 @@
    }
    var body: some View {
        contentStack
            .onAppear(perform: handleContentAppear)
            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow), perform: handleOverlayDidShow)
            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide), perform: handleOverlayDidHide)
            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested), perform: handleQuickSearchRequested)
            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchDismissRequested), perform: handleQuickSearchDismissRequested)
            .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification), perform: handleApplicationDidBecomeActive)
            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange), perform: handleDataDidChange)
            .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange), perform: handleAppLanguageDidChange)
            .onChange(of: editPhase) { _, newPhase in
                let active = newPhase != .none
                dismissAppBubble()
                NotificationCenter.default.post(
                    name: .tagLauncherEditModeChanged,
                    object: nil,
                    userInfo: ["active": active]
                )
            }
            .onChange(of: bubbleNoteFocused) { _, focused in
                if appGridInteraction.editingBubble != nil && !focused {
                    commitBubbleNote()
                }
            }
            .onChange(of: draggedTagNames) { _, newOrder in
                rebuildDisplayGroups(apps: allApps, tagOrder: newOrder)
            }
            .onChange(of: defaultGroupName) { _, _ in
                rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
            }
            .onChange(of: quickSearchQuery) { _, _ in
                quickSearchErrorMessage = nil
                refreshQuickSearchResults()
            }
            .onChange(of: appGridInteraction.pendingUncategorizedDrop != nil) { _, _ in
                publishModalInteractionState()
            }
            .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in
                publishModalInteractionState()
            }
            .onChange(of: appGridInteraction.proPrompt != nil) { _, _ in
                publishModalInteractionState()
            }
            .onChange(of: smartStartNotice != nil) { _, _ in
                publishModalInteractionState()
            }
            .onChange(of: proEntitlement.accessState) { _, newState in
                handleProAccessStateChange(newState)
            }
            .onDisappear {
                NotificationCenter.default.post(
                    name: .tagLauncherModalInteractionChanged,
                    object: nil,
                    userInfo: ["active": false]
                )
            }
    }
    @ViewBuilder
    private var contentStack: some View {
        ZStack {
            if shouldRenderAppGridBehindQuickSearch {
                VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
                    .ignoresSafeArea()
                    .allowsHitTesting(false)
                appGridBackground
            }
            if shouldRenderAppGridBehindQuickSearch {
                if notchHeight > 0 {
                    VStack {
                        Rectangle().fill(.black)
                            .frame(height: notchHeight)
                            .ignoresSafeArea(edges: .top)
                        Spacer()
                    }
                    .allowsHitTesting(false)
                }
                switch editPhase {
                case .none:
                    normalContent
                case .editingTags:
                    editTagsView
                case .editingApps:
                    editAppsView
                }
                uncommonAppBubbleOverlay
                smartStartNoticeOverlay
                editActionFeedbackOverlay
                uncategorizedDropConfirmOverlay
                tagRemovalDropConfirmOverlay
                appGridInteractionLayer
            }
            quickSearchOverlay
            if shouldRenderAppGridBehindQuickSearch, let message = appGridInteraction.dropWarningToast {
                Text(message)
                    .font(.system(size: 16, weight: .semibold))
                    .foregroundStyle(.primary)
                    .padding(.horizontal, 22)
                    .padding(.vertical, 12)
                    .background(
                        RoundedRectangle(cornerRadius: 10)
                            .fill(.ultraThickMaterial)
                            .shadow(color: .black.opacity(0.22), radius: 18, y: 10)
                    )
                    .transition(.scale(scale: 0.96).combined(with: .opacity))
                    .allowsHitTesting(false)
            }
            if shouldRenderAppGridBehindQuickSearch && appGridInteraction.dropRefreshVisible {
                Color.black.opacity(0.08)
                    .ignoresSafeArea()
                    .transition(.opacity)
                    .allowsHitTesting(false)
                ProgressView()
                    .progressViewStyle(.circular)
                    .controlSize(.large)
                    .scaleEffect(1.15)
                    .padding(24)
                    .background(
                        RoundedRectangle(cornerRadius: 12)
                            .fill(.ultraThickMaterial)
                            .shadow(color: .black.opacity(0.26), radius: 24, y: 12)
                    )
                    .transition(.scale(scale: 0.92).combined(with: .opacity))
                    .allowsHitTesting(false)
            }
            dropWarningToastOverlay
            dropRefreshOverlay
        }
        .onAppear {
            refreshNotchHeight()
            refreshAppsForOverlay()
            if let initialQuickSearchSource, !initialQuickSearchConsumed {
                initialQuickSearchConsumed = true
                if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
                    quickSearchCloseHidesOverlay = true
                    quickSearchOnlySession = true
                }
                if !quickSearchVisible {
                    quickSearchVisible = true
                    quickSearchFocusToken &+= 1
                }
                quickSearchOpenedAt = Date()
                refreshQuickSearchResults()
                refreshAppsForQuickSearch()
                NotificationCenter.default.post(
                    name: .tagLauncherQuickSearchVisibilityChanged,
                    object: nil,
                    userInfo: [
                        "active": true,
                        "hideOverlayOnClose": quickSearchCloseHidesOverlay
                    ]
    }
    @ViewBuilder
    private var appGridInteractionLayer: some View {
        if notchHeight > 0 {
            VStack {
                Rectangle().fill(.black)
                    .frame(height: notchHeight)
                    .ignoresSafeArea(edges: .top)
                Spacer()
            }
            .allowsHitTesting(false)
        }
        switch editPhase {
        case .none:
            normalContent
        case .editingTags:
            editTagsView
        case .editingApps:
            editAppsView
        }
        uncommonAppBubbleOverlay
        smartStartNoticeOverlay
        editActionFeedbackOverlay
        uncategorizedDropConfirmOverlay
        tagRemovalDropConfirmOverlay
        usageTipsCloseReminderOverlay
        proAccessPromptOverlay
    }
    @ViewBuilder
    private var dropWarningToastOverlay: some View {
        if shouldRenderAppGridBehindQuickSearch, let message = appGridInteraction.dropWarningToast {
            Text(message)
                .font(.system(size: 16, weight: .semibold))
                .foregroundStyle(.primary)
                .padding(.horizontal, 22)
                .padding(.vertical, 12)
                .background(
                    RoundedRectangle(cornerRadius: 10)
                        .fill(.ultraThickMaterial)
                        .shadow(color: .black.opacity(0.22), radius: 18, y: 10)
                )
                .transition(.scale(scale: 0.96).combined(with: .opacity))
                .allowsHitTesting(false)
        }
    }
    @ViewBuilder
    private var proAccessPromptOverlay: some View {
        if let prompt = appGridInteraction.proPrompt {
            ZStack {
                Color.black.opacity(0.16)
                    .ignoresSafeArea()
                ProUpgradePromptView(
                    title: tr(prompt.feature.titleKey),
                    message: proPromptMessage(for: prompt),
                    benefitText: tr(prompt.feature.benefitKey),
                    operationText: proEntitlement.operationState.statusMessageKey.map(tr),
                    unlockTitle: tr("pro.card.unlock"),
                    restoreTitle: proPromptSecondaryTitle(for: prompt),
                    isBusy: proEntitlement.operationState.isBusy,
                    onClose: dismissProPrompt,
                    onUnlock: { proEntitlement.purchasePro() },
                    onRestore: { handleProPromptSecondaryAction(prompt) }
                )
            }
            .transition(.opacity)
            .zIndex(920)
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow)) { _ in
            resetTransientDragState()
            refreshNotchHeight()
            refreshAppsForOverlay()
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide)) { _ in
            resetTransientDragState()
            closeQuickSearch(notify: true, hideOverlayIfNeeded: false)
            quickSearchOnlySession = false
            quickSearchCloseHidesOverlay = false
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested)) { notification in
            let source = notification.userInfo?["source"] as? String ?? QuickSearchOpenSource.mainOverlay
            openQuickSearch(source: source)
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchDismissRequested)) { notification in
            let source = notification.userInfo?["source"] as? String
            closeQuickSearch(dismissalSource: source)
        }
        .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
            guard allApps.isEmpty, !refreshInProgress else { return }
            refreshApps()
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in
            refreshApps(forceLayoutRefresh: true)
        }
        .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
            let appsSnapshot = allApps
            DispatchQueue.global(qos: .userInitiated).async {
                _ = AppleDefaultAppCatalog.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
                _ = SmartStartService.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
                DispatchQueue.main.async {
                    refreshApps(forceLayoutRefresh: true)
                }
            }
        }
        .onChange(of: editPhase) { _, newPhase in
            let active = newPhase != .none
            dismissAppBubble()
            NotificationCenter.default.post(
                name: .tagLauncherEditModeChanged,
                object: nil,
                userInfo: ["active": active]
            )
        }
        .onChange(of: bubbleNoteFocused) { _, focused in
            if appGridInteraction.editingBubble != nil && !focused {
                commitBubbleNote()
            }
        }
        .onChange(of: draggedTagNames) { _, newOrder in
            rebuildDisplayGroups(apps: allApps, tagOrder: newOrder)
        }
        .onChange(of: defaultGroupName) { _, _ in
            rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
        }
        .onChange(of: quickSearchQuery) { _, _ in
            quickSearchErrorMessage = nil
            refreshQuickSearchResults()
        }
        .onChange(of: appGridInteraction.pendingUncategorizedDrop != nil) { _, _ in
            publishModalInteractionState()
        }
        .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in
            publishModalInteractionState()
        }
        .onDisappear {
            NotificationCenter.default.post(
                name: .tagLauncherModalInteractionChanged,
                object: nil,
                userInfo: ["active": false]
            )
    }
    @ViewBuilder
    private var dropRefreshOverlay: some View {
        if shouldRenderAppGridBehindQuickSearch && appGridInteraction.dropRefreshVisible {
            Color.black.opacity(0.08)
                .ignoresSafeArea()
                .transition(.opacity)
                .allowsHitTesting(false)
            ProgressView()
                .progressViewStyle(.circular)
                .controlSize(.large)
                .scaleEffect(1.15)
                .padding(24)
                .background(
                    RoundedRectangle(cornerRadius: 12)
                        .fill(.ultraThickMaterial)
                        .shadow(color: .black.opacity(0.26), radius: 24, y: 12)
                )
                .transition(.scale(scale: 0.92).combined(with: .opacity))
                .allowsHitTesting(false)
        }
    }
@@ -636,7 +757,13 @@
        NotificationCenter.default.post(
            name: .tagLauncherModalInteractionChanged,
            object: nil,
            userInfo: ["active": appGridInteraction.pendingUncategorizedDrop != nil || appGridInteraction.pendingTagRemovalDrop != nil]
            userInfo: [
                "active": smartStartNotice != nil
                    || appGridInteraction.pendingUncategorizedDrop != nil
                    || appGridInteraction.pendingTagRemovalDrop != nil
                    || appGridInteraction.usageTipsCloseReminderVisible
                    || appGridInteraction.proPrompt != nil
            ]
        )
    }
@@ -648,7 +775,10 @@
            // Always sync tag list from database when entering edit mode
            let store = TagDatabase.load()
            tagColors = store.tags.mapValues { $0.color }
            tagCustomColors = store.tags.compactMapValues { $0.customColor }
            tagDefinitions = store.tags
            draggedTagNames = TagEditor.orderedTagNames()
            refreshCustomContainerQuotaStatus(in: store)
        }
        editPhase = phase
        if phase == .none {
@@ -919,6 +1049,40 @@
        }
    }
    private var appGridBackground: some View {
        let theme = renderedAppGridTheme
        let tuning = renderedAppGridThemeTuning
        return ZStack {
            if theme.usesVisualEffectBackdrop {
                VisualEffectView(
                    material: theme.material,
                    blendingMode: .behindWindow
                )
            }
            if theme.isPureBlackBackground {
                Color.black
            }
            if !theme.isDefaultLight {
                LinearGradient(
                    colors: theme.backgroundBaseColors(tuning: tuning),
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
                LinearGradient(
                    colors: theme.backgroundAccentColors(tuning: tuning),
                    startPoint: .topTrailing,
                    endPoint: .bottomLeading
                )
                if theme.backgroundDimmingOpacity > 0 {
                    Rectangle()
                        .fill(Color.black.opacity(theme.backgroundDimmingOpacity))
                }
            }
        }
        .ignoresSafeArea()
        .allowsHitTesting(false)
    }
    private var floatingActionButtons: some View {
        HStack(spacing: 8) {
            floatingOverlayButton(systemImage: "pencil.line") {
@@ -1016,6 +1180,9 @@
            onActivate: { tagID in
                activateTagNavigation(tagID)
            },
            onDoubleActivate: { _ in
                openTagSettingsFromNavigation()
            },
            onHoverChange: { tagID, active in
                handleTagNavigationHover(tagID, active: active)
            },
@@ -1043,11 +1210,20 @@
        )
    }
    private func openTagSettingsFromNavigation() {
        NotificationCenter.default.post(
            name: .tagLauncherOpenPreferencesRequested,
            object: nil,
            userInfo: [SettingsTabTarget.userInfoKey: SettingsTabTarget.tags]
        )
    }
    private var swiftUITopTagBar: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 8) {
                ForEach(tagLabels) { tag in
                    TagPill(name: tag.name, colorIndex: tag.colorIndex,
                            customColor: tag.customColor,
                            dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
                            isDragging: tagNavDragItem == tag.name,
                            action: {
@@ -1075,6 +1251,7 @@
            VStack(spacing: 6) {
                ForEach(tagLabels) { tag in
                    SideTagPill(name: tag.name, colorIndex: tag.colorIndex,
                                customColor: tag.customColor,
                                dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
                                isDragging: tagNavDragItem == tag.name,
                                action: {
@@ -1104,18 +1281,26 @@
        Group {
            if allApps.isEmpty {
                Spacer()
                ProgressView().scaleEffect(0.8)
                if loadingSpinnerVisible {
                    ProgressView().scaleEffect(0.8)
                }
                Spacer()
            } else {
                AppGridCollectionView(
                    groups: displayGroups,
                    tagColors: tagColors,
                    displayMode: displayMode,
                    tagCustomColors: tagCustomColors,
                    displayMode: renderedDisplayMode,
                    iconSize: iconSize,
                    showNames: !hideAppNames,
                    appGridTheme: renderedAppGridTheme,
                    bubbleDisabled: appBubbleDisabled,
                    showUncommonAppBubbles: showUncommonAppBubbles,
                    highlightedGroupName: appGridHighlightedGroupName,
                    bottomContentPadding: shouldShowUsageTips ? AppGridUsageTipsMetrics.reservedHeight : 0,
                    usageTipsVisible: shouldShowUsageTips,
                    usageTips: appGridUsageTips,
                    selectedUsageTipIndex: $selectedUsageTipIndex,
                    contentRevision: groupLayoutVersion,
                    scrollTargetID: appGridScrollTargetID,
                    scrollRequestToken: appGridScrollRequestToken,
@@ -1128,13 +1313,18 @@
                    onDropOutsideGroup: { path, source, copy in
                        dropAppOutsideGroup(path: path, sourceTag: source, copy: copy)
                    },
                    onReorderApps: { containerID, orderedPaths in
                        reorderApps(inContainer: containerID, orderedPaths: orderedPaths)
                    },
                    onGroupActivate: { groupName in
                        if isColorlessContainerMode {
                            toggleColorlessFill(groupName)
                        }
                    },
                    onScrollActivity: handleAppGridScrollActivity,
                    onDragModeChange: { setAppDragMode($0) }
                    onDragModeChange: { setAppDragMode($0) },
                    onHideUsageTips: requestHideUsageTips,
                    onUsageTipsHoverChange: handleUsageTipsHoverChange
                )
            }
        }
@@ -1162,6 +1352,8 @@
                    isEditing: editing,
                    placement: placement,
                    arrowOffset: metrics.arrowOffset,
                    noteQuotaText: editing ? noteQuotaHintText(for: context.app) : nil,
                    noteQuotaWarning: editing && isNoteQuotaWarning(for: context.app),
                    draftNote: $appGridInteraction.bubbleDraftNote,
                    noteFocused: $bubbleNoteFocused,
                    onCommit: commitBubbleNote,
@@ -1196,24 +1388,80 @@
                } label: {
                    Label(tr("edit.exit"), systemImage: "rectangle.portrait.and.arrow.right")
                        .font(.system(size: 12))
                        .foregroundStyle(renderedAppGridTheme.editPrimaryTextColor)
                        .padding(.horizontal, 8)
                        .frame(height: 28)
                        .background(
                            RoundedRectangle(cornerRadius: 7, style: .continuous)
                                .fill(renderedAppGridTheme.editControlSurfaceColor)
                        )
                        .overlay(
                            RoundedRectangle(cornerRadius: 7, style: .continuous)
                                .stroke(renderedAppGridTheme.editControlStrokeColor, lineWidth: 1)
                        )
                }
                .buttonStyle(.bordered)
                .buttonStyle(.plain)
                .shadow(color: renderedAppGridTheme.editButtonShadowColor, radius: 10, y: 4)
                Spacer()
                Text(tr("edit.tags")).font(.headline)
                Text(tr("edit.tags"))
                    .font(.headline)
                    .foregroundStyle(renderedAppGridTheme.editPrimaryTextColor)
                Spacer()
            }
            .padding(.horizontal, 24)
            .padding(.top, notchHeight > 0 ? notchHeight + 10 : 20)
            .padding(.bottom, 12)
            .background(renderedAppGridTheme.editToolbarSurfaceColor)
            Divider().opacity(0.3)
            Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
            TagEditorView(
                tagColors: $tagColors,
                tagCustomColors: $tagCustomColors,
                excludedTagNames: ["Mac自带", defaultGroupName],
                onRefresh: { refreshApps() }
                isCustomColorUnlocked: proEntitlement.isUnlocked,
                onLockedCustomColor: { presentProPrompt(for: .customTagColors) },
                onCustomContainerQuotaExceeded: presentCustomContainerQuotaPrompt,
                onCustomContainerQuotaStatusChanged: refreshCustomContainerQuotaStatus,
                topLeadingAccessory: AnyView(customContainerQuotaAccessory),
                onRefresh: {
                    refreshCustomContainerQuotaStatus()
                    refreshApps()
                }
            )
        }
    }
    private var effectiveCustomContainerQuotaStatus: ProCustomContainerQuotaStatus {
        if proEntitlement.isUnlocked {
            return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
        }
        return customContainerQuotaStatus
    }
    private var customContainerQuotaAccessory: some View {
        let status = effectiveCustomContainerQuotaStatus
        let text = status.isUnlimited
            ? tr("pro.customContainers.quotaUnlimitedShort")
            : tr(
                "pro.customContainers.quotaShort",
                replacements: [
                    "%used%": "\(status.used)",
                    "%limit%": "\(status.limit)"
                ]
            )
        return Text(text)
            .font(.system(size: 12, weight: .medium))
            .foregroundStyle(.secondary)
            .lineLimit(1)
    }
    private func refreshCustomContainerQuotaStatus(_ status: ProCustomContainerQuotaStatus) {
        customContainerQuotaStatus = status
    }
    private func refreshCustomContainerQuotaStatus(in store: TagDatabase.Store? = nil) {
        customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
    }
    // MARK: - Edit Apps View
@@ -1221,6 +1469,7 @@
    private var editAppsView: some View {
        VStack(spacing: 0) {
            EditAppsHeaderView(
                theme: renderedAppGridTheme,
                operation: editTagOperation,
                hintText: editModeHintText,
                confirmTitle: editConfirmTitle,
@@ -1234,11 +1483,12 @@
                onConfirm: confirmAssign
            )
            Divider().opacity(0.3)
            Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
            HStack(spacing: 0) {
                VStack(alignment: .leading, spacing: 4) {
                    EditAppsSidebarIntroView(
                        theme: renderedAppGridTheme,
                        width: editSidebarWidth,
                        horizontalInset: editSidebarHorizontalInset
                    )
@@ -1270,10 +1520,14 @@
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                }
                .frame(width: editSidebarWidth, alignment: .topLeading)
                Rectangle().fill(.secondary.opacity(0.12)).frame(width: 1)
                Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(width: 1)
                if allApps.isEmpty {
                    Spacer(); ProgressView().scaleEffect(0.8); Spacer()
                    Spacer()
                    if loadingSpinnerVisible {
                        ProgressView().scaleEffect(0.8)
                    }
                    Spacer()
                } else {
                    editAppsGrid
                }
@@ -1297,12 +1551,12 @@
    private func editFlatGroup(_ group: TagGroup) -> some View {
        VStack(alignment: .leading, spacing: 0) {
            HStack(spacing: 0) {
                Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
                Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
                Text(group.name)
                    .font(.system(size: tagFontSize, weight: .semibold))
                    .foregroundStyle(.secondary)
                    .foregroundStyle(renderedAppGridTheme.editSecondaryTextColor)
                    .padding(.horizontal, 10)
                Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
                Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
            }
            .padding(.bottom, 6)
@@ -1335,7 +1589,8 @@
            colorIndex: colorIndex,
            operation: editTagOperation,
            isSelected: isSelected,
            isRemovableCandidate: isRemovableCandidate
            isRemovableCandidate: isRemovableCandidate,
            theme: renderedAppGridTheme
        ) {
            if isSelected {
                selectedTagNames.remove(tagName)
@@ -1358,7 +1613,7 @@
            .onEnded { _ in
                guard canReorderTag(tagName) else { return }
                dragItem = nil
                TagEditor.reorderTags(draggedTagNames)
                persistTagOrderIfAllowed()
            }
    }
@@ -1386,9 +1641,11 @@
        switch editTagOperation {
        case .add:
            TagEditor.appendTags(tags, to: paths)
            let result = TagEditor.appendTags(tags, to: paths)
            guard handleTagMutationResult(result) else { return }
        case .remove:
            TagEditor.removeTags(tags, from: paths)
            let result = TagEditor.removeTags(tags, from: paths)
            guard handleTagMutationResult(result) else { return }
        }
        selectedAppPaths = []
        selectedTagNames = []
@@ -1409,7 +1666,8 @@
        return EditableAppSelectionItem(
            app: app,
            iconSize: iconSize,
            isSelected: isSelected
            isSelected: isSelected,
            theme: renderedAppGridTheme
        ) {
            toggleEditableAppSelection(app)
        }
@@ -1557,6 +1815,34 @@
        }
        .ignoresSafeArea()
        .allowsHitTesting(appGridInteraction.pendingTagRemovalDrop != nil)
    }
    private var usageTipsCloseReminderOverlay: some View {
        GeometryReader { proxy in
            if appGridInteraction.usageTipsCloseReminderVisible {
                ZStack {
                    Color.black.opacity(0.14)
                        .ignoresSafeArea()
                    UsageTipsCloseReminderBubble(
                        title: tr("usageTips.closeReminder.title"),
                        message: tr("usageTips.closeReminder.message"),
                        previewCallout: tr("usageTips.closeReminder.previewCallout"),
                        doNotRemindTitle: tr("usageTips.closeReminder.doNotRemind"),
                        doNotRemind: $appGridInteraction.usageTipsCloseSuppressFuturePrompt,
                        confirmTitle: tr("edit.confirm"),
                        onCancel: dismissUsageTipsCloseReminder,
                        onConfirm: confirmUsageTipsCloseReminder
                    )
                    .frame(width: min(760, max(420, proxy.size.width - 120)))
                    .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
                    .transition(.scale(scale: 0.94).combined(with: .opacity))
                }
                .zIndex(712)
            }
        }
        .ignoresSafeArea()
        .allowsHitTesting(appGridInteraction.usageTipsCloseReminderVisible)
    }
    private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback {
@@ -1775,13 +2061,27 @@
    private func makeDisplayGroups(apps: [AppInfo], tagOrder: [String]) -> [TagGroup] {
        let order = tagOrder.isEmpty ? TagEditor.orderedTagNames() : tagOrder
        let rawGroups = AppIndexer.group(apps: apps, defaultGroupName: defaultGroupName, tagOrder: order)
        let rawGroups = AppIndexer.group(
            apps: apps,
            defaultGroupName: defaultGroupName,
            tagOrder: order,
            tagDefinitions: tagDefinitions,
            containerAppOrder: containerAppOrder
        )
        return rawGroups.map { group in
            if group.name == defaultGroupName {
                return TagGroup(name: tr("group.uncategorized"), apps: group.apps)
                return TagGroup(
                    containerID: group.containerID,
                    name: tr("group.uncategorized"),
                    apps: group.apps
                )
            }
            if group.name == "Mac自带" {
                return TagGroup(name: tr("group.appleBuiltIn"), apps: group.apps)
                return TagGroup(
                    containerID: group.containerID,
                    name: tr("group.appleBuiltIn"),
                    apps: group.apps
                )
            }
            return group
        }
@@ -1793,7 +2093,13 @@
    }
    private var tagLabels: [TagNavigationItem] {
        displayGroups.map { TagNavigationItem(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
        displayGroups.map {
            TagNavigationItem(
                name: $0.name,
                colorIndex: tagColors[$0.name] ?? 0,
                customColor: tagCustomColors[$0.name]
            )
        }
    }
    private var editGroups: [TagGroup] {
@@ -1818,16 +2124,24 @@
        // Each overlay show creates a fresh ContentView with empty `allApps`. The indexer
        // cache may still be warm from a prior overlay/settings scan, so path-signature
        // alone must not skip the first hydrate or the grid spinner never clears.
        guard allApps.isEmpty || AppIndexer.shouldRefreshForSearchPathChanges() else { return }
        let wasEmpty = allApps.isEmpty
        hydrateFromLastAppLibrarySnapshotIfNeeded()
        guard wasEmpty || AppIndexer.shouldRefreshForSearchPathChanges() else { return }
        refreshApps()
    }
    private func refreshAppsForQuickSearch() {
        hydrateFromLastAppLibrarySnapshotIfNeeded()
        guard allApps.isEmpty
            || quickSearchDocuments.isEmpty
            || AppIndexer.shouldRefreshForSearchPathChanges()
        else { return }
        refreshApps()
    }
    private func hydrateFromLastAppLibrarySnapshotIfNeeded() {
        guard allApps.isEmpty, let snapshot = AppLibraryController.lastSnapshot() else { return }
        applyAppLibrarySnapshot(snapshot)
    }
    private func refreshNotchHeight() {
@@ -1840,6 +2154,13 @@
    private func handleAppGridScrollActivity() {
        if appGridInteraction.hoveredBubble != nil {
            appGridInteraction.hoveredBubble = nil
        }
    }
    private func handleUsageTipsHoverChange(_ hovering: Bool) {
        usageTipsHovered = hovering
        if hovering, appGridInteraction.hoveredBubble != nil {
            appGridInteraction.hoveredBubble = nil
        }
    }
@@ -1879,10 +2200,18 @@
    private func commitBubbleNote() {
        guard let context = appGridInteraction.editingBubble else { return }
        guard appGridInteraction.proPrompt == nil else { return }
        let limited = String(appGridInteraction.bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength))
            .trimmingCharacters(in: .whitespacesAndNewlines)
        TagEditor.setAppNote(limited, for: context.app.path.path)
        let decision = TagEditor.setAppNote(limited, for: context.app.path.path)
        appGridInteraction.bubbleDraftNote = limited
        guard case .allow = decision else {
            if case .blocked(let status) = decision {
                presentProPrompt(for: .unlimitedNotes, noteQuotaStatus: status)
            }
            bubbleNoteFocused = false
            return
        }
        appGridInteraction.editingBubble = nil
        bubbleNoteFocused = false
        notifyAppNoteEditing(active: false)
@@ -1926,6 +2255,215 @@
        return app.note ?? ""
    }
    private func noteQuotaStatus(for app: AppInfo) -> ProNoteQuotaStatus? {
        guard !proEntitlement.isUnlocked else { return nil }
        let decision = ProEntitlementPolicy.noteSaveDecision(
            note: appGridInteraction.bubbleDraftNote,
            for: app.path.path
        )
        switch decision {
        case .allow(let status), .blocked(let status):
            return status
        }
    }
    private func noteQuotaHintText(for app: AppInfo) -> String? {
        guard let status = noteQuotaStatus(for: app), !status.isUnlimited else { return nil }
        if status.remaining <= 0 {
            return tr("pro.notes.limitReached")
        }
        return tr(
            "pro.notes.remaining",
            replacements: [
                "%remaining%": "\(status.remaining)",
                "%limit%": "\(status.limit)"
            ]
        )
    }
    private func isNoteQuotaWarning(for app: AppInfo) -> Bool {
        guard let status = noteQuotaStatus(for: app), !status.isUnlimited else { return false }
        return status.remaining <= 0
    }
    private func proPromptMessage(for prompt: PendingProPrompt) -> String {
        if prompt.feature == .unlimitedNotes,
           let status = prompt.noteQuotaStatus,
           !status.isUnlimited,
           status.remaining <= 0 {
            return tr("pro.notes.limitReached")
        }
        if prompt.feature == .customContainerQuota,
           let status = prompt.customContainerQuotaStatus,
           !status.isUnlimited {
            return tr(
                "pro.customContainers.quotaPrompt",
                replacements: [
                    "%used%": "\(status.used)",
                    "%limit%": "\(status.limit)",
                    "%remaining%": "\(status.remaining)"
                ]
            )
        }
        return tr(prompt.feature.promptMessageKey)
    }
    private func proPromptSecondaryTitle(for prompt: PendingProPrompt) -> String {
        prompt.feature == .customContainerQuota
            ? tr("pro.customContainers.manageExisting")
            : tr("pro.card.restore")
    }
    private func handleProPromptSecondaryAction(_ prompt: PendingProPrompt) {
        if prompt.feature == .customContainerQuota {
            dismissProPrompt()
            setEditPhase(.editingTags)
            return
        }
        proEntitlement.restorePurchases()
    }
    private func presentProPrompt(
        for feature: ProFeature,
        noteQuotaStatus: ProNoteQuotaStatus? = nil,
        customContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
    ) {
        proEntitlement.clearTransientOperationState()
        withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) {
            appGridInteraction.proPrompt = PendingProPrompt(
                feature: feature,
                noteQuotaStatus: noteQuotaStatus,
                customContainerQuotaStatus: customContainerQuotaStatus
            )
        }
    }
    private func presentCustomContainerQuotaPrompt(_ status: ProCustomContainerQuotaStatus) {
        presentProPrompt(
            for: .customContainerQuota,
            customContainerQuotaStatus: status
        )
    }
    private func handleTagMutationResult(_ result: TagEditorMutationResult) -> Bool {
        switch result {
        case .saved:
            refreshCustomContainerQuotaStatus()
            return true
        case .noChange:
            return false
        case .blockedCustomContainerQuota(let status):
            presentCustomContainerQuotaPrompt(status)
            return false
        }
    }
    private func dismissProPrompt() {
        proEntitlement.clearTransientOperationState()
        withAnimation(.easeOut(duration: 0.18)) {
            appGridInteraction.proPrompt = nil
        }
    }
    private func handleProAccessStateChange(_ newState: ProAccessState) {
        guard newState.isUnlocked else { return }
        if let preview = proEntitlement.themePreviewState {
            appGridThemeID = preview.theme.rawValue
            proEntitlement.stopThemePreview()
        }
        if let preview = proEntitlement.displayModePreviewState {
            displayMode = preview.displayMode
            proEntitlement.stopDisplayModePreview()
        }
        let hadPrompt = appGridInteraction.proPrompt != nil
        if hadPrompt {
            dismissProPrompt()
        }
        if appGridInteraction.editingBubble != nil {
            DispatchQueue.main.async {
                bubbleNoteFocused = true
            }
        }
    }
    private func handleContentAppear() {
        refreshNotchHeight()
        refreshAppsForOverlay()
        guard let initialQuickSearchSource, !initialQuickSearchConsumed else { return }
        initialQuickSearchConsumed = true
        if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
            quickSearchCloseHidesOverlay = true
            quickSearchOnlySession = true
        }
        if !quickSearchVisible {
            quickSearchVisible = true
            quickSearchFocusToken &+= 1
        }
        quickSearchOpenedAt = Date()
        refreshQuickSearchResults()
        refreshAppsForQuickSearch()
        NotificationCenter.default.post(
            name: .tagLauncherQuickSearchVisibilityChanged,
            object: nil,
            userInfo: [
                "active": true,
                "hideOverlayOnClose": quickSearchCloseHidesOverlay
            ]
        )
    }
    private func handleOverlayDidShow(_ notification: Notification) {
        _ = notification
        resetTransientDragState()
        refreshNotchHeight()
        refreshAppsForOverlay()
    }
    private func handleOverlayDidHide(_ notification: Notification) {
        _ = notification
        resetTransientDragState()
        closeQuickSearch(notify: true, hideOverlayIfNeeded: false)
        quickSearchOnlySession = false
        quickSearchCloseHidesOverlay = false
    }
    private func handleQuickSearchRequested(_ notification: Notification) {
        let source = notification.userInfo?["source"] as? String ?? QuickSearchOpenSource.mainOverlay
        openQuickSearch(source: source)
    }
    private func handleQuickSearchDismissRequested(_ notification: Notification) {
        let source = notification.userInfo?["source"] as? String
        closeQuickSearch(dismissalSource: source)
    }
    private func handleApplicationDidBecomeActive(_ notification: Notification) {
        _ = notification
        guard allApps.isEmpty, !refreshInProgress else { return }
        refreshApps()
    }
    private func handleDataDidChange(_ notification: Notification) {
        _ = notification
        refreshApps(forceLayoutRefresh: true)
    }
    private func handleAppLanguageDidChange(_ notification: Notification) {
        _ = notification
        let appsSnapshot = allApps
        DispatchQueue.global(qos: .userInitiated).async {
            _ = AppleDefaultAppCatalog.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
            _ = SmartStartService.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
            DispatchQueue.main.async {
                refreshApps(forceLayoutRefresh: true)
            }
        }
    }
    private func bubblePlacement(for frame: CGRect, rootFrame: CGRect) -> BubblePlacement {
        frame.minY < 170 ? .below : .above
    }
@@ -1961,7 +2499,8 @@
    private func estimatedBubbleHeight(for app: AppInfo, width: CGFloat, isEditing: Bool) -> CGFloat {
        if isEditing {
            return 120
            let hint = noteQuotaHintText(for: app)
            return hint == nil ? 124 : 148
        }
        let note = currentNote(for: app).trimmingCharacters(in: .whitespacesAndNewlines)
        guard !note.isEmpty else { return 68 }
@@ -1985,7 +2524,7 @@
    }
    private func handleTagNavigationHover(_ id: String, active: Bool) {
        if appGridInteraction.appDragModeActive {
        if appGridInteraction.appDragModeActive || tagNavDragModeActive {
            if !active, appGridInteraction.tagNavigationHoveredGroupName == id {
                appGridInteraction.tagNavigationHoveredGroupName = nil
            }
@@ -2001,7 +2540,7 @@
        appGridInteraction.tagNavigationHoveredGroupName = id
        fillColorlessContainer(id)
        scrollToTagFromHover(id)
        scheduleTagNavigationHoverScroll(id)
    }
    private func handleTagNavigationAppDropHover(_ id: String, active: Bool) {
@@ -2020,6 +2559,14 @@
        }
        appGridInteraction.tagNavigationAppDropTargetName = id
    }
    private func scheduleTagNavigationHoverScroll(_ id: String) {
        DispatchQueue.main.asyncAfter(deadline: .now() + tagNavigationHoverScrollDelay) {
            guard appGridInteraction.tagNavigationHoveredGroupName == id else { return }
            guard !appGridInteraction.appDragModeActive && !tagNavDragModeActive else { return }
            scrollToTagFromHover(id)
        }
    }
    private func scrollToTagFromHover(_ id: String) {
@@ -2097,11 +2644,22 @@
        let hadDragState = tagNavDragModeActive || tagNavDragItem != nil
        guard hadDragState else { return }
        if tagNavDragModeActive && tagNavReorderDidMove {
            TagEditor.reorderTags(draggedTagNames)
            persistTagOrderIfAllowed()
        }
        tagNavDragModeActive = false
        tagNavDragItem = nil
        tagNavReorderDidMove = false
    }
    private func persistTagOrderIfAllowed() {
        guard proEntitlement.isUnlocked else {
            showDropWarning(message: tr("pro.tagSorting.previewToast"))
            return
        }
        guard TagEditor.reorderTags(draggedTagNames) else {
            showDropWarning(message: tr("pro.tagSorting.previewToast"))
            return
        }
    }
    private func cancelTagNavReorderVisualState() {
@@ -2136,6 +2694,8 @@
    private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) {
        resetTransientDragState(keepingPendingUncategorizedDrop: true)
        guard sourceTag != targetTag else { return }
        if isUncategorizedDropTarget(targetTag) {
            confirmAndMoveAppToUncategorized(path: path)
            return
@@ -2147,14 +2707,14 @@
        }
        guard tagColors[targetTag] != nil else { return }
        guard sourceTag != targetTag || copy else { return }
        TagEditor.moveApp(
        let result = TagEditor.moveApp(
            path: path,
            from: sourceTag,
            to: targetTag,
            color: tagColors[targetTag] ?? 0,
            copy: copy
        )
        guard handleTagMutationResult(result) else { return }
        showDropRefresh()
        refreshApps(forceLayoutRefresh: true)
    }
@@ -2164,8 +2724,25 @@
        guard canAssignDroppedAppToTag(targetTag) else { return }
        guard let app = allApps.first(where: { $0.path.path == path }) else { return }
        guard !appHasTag(app, tagName: targetTag) else { return }
        TagEditor.appendTags([targetTag], to: [path])
        let result = TagEditor.appendTags([targetTag], to: [path])
        guard handleTagMutationResult(result) else { return }
        showDropRefresh()
        refreshApps(forceLayoutRefresh: true)
    }
    private func reorderApps(inContainer containerID: String, orderedPaths: [String]) {
        resetTransientDragState()
        let key = TagDatabase.normalizedContainerID(containerID, tags: tagDefinitions)
        let paths = TagDatabase.normalizedAppOrderPaths(orderedPaths)
        guard !key.isEmpty, !paths.isEmpty else { return }
        containerAppOrder[key] = paths
        rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
        guard proEntitlement.isUnlocked else {
            showDropWarning(message: tr("pro.sorting.previewToast"))
            return
        }
        TagEditor.reorderApps(inContainer: key, orderedPaths: paths)
        refreshApps(forceLayoutRefresh: true)
    }
@@ -2285,7 +2862,8 @@
    private func moveDroppedAppToUncategorized(path: String, tags: [String]) {
        guard !tags.isEmpty else { return }
        TagEditor.removeTags(tags, from: [path])
        let result = TagEditor.removeTags(tags, from: [path])
        guard handleTagMutationResult(result) else { return }
        showDropRefresh()
        refreshApps(forceLayoutRefresh: true)
    }
@@ -2314,11 +2892,46 @@
        }
    }
    private func requestHideUsageTips() {
        guard !hideUsageTips else { return }
        guard !skipUsageTipsCloseReminder else {
            hideUsageTips = true
            return
        }
        clearAppBubbleState()
        appGridInteraction.usageTipsCloseSuppressFuturePrompt = false
        withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
            appGridInteraction.usageTipsCloseReminderVisible = true
        }
        publishModalInteractionState()
    }
    private func dismissUsageTipsCloseReminder() {
        appGridInteraction.usageTipsCloseSuppressFuturePrompt = false
        withAnimation(.easeOut(duration: 0.18)) {
            appGridInteraction.usageTipsCloseReminderVisible = false
        }
        publishModalInteractionState()
    }
    private func confirmUsageTipsCloseReminder() {
        if appGridInteraction.usageTipsCloseSuppressFuturePrompt {
            skipUsageTipsCloseReminder = true
        }
        appGridInteraction.usageTipsCloseSuppressFuturePrompt = false
        withAnimation(.easeOut(duration: 0.16)) {
            appGridInteraction.usageTipsCloseReminderVisible = false
        }
        hideUsageTips = true
        publishModalInteractionState()
    }
    private func removeTagFromDroppedApp(app: AppInfo, tagName: String) {
        guard isRemovableRegularTag(tagName),
              appHasTag(app, tagName: tagName)
        else { return }
        TagEditor.removeTags([tagName], from: [app.path.path])
        let result = TagEditor.removeTags([tagName], from: [app.path.path])
        guard handleTagMutationResult(result) else { return }
        showDropRefresh()
        refreshApps(forceLayoutRefresh: true)
    }
@@ -2351,9 +2964,9 @@
        return !protectedNames.contains(tag) && tagColors[tag] != nil
    }
    private func showDropWarning() {
    private func showDropWarning(message: String = tr("drop.systemDefaultWarning")) {
        withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
            appGridInteraction.dropWarningToast = tr("drop.systemDefaultWarning")
            appGridInteraction.dropWarningToast = message
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) {
            withAnimation(.easeOut(duration: 0.18)) {
@@ -2447,6 +3060,7 @@
        }
        refreshInProgress = true
        scheduleLoadingSpinnerIfNeeded()
        DispatchQueue.global(qos: .userInitiated).async {
            let result = AppLibraryController.refresh(useCache: useCache)
            DispatchQueue.main.async {
@@ -2456,6 +3070,7 @@
                    finishDropRefreshAfterMinimumDuration()
                }
                refreshInProgress = false
                hideLoadingSpinner()
                if refreshAgainAfterCurrent {
                    let shouldForceLayout = refreshAgainForceLayout
                    let shouldUseCache = refreshAgainUseCache
@@ -2468,11 +3083,34 @@
        }
    }
    private func scheduleLoadingSpinnerIfNeeded() {
        loadingSpinnerToken &+= 1
        let token = loadingSpinnerToken
        loadingSpinnerVisible = false
        guard allApps.isEmpty else { return }
        DispatchQueue.main.asyncAfter(deadline: .now() + loadingSpinnerDelay) {
            guard token == loadingSpinnerToken,
                  refreshInProgress,
                  allApps.isEmpty
            else { return }
            loadingSpinnerVisible = true
        }
    }
    private func hideLoadingSpinner() {
        loadingSpinnerToken &+= 1
        loadingSpinnerVisible = false
    }
    private func applyAppLibrarySnapshot(_ snapshot: AppLibrarySnapshot) {
        allApps = snapshot.apps
        quickSearchDocuments = snapshot.quickSearchDocuments
        tagColors = snapshot.tagColors
        tagDefinitions = snapshot.tagDefinitions
        tagCustomColors = snapshot.tagDefinitions.compactMapValues { $0.customColor }
        containerAppOrder = snapshot.containerAppOrder
        draggedTagNames = snapshot.tagOrder
        refreshCustomContainerQuotaStatus()
        rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder)
        if quickSearchVisible {
            refreshQuickSearchResults()
@@ -2501,6 +3139,15 @@
            closeQuickSearch(hideOverlayIfNeeded: true)
            hideOverlay()
        }
        if isCurrentApp(app) {
            if closeQuickSearchOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
                closeQuickSearch(hideOverlayIfNeeded: closeOverlayOnSuccess)
            }
            if closeOverlayOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
                hideOverlay()
            }
            return
        }
        let configuration = NSWorkspace.OpenConfiguration()
        NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in
            DispatchQueue.main.async {
@@ -2522,6 +3169,16 @@
        }
    }
    private func isCurrentApp(_ app: AppInfo) -> Bool {
        if app.bundleIdentifier?.caseInsensitiveCompare(AppIdentity.bundleIdentifier) == .orderedSame {
            return true
        }
        let currentURL = Bundle.main.bundleURL.standardizedFileURL.resolvingSymlinksInPath()
        let appURL = app.path.standardizedFileURL.resolvingSymlinksInPath()
        return currentURL == appURL
    }
    func openApp(_ app: AppInfo) {
        hideOverlay()
        launchApp(app, closeOverlayOnSuccess: false)