// // CVMakerPageView.swift // App for Indeed // // Template gallery for the CV Maker sidebar destination: page header, category // toggle, style chips, thumbnail grid, and a sticky bottom CTA. Follows the // active dashboard light / dark appearance via `AppDashboardTheme`. // import Cocoa import QuartzCore // MARK: - Data model enum CVCategoryGroup: Hashable { case designBased case professionBased var title: String { switch self { case .designBased: return L("Design-Based") case .professionBased: return L("Profession-Based") } } } enum CVDesignFamily: String, CaseIterable, Hashable { case professional, modern, creative, minimal, executive var title: String { switch self { case .professional: return L("Professional") case .modern: return L("Modern") case .creative: return L("Creative") case .minimal: return L("Minimal") case .executive: return L("Executive") } } } /// High-level layout bucket for catalog metadata and filtering. enum CVTemplateLayoutType: String, Hashable { case atsSingleColumn case twoColumnSidebarLeading case twoColumnSidebarTrailing var gallerySubtitle: String { switch self { case .atsSingleColumn: return L("ATS layout") case .twoColumnSidebarLeading: return L("Sidebar left") case .twoColumnSidebarTrailing: return L("Sidebar right") } } } /// Visual recipe used by the mini preview renderer so every template can vary /// the headline style, accent line, and sidebar layout without bespoke views. struct CVTemplate: Hashable { enum Headline: Hashable { /// Big name centered above the body. case centered /// Name aligned to the leading edge, role beneath it. case leftAligned /// Name on the leading edge with circular initials avatar on the trailing edge. case leftWithInitials /// Initials avatar above a centered name (single column). case avatarStacked } enum Accent: Hashable { case none case redUnderline case redBar case blueBar } enum SidebarSide: Hashable { case leading, trailing } enum Layout: Hashable { case singleColumn case twoColumn(sidebar: SidebarSide, tinted: Bool) } enum SectionLabelStyle: Hashable { case uppercase case slashed // "// EXPERIENCE" case bracketed // "[ EXPERIENCE ]" } let id: String let name: String let family: CVDesignFamily let headline: Headline let accent: Accent let layout: Layout let sectionLabelStyle: SectionLabelStyle /// sRGB accent used for headers, tags, and sidebar tints in the mini preview. let themeRed: CGFloat let themeGreen: CGFloat let themeBlue: CGFloat /// Shown on cards; mirrors the design family in this build. var category: String { family.title } var layoutType: CVTemplateLayoutType { switch layout { case .singleColumn: return .atsSingleColumn case .twoColumn(sidebar: .leading, _): return .twoColumnSidebarLeading case .twoColumn(sidebar: .trailing, _): return .twoColumnSidebarTrailing } } /// Top-level gallery tab: expressive layouts for design-led roles vs. conservative ATS-friendly styles. var galleryGroup: CVCategoryGroup { switch family { case .modern, .creative: return .designBased case .minimal, .professional, .executive: return .professionBased } } var themeColor: NSColor { NSColor(srgbRed: themeRed, green: themeGreen, blue: themeBlue, alpha: 1) } /// User-facing template title for the active language (`name` is the English localization key). var localizedName: String { localizedTemplateName(name) } /// Optional bundle image name; `nil` means render a live vector/text preview. var previewImageAssetName: String? { nil } init( id: String, name: String, family: CVDesignFamily, headline: Headline, accent: Accent, layout: Layout, sectionLabelStyle: SectionLabelStyle, themeRed: CGFloat? = nil, themeGreen: CGFloat? = nil, themeBlue: CGFloat? = nil ) { self.id = id self.name = name self.family = family self.headline = headline self.accent = accent self.layout = layout self.sectionLabelStyle = sectionLabelStyle if let tr = themeRed, let tg = themeGreen, let tb = themeBlue { self.themeRed = tr self.themeGreen = tg self.themeBlue = tb } else { let rgb = Self.resolvedThemeRGB(family: family, id: id) self.themeRed = rgb.0 self.themeGreen = rgb.1 self.themeBlue = rgb.2 } } private static func resolvedThemeRGB(family: CVDesignFamily, id: String) -> (CGFloat, CGFloat, CGFloat) { var hash: UInt64 = 1469598103934665603 for b in id.utf8 { hash ^= UInt64(b) hash &*= 1_099_511_628_211 } let t = Double(hash % 1000) / 1000.0 switch family { case .professional: let r = 0.12 + t * 0.06 let g = 0.32 + t * 0.08 let b = 0.58 + t * 0.12 return (r, g, b) case .modern: let r = 0.0 + t * 0.08 let g = 0.45 + t * 0.12 let bl = 0.85 + t * 0.1 return (min(r, 1), min(g, 1), min(bl, 1)) case .minimal: return (0.45 + t * 0.05, 0.48 + t * 0.04, 0.55 + t * 0.06) case .executive: let r = 0.08 + t * 0.06 let g = 0.12 + t * 0.05 let b = 0.22 + t * 0.08 return (r, g, b) case .creative: let r = 0.25 + t * 0.2 let g = 0.35 + t * 0.15 let b = 0.72 + t * 0.15 return (min(r, 1), min(g, 1), min(b, 1)) } } } extension CVTemplate { /// Same 0…11 silhouette index as `CVTemplatePreviewView` so the filled résumé matches the gallery thumbnail for that template. var galleryLayoutVariant: Int { var h: UInt64 = 1469598103934665603 let layoutDesc: String switch layout { case .singleColumn: layoutDesc = "1col" case .twoColumn(let s, let t): layoutDesc = "2col_\(s)_\(t)" } let blob = "\(id)|\(family.rawValue)|\(headline)|\(accent)|\(layoutDesc)|\(sectionLabelStyle)" for b in blob.utf8 { h ^= UInt64(b) h &*= 1_099_511_628_211 } return Int(h % 12) } } // MARK: - Catalog enum CVTemplateCatalog { static let all: [CVTemplate] = [ // Minimal family (matches the reference screenshot) CVTemplate( id: "paper-white", name: "Paper White", family: .minimal, headline: .centered, accent: .none, layout: .singleColumn, sectionLabelStyle: .uppercase ), CVTemplate( id: "swiss", name: "Swiss", family: .minimal, headline: .centered, accent: .redUnderline, layout: .twoColumn(sidebar: .leading, tinted: false), sectionLabelStyle: .uppercase ), CVTemplate( id: "mono", name: "Mono", family: .minimal, headline: .leftAligned, accent: .redUnderline, layout: .singleColumn, sectionLabelStyle: .slashed ), CVTemplate( id: "airy", name: "Airy", family: .minimal, headline: .leftWithInitials, accent: .none, layout: .twoColumn(sidebar: .trailing, tinted: false), sectionLabelStyle: .uppercase ), CVTemplate( id: "tabular", name: "Tabular", family: .minimal, headline: .leftAligned, accent: .none, layout: .singleColumn, sectionLabelStyle: .bracketed ), CVTemplate( id: "facet", name: "Facet", family: .minimal, headline: .avatarStacked, accent: .none, layout: .twoColumn(sidebar: .leading, tinted: true), sectionLabelStyle: .uppercase ), // Professional family CVTemplate( id: "corporate", name: "Corporate", family: .professional, headline: .leftAligned, accent: .blueBar, layout: .singleColumn, sectionLabelStyle: .uppercase ), CVTemplate( id: "atlas", name: "Atlas", family: .professional, headline: .centered, accent: .blueBar, layout: .twoColumn(sidebar: .leading, tinted: true), sectionLabelStyle: .uppercase ), CVTemplate( id: "ledger", name: "Ledger", family: .professional, headline: .leftAligned, accent: .blueBar, layout: .twoColumn(sidebar: .trailing, tinted: false), sectionLabelStyle: .uppercase ), CVTemplate( id: "harbor", name: "Harbor", family: .professional, headline: .leftWithInitials, accent: .none, layout: .twoColumn(sidebar: .leading, tinted: true), sectionLabelStyle: .uppercase ), CVTemplate( id: "metro", name: "Clear Path", family: .professional, headline: .centered, accent: .blueBar, layout: .singleColumn, sectionLabelStyle: .uppercase ), CVTemplate( id: "pinstripe", name: "Pinstripe", family: .professional, headline: .leftAligned, accent: .blueBar, layout: .twoColumn(sidebar: .leading, tinted: false), sectionLabelStyle: .uppercase ), CVTemplate( id: "briefing", name: "Briefing", family: .professional, headline: .leftAligned, accent: .blueBar, layout: .twoColumn(sidebar: .leading, tinted: true), sectionLabelStyle: .uppercase ), CVTemplate( id: "quorum", name: "Quorum", family: .professional, headline: .leftWithInitials, accent: .none, layout: .singleColumn, sectionLabelStyle: .bracketed ), CVTemplate( id: "docket", name: "Docket", family: .professional, headline: .centered, accent: .blueBar, layout: .twoColumn(sidebar: .trailing, tinted: false), sectionLabelStyle: .uppercase ), CVTemplate( id: "conduit", name: "Conduit", family: .professional, headline: .leftAligned, accent: .blueBar, layout: .singleColumn, sectionLabelStyle: .slashed ), CVTemplate( id: "principal", name: "Principal", family: .professional, headline: .leftWithInitials, accent: .blueBar, layout: .twoColumn(sidebar: .trailing, tinted: true), sectionLabelStyle: .uppercase ), CVTemplate( id: "charter", name: "Charter", family: .professional, headline: .leftAligned, accent: .none, layout: .twoColumn(sidebar: .leading, tinted: false), sectionLabelStyle: .uppercase ), // Modern family CVTemplate( id: "vertex", name: "Vertex", family: .modern, headline: .leftWithInitials, accent: .blueBar, layout: .twoColumn(sidebar: .leading, tinted: true), sectionLabelStyle: .slashed ), CVTemplate( id: "linea", name: "Linea", family: .modern, headline: .leftAligned, accent: .blueBar, layout: .singleColumn, sectionLabelStyle: .slashed ), CVTemplate( id: "prism", name: "Prism", family: .modern, headline: .avatarStacked, accent: .blueBar, layout: .twoColumn(sidebar: .trailing, tinted: true), sectionLabelStyle: .uppercase ), CVTemplate( id: "circuit", name: "Circuit", family: .modern, headline: .leftAligned, accent: .blueBar, layout: .twoColumn(sidebar: .trailing, tinted: false), sectionLabelStyle: .slashed ), CVTemplate( id: "north", name: "North", family: .modern, headline: .leftWithInitials, accent: .none, layout: .twoColumn(sidebar: .leading, tinted: false), sectionLabelStyle: .uppercase ), CVTemplate( id: "axis", name: "Axis", family: .modern, headline: .centered, accent: .blueBar, layout: .singleColumn, sectionLabelStyle: .bracketed ), // Creative family CVTemplate( id: "marigold", name: "Marigold", family: .creative, headline: .avatarStacked, accent: .redBar, layout: .twoColumn(sidebar: .leading, tinted: true), sectionLabelStyle: .slashed ), CVTemplate( id: "ember", name: "Ember", family: .creative, headline: .leftWithInitials, accent: .redBar, layout: .twoColumn(sidebar: .trailing, tinted: true), sectionLabelStyle: .slashed ), CVTemplate( id: "lattice", name: "Lattice", family: .creative, headline: .leftAligned, accent: .redUnderline, layout: .singleColumn, sectionLabelStyle: .bracketed ), CVTemplate( id: "bloom", name: "Bloom", family: .creative, headline: .avatarStacked, accent: .redBar, layout: .singleColumn, sectionLabelStyle: .slashed ), CVTemplate( id: "studio", name: "Studio", family: .creative, headline: .leftWithInitials, accent: .redUnderline, layout: .twoColumn(sidebar: .leading, tinted: true), sectionLabelStyle: .uppercase ), CVTemplate( id: "kite", name: "Kite", family: .creative, headline: .centered, accent: .redBar, layout: .twoColumn(sidebar: .trailing, tinted: false), sectionLabelStyle: .slashed ), // Executive family CVTemplate( id: "regent", name: "Regent", family: .executive, headline: .centered, accent: .blueBar, layout: .twoColumn(sidebar: .leading, tinted: true), sectionLabelStyle: .uppercase ), CVTemplate( id: "monarch", name: "Monarch", family: .executive, headline: .centered, accent: .blueBar, layout: .singleColumn, sectionLabelStyle: .uppercase ), CVTemplate( id: "sterling", name: "Sterling", family: .executive, headline: .leftAligned, accent: .blueBar, layout: .twoColumn(sidebar: .trailing, tinted: false), sectionLabelStyle: .uppercase ), CVTemplate( id: "summit", name: "Summit", family: .executive, headline: .centered, accent: .redUnderline, layout: .twoColumn(sidebar: .leading, tinted: false), sectionLabelStyle: .uppercase ), CVTemplate( id: "estate", name: "Estate", family: .executive, headline: .leftWithInitials, accent: .blueBar, layout: .twoColumn(sidebar: .trailing, tinted: true), sectionLabelStyle: .uppercase ), CVTemplate( id: "chairman", name: "Chairman", family: .executive, headline: .leftAligned, accent: .blueBar, layout: .singleColumn, sectionLabelStyle: .uppercase ) ] } // MARK: - View /// Standalone NSView for the CV Maker route. Renders the template gallery with /// header, segmented category groups, family chips, a thumbnail grid, and a /// bottom CTA. Hosts inside the same `nonHomeHost` slot as Saved Jobs/Settings. final class CVMakerPageView: NSView { private enum Palette { static var primaryText: NSColor { AppDashboardTheme.primaryText } static var secondaryText: NSColor { AppDashboardTheme.secondaryText } static var cardBackground: NSColor { AppDashboardTheme.cardBackground } static var cardBorder: NSColor { AppDashboardTheme.border } static var cardBorderHover: NSColor { AppDashboardTheme.cvMakerCardBorderHover } static var cardBorderSelected: NSColor { AppDashboardTheme.brandBlue } static var cardFooter: NSColor { AppDashboardTheme.cvMakerCardFooter } static var previewSurface: NSColor { AppDashboardTheme.cvMakerPreviewSurface } static var previewPaper: NSColor { CVResumeAppearance.colors().paper } static var previewSidebarTint: NSColor { CVResumeAppearance.colors().sidebarTint } static var previewInk: NSColor { CVResumeAppearance.colors().ink } static var previewMuted: NSColor { CVResumeAppearance.colors().muted } static var previewAccentRed: NSColor { CVResumeAppearance.colors().accentRed } static var previewAccentBlue: NSColor { CVResumeAppearance.colors().accentBlue } static var ctaBackground: NSColor { AppDashboardTheme.brandBlue } static var ctaHover: NSColor { AppDashboardTheme.brandBlueHover } static var ctaText: NSColor { AppDashboardTheme.proCTAText } static var selectionGlow: NSColor { AppDashboardTheme.cvMakerSelectionGlow } static var gradientTop: NSColor { AppDashboardTheme.cvMakerPageGradientTop } static var gradientBottom: NSColor { AppDashboardTheme.cvMakerPageGradientBottom } static var filterChromeBorder: NSColor { AppDashboardTheme.cvMakerFilterChromeBorder } } private var appearanceObserver: NSObjectProtocol? private var languageObserver: NSObjectProtocol? private let pageGradientLayer = CAGradientLayer() private let filterChrome = NSVisualEffectView() private let filterStack = NSStackView() private let titleLabel = NSTextField(labelWithString: L("Templates")) private let subtitleLabel = NSTextField(labelWithString: L("Polished layouts with live previews — pick a style that fits your story.")) private let groupTabsRow = NSStackView() private let familyChipsRow = NSStackView() private let scrollView = NSScrollView() private let gridDocument = TopFlippedView() private let gridStack = NSStackView() private let ctaButton = CVHoverableButton(title: L("Use Template & Select Profile →"), target: nil, action: nil) private var selectedGroup: CVCategoryGroup = .professionBased private var selectedFamily: CVDesignFamily? = nil // nil == "All" private var selectedTemplateID: String? = "metro" /// Exactly one gallery card — avoids multiple highlighted cards when catalog entries share the same `template.id`. private var selectedTemplateCardToken: UUID? /// Shown immediately; replaced when `CVTemplateFetchService` returns AI-generated entries. private var activeCatalog: [CVTemplate] = CVTemplateCatalog.all private var groupTabButtons: [CVCategoryGroup: CVChipButton] = [:] private var familyChipButtons: [CVDesignFamily?: CVChipButton] = [:] /// Every visible gallery card (not keyed by id — duplicate AI ids would collapse in a dictionary and break single-selection visuals). private var templateCardsInGrid: [CVTemplateCard] = [] /// Invoked when the user taps **Use Template & Select Profile** with a valid gallery selection. Delivers the same `CVTemplate` instance the card used for its thumbnail (not a re-lookup by id), so the filled résumé cannot drift from the user’s pick. var onContinueToProfileSelection: ((CVTemplate) -> Void)? func templateInGallery(withID id: String) -> CVTemplate? { resolvedTemplate(withID: id) } /// Resolves a template from the live gallery, then falls back to the built-in catalog when AI fetch replaces `activeCatalog` (so the user’s selection still previews correctly). func resolvedTemplate(withID id: String) -> CVTemplate? { if let match = activeCatalog.first(where: { $0.id == id }) { return match } return CVTemplateCatalog.all.first { $0.id == id } } private var appliedGridColumnCount: Int = 0 /// Family filter row always renders this many slots so chip widths stay stable /// when switching between Design-Based (3 labels) and Profession-Based (4). private let familyChipSlotCount = 4 private enum FilterChromeLayout { static let padding: CGFloat = 12 static let rowGap: CGFloat = 12 static let groupRowHeight: CGFloat = 38 static let familyRowHeight: CGFloat = 30 static var height: CGFloat { padding * 2 + groupRowHeight + rowGap + familyRowHeight } } override init(frame frameRect: NSRect) { super.init(frame: frameRect) configureLayout() reloadFamilyChips() reloadTemplateGrid() updateSelectedChipStates() beginLoadingAICatalogIfPossible() appearanceObserver = NotificationCenter.default.addObserver( forName: AppAppearanceManager.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in self?.applyCurrentAppearance() } languageObserver = NotificationCenter.default.addObserver( forName: AppLanguageManager.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in self?.applyLocalizedStrings() } applyCurrentAppearance() applyLocalizedStrings() } deinit { if let appearanceObserver { NotificationCenter.default.removeObserver(appearanceObserver) } if let languageObserver { NotificationCenter.default.removeObserver(languageObserver) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() applyCurrentAppearance() } override func layout() { super.layout() pageGradientLayer.frame = bounds layoutGridCardsIfNeeded() } func applyCurrentAppearance() { pageGradientLayer.colors = [Palette.gradientBottom.cgColor, Palette.gradientTop.cgColor] filterChrome.layer?.borderColor = Palette.filterChromeBorder.cgColor titleLabel.textColor = Palette.primaryText subtitleLabel.textColor = Palette.secondaryText styleCTAButton(ctaButton) for chip in groupTabButtons.values { chip.applyCurrentAppearance() } for chip in familyChipButtons.values { chip.applyCurrentAppearance() } reloadTemplateGrid() updateSelectedChipStates() } func applyLocalizedStrings() { titleLabel.stringValue = L("Templates") subtitleLabel.stringValue = L("Polished layouts with live previews — pick a style that fits your story.") styleCTAButton(ctaButton) configureGroupTabs() reloadFamilyChips() reloadTemplateGrid() updateSelectedChipStates() } // MARK: Setup private func configureLayout() { wantsLayer = true layer?.backgroundColor = NSColor.clear.cgColor pageGradientLayer.locations = [0, 1] as [NSNumber] pageGradientLayer.startPoint = CGPoint(x: 0.5, y: 0) pageGradientLayer.endPoint = CGPoint(x: 0.5, y: 1) layer?.insertSublayer(pageGradientLayer, at: 0) filterChrome.translatesAutoresizingMaskIntoConstraints = false filterChrome.material = .contentBackground filterChrome.blendingMode = .withinWindow filterChrome.state = .active filterChrome.wantsLayer = true filterChrome.layer?.cornerRadius = 18 filterChrome.layer?.borderWidth = 1 filterStack.orientation = .vertical filterStack.spacing = 12 filterStack.alignment = .leading filterStack.translatesAutoresizingMaskIntoConstraints = false filterStack.addArrangedSubview(groupTabsRow) filterStack.addArrangedSubview(familyChipsRow) filterChrome.addSubview(filterStack) NSLayoutConstraint.activate([ filterStack.leadingAnchor.constraint(equalTo: filterChrome.leadingAnchor, constant: 14), filterStack.trailingAnchor.constraint(equalTo: filterChrome.trailingAnchor, constant: -14), filterStack.topAnchor.constraint(equalTo: filterChrome.topAnchor, constant: 12), filterStack.bottomAnchor.constraint(equalTo: filterChrome.bottomAnchor, constant: -12), // On this SDK `alignment` is `NSLayoutConstraint.Attribute` (no `.fill`). // Pin row widths so group tabs stay full-width / equal split instead of // shrinking to intrinsic width when selection changes. groupTabsRow.widthAnchor.constraint(equalTo: filterStack.widthAnchor), familyChipsRow.widthAnchor.constraint(equalTo: filterStack.widthAnchor) ]) titleLabel.font = .systemFont(ofSize: 22, weight: .bold) titleLabel.textColor = Palette.primaryText titleLabel.alignment = .left subtitleLabel.font = .systemFont(ofSize: 13, weight: .regular) subtitleLabel.textColor = Palette.secondaryText subtitleLabel.alignment = .left subtitleLabel.maximumNumberOfLines = 1 let headerStack = NSStackView(views: [titleLabel, subtitleLabel]) headerStack.orientation = .vertical headerStack.spacing = 4 headerStack.alignment = .leading headerStack.translatesAutoresizingMaskIntoConstraints = false groupTabsRow.orientation = .horizontal groupTabsRow.spacing = 8 groupTabsRow.alignment = .centerY groupTabsRow.distribution = .fillEqually groupTabsRow.translatesAutoresizingMaskIntoConstraints = false groupTabsRow.heightAnchor.constraint(equalToConstant: 38).isActive = true configureGroupTabs() familyChipsRow.orientation = .horizontal familyChipsRow.spacing = 8 familyChipsRow.alignment = .centerY // Match the top row: equal-width segments, evenly spaced across the row. familyChipsRow.distribution = .fillEqually familyChipsRow.translatesAutoresizingMaskIntoConstraints = false familyChipsRow.heightAnchor.constraint(equalToConstant: 30).isActive = true gridStack.orientation = .vertical gridStack.spacing = 26 gridStack.alignment = .leading gridStack.distribution = .fill gridStack.translatesAutoresizingMaskIntoConstraints = false gridDocument.translatesAutoresizingMaskIntoConstraints = false gridDocument.addSubview(gridStack) NSLayoutConstraint.activate([ gridStack.leadingAnchor.constraint(equalTo: gridDocument.leadingAnchor), gridStack.trailingAnchor.constraint(equalTo: gridDocument.trailingAnchor), gridStack.topAnchor.constraint(equalTo: gridDocument.topAnchor), gridStack.bottomAnchor.constraint(equalTo: gridDocument.bottomAnchor) ]) scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.scrollerStyle = .legacy scrollView.autohidesScrollers = true scrollView.drawsBackground = false scrollView.borderType = .noBorder scrollView.documentView = gridDocument NSLayoutConstraint.activate([ gridDocument.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), gridDocument.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor), gridDocument.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor) ]) ctaButton.translatesAutoresizingMaskIntoConstraints = false styleCTAButton(ctaButton) ctaButton.target = self ctaButton.action = #selector(didTapUseTemplate) addSubview(headerStack) addSubview(filterChrome) addSubview(scrollView) addSubview(ctaButton) let horizontalInset: CGFloat = 32 NSLayoutConstraint.activate([ headerStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset), headerStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset), headerStack.topAnchor.constraint(equalTo: topAnchor, constant: 8), filterChrome.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset), filterChrome.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset), filterChrome.topAnchor.constraint(equalTo: headerStack.bottomAnchor, constant: 16), filterChrome.heightAnchor.constraint(equalToConstant: FilterChromeLayout.height), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset), scrollView.topAnchor.constraint(equalTo: filterChrome.bottomAnchor, constant: 18), scrollView.bottomAnchor.constraint(equalTo: ctaButton.topAnchor, constant: -18), ctaButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset), ctaButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset), ctaButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24), ctaButton.heightAnchor.constraint(equalToConstant: 52) ]) } private func configureGroupTabs() { groupTabsRow.arrangedSubviews.forEach { groupTabsRow.removeArrangedSubview($0) $0.removeFromSuperview() } groupTabButtons.removeAll() let groups: [(CVCategoryGroup, String)] = [ (.designBased, "rectangle.3.group"), (.professionBased, "person.2") ] for (group, symbolName) in groups { let count = templates(forGroup: group, family: nil).count let chip = CVChipButton( title: group.title, badgeText: "\(count)", leadingSymbol: symbolName, style: .pillLarge ) chip.translatesAutoresizingMaskIntoConstraints = false chip.onSelect = { [weak self] in self?.didSelectGroup(group) } groupTabsRow.addArrangedSubview(chip) groupTabButtons[group] = chip } } private func reloadFamilyChips() { familyChipsRow.arrangedSubviews.forEach { familyChipsRow.removeArrangedSubview($0) $0.removeFromSuperview() } familyChipButtons.removeAll() let allCount = templates(forGroup: selectedGroup, family: nil).count let allChip = CVChipButton(title: L("All"), badgeText: "\(allCount)", leadingSymbol: nil, style: .pillSmall) allChip.translatesAutoresizingMaskIntoConstraints = false allChip.onSelect = { [weak self] in self?.didSelectFamily(nil) } familyChipsRow.addArrangedSubview(allChip) familyChipButtons[nil] = allChip for family in CVDesignFamily.allCases { let count = templates(forGroup: selectedGroup, family: family).count guard count > 0 else { continue } let chip = CVChipButton(title: family.title, badgeText: "\(count)", leadingSymbol: nil, style: .pillSmall) chip.translatesAutoresizingMaskIntoConstraints = false chip.onSelect = { [weak self] in self?.didSelectFamily(family) } familyChipsRow.addArrangedSubview(chip) familyChipButtons[family] = chip } // Pad to a fixed slot count so `fillEqually` chip widths never change when // the visible family count differs between category groups. while familyChipsRow.arrangedSubviews.count < familyChipSlotCount { let slot = NSView() slot.translatesAutoresizingMaskIntoConstraints = false slot.setContentHuggingPriority(.defaultLow, for: .horizontal) slot.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) familyChipsRow.addArrangedSubview(slot) } if let f = selectedFamily, templates(forGroup: selectedGroup, family: f).isEmpty { selectedFamily = nil } } // MARK: Data filtering private func templates(forGroup group: CVCategoryGroup, family: CVDesignFamily?) -> [CVTemplate] { let base = activeCatalog.filter { $0.galleryGroup == group } guard let family else { return base } return base.filter { $0.family == family } } private var visibleTemplates: [CVTemplate] { templates(forGroup: selectedGroup, family: selectedFamily) } // MARK: Grid private func reloadTemplateGrid() { gridStack.arrangedSubviews.forEach { gridStack.removeArrangedSubview($0) $0.removeFromSuperview() } templateCardsInGrid.removeAll() let templates = visibleTemplates if templates.isEmpty { let empty = NSTextField(labelWithString: L("No templates yet for this category.")) empty.font = .systemFont(ofSize: 13) empty.textColor = Palette.secondaryText gridStack.addArrangedSubview(empty) selectedTemplateCardToken = nil return } let columns = resolvedGridColumnCount() var index = 0 while index < templates.count { let row = NSStackView() row.orientation = .horizontal row.spacing = 22 row.distribution = .fillEqually row.alignment = .top row.translatesAutoresizingMaskIntoConstraints = false row.setHuggingPriority(.defaultLow, for: .horizontal) for column in 0.. CGFloat { if bounds.width > 8 { return bounds.width } if let s = superview, s.bounds.width > 8 { return max(s.bounds.width - 64, 400) } return 900 } private func layoutGridCardsIfNeeded() { let cols = resolvedGridColumnCount() guard cols != appliedGridColumnCount else { return } appliedGridColumnCount = cols reloadTemplateGrid() updateSelectedChipStates() } private func resolvedGridColumnCount() -> Int { let w = max(galleryLayoutWidth(), 400) if w < 780 { return 2 } if w < 1080 { return 3 } return 4 } private func applySelectionToCards() { let token = selectedTemplateCardToken for card in templateCardsInGrid { card.isSelected = (card.selectionToken == token) } } private func palette() -> CVTemplateCardPalette { CVTemplateCardPalette( border: Palette.cardBorder, borderHover: Palette.cardBorderHover, borderSelected: Palette.cardBorderSelected, selectionGlow: Palette.selectionGlow, cardShellBackground: Palette.cardBackground, footerBackground: Palette.cardFooter, previewSurface: Palette.previewSurface, previewPaper: Palette.previewPaper, previewSidebarTint: Palette.previewSidebarTint, previewInk: Palette.previewInk, previewMuted: Palette.previewMuted, previewAccentRed: Palette.previewAccentRed, previewAccentBlue: Palette.previewAccentBlue, primaryText: Palette.primaryText, secondaryText: Palette.secondaryText ) } // MARK: Selection private func didSelectGroup(_ group: CVCategoryGroup) { guard selectedGroup != group else { return } selectedGroup = group selectedFamily = nil reloadFamilyChips() reloadTemplateGrid() updateSelectedChipStates() } private func didSelectFamily(_ family: CVDesignFamily?) { guard selectedFamily != family else { return } selectedFamily = family reloadTemplateGrid() updateSelectedChipStates() } private func didSelectCard(_ card: CVTemplateCard) { selectedTemplateCardToken = card.selectionToken selectedTemplateID = card.templateID applySelectionToCards() } @objc private func didTapUseTemplate() { let chosen: CVTemplate? if let token = selectedTemplateCardToken, let card = templateCardsInGrid.first(where: { $0.selectionToken == token }) { chosen = card.catalogTemplate } else if let id = selectedTemplateID { chosen = resolvedTemplate(withID: id) } else { chosen = nil } guard let template = chosen else { presentPlaceholderAlert(title: L("Pick a template"), message: L("Select a template first, then choose a profile to continue.")) return } onContinueToProfileSelection?(template) } private func updateSelectedChipStates() { for (group, chip) in groupTabButtons { chip.isSelected = (group == selectedGroup) } for (family, chip) in familyChipButtons { chip.isSelected = (family == selectedFamily) } } private func styleCTAButton(_ button: CVHoverableButton) { button.title = L("Use Template & Select Profile →") button.font = .systemFont(ofSize: 14, weight: .semibold) button.isBordered = false button.bezelStyle = .rounded button.focusRingType = .none button.contentTintColor = Palette.ctaText button.wantsLayer = true button.layer?.cornerRadius = 14 button.layer?.backgroundColor = Palette.ctaBackground.cgColor button.pointerCursor = true let attrs: [NSAttributedString.Key: Any] = [ .foregroundColor: Palette.ctaText, .font: NSFont.systemFont(ofSize: 14, weight: .semibold) ] button.attributedTitle = NSAttributedString(string: button.title, attributes: attrs) button.hoverHandler = { [weak button] hovering in button?.layer?.backgroundColor = (hovering ? Palette.ctaHover : Palette.ctaBackground).cgColor } } private func beginLoadingAICatalogIfPossible() { guard OpenAIConfiguration.hasAPIKey else { return } let defaultSubtitle = L("Polished layouts with live previews — pick a style that fits your story.") subtitleLabel.stringValue = L("Fetching AI-curated templates…") CVTemplateFetchService.shared.fetchTemplates { [weak self] result in DispatchQueue.main.async { guard let self else { return } switch result { case .success(let list): self.subtitleLabel.stringValue = defaultSubtitle if !list.isEmpty { self.activeCatalog = list self.configureGroupTabs() self.reloadFamilyChips() self.reloadTemplateGrid() self.updateSelectedChipStates() } case .failure: self.subtitleLabel.stringValue = L("Couldn’t load AI templates — showing the built-in gallery.") DispatchQueue.main.asyncAfter(deadline: .now() + 5.5) { [weak self] in self?.subtitleLabel.stringValue = defaultSubtitle } } } } } private func presentPlaceholderAlert(title: String, message: String) { let alert = NSAlert() alert.messageText = title alert.informativeText = message alert.alertStyle = .informational alert.addButton(withTitle: L("OK")) if let window { alert.beginSheetModal(for: window) } else { alert.runModal() } } } // MARK: - Chip / pill button /// Reusable pill button used for both the top section toggle ("Design-Based" /// vs "Profession-Based") and the family filter chips. Switches between an /// active brand-blue state and a soft neutral state, with an inline count badge. private final class CVChipButton: NSView { enum Style { case pillLarge, pillSmall } var onSelect: (() -> Void)? var isSelected: Bool = false { didSet { applyState() } } private let style: Style private let titleLabel = NSTextField(labelWithString: "") private let badgeLabel = NSTextField(labelWithString: "") private let badgePill = NSView() private let symbolView = NSImageView() private let stack = NSStackView() private var isHovering: Bool = false private var didPushCursor: Bool = false private var trackingArea: NSTrackingArea? private enum Palette { static var restFill: NSColor { AppDashboardTheme.cvMakerChipRestFill } static var restBorder: NSColor { AppDashboardTheme.cvMakerChipRestBorder } static var hoverFill: NSColor { AppDashboardTheme.cvMakerChipHoverFill } static var activeFill: NSColor { AppDashboardTheme.brandBlue } static var activeFillHover: NSColor { AppDashboardTheme.brandBlueHover } static var activeBorder: NSColor { AppDashboardTheme.brandBlueHover } static var restText: NSColor { AppDashboardTheme.primaryText } static var activeText: NSColor { AppDashboardTheme.proCTAText } static var restBadge: NSColor { AppDashboardTheme.cvMakerChipBadgeBackground } static var restBadgeText: NSColor { AppDashboardTheme.cvMakerChipBadgeText } static var activeBadge: NSColor { NSColor.white.withAlphaComponent(0.22) } static var activeBadgeText: NSColor { AppDashboardTheme.proCTAText } } func applyCurrentAppearance() { applyState() } private static let symbolSide: CGFloat = 18 private static let badgeWidthLarge: CGFloat = 28 private static let badgeWidthSmall: CGFloat = 26 init(title: String, badgeText: String, leadingSymbol: String?, style: Style) { self.style = style super.init(frame: .zero) wantsLayer = true translatesAutoresizingMaskIntoConstraints = false let height: CGFloat = style == .pillLarge ? 38 : 30 layer?.cornerRadius = height / 2 layer?.borderWidth = 1 heightAnchor.constraint(equalToConstant: height).isActive = true titleLabel.stringValue = title titleLabel.font = .systemFont(ofSize: style == .pillLarge ? 13 : 12, weight: .semibold) titleLabel.maximumNumberOfLines = 1 titleLabel.lineBreakMode = .byTruncatingTail titleLabel.cell?.lineBreakMode = .byTruncatingTail titleLabel.isBordered = false titleLabel.drawsBackground = false titleLabel.isEditable = false titleLabel.isSelectable = false badgeLabel.stringValue = badgeText badgeLabel.font = .monospacedDigitSystemFont(ofSize: 10.5, weight: .semibold) badgeLabel.alignment = .center badgeLabel.isBordered = false badgeLabel.drawsBackground = false badgeLabel.isEditable = false badgeLabel.isSelectable = false badgePill.translatesAutoresizingMaskIntoConstraints = false badgePill.wantsLayer = true badgePill.layer?.cornerRadius = 9 badgePill.addSubview(badgeLabel) badgeLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ badgeLabel.leadingAnchor.constraint(equalTo: badgePill.leadingAnchor, constant: 7), badgeLabel.trailingAnchor.constraint(equalTo: badgePill.trailingAnchor, constant: -7), badgeLabel.centerYAnchor.constraint(equalTo: badgePill.centerYAnchor), badgePill.heightAnchor.constraint(equalToConstant: 18), badgePill.widthAnchor.constraint( equalToConstant: style == .pillLarge ? Self.badgeWidthLarge : Self.badgeWidthSmall ) ]) stack.orientation = .horizontal stack.spacing = 8 stack.alignment = .centerY stack.translatesAutoresizingMaskIntoConstraints = false if let symbol = leadingSymbol, style == .pillLarge { symbolView.translatesAutoresizingMaskIntoConstraints = false symbolView.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) symbolView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold) symbolView.setContentHuggingPriority(.required, for: .horizontal) symbolView.setContentCompressionResistancePriority(.required, for: .horizontal) stack.addArrangedSubview(symbolView) NSLayoutConstraint.activate([ symbolView.widthAnchor.constraint(equalToConstant: Self.symbolSide), symbolView.heightAnchor.constraint(equalToConstant: Self.symbolSide) ]) } stack.addArrangedSubview(titleLabel) stack.addArrangedSubview(badgePill) addSubview(stack) let horizontalInset: CGFloat = style == .pillLarge ? 16 : 12 // Leading alignment for every chip so selected vs unselected pills share the // same geometry (centered stacks shift when badge digits change). if style == .pillLarge { NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset), stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset), stack.centerYAnchor.constraint(equalTo: centerYAnchor) ]) setContentHuggingPriority(.defaultLow, for: .horizontal) } else { // Parent row uses `fillEqually`; center label + badge inside each segment. NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: centerXAnchor), stack.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: horizontalInset), stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset), stack.centerYAnchor.constraint(equalTo: centerYAnchor) ]) setContentHuggingPriority(.defaultLow, for: .horizontal) setContentCompressionResistancePriority(.defaultLow, for: .horizontal) } applyState() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func hitTest(_ point: NSPoint) -> NSView? { guard let superview else { return super.hitTest(point) } let local = convert(point, from: superview) return bounds.contains(local) ? self : nil } override func mouseDown(with event: NSEvent) { onSelect?() } override func updateTrackingAreas() { super.updateTrackingAreas() if let area = trackingArea { removeTrackingArea(area) } let area = NSTrackingArea( rect: bounds, options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil ) addTrackingArea(area) trackingArea = area } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) isHovering = true applyState() if !didPushCursor { NSCursor.pointingHand.push() didPushCursor = true } } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) isHovering = false applyState() if didPushCursor { NSCursor.pop() didPushCursor = false } } override func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) if newWindow == nil, didPushCursor { NSCursor.pop() didPushCursor = false isHovering = false } } private func applyState() { let fill: NSColor let border: NSColor let textColor: NSColor let badgeFill: NSColor let badgeText: NSColor if isSelected { fill = isHovering ? Palette.activeFillHover : Palette.activeFill border = Palette.activeBorder textColor = Palette.activeText badgeFill = Palette.activeBadge badgeText = Palette.activeBadgeText } else { fill = isHovering ? Palette.hoverFill : Palette.restFill border = Palette.restBorder textColor = Palette.restText badgeFill = Palette.restBadge badgeText = Palette.restBadgeText } layer?.backgroundColor = fill.cgColor layer?.borderColor = border.cgColor layer?.borderWidth = 1 titleLabel.textColor = textColor symbolView.contentTintColor = textColor badgePill.layer?.backgroundColor = badgeFill.cgColor badgeLabel.textColor = badgeText } } // MARK: - Template card /// Premium gallery card: live résumé thumbnail, soft shadow, and an animated /// brand border when selected. private final class CVTemplateCard: NSView { static let layoutHeight: CGFloat = 292 var onSelect: (() -> Void)? var isSelected: Bool = false { didSet { applyChrome() } } /// Distinguishes this card from others that may share the same catalog `template.id`. let selectionToken = UUID() var templateID: String { template.id } /// Definition used for this card’s preview; pass through on “Use template” so layout cannot diverge from a later id-only lookup. var catalogTemplate: CVTemplate { template } private let template: CVTemplate private let palette: CVTemplateCardPalette private let previewSurface = NSView() private let footerView = NSView() private let preview: CVTemplatePreviewView private let nameLabel = NSTextField(labelWithString: "") private let categoryLabel = NSTextField(labelWithString: "") private var trackingArea: NSTrackingArea? private var isHovering: Bool = false private var didPushCursor: Bool = false init(template: CVTemplate, palette: CVTemplateCardPalette) { self.template = template self.palette = palette self.preview = CVTemplatePreviewView(template: template, palette: palette) super.init(frame: .zero) wantsLayer = true layer?.masksToBounds = false layer?.cornerRadius = 24 layer?.backgroundColor = palette.cardShellBackground.cgColor translatesAutoresizingMaskIntoConstraints = false heightAnchor.constraint(equalToConstant: Self.layoutHeight).isActive = true previewSurface.translatesAutoresizingMaskIntoConstraints = false previewSurface.wantsLayer = true previewSurface.layer?.backgroundColor = palette.previewSurface.cgColor preview.translatesAutoresizingMaskIntoConstraints = false previewSurface.addSubview(preview) nameLabel.stringValue = template.localizedName nameLabel.font = .systemFont(ofSize: 14, weight: .semibold) nameLabel.textColor = palette.primaryText nameLabel.isBordered = false nameLabel.drawsBackground = false nameLabel.isEditable = false nameLabel.isSelectable = false categoryLabel.stringValue = "\(template.category) · \(template.layoutType.gallerySubtitle)" categoryLabel.font = .systemFont(ofSize: 11.5, weight: .regular) categoryLabel.textColor = palette.secondaryText categoryLabel.isBordered = false categoryLabel.drawsBackground = false categoryLabel.isEditable = false categoryLabel.isSelectable = false let footerStack = NSStackView(views: [nameLabel, categoryLabel]) footerStack.orientation = .vertical footerStack.spacing = 3 footerStack.alignment = .leading footerStack.translatesAutoresizingMaskIntoConstraints = false footerView.translatesAutoresizingMaskIntoConstraints = false footerView.wantsLayer = true footerView.layer?.backgroundColor = palette.footerBackground.cgColor footerView.addSubview(footerStack) NSLayoutConstraint.activate([ footerStack.leadingAnchor.constraint(equalTo: footerView.leadingAnchor, constant: 16), footerStack.trailingAnchor.constraint(lessThanOrEqualTo: footerView.trailingAnchor, constant: -16), footerStack.topAnchor.constraint(equalTo: footerView.topAnchor, constant: 13), footerStack.bottomAnchor.constraint(equalTo: footerView.bottomAnchor, constant: -13) ]) addSubview(previewSurface) addSubview(footerView) NSLayoutConstraint.activate([ previewSurface.topAnchor.constraint(equalTo: topAnchor), previewSurface.leadingAnchor.constraint(equalTo: leadingAnchor), previewSurface.trailingAnchor.constraint(equalTo: trailingAnchor), previewSurface.heightAnchor.constraint(greaterThanOrEqualToConstant: 236), preview.topAnchor.constraint(equalTo: previewSurface.topAnchor, constant: 14), preview.leadingAnchor.constraint(equalTo: previewSurface.leadingAnchor, constant: 16), preview.trailingAnchor.constraint(equalTo: previewSurface.trailingAnchor, constant: -16), preview.bottomAnchor.constraint(equalTo: previewSurface.bottomAnchor, constant: -14), footerView.topAnchor.constraint(equalTo: previewSurface.bottomAnchor), footerView.leadingAnchor.constraint(equalTo: leadingAnchor), footerView.trailingAnchor.constraint(equalTo: trailingAnchor), footerView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) applyChrome() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layout() { super.layout() layer?.shadowPath = CGPath(roundedRect: bounds, cornerWidth: 24, cornerHeight: 24, transform: nil) } override func hitTest(_ point: NSPoint) -> NSView? { guard let superview else { return super.hitTest(point) } let local = convert(point, from: superview) return bounds.contains(local) ? self : nil } override func mouseDown(with event: NSEvent) { playTapPulse() onSelect?() } private func playTapPulse() { guard let l = layer else { return } let a = CABasicAnimation(keyPath: "transform") a.fromValue = NSValue(caTransform3D: CATransform3DIdentity) a.toValue = NSValue(caTransform3D: CATransform3DMakeScale(0.985, 0.985, 1)) a.duration = 0.1 a.autoreverses = true a.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) l.add(a, forKey: "tapPulse") } override func updateTrackingAreas() { super.updateTrackingAreas() if let area = trackingArea { removeTrackingArea(area) } let area = NSTrackingArea( rect: bounds, options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil ) addTrackingArea(area) trackingArea = area } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) isHovering = true applyChrome() if !didPushCursor { NSCursor.pointingHand.push() didPushCursor = true } } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) isHovering = false applyChrome() if didPushCursor { NSCursor.pop() didPushCursor = false } } override func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) if newWindow == nil, didPushCursor { NSCursor.pop() didPushCursor = false isHovering = false } } private func applyChrome() { // Same border + shadow metrics in every state — only color changes on select // so thumbnails keep identical insets (stroke is drawn inside the layer). let uniformBorder: CGFloat = 2 let borderColor: NSColor if isSelected { borderColor = palette.borderSelected } else if isHovering { borderColor = palette.borderHover } else { borderColor = palette.border } layer?.backgroundColor = palette.cardShellBackground.cgColor previewSurface.layer?.backgroundColor = palette.previewSurface.cgColor footerView.layer?.backgroundColor = palette.footerBackground.cgColor nameLabel.textColor = palette.primaryText categoryLabel.textColor = palette.secondaryText layer?.borderColor = borderColor.cgColor layer?.borderWidth = uniformBorder layer?.shadowColor = NSColor.black.cgColor layer?.shadowOpacity = isSelected ? 0.14 : (isHovering ? 0.11 : 0.08) layer?.shadowRadius = 14 layer?.shadowOffset = CGSize(width: 0, height: 8) } } // MARK: - Helpers /// Flipped origin so the grid stacks fill from the top. private final class TopFlippedView: NSView { override var isFlipped: Bool { true } } /// Local copy of the dashboard's hoverable button so this file stays /// self-contained without exposing the existing private classes. private final class CVHoverableButton: NSButton { var hoverHandler: ((Bool) -> Void)? var pointerCursor: Bool = false private(set) var isHovering: Bool = false private var trackingArea: NSTrackingArea? private var didPushCursor: Bool = false override func updateTrackingAreas() { super.updateTrackingAreas() if let area = trackingArea { removeTrackingArea(area) } let area = NSTrackingArea( rect: bounds, options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil ) addTrackingArea(area) trackingArea = area } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) isHovering = true hoverHandler?(true) if pointerCursor, !didPushCursor { NSCursor.pointingHand.push() didPushCursor = true } } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) isHovering = false hoverHandler?(false) if didPushCursor { NSCursor.pop() didPushCursor = false } } override func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) if newWindow == nil, didPushCursor { NSCursor.pop() didPushCursor = false isHovering = false } } }