Ariver
2026-06-30 4c6f31c43a03ecb5a85ffb6a3ea3905a661af0f4
src/Apptag/AppGridCollectionView.swift
@@ -38,8 +38,8 @@
}
enum AppGridUsageTipsMetrics {
    static let barHeight: CGFloat = 136
    static let reservedHeight: CGFloat = 176
    static let barHeight: CGFloat = 154
    static let reservedHeight: CGFloat = 194
    static let bottomMargin: CGFloat = 20
    static let horizontalInset: CGFloat = 24
    static let minWidth: CGFloat = 640
@@ -48,9 +48,11 @@
struct AppGridCollectionView: NSViewRepresentable {
    let groups: [TagGroup]
    let tagColors: [String: Int]
    let tagCustomColors: [String: TagCustomColor]
    let displayMode: String
    let iconSize: CGFloat
    let showNames: Bool
    let appGridTheme: AppGridTheme
    let bubbleDisabled: Bool
    let showUncommonAppBubbles: Bool
    let highlightedGroupName: String?
@@ -70,6 +72,7 @@
    let onGroupActivate: (String) -> Void
    let onScrollActivity: () -> Void
    let onDragModeChange: (Bool) -> Void
    let onHideUsageTips: () -> Void
    let onUsageTipsHoverChange: (Bool) -> Void
    func makeCoordinator() -> Coordinator {
@@ -87,9 +90,11 @@
        context.coordinator.update(
            groups: groups,
            tagColors: tagColors,
            tagCustomColors: tagCustomColors,
            displayMode: displayMode,
            iconSize: iconSize,
            showNames: showNames,
            appGridTheme: appGridTheme,
            bubbleDisabled: bubbleDisabled,
            showUncommonAppBubbles: showUncommonAppBubbles,
            highlightedGroupName: highlightedGroupName,
@@ -109,6 +114,7 @@
            onGroupActivate: onGroupActivate,
            onScrollActivity: onScrollActivity,
            onDragModeChange: onDragModeChange,
            onHideUsageTips: onHideUsageTips,
            onUsageTipIndexChange: { selectedUsageTipIndexBinding.wrappedValue = $0 },
            onUsageTipsHoverChange: onUsageTipsHoverChange
        )
@@ -118,9 +124,11 @@
    final class Coordinator: NSObject, NSCollectionViewDataSource, NSCollectionViewDelegate {
        var groups: [TagGroup] = []
        var tagColors: [String: Int] = [:]
        var tagCustomColors: [String: TagCustomColor] = [:]
        var displayMode = AppDefaults.displayMode
        var iconSize: CGFloat = AppDefaults.iconSize
        var showNames = true
        var appGridTheme = AppGridTheme.fallback
        private var externalBubbleDisabled = false
        private var scrollBubbleDisabled = false
        var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
@@ -146,6 +154,7 @@
        var onGroupActivate: (String) -> Void = { _ in }
        var onScrollActivity: () -> Void = {}
        var onDragModeChange: (Bool) -> Void = { _ in }
        var onHideUsageTips: () -> Void = {}
        var onUsageTipIndexChange: (Int) -> Void = { _ in }
        var onUsageTipsHoverChange: (Bool) -> Void = { _ in }
@@ -158,9 +167,11 @@
        func update(
            groups: [TagGroup],
            tagColors: [String: Int],
            tagCustomColors: [String: TagCustomColor],
            displayMode: String,
            iconSize: CGFloat,
            showNames: Bool,
            appGridTheme: AppGridTheme,
            bubbleDisabled: Bool,
            showUncommonAppBubbles: Bool,
            highlightedGroupName: String?,
@@ -180,14 +191,17 @@
            onGroupActivate: @escaping (String) -> Void,
            onScrollActivity: @escaping () -> Void,
            onDragModeChange: @escaping (Bool) -> Void,
            onHideUsageTips: @escaping () -> Void,
            onUsageTipIndexChange: @escaping (Int) -> Void,
            onUsageTipsHoverChange: @escaping (Bool) -> Void
        ) {
            self.groups = groups
            self.tagColors = tagColors
            self.tagCustomColors = tagCustomColors
            self.displayMode = displayMode
            self.iconSize = iconSize
            self.showNames = showNames
            self.appGridTheme = appGridTheme
            self.externalBubbleDisabled = bubbleDisabled
            self.showUncommonAppBubbles = showUncommonAppBubbles
            self.highlightedGroupName = highlightedGroupName
@@ -207,6 +221,7 @@
            self.onGroupActivate = onGroupActivate
            self.onScrollActivity = onScrollActivity
            self.onDragModeChange = onDragModeChange
            self.onHideUsageTips = onHideUsageTips
            self.onUsageTipIndexChange = onUsageTipIndexChange
            self.onUsageTipsHoverChange = onUsageTipsHoverChange
            if !self.usageTipsVisible {
@@ -215,9 +230,11 @@
            let nextSignature = Self.signature(
                tagColors: tagColors,
                tagCustomColors: tagCustomColors,
                displayMode: displayMode,
                iconSize: iconSize,
                showNames: showNames,
                appGridTheme: appGridTheme,
                showUncommonAppBubbles: showUncommonAppBubbles,
                bottomContentPadding: self.bottomContentPadding,
                contentRevision: contentRevision
@@ -390,9 +407,11 @@
        private static func signature(
            tagColors: [String: Int],
            tagCustomColors: [String: TagCustomColor],
            displayMode: String,
            iconSize: CGFloat,
            showNames: Bool,
            appGridTheme: AppGridTheme,
            showUncommonAppBubbles: Bool,
            bottomContentPadding: CGFloat,
            contentRevision: Int
@@ -401,15 +420,28 @@
                .sorted { $0.key < $1.key }
                .map { "\($0.key)=\($0.value)" }
                .joined(separator: ",")
            let customColorPart = tagCustomColors
                .sorted { $0.key < $1.key }
                .map { "\($0.key)=\($0.value.signature)" }
                .joined(separator: ",")
            return [
                displayMode,
                "\(Int(iconSize.rounded()))",
                showNames ? "names" : "nonames",
                "theme=\(appGridTheme.rawValue)",
                showUncommonAppBubbles ? "uncommon" : "allbubbles",
                "bottom=\(Int(bottomContentPadding.rounded()))",
                colorPart,
                customColorPart,
                "rev=\(contentRevision)"
            ].joined(separator: "|")
        }
        func tagColor(for groupName: String) -> NSColor {
            TagColor.nsColor(
                for: tagColors[groupName] ?? 0,
                customColor: tagCustomColors[groupName]
            )
        }
        private static func clampedUsageTipIndex(_ index: Int, tips: [AppGridUsageTip]) -> Int {
@@ -477,6 +509,7 @@
    func applyCoordinatorUpdate() {
        guard let coordinator else { return }
        applyAppearance()
        if coordinator.needsReload {
            coordinator.needsReload = false
            collectionView.reloadData()
@@ -548,6 +581,17 @@
        DispatchQueue.main.async {
            coordinator.onDropOutsideGroup(path, source, copy)
        }
    }
    func canPreviewEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) -> Bool {
        guard let coordinator,
              coordinator.displayStyle != .flat
        else { return false }
        return !coordinator.shouldCancelEmptyDropForActiveReorder(
            path: path,
            screenPoint: screenPoint,
            copy: copy
        )
    }
    private func setup() {
@@ -766,6 +810,12 @@
        }
        return didScroll
    }
    private func applyAppearance() {
        let usesDarkGlass = coordinator?.appGridTheme.usesDarkGlass == true
        layer?.backgroundColor = NSColor.clear.cgColor
        collectionView.appearance = usesDarkGlass ? NSAppearance(named: .darkAqua) : nil
    }
}
private final class AppGridUsageTipsShieldView: NSView {
@@ -823,10 +873,12 @@
    weak var coordinator: AppGridCollectionView.Coordinator?
    private let backgroundView = NSVisualEffectView()
    private let titlePanelView = NSView()
    private let iconView = AppGridDecorativeImageView()
    private let titleLabel = NSTextField(labelWithString: "")
    private let detailScrollView = NSScrollView()
    private let detailLabel = NSTextField(labelWithString: "")
    private let closeButton = AppGridUsageTipIconButton(systemImage: "xmark")
    private let previousButton = AppGridUsageTipIconButton(systemImage: "chevron.left")
    private let nextButton = AppGridUsageTipIconButton(systemImage: "chevron.right")
    private let dotsView = AppGridUsageTipDotsView()
@@ -835,9 +887,10 @@
    private var visualWidth: CGFloat = AppGridUsageTipsMetrics.minWidth
    private var visualHeight: CGFloat = AppGridUsageTipsMetrics.barHeight
    private var visualBottomMargin: CGFloat = AppGridUsageTipsMetrics.bottomMargin
    private var currentDetailText = ""
    private let titleFont = NSFont.systemFont(ofSize: 24, weight: .semibold)
    private let detailFont = NSFont.systemFont(ofSize: 24, weight: .regular)
    private let titleFont = NSFont.systemFont(ofSize: 25, weight: .bold)
    private let detailFont = NSFont.systemFont(ofSize: 24, weight: .medium)
    override var isFlipped: Bool { true }
    override var acceptsFirstResponder: Bool { true }
@@ -939,41 +992,65 @@
        let visualFrame = currentVisualFrame()
        backgroundView.frame = visualFrame
        let paddingLeft: CGFloat = 22
        let paddingRight: CGFloat = 16
        let iconSize: CGFloat = 24
        let iconTextGap: CGFloat = 10
        let controlZoneWidth: CGFloat = 176
        let buttonGap: CGFloat = 6
        let buttonSize: CGFloat = 48
        let buttonDotsGap: CGFloat = 6
        let outerPaddingX: CGFloat = 20
        let outerPaddingY: CGFloat = 19
        let titlePanelWidth = min(
            max(350, visualFrame.width * 0.24),
            min(460, visualFrame.width * 0.36)
        )
        let titlePanelHeight = max(1, min(116, visualFrame.height - outerPaddingY * 2))
        let titlePanelFrame = NSRect(
            x: visualFrame.minX + outerPaddingX,
            y: visualFrame.midY - titlePanelHeight / 2,
            width: titlePanelWidth,
            height: titlePanelHeight
        )
        titlePanelView.frame = titlePanelFrame
        let iconSize: CGFloat = 26
        let controlZoneWidth: CGFloat = min(148, max(128, visualFrame.width * 0.08))
        let buttonGap: CGFloat = 12
        let buttonSize: CGFloat = 52
        let buttonDotsGap: CGFloat = 10
        let dotsHeight: CGFloat = 10
        let dotsWidth = dotsView.preferredWidth
        let titleX = visualFrame.minX + paddingLeft + iconSize + iconTextGap
        let titleIconGap: CGFloat = 20
        let titleSidePadding: CGFloat = 28
        let titleX = titlePanelFrame.minX + titleSidePadding + iconSize + titleIconGap
        let titleWidth = max(1, titlePanelFrame.maxX - titleX - titleSidePadding)
        let titleHeight: CGFloat = 68
        let titleY = titlePanelFrame.midY - titleHeight / 2
        let detailGap = visualFrame.width < 980 ? CGFloat(30) : CGFloat(52)
        let controlGap = visualFrame.width < 980 ? CGFloat(24) : CGFloat(36)
        let detailX = titlePanelFrame.maxX + detailGap
        let controlsMaxX = visualFrame.maxX - 54
        let textRight = controlsMaxX - controlZoneWidth - controlGap
        let detailHeight: CGFloat = 88
        let detailY = visualFrame.minY + max(0, (visualFrame.height - detailHeight) / 2)
        let buttonsWidth = buttonSize * 2 + buttonGap
        let buttonsGroupMinX = visualFrame.maxX - paddingRight - buttonsWidth
        let textRight = visualFrame.maxX - controlZoneWidth
        let availableTextWidth = max(1, textRight - titleX)
        let titleWidth = availableTextWidth
        let titleHeight: CGFloat = 32
        let detailHeight: CGFloat = 68
        let lineGap: CGFloat = 8
        let textBlockHeight = titleHeight + lineGap + detailHeight
        let textBlockY = visualFrame.minY + max(0, (visualFrame.height - textBlockHeight) / 2)
        let buttonsGroupMinX = controlsMaxX - buttonsWidth
        iconView.frame = NSRect(
            x: visualFrame.minX + paddingLeft,
            y: visualFrame.midY - iconSize / 2,
            x: titlePanelFrame.minX + titleSidePadding,
            y: titlePanelFrame.midY - iconSize / 2,
            width: iconSize,
            height: iconSize
        )
        titleLabel.frame = NSRect(
            x: titleX,
            y: textBlockY,
            y: titleY,
            width: titleWidth,
            height: titleHeight
        )
        closeButton.frame = NSRect(
            x: visualFrame.maxX - 40,
            y: visualFrame.minY + 10,
            width: 30,
            height: 30
        )
        let controlGroupHeight = buttonSize + buttonDotsGap + dotsHeight
@@ -997,12 +1074,10 @@
            height: dotsHeight
        )
        let detailX = titleX
        let detailRight = textRight
        detailScrollView.frame = NSRect(
            x: detailX,
            y: titleLabel.frame.maxY + lineGap,
            width: max(1, detailRight - detailX),
            y: detailY,
            width: max(1, textRight - detailX),
            height: detailHeight
        )
        layoutDetailLabel()
@@ -1022,7 +1097,9 @@
        let safeIndex = min(max(0, coordinator.selectedUsageTipIndex), coordinator.usageTips.count - 1)
        let tip = coordinator.usageTips[safeIndex]
        titleLabel.stringValue = tr(tip.titleKey)
        detailLabel.stringValue = formattedTipDetail(tr(tip.detailKey))
        updateTipIcon(id: tip.id)
        currentDetailText = formattedTipDetail(tr(tip.detailKey))
        closeButton.buttonAccessibilityLabel = tr("usageTips.close")
        previousButton.buttonAccessibilityLabel = tr("usageTips.previous")
        nextButton.buttonAccessibilityLabel = tr("usageTips.next")
        dotsView.configure(count: coordinator.usageTips.count, selectedIndex: safeIndex)
@@ -1044,13 +1121,7 @@
    }
    func preferredWidth(maxAvailableWidth: CGFloat) -> CGFloat {
        let padding: CGFloat = 22 + 16
        let fixedWidth: CGFloat = 24 + 10 + 24 + 48 + 6 + 48
        let titleWidth = ceil(titleLabel.attributedStringValue.size().width)
        let detailWidth = measuredDetailLineWidth()
        let contentWidth = padding + fixedWidth + max(titleWidth, detailWidth)
        let preferredWidth = max(AppGridUsageTipsMetrics.minWidth, contentWidth)
        return min(maxAvailableWidth, preferredWidth)
        max(1, maxAvailableWidth)
    }
    private func setup() {
@@ -1069,7 +1140,13 @@
        backgroundView.layer?.borderWidth = 1
        addSubview(backgroundView)
        let iconConfig = NSImage.SymbolConfiguration(pointSize: 24, weight: .semibold)
        titlePanelView.wantsLayer = true
        titlePanelView.layer?.cornerRadius = 18
        titlePanelView.layer?.masksToBounds = true
        titlePanelView.layer?.borderWidth = 1
        addSubview(titlePanelView)
        let iconConfig = NSImage.SymbolConfiguration(pointSize: 26, weight: .bold)
        iconView.image = NSImage(
            systemSymbolName: "lightbulb.fill",
            accessibilityDescription: nil
@@ -1077,7 +1154,7 @@
        iconView.imageScaling = .scaleProportionallyDown
        addSubview(iconView)
        configureLabel(titleLabel, font: titleFont, lineBreakMode: .byClipping)
        configureLabel(titleLabel, font: titleFont, lineBreakMode: .byWordWrapping)
        configureDetailLabel()
        detailScrollView.drawsBackground = false
@@ -1087,12 +1164,14 @@
        detailScrollView.borderType = .noBorder
        detailScrollView.documentView = detailLabel
        closeButton.action = { [weak self] in self?.requestHideUsageTips() }
        previousButton.action = { [weak self] in self?.selectUsageTip(offset: -1) }
        nextButton.action = { [weak self] in self?.selectUsageTip(offset: 1) }
        addSubview(titleLabel)
        addSubview(detailScrollView)
        addSubview(dotsView)
        addSubview(closeButton)
        addSubview(previousButton)
        addSubview(nextButton)
@@ -1107,13 +1186,13 @@
        label.drawsBackground = false
        label.isBordered = false
        label.lineBreakMode = lineBreakMode
        label.maximumNumberOfLines = 1
        label.alignment = .center
        label.maximumNumberOfLines = 2
        label.alignment = .left
        label.font = font
    }
    private func configureDetailLabel() {
        detailLabel.cell = NSTextFieldCell(textCell: "")
        detailLabel.cell = AppGridCenteredMultilineTextFieldCell(textCell: "")
        detailLabel.isEditable = false
        detailLabel.isSelectable = false
        detailLabel.drawsBackground = false
@@ -1145,9 +1224,21 @@
        applyCoordinatorState()
    }
    private func requestHideUsageTips() {
        Diagnostics.log("usageTips.hud.close")
        clearHoverState()
        coordinator?.onHideUsageTips()
    }
    private func routeButtonClickIfNeeded(_ event: NSEvent) -> Bool {
        let point = usageTipsPoint(for: event)
        let hitOutset: CGFloat = 24
        let closeHitOutset: CGFloat = 8
        if closeButton.frame.insetBy(dx: -closeHitOutset, dy: -closeHitOutset).contains(point) {
            closeButton.performPressFeedback()
            requestHideUsageTips()
            return true
        }
        let hitOutset: CGFloat = 6
        if nextButton.frame.insetBy(dx: -hitOutset, dy: -hitOutset).contains(point) {
            Diagnostics.log("usageTips.hud.routedNext")
            selectUsageTip(offset: 1)
@@ -1196,14 +1287,6 @@
        detailLabel.frame = NSRect(x: 0, y: 0, width: width, height: detailScrollView.bounds.height)
    }
    private func measuredDetailLineWidth() -> CGFloat {
        let attributes: [NSAttributedString.Key: Any] = [.font: detailFont]
        let lineWidths = detailLabel.stringValue
            .components(separatedBy: .newlines)
            .map { NSAttributedString(string: $0, attributes: attributes).size().width }
        return ceil(lineWidths.max() ?? detailLabel.attributedStringValue.size().width)
    }
    private func currentVisualFrame() -> NSRect {
        let width = min(bounds.width, visualWidth)
        let height = min(bounds.height, visualHeight)
@@ -1216,21 +1299,197 @@
    }
    private func formattedTipDetail(_ text: String) -> String {
        text
        let normalized = text
            .replacingOccurrences(of: "\\s*(?:-->|->|>|→|>|,)\\s*", with: "\n", options: .regularExpression)
            .replacingOccurrences(of: "[ \\t]{2,}", with: " ", options: .regularExpression)
            .replacingOccurrences(of: "\\n{2,}", with: "\n", options: .regularExpression)
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return orderedTipDetail(normalized)
    }
    private func orderedTipDetail(_ text: String) -> String {
        let lines = text
            .components(separatedBy: .newlines)
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
        guard lines.count > 1 else { return text }
        return lines
            .enumerated()
            .map { "\($0.offset + 1). \($0.element)" }
            .joined(separator: "\n")
    }
    private func updateTipIcon(id: Int) {
        let symbolName: String
        switch id {
        case 1:
            symbolName = "tag.fill"
        case 2:
            symbolName = "checkmark.seal.fill"
        case 3:
            symbolName = "arrow.up.and.down.and.arrow.left.and.right"
        case 4:
            symbolName = "doc.on.doc.fill"
        case 5:
            symbolName = "minus.circle.fill"
        case 6:
            symbolName = "arrow.up.arrow.down.circle.fill"
        case 7:
            symbolName = "square.grid.2x2.fill"
        case 8:
            symbolName = "note.text"
        default:
            symbolName = "lightbulb.fill"
        }
        let iconConfig = NSImage.SymbolConfiguration(pointSize: 26, weight: .bold)
        iconView.image = NSImage(
            systemSymbolName: symbolName,
            accessibilityDescription: nil
        )?.withSymbolConfiguration(iconConfig)
        ?? NSImage(
            systemSymbolName: "lightbulb.fill",
            accessibilityDescription: nil
        )?.withSymbolConfiguration(iconConfig)
    }
    private func updateColors() {
        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
        let usesDarkGlass = coordinator?.appGridTheme.usesDarkGlass == true
        backgroundView.material = usesDarkGlass ? .underWindowBackground : .popover
        backgroundView.appearance = usesDarkGlass ? NSAppearance(named: .darkAqua) : nil
        backgroundView.layer?.backgroundColor = (
            usesDarkGlass
                ? NSColor.black.withAlphaComponent(0.46)
                : NSColor.white.withAlphaComponent(0.54)
        ).cgColor
        backgroundView.layer?.borderColor = (
            usesDarkGlass
                ? NSColor.white.withAlphaComponent(0.22)
                : NSColor.black.withAlphaComponent(0.18)
        ).cgColor
        titlePanelView.layer?.backgroundColor = (
            usesDarkGlass
                ? NSColor.white.withAlphaComponent(0.08)
                : NSColor.white.withAlphaComponent(0.42)
        ).cgColor
        titlePanelView.layer?.borderColor = (
            usesDarkGlass
                ? NSColor.white.withAlphaComponent(0.12)
                : NSColor.black.withAlphaComponent(0.08)
        ).cgColor
        let theme = coordinator?.appGridTheme ?? .fallback
        let accentColor = usageTipAccentColor(for: theme)
        iconView.contentTintColor = accentColor
        titleLabel.textColor = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.92)
            : accentColor.withAlphaComponent(0.94)
        detailLabel.textColor = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.95)
            : NSColor(calibratedRed: 0.08, green: 0.10, blue: 0.13, alpha: 0.94)
        applyDetailTextStyle()
        let buttonTint = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.90)
            : NSColor(calibratedWhite: 0.08, alpha: 0.92)
        let buttonHoverTint = usesDarkGlass
            ? NSColor.white
            : NSColor(calibratedWhite: 0.04, alpha: 1.00)
        let buttonPressedTint = usesDarkGlass
            ? NSColor.white
            : NSColor.black
        let buttonSurface = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.10)
            : NSColor.white.withAlphaComponent(0.38)
        let buttonHoverSurface = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.20)
            : NSColor.white.withAlphaComponent(0.70)
        let buttonPressedSurface = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.28)
            : NSColor.white.withAlphaComponent(0.86)
        let buttonBorder = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.14)
            : NSColor.black.withAlphaComponent(0.10)
        [previousButton, nextButton].forEach { button in
            button.tintColor = buttonTint
            button.hoveredTintColor = buttonHoverTint
            button.pressedTintColor = buttonPressedTint
            button.surfaceColor = buttonSurface
            button.hoveredSurfaceColor = buttonHoverSurface
            button.pressedSurfaceColor = buttonPressedSurface
            button.borderColor = buttonBorder
        }
        closeButton.tintColor = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.42)
            : NSColor.black.withAlphaComponent(0.34)
        closeButton.hoveredTintColor = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.86)
            : NSColor.black.withAlphaComponent(0.78)
        closeButton.pressedTintColor = usesDarkGlass ? .white : .black
        closeButton.surfaceColor = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.04)
            : NSColor.white.withAlphaComponent(0.08)
        closeButton.hoveredSurfaceColor = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.14)
            : NSColor.white.withAlphaComponent(0.62)
        closeButton.pressedSurfaceColor = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.22)
            : NSColor.white.withAlphaComponent(0.82)
        closeButton.borderColor = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.08)
            : NSColor.black.withAlphaComponent(0.08)
        dotsView.selectedColor = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.88)
            : NSColor.labelColor.withAlphaComponent(0.74)
        dotsView.idleColor = usesDarkGlass
            ? NSColor.white.withAlphaComponent(0.28)
            : NSColor.labelColor.withAlphaComponent(0.24)
        layer?.shadowOpacity = usesDarkGlass ? 0.28 : 0.14
        layer?.shadowRadius = usesDarkGlass ? 18 : 12
        needsDisplay = true
    }
    private func usageTipAccentColor(for theme: AppGridTheme) -> NSColor {
        switch theme {
        case .defaultLight:
            return NSColor(calibratedRed: 0.34, green: 0.40, blue: 0.48, alpha: 1.00)
        case .deepBlue:
            return NSColor(calibratedRed: 0.42, green: 0.80, blue: 1.00, alpha: 1.00)
        case .black:
            return NSColor(calibratedRed: 0.36, green: 0.74, blue: 1.00, alpha: 1.00)
        case .pink:
            return NSColor(calibratedRed: 0.70, green: 0.18, blue: 0.45, alpha: 1.00)
        case .purple:
            return NSColor(calibratedRed: 0.40, green: 0.22, blue: 0.70, alpha: 1.00)
        case .green:
            return NSColor(calibratedRed: 0.08, green: 0.42, blue: 0.22, alpha: 1.00)
        case .blue:
            return NSColor(calibratedRed: 0.06, green: 0.34, blue: 0.74, alpha: 1.00)
        case .colorful:
            return NSColor(calibratedRed: 0.06, green: 0.44, blue: 0.78, alpha: 1.00)
        }
    }
    private func applyDetailTextStyle() {
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = .left
        paragraphStyle.lineBreakMode = .byWordWrapping
        paragraphStyle.minimumLineHeight = 31
        paragraphStyle.maximumLineHeight = 34
        paragraphStyle.lineSpacing = 6
        detailLabel.attributedStringValue = NSAttributedString(
            string: currentDetailText,
            attributes: [
                .font: detailFont,
                .foregroundColor: detailLabel.textColor ?? NSColor.labelColor,
                .paragraphStyle: paragraphStyle
            ]
        )
    }
}
@@ -1240,6 +1499,20 @@
        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 AppGridCenteredMultilineTextFieldCell: NSTextFieldCell {
    override func drawingRect(forBounds rect: NSRect) -> NSRect {
        var drawingRect = super.drawingRect(forBounds: rect)
        let textHeight = attributedStringValue.boundingRect(
            with: NSSize(width: max(1, rect.width), height: .greatestFiniteMagnitude),
            options: [.usesLineFragmentOrigin, .usesFontLeading]
        ).height
        let centeredHeight = min(rect.height, ceil(textHeight) + 4)
        drawingRect.origin.y = rect.origin.y + max(0, (rect.height - centeredHeight) / 2)
        drawingRect.size.height = centeredHeight
        return drawingRect
    }
}
@@ -1263,8 +1536,26 @@
    }
    var tintColor: NSColor = .labelColor {
        didSet {
            imageView.contentTintColor = tintColor
            updateIconTint()
        }
    }
    var hoveredTintColor: NSColor? {
        didSet { updateIconTint() }
    }
    var pressedTintColor: NSColor? {
        didSet { updateIconTint() }
    }
    var surfaceColor: NSColor = .clear {
        didSet { needsDisplay = true }
    }
    var hoveredSurfaceColor: NSColor = NSColor.labelColor.withAlphaComponent(0.08) {
        didSet { needsDisplay = true }
    }
    var pressedSurfaceColor: NSColor = NSColor.labelColor.withAlphaComponent(0.14) {
        didSet { needsDisplay = true }
    }
    var borderColor: NSColor = .clear {
        didSet { needsDisplay = true }
    }
    private let imageView = AppGridDecorativeImageView()
@@ -1304,9 +1595,20 @@
        return true
    }
    func performPressFeedback() {
        isPressed = true
        needsDisplay = true
        updateIconTint()
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak self] in
            self?.isPressed = false
            self?.needsDisplay = true
            self?.updateIconTint()
        }
    }
    override func layout() {
        super.layout()
        let size: CGFloat = 26
        let size = min(CGFloat(26), max(14, min(bounds.width, bounds.height) * 0.62))
        imageView.frame = NSRect(
            x: (bounds.width - size) / 2,
            y: (bounds.height - size) / 2,
@@ -1337,31 +1639,32 @@
    override func mouseEntered(with event: NSEvent) {
        isHovered = true
        needsDisplay = true
        updateIconTint()
    }
    override func mouseExited(with event: NSEvent) {
        isHovered = false
        isPressed = false
        needsDisplay = true
        updateIconTint()
    }
    override func mouseDown(with event: NSEvent) {
        Diagnostics.log("usageTips.button.mouseDown", [
            "label": buttonAccessibilityLabel
        ])
        isPressed = true
        needsDisplay = true
        performPressFeedback()
        action?()
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak self] in
            self?.isPressed = false
            self?.needsDisplay = true
        }
    }
    override func draw(_ dirtyRect: NSRect) {
        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()
        let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 8, yRadius: 8)
        let fill = isPressed ? pressedSurfaceColor : isHovered ? hoveredSurfaceColor : surfaceColor
        fill.setFill()
        path.fill()
        borderColor.setStroke()
        path.lineWidth = 1
        path.stroke()
    }
    private func setup(systemImage: String) {
@@ -1376,11 +1679,27 @@
        addSubview(imageView)
        setAccessibilityRole(.button)
    }
    private func updateIconTint() {
        if isPressed, let pressedTintColor {
            imageView.contentTintColor = pressedTintColor
        } else if isHovered, let hoveredTintColor {
            imageView.contentTintColor = hoveredTintColor
        } else {
            imageView.contentTintColor = tintColor
        }
    }
}
private final class AppGridUsageTipDotsView: NSView {
    private var count = 0
    private var selectedIndex = 0
    var selectedColor: NSColor = .labelColor {
        didSet { needsDisplay = true }
    }
    var idleColor: NSColor = NSColor.labelColor.withAlphaComponent(0.28) {
        didSet { needsDisplay = true }
    }
    override var isFlipped: Bool { true }
@@ -1405,7 +1724,7 @@
        for index in 0..<count {
            let selected = index == selectedIndex
            let dotSize: CGFloat = selected ? 6 : 5
            let color = NSColor.labelColor.withAlphaComponent(selected ? 0.85 : 0.28)
            let color = selected ? selectedColor : idleColor
            color.setFill()
            NSBezierPath(
                ovalIn: NSRect(
@@ -1418,6 +1737,7 @@
            x += 5 + spacing
        }
    }
}
private final class AppGridContainerCollectionLayout: NSCollectionViewLayout {
@@ -1476,7 +1796,7 @@
    private struct LayoutItem {
        let index: Int
        let frame: NSRect
        var frame: NSRect
    }
    private struct LayoutPlan {
@@ -1612,8 +1932,20 @@
        let columnWidth = floor((availableWidth - gap * CGFloat(columnCount - 1)) / CGFloat(columnCount))
        var columnHeights = Array(repeating: outerPadding, count: columnCount)
        var items: [LayoutItem] = []
        var lastItemIndexForColumn = Array<Int?>(repeating: nil, count: columnCount)
        let bottomPinnedGroups = groups.enumerated()
            .filter { bottomPinnedPriority(for: $0.element.containerID) != nil }
            .sorted {
                let lhsPriority = bottomPinnedPriority(for: $0.element.containerID) ?? Int.max
                let rhsPriority = bottomPinnedPriority(for: $1.element.containerID) ?? Int.max
                if lhsPriority != rhsPriority { return lhsPriority < rhsPriority }
                return $0.offset < $1.offset
            }
        let bottomPinnedIndexes = Set(bottomPinnedGroups.map { $0.offset })
        for (index, group) in groups.enumerated() {
            if bottomPinnedIndexes.contains(index) { continue }
            let columnIndex = columnHeights.indices.min { columnHeights[$0] < columnHeights[$1] } ?? 0
            let x = outerPadding + CGFloat(columnIndex) * (columnWidth + gap)
            let y = columnHeights[columnIndex]
@@ -1628,16 +1960,54 @@
                    frame: NSRect(x: x, y: y, width: columnWidth, height: height)
                )
            )
            lastItemIndexForColumn[columnIndex] = items.count - 1
            columnHeights[columnIndex] = y + height + gap
        }
        let tallest = columnHeights.max() ?? outerPadding
        let contentHeight = (groups.isEmpty ? outerPadding * 2 : tallest - gap + outerPadding)
        var contentBottom = columnHeights.max() ?? outerPadding
        if !bottomPinnedGroups.isEmpty {
            let alignedBottom = max(outerPadding, contentBottom - gap)
            for itemIndex in lastItemIndexForColumn.compactMap({ $0 }) {
                let currentFrame = items[itemIndex].frame
                let alignedHeight = alignedBottom - currentFrame.minY
                if alignedHeight > currentFrame.height {
                    items[itemIndex].frame.size.height = alignedHeight
                }
            }
        }
        for (index, group) in bottomPinnedGroups {
            let y = max(outerPadding, contentBottom)
            let height = AppGridCollectionMetrics.cardHeight(
                appCount: group.apps.count,
                width: availableWidth,
                iconSize: iconSize
            )
            items.append(
                LayoutItem(
                    index: index,
                    frame: NSRect(x: outerPadding, y: y, width: availableWidth, height: height)
                )
            )
            contentBottom = y + height + gap
        }
        let contentHeight = (items.isEmpty ? outerPadding * 2 : contentBottom - gap + outerPadding)
            + max(0, bottomContentPadding)
        return LayoutPlan(
            items: items,
            contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight))
        )
    }
    private static func bottomPinnedPriority(for containerID: String) -> Int? {
        switch containerID {
        case AppContainerID.uncategorized:
            return 0
        case AppContainerID.appleBuiltIn:
            return 1
        default:
            return nil
        }
    }
    private struct RowItem {
@@ -1974,7 +2344,7 @@
    override func draw(_ dirtyRect: NSRect) {
        guard let coordinator, let group else { return }
        let displayStyle = coordinator.displayStyle
        let tagColor = TagColor.nsColor(for: coordinator.tagColors[group.name] ?? 0)
        let tagColor = coordinator.tagColor(for: group.name)
        let isNavigationHighlighted = coordinator.highlightedGroupName == group.name
        let isColorlessActive = coordinator.isColorlessContainerMode
            && (isHovered || isNavigationHighlighted)
@@ -1982,15 +2352,24 @@
        if displayStyle.usesCardSurface {
            let rect = bounds.insetBy(dx: 0.5, dy: 0.5)
            let path = NSBezierPath(roundedRect: rect, xRadius: 14, yRadius: 14)
            cardSurfaceColor().setFill()
            path.fill()
            if coordinator.isColoredContainerMode || isColorlessActive {
                tagColor.withAlphaComponent(0.30).setFill()
            if coordinator.appGridTheme.usesDarkGlass {
                drawDarkCardSurface(
                    path: path,
                    tagColor: tagColor,
                    isActive: isHovered || isNavigationHighlighted,
                    isTinted: coordinator.isColoredContainerMode || isColorlessActive
                )
            } else {
                cardSurfaceColor().setFill()
                path.fill()
                if coordinator.isColoredContainerMode || isColorlessActive {
                    tagColor.withAlphaComponent(0.30).setFill()
                    path.fill()
                }
                NSColor.labelColor.withAlphaComponent(0.08).setStroke()
                path.lineWidth = 1
                path.stroke()
            }
            NSColor.labelColor.withAlphaComponent(0.08).setStroke()
            path.lineWidth = 1
            path.stroke()
        }
        drawHeader(title: group.name, displayStyle: displayStyle)
@@ -2210,6 +2589,17 @@
            && (isHovered || isNavigationHighlighted)
        let shouldShadow = coordinator.displayStyle.usesCardSurface
            && ((coordinator.isColoredContainerMode && (isHovered || isNavigationHighlighted)) || isColorlessActive)
        if coordinator.appGridTheme.usesDarkGlass && coordinator.displayStyle.usesCardSurface {
            let tagColor = coordinator.tagColor(for: group?.name ?? "")
            let active = isHovered || isNavigationHighlighted
            layer?.shadowColor = active
                ? tagColor.withAlphaComponent(0.82).cgColor
                : NSColor.black.withAlphaComponent(0.72).cgColor
            layer?.shadowOpacity = active ? 0.50 : 0.28
            layer?.shadowRadius = active ? 24 : 14
            layer?.shadowOffset = NSSize(width: 0, height: active ? -8 : -4)
            return
        }
        layer?.shadowColor = NSColor.black.cgColor
        layer?.shadowOpacity = shouldShadow ? 0.22 : 0
        layer?.shadowRadius = shouldShadow ? 8 : 0
@@ -2245,6 +2635,40 @@
        return NSColor.white.withAlphaComponent(0.62)
    }
    private func drawDarkCardSurface(
        path: NSBezierPath,
        tagColor: NSColor,
        isActive: Bool,
        isTinted: Bool
    ) {
        NSGraphicsContext.saveGraphicsState()
        if isActive {
            let glow = NSShadow()
            glow.shadowColor = tagColor.withAlphaComponent(0.36)
            glow.shadowBlurRadius = 28
            glow.shadowOffset = NSSize(width: 0, height: -8)
            glow.set()
        }
        NSColor(calibratedRed: 0.12, green: 0.18, blue: 0.25, alpha: 0.58).setFill()
        path.fill()
        NSGraphicsContext.restoreGraphicsState()
        if isTinted {
            tagColor.withAlphaComponent(isActive ? 0.18 : 0.10).setFill()
            path.fill()
        }
        NSColor.white.withAlphaComponent(isActive ? 0.08 : 0.045).setFill()
        path.fill()
        let strokeColor = isActive
            ? tagColor.withAlphaComponent(0.72)
            : NSColor.white.withAlphaComponent(0.18)
        strokeColor.setStroke()
        path.lineWidth = isActive ? 1.4 : 1.0
        path.stroke()
    }
    private func drawHeader(title: String, displayStyle: AppGridCollectionDisplayMode) {
        let horizontalInset = displayStyle.usesCardSurface ? AppGridCollectionMetrics.cardPadding : 0
        let verticalInset = displayStyle.usesCardSurface ? AppGridCollectionMetrics.cardPadding : 0
@@ -2254,9 +2678,13 @@
            width: max(1, bounds.width - horizontalInset * 2),
            height: AppGridCollectionMetrics.headerHeight
        )
        let isDarkGrid = coordinator?.appGridTheme.usesDarkGlass == true
        let headerTextColor = isDarkGrid
            ? NSColor.white.withAlphaComponent(0.74)
            : NSColor.secondaryLabelColor
        let attributes: [NSAttributedString.Key: Any] = [
            .font: NSFont.systemFont(ofSize: 18, weight: .semibold),
            .foregroundColor: NSColor.secondaryLabelColor,
            .foregroundColor: headerTextColor,
            .paragraphStyle: centeredParagraph(lineBreak: .byTruncatingMiddle)
        ]
        let titleSize = title.size(withAttributes: attributes)
@@ -2268,7 +2696,9 @@
            height: headerRect.height - 6
        )
        let lineY = headerRect.midY
        NSColor.secondaryLabelColor.withAlphaComponent(0.25).setStroke()
        (isDarkGrid ? NSColor.white : NSColor.secondaryLabelColor)
            .withAlphaComponent(isDarkGrid ? 0.16 : 0.25)
            .setStroke()
        let leftLine = NSBezierPath()
        leftLine.move(to: NSPoint(x: headerRect.minX, y: lineY))
        leftLine.line(to: NSPoint(x: max(headerRect.minX, titleRect.minX - 2), y: lineY))