|
@@ -20,8 +20,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
static let chromeBackground = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
|
|
static let chromeBackground = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
|
|
|
static let sidebarBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
|
static let sidebarBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
|
|
static let mainHostBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
|
static let mainHostBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
|
|
- /// Subtitle on the welcome hero: readable blue-gray aligned with brand.
|
|
|
|
|
- static let welcomeSubtitleText = NSColor(srgbRed: 52 / 255, green: 92 / 255, blue: 142 / 255, alpha: 1)
|
|
|
|
|
|
|
+ /// Subtitle on the welcome hero: dark neutral gray to match the reference layout.
|
|
|
|
|
+ static let welcomeSubtitleText = NSColor(srgbRed: 64 / 255, green: 64 / 255, blue: 64 / 255, alpha: 1)
|
|
|
static let selectionFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.12)
|
|
static let selectionFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.12)
|
|
|
static let cardBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
|
static let cardBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
|
|
static let toggleBackground = NSColor(srgbRed: 232 / 255, green: 232 / 255, blue: 232 / 255, alpha: 1)
|
|
static let toggleBackground = NSColor(srgbRed: 232 / 255, green: 232 / 255, blue: 232 / 255, alpha: 1)
|
|
@@ -29,10 +29,13 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
|
|
static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
|
|
|
static let tertiaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
|
|
static let tertiaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
|
|
|
static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
|
|
static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
|
|
|
- /// Job search bar outer stroke (charcoal).
|
|
|
|
|
- static let searchBarBorder = NSColor(srgbRed: 58 / 255, green: 58 / 255, blue: 58 / 255, alpha: 1)
|
|
|
|
|
|
|
+ /// Home status strip and soft accents (light blue panel in the reference UI).
|
|
|
|
|
+ static let statusBannerBackground = NSColor(srgbRed: 232 / 255, green: 242 / 255, blue: 252 / 255, alpha: 1)
|
|
|
|
|
+ static let featureIconWell = NSColor(srgbRed: 220 / 255, green: 235 / 255, blue: 252 / 255, alpha: 1)
|
|
|
|
|
+ /// Job search bar outer stroke (soft blue-gray, pill field in the reference UI).
|
|
|
|
|
+ static let searchBarBorder = NSColor(srgbRed: 180 / 255, green: 200 / 255, blue: 228 / 255, alpha: 1)
|
|
|
/// Search bar border on hover (brand-tinted, matches focus affordance elsewhere).
|
|
/// Search bar border on hover (brand-tinted, matches focus affordance elsewhere).
|
|
|
- static let searchBarBorderHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.45)
|
|
|
|
|
|
|
+ static let searchBarBorderHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55)
|
|
|
static let proCardFill = NSColor(srgbRed: 239 / 255, green: 244 / 255, blue: 252 / 255, alpha: 1)
|
|
static let proCardFill = NSColor(srgbRed: 239 / 255, green: 244 / 255, blue: 252 / 255, alpha: 1)
|
|
|
static let proCardBorder = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
|
|
static let proCardBorder = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
|
|
|
static let proAccent = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
|
|
static let proAccent = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
|
|
@@ -81,11 +84,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
private let findJobsCTAHost = NSView()
|
|
private let findJobsCTAHost = NSView()
|
|
|
private let findJobsCTAChrome = HoverableView()
|
|
private let findJobsCTAChrome = HoverableView()
|
|
|
private var findJobsCTAGradientLayer: CAGradientLayer?
|
|
private var findJobsCTAGradientLayer: CAGradientLayer?
|
|
|
- private let chatStatusStack = NSStackView()
|
|
|
|
|
|
|
+ private let statusBannerContainer = NSView()
|
|
|
|
|
+ private let statusBannerInner = NSView()
|
|
|
|
|
+ private let statusBannerRow = NSStackView()
|
|
|
private let chatStatusSymbolContainer = NSView()
|
|
private let chatStatusSymbolContainer = NSView()
|
|
|
private let chatStatusIcon = NSImageView()
|
|
private let chatStatusIcon = NSImageView()
|
|
|
private let chatStatusLoadingIndicator = NSProgressIndicator()
|
|
private let chatStatusLoadingIndicator = NSProgressIndicator()
|
|
|
- private let chatStatusLabel = NSTextField(labelWithString: "Opening the vault...")
|
|
|
|
|
|
|
+ private let chatStatusLabel = NSTextField(wrappingLabelWithString: "Opening the vault...")
|
|
|
|
|
+ private let statusBrandTrailing = NSStackView()
|
|
|
|
|
+ private let statusBrandStarIcon = NSImageView()
|
|
|
|
|
+ private let statusBrandLabel = NSTextField(labelWithString: "AI Job Finder")
|
|
|
|
|
+ private let welcomeSparkleIcon = NSImageView()
|
|
|
|
|
+ private let featureCardsRow = NSStackView()
|
|
|
private let chatScrollView = NSScrollView()
|
|
private let chatScrollView = NSScrollView()
|
|
|
private let chatDocumentView = JobListingsDocumentView()
|
|
private let chatDocumentView = JobListingsDocumentView()
|
|
|
private let chatStack = NSStackView()
|
|
private let chatStack = NSStackView()
|
|
@@ -145,6 +155,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
updateFindJobsCTAShadowPath()
|
|
updateFindJobsCTAShadowPath()
|
|
|
updateJobListingDescriptionWidths()
|
|
updateJobListingDescriptionWidths()
|
|
|
updateChatBubbleWidths()
|
|
updateChatBubbleWidths()
|
|
|
|
|
+ updateStatusBannerLabelWidth()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func render(_ data: DashboardData) {
|
|
func render(_ data: DashboardData) {
|
|
@@ -226,14 +237,21 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
configureSearchBar()
|
|
configureSearchBar()
|
|
|
configureChatViews()
|
|
configureChatViews()
|
|
|
|
|
|
|
|
- let titleBlock = NSStackView(views: [greetingLabel, subtitleLabel])
|
|
|
|
|
|
|
+ welcomeSparkleIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ welcomeSparkleIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
|
|
|
|
|
+ welcomeSparkleIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
|
|
|
|
|
+ welcomeSparkleIcon.contentTintColor = Theme.brandBlue
|
|
|
|
|
+
|
|
|
|
|
+ let titleBlock = NSStackView(views: [greetingLabel, subtitleLabel, welcomeSparkleIcon])
|
|
|
titleBlock.orientation = .vertical
|
|
titleBlock.orientation = .vertical
|
|
|
titleBlock.spacing = 10
|
|
titleBlock.spacing = 10
|
|
|
titleBlock.alignment = .centerX
|
|
titleBlock.alignment = .centerX
|
|
|
|
|
|
|
|
|
|
+ configureFeatureShortcutCards()
|
|
|
|
|
+
|
|
|
let midSpacer = NSView()
|
|
let midSpacer = NSView()
|
|
|
midSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
midSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- midSpacer.heightAnchor.constraint(equalToConstant: 18).isActive = true
|
|
|
|
|
|
|
+ midSpacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
|
|
|
|
|
|
|
|
let chatTopSpacer = NSView()
|
|
let chatTopSpacer = NSView()
|
|
|
chatTopSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
chatTopSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -245,8 +263,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
|
|
mainOverlay.addArrangedSubview(topInset)
|
|
mainOverlay.addArrangedSubview(topInset)
|
|
|
mainOverlay.addArrangedSubview(titleBlock)
|
|
mainOverlay.addArrangedSubview(titleBlock)
|
|
|
|
|
+ mainOverlay.addArrangedSubview(featureCardsRow)
|
|
|
mainOverlay.addArrangedSubview(midSpacer)
|
|
mainOverlay.addArrangedSubview(midSpacer)
|
|
|
- mainOverlay.addArrangedSubview(chatStatusStack)
|
|
|
|
|
|
|
+ mainOverlay.addArrangedSubview(statusBannerContainer)
|
|
|
mainOverlay.addArrangedSubview(chatTopSpacer)
|
|
mainOverlay.addArrangedSubview(chatTopSpacer)
|
|
|
mainOverlay.addArrangedSubview(chatScrollView)
|
|
mainOverlay.addArrangedSubview(chatScrollView)
|
|
|
mainOverlay.addArrangedSubview(chatBottomSpacer)
|
|
mainOverlay.addArrangedSubview(chatBottomSpacer)
|
|
@@ -280,7 +299,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
nonHomeHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
|
|
nonHomeHost.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
|
|
|
|
|
|
|
|
searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
- chatStatusStack.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
|
|
|
|
+ statusBannerContainer.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
|
|
+ featureCardsRow.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
chatScrollView.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
|
|
|
|
|
|
greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16),
|
|
greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 16),
|
|
@@ -290,19 +310,60 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
])
|
|
])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func configureFeatureShortcutCards() {
|
|
|
|
|
+ featureCardsRow.orientation = .horizontal
|
|
|
|
|
+ featureCardsRow.spacing = 12
|
|
|
|
|
+ featureCardsRow.distribution = .fillEqually
|
|
|
|
|
+ featureCardsRow.alignment = .top
|
|
|
|
|
+ featureCardsRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let specs: [(symbol: String, title: String, subtitle: String, action: Selector)] = [
|
|
|
|
|
+ ("briefcase.fill", "Role", "Explore similar or better job roles", #selector(didTapFeatureRole)),
|
|
|
|
|
+ ("building.2.fill", "Company", "Find opportunities at other companies", #selector(didTapFeatureCompany)),
|
|
|
|
|
+ ("chevron.left.forwardslash.chevron.right", "Skill", "Match jobs that fit your skills", #selector(didTapFeatureSkill))
|
|
|
|
|
+ ]
|
|
|
|
|
+ for spec in specs {
|
|
|
|
|
+ let card = FeatureShortcutCardView(
|
|
|
|
|
+ symbolName: spec.symbol,
|
|
|
|
|
+ title: spec.title,
|
|
|
|
|
+ subtitle: spec.subtitle,
|
|
|
|
|
+ target: self,
|
|
|
|
|
+ action: spec.action
|
|
|
|
|
+ )
|
|
|
|
|
+ featureCardsRow.addArrangedSubview(card)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func configureChatViews() {
|
|
private func configureChatViews() {
|
|
|
- chatStatusStack.orientation = .vertical
|
|
|
|
|
- chatStatusStack.spacing = 6
|
|
|
|
|
- chatStatusStack.alignment = .centerX
|
|
|
|
|
- chatStatusStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
+ statusBannerContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ statusBannerContainer.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
|
|
|
|
+
|
|
|
|
|
+ statusBannerInner.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ statusBannerInner.wantsLayer = true
|
|
|
|
|
+ statusBannerInner.layer?.backgroundColor = Theme.statusBannerBackground.cgColor
|
|
|
|
|
+ statusBannerInner.layer?.cornerRadius = 14
|
|
|
|
|
+ if #available(macOS 11.0, *) {
|
|
|
|
|
+ statusBannerInner.layer?.cornerCurve = .continuous
|
|
|
|
|
+ }
|
|
|
|
|
+ statusBannerInner.layer?.masksToBounds = true
|
|
|
|
|
+
|
|
|
|
|
+ statusBannerContainer.addSubview(statusBannerInner)
|
|
|
|
|
+
|
|
|
|
|
+ statusBannerRow.orientation = .horizontal
|
|
|
|
|
+ statusBannerRow.spacing = 12
|
|
|
|
|
+ statusBannerRow.alignment = .top
|
|
|
|
|
+ statusBannerRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
chatStatusSymbolContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
chatStatusSymbolContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ chatStatusSymbolContainer.wantsLayer = true
|
|
|
|
|
+ chatStatusSymbolContainer.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
|
|
+ chatStatusSymbolContainer.layer?.cornerRadius = 20
|
|
|
|
|
|
|
|
chatStatusIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
chatStatusIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
chatStatusIcon.wantsLayer = true
|
|
chatStatusIcon.wantsLayer = true
|
|
|
- chatStatusIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .regular)
|
|
|
|
|
|
|
+ chatStatusIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
|
|
|
chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
|
|
chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
|
|
|
- chatStatusIcon.contentTintColor = Theme.brandBlue
|
|
|
|
|
|
|
+ chatStatusIcon.contentTintColor = .white
|
|
|
|
|
|
|
|
chatStatusLoadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
|
chatStatusLoadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
|
|
chatStatusLoadingIndicator.style = .spinning
|
|
chatStatusLoadingIndicator.style = .spinning
|
|
@@ -323,19 +384,66 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
chatStatusIcon.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
|
|
chatStatusIcon.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
|
|
|
chatStatusLoadingIndicator.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
|
|
chatStatusLoadingIndicator.centerXAnchor.constraint(equalTo: chatStatusSymbolContainer.centerXAnchor),
|
|
|
chatStatusLoadingIndicator.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
|
|
chatStatusLoadingIndicator.centerYAnchor.constraint(equalTo: chatStatusSymbolContainer.centerYAnchor),
|
|
|
- chatStatusLoadingIndicator.widthAnchor.constraint(equalToConstant: 32),
|
|
|
|
|
- chatStatusLoadingIndicator.heightAnchor.constraint(equalToConstant: 32)
|
|
|
|
|
|
|
+ chatStatusLoadingIndicator.widthAnchor.constraint(equalToConstant: 26),
|
|
|
|
|
+ chatStatusLoadingIndicator.heightAnchor.constraint(equalToConstant: 26)
|
|
|
])
|
|
])
|
|
|
|
|
|
|
|
- chatStatusLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
|
|
|
|
|
|
+ chatStatusLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
|
|
chatStatusLabel.textColor = Theme.primaryText
|
|
chatStatusLabel.textColor = Theme.primaryText
|
|
|
- chatStatusLabel.alignment = .center
|
|
|
|
|
- chatStatusLabel.maximumNumberOfLines = 1
|
|
|
|
|
|
|
+ chatStatusLabel.alignment = .left
|
|
|
|
|
+ chatStatusLabel.maximumNumberOfLines = 0
|
|
|
|
|
+ chatStatusLabel.lineBreakMode = .byWordWrapping
|
|
|
|
|
+ chatStatusLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ chatStatusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ chatStatusLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ statusBrandStarIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ statusBrandStarIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
|
|
|
+ statusBrandStarIcon.image = NSImage(systemSymbolName: "sparkle", accessibilityDescription: nil)
|
|
|
|
|
+ statusBrandStarIcon.contentTintColor = Theme.brandBlue
|
|
|
|
|
+
|
|
|
|
|
+ statusBrandLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
|
|
|
+ statusBrandLabel.textColor = Theme.brandBlue
|
|
|
|
|
+ statusBrandLabel.alignment = .right
|
|
|
|
|
+ statusBrandLabel.maximumNumberOfLines = 1
|
|
|
|
|
+
|
|
|
|
|
+ statusBrandTrailing.orientation = .horizontal
|
|
|
|
|
+ statusBrandTrailing.spacing = 5
|
|
|
|
|
+ statusBrandTrailing.alignment = .centerY
|
|
|
|
|
+ statusBrandTrailing.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ statusBrandTrailing.addArrangedSubview(statusBrandStarIcon)
|
|
|
|
|
+ statusBrandTrailing.addArrangedSubview(statusBrandLabel)
|
|
|
|
|
+ statusBrandTrailing.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
|
|
+ statusBrandTrailing.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
|
+
|
|
|
|
|
+ statusBannerRow.addArrangedSubview(chatStatusSymbolContainer)
|
|
|
|
|
+ statusBannerRow.addArrangedSubview(chatStatusLabel)
|
|
|
|
|
+
|
|
|
|
|
+ statusBannerInner.addSubview(statusBannerRow)
|
|
|
|
|
+ statusBannerContainer.addSubview(statusBannerInner)
|
|
|
|
|
+ statusBannerContainer.addSubview(statusBrandTrailing)
|
|
|
|
|
+ statusBannerInner.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
|
|
|
+ statusBannerInner.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
|
|
|
- chatStatusStack.addArrangedSubview(chatStatusSymbolContainer)
|
|
|
|
|
- chatStatusStack.addArrangedSubview(chatStatusLabel)
|
|
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ statusBannerInner.leadingAnchor.constraint(equalTo: statusBannerContainer.leadingAnchor),
|
|
|
|
|
+ statusBannerInner.topAnchor.constraint(equalTo: statusBannerContainer.topAnchor),
|
|
|
|
|
+ statusBannerInner.bottomAnchor.constraint(equalTo: statusBannerContainer.bottomAnchor),
|
|
|
|
|
+ statusBannerInner.trailingAnchor.constraint(lessThanOrEqualTo: statusBrandTrailing.leadingAnchor, constant: -16),
|
|
|
|
|
+
|
|
|
|
|
+ statusBrandTrailing.trailingAnchor.constraint(equalTo: statusBannerContainer.trailingAnchor),
|
|
|
|
|
+ statusBrandTrailing.centerYAnchor.constraint(equalTo: statusBannerContainer.centerYAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ statusBannerRow.leadingAnchor.constraint(equalTo: statusBannerInner.leadingAnchor, constant: 14),
|
|
|
|
|
+ statusBannerRow.trailingAnchor.constraint(equalTo: statusBannerInner.trailingAnchor, constant: -16),
|
|
|
|
|
+ statusBannerRow.topAnchor.constraint(equalTo: statusBannerInner.topAnchor, constant: 10),
|
|
|
|
|
+ statusBannerRow.bottomAnchor.constraint(equalTo: statusBannerInner.bottomAnchor, constant: -10),
|
|
|
|
|
+
|
|
|
|
|
+ statusBannerContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 52)
|
|
|
|
|
+ ])
|
|
|
|
|
|
|
|
syncChatStatusLoadingIndicator(forStatusText: chatStatusLabel.stringValue)
|
|
syncChatStatusLoadingIndicator(forStatusText: chatStatusLabel.stringValue)
|
|
|
|
|
+ syncChatStatusBannerVisuals(forStatusText: chatStatusLabel.stringValue)
|
|
|
|
|
|
|
|
chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
|
|
chatDocumentView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
chatStack.orientation = .vertical
|
|
chatStack.orientation = .vertical
|
|
@@ -374,7 +482,43 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
private func setChatStatusLabel(_ text: String) {
|
|
private func setChatStatusLabel(_ text: String) {
|
|
|
chatStatusLabel.stringValue = text
|
|
chatStatusLabel.stringValue = text
|
|
|
syncChatStatusLoadingIndicator(forStatusText: text)
|
|
syncChatStatusLoadingIndicator(forStatusText: text)
|
|
|
|
|
+ syncChatStatusBannerVisuals(forStatusText: text)
|
|
|
syncChatStatusSparkleAnimation()
|
|
syncChatStatusSparkleAnimation()
|
|
|
|
|
+ updateStatusBannerLabelWidth()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func updateStatusBannerLabelWidth() {
|
|
|
|
|
+ guard statusBannerContainer.bounds.width > 1 else { return }
|
|
|
|
|
+ let trailingWidth = max(96, statusBrandTrailing.fittingSize.width)
|
|
|
|
|
+ let chrome: CGFloat = 14 + 40 + 12 + 16 + 16 + trailingWidth
|
|
|
|
|
+ let target = max(80, statusBannerContainer.bounds.width - chrome)
|
|
|
|
|
+ if abs(chatStatusLabel.preferredMaxLayoutWidth - target) > 0.5 {
|
|
|
|
|
+ chatStatusLabel.preferredMaxLayoutWidth = target
|
|
|
|
|
+ chatStatusLabel.invalidateIntrinsicContentSize()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Leading status glyph: sparkles for prompts, magnifying glass for search-result summaries and errors.
|
|
|
|
|
+ private func syncChatStatusBannerVisuals(forStatusText text: String) {
|
|
|
|
|
+ if text == "Thinking..." { return }
|
|
|
|
|
+ let lower = text.lowercased()
|
|
|
|
|
+ let useMagnifyingGlass =
|
|
|
|
|
+ text.hasPrefix("Found ")
|
|
|
|
|
+ || text.hasPrefix("Here are")
|
|
|
|
|
+ || text.hasPrefix("No jobs")
|
|
|
|
|
+ || text.contains("couldn't find")
|
|
|
|
|
+ || lower.contains("try again")
|
|
|
|
|
+ || lower.contains("could not reach")
|
|
|
|
|
+ || lower.contains("search did not finish")
|
|
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
|
|
|
|
|
+ chatStatusIcon.symbolConfiguration = config
|
|
|
|
|
+ if useMagnifyingGlass {
|
|
|
|
|
+ chatStatusIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search status")
|
|
|
|
|
+ } else {
|
|
|
|
|
+ chatStatusIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Assistant status")
|
|
|
|
|
+ }
|
|
|
|
|
+ chatStatusIcon.contentTintColor = .white
|
|
|
|
|
+ chatStatusSymbolContainer.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func syncChatStatusLoadingIndicator(forStatusText text: String) {
|
|
private func syncChatStatusLoadingIndicator(forStatusText text: String) {
|
|
@@ -383,9 +527,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
chatStatusIcon.isHidden = true
|
|
chatStatusIcon.isHidden = true
|
|
|
chatStatusLoadingIndicator.isHidden = false
|
|
chatStatusLoadingIndicator.isHidden = false
|
|
|
chatStatusLoadingIndicator.startAnimation(nil)
|
|
chatStatusLoadingIndicator.startAnimation(nil)
|
|
|
|
|
+ chatStatusSymbolContainer.layer?.backgroundColor = Theme.featureIconWell.cgColor
|
|
|
} else {
|
|
} else {
|
|
|
chatStatusLoadingIndicator.stopAnimation(nil)
|
|
chatStatusLoadingIndicator.stopAnimation(nil)
|
|
|
chatStatusIcon.isHidden = false
|
|
chatStatusIcon.isHidden = false
|
|
|
|
|
+ chatStatusLoadingIndicator.isHidden = true
|
|
|
|
|
+ chatStatusSymbolContainer.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -399,7 +546,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
|
|
private func shouldAnimateChatStatusSparkles(for statusText: String) -> Bool {
|
|
private func shouldAnimateChatStatusSparkles(for statusText: String) -> Bool {
|
|
|
statusText == "Ask me to find jobs"
|
|
statusText == "Ask me to find jobs"
|
|
|
- || statusText == "Ask for another role, company, or skill match"
|
|
|
|
|
|
|
+ || statusText == "Opening the vault..."
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func syncChatStatusSparkleAnimation() {
|
|
private func syncChatStatusSparkleAnimation() {
|
|
@@ -542,30 +689,99 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
persistSavedJobs()
|
|
persistSavedJobs()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func jobListingHostSubtitle(_ job: JobListing) -> String {
|
|
|
|
|
+ guard let raw = job.url, let url = URL(string: raw), let host = url.host?.lowercased() else {
|
|
|
|
|
+ return "Indeed"
|
|
|
|
|
+ }
|
|
|
|
|
+ if host.hasPrefix("www.") {
|
|
|
|
|
+ return String(host.dropFirst(4))
|
|
|
|
|
+ }
|
|
|
|
|
+ return host
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func jobListingCategorySymbol(for job: JobListing) -> String {
|
|
|
|
|
+ let blob = (job.title + " " + job.description).lowercased()
|
|
|
|
|
+ if blob.contains("machine learning") || blob.contains("deep learning") || blob.contains(" ml ") {
|
|
|
|
|
+ return "brain.head.profile"
|
|
|
|
|
+ }
|
|
|
|
|
+ if blob.contains("audio") || blob.contains(" sound ") || blob.contains("dsp") {
|
|
|
|
|
+ return "waveform"
|
|
|
|
|
+ }
|
|
|
|
|
+ if blob.contains("ios") || blob.contains("swift") || blob.contains("mobile") {
|
|
|
|
|
+ return "iphone"
|
|
|
|
|
+ }
|
|
|
|
|
+ if blob.contains("design") || blob.contains(" ux") || blob.contains("figma") {
|
|
|
|
|
+ return "paintpalette.fill"
|
|
|
|
|
+ }
|
|
|
|
|
+ if blob.contains("data ") || blob.contains("analytics") {
|
|
|
|
|
+ return "chart.bar.fill"
|
|
|
|
|
+ }
|
|
|
|
|
+ if blob.contains("ai") || blob.contains("llm") || blob.contains("nlp") {
|
|
|
|
|
+ return "cpu"
|
|
|
|
|
+ }
|
|
|
|
|
+ return "briefcase.fill"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func makeJobListingCard(_ job: JobListing, context: JobListingCardContext) -> NSView {
|
|
private func makeJobListingCard(_ job: JobListing, context: JobListingCardContext) -> NSView {
|
|
|
let card = NSView()
|
|
let card = NSView()
|
|
|
card.translatesAutoresizingMaskIntoConstraints = false
|
|
card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
card.wantsLayer = true
|
|
card.wantsLayer = true
|
|
|
card.layer?.backgroundColor = Theme.cardBackground.cgColor
|
|
card.layer?.backgroundColor = Theme.cardBackground.cgColor
|
|
|
- card.layer?.cornerRadius = 12
|
|
|
|
|
|
|
+ card.layer?.cornerRadius = 14
|
|
|
card.layer?.borderWidth = 1
|
|
card.layer?.borderWidth = 1
|
|
|
card.layer?.borderColor = Theme.border.cgColor
|
|
card.layer?.borderColor = Theme.border.cgColor
|
|
|
card.layer?.masksToBounds = true
|
|
card.layer?.masksToBounds = true
|
|
|
|
|
|
|
|
|
|
+ let iconBox = NSView()
|
|
|
|
|
+ iconBox.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ iconBox.wantsLayer = true
|
|
|
|
|
+ iconBox.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
|
|
+ iconBox.layer?.cornerRadius = 12
|
|
|
|
|
+ if #available(macOS 11.0, *) {
|
|
|
|
|
+ iconBox.layer?.cornerCurve = .continuous
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let categoryIcon = NSImageView()
|
|
|
|
|
+ categoryIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ categoryIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 22, weight: .medium)
|
|
|
|
|
+ categoryIcon.image = NSImage(systemSymbolName: jobListingCategorySymbol(for: job), accessibilityDescription: nil)
|
|
|
|
|
+ categoryIcon.contentTintColor = .white
|
|
|
|
|
+ iconBox.addSubview(categoryIcon)
|
|
|
|
|
+
|
|
|
let titleField = NSTextField(labelWithString: job.title)
|
|
let titleField = NSTextField(labelWithString: job.title)
|
|
|
titleField.font = .systemFont(ofSize: 16, weight: .semibold)
|
|
titleField.font = .systemFont(ofSize: 16, weight: .semibold)
|
|
|
- titleField.textColor = Theme.primaryText
|
|
|
|
|
|
|
+ titleField.textColor = Theme.brandBlue
|
|
|
titleField.maximumNumberOfLines = 2
|
|
titleField.maximumNumberOfLines = 2
|
|
|
titleField.lineBreakMode = .byWordWrapping
|
|
titleField.lineBreakMode = .byWordWrapping
|
|
|
titleField.alignment = .left
|
|
titleField.alignment = .left
|
|
|
|
|
+ titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
titleField.translatesAutoresizingMaskIntoConstraints = false
|
|
titleField.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
|
|
+ let buildingIcon = NSImageView()
|
|
|
|
|
+ buildingIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ buildingIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .medium)
|
|
|
|
|
+ buildingIcon.image = NSImage(systemSymbolName: "building.2.fill", accessibilityDescription: nil)
|
|
|
|
|
+ buildingIcon.contentTintColor = Theme.welcomeSubtitleText
|
|
|
|
|
+
|
|
|
|
|
+ let companyLabel = NSTextField(labelWithString: jobListingHostSubtitle(job))
|
|
|
|
|
+ companyLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
|
|
|
|
+ companyLabel.textColor = Theme.welcomeSubtitleText
|
|
|
|
|
+ companyLabel.maximumNumberOfLines = 1
|
|
|
|
|
+ companyLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
|
+ companyLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let companyRow = NSStackView(views: [buildingIcon, companyLabel])
|
|
|
|
|
+ companyRow.orientation = .horizontal
|
|
|
|
|
+ companyRow.spacing = 5
|
|
|
|
|
+ companyRow.alignment = .centerY
|
|
|
|
|
+ companyRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
let descriptionField = NSTextField(wrappingLabelWithString: job.description)
|
|
let descriptionField = NSTextField(wrappingLabelWithString: job.description)
|
|
|
descriptionField.font = .systemFont(ofSize: 13, weight: .regular)
|
|
descriptionField.font = .systemFont(ofSize: 13, weight: .regular)
|
|
|
descriptionField.textColor = Theme.secondaryText
|
|
descriptionField.textColor = Theme.secondaryText
|
|
|
- descriptionField.maximumNumberOfLines = 0
|
|
|
|
|
- descriptionField.alignment = .left
|
|
|
|
|
|
|
+ descriptionField.maximumNumberOfLines = 2
|
|
|
descriptionField.lineBreakMode = .byWordWrapping
|
|
descriptionField.lineBreakMode = .byWordWrapping
|
|
|
|
|
+ descriptionField.alignment = .left
|
|
|
descriptionField.baseWritingDirection = .leftToRight
|
|
descriptionField.baseWritingDirection = .leftToRight
|
|
|
descriptionField.attributedStringValue = Self.jobListingDescriptionAttributedString(job.description)
|
|
descriptionField.attributedStringValue = Self.jobListingDescriptionAttributedString(job.description)
|
|
|
if let cell = descriptionField.cell as? NSTextFieldCell {
|
|
if let cell = descriptionField.cell as? NSTextFieldCell {
|
|
@@ -584,7 +800,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
applyButton.bezelStyle = .rounded
|
|
applyButton.bezelStyle = .rounded
|
|
|
applyButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
applyButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
applyButton.wantsLayer = true
|
|
applyButton.wantsLayer = true
|
|
|
- applyButton.layer?.cornerRadius = 6
|
|
|
|
|
|
|
+ applyButton.layer?.cornerRadius = 8
|
|
|
applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
applyButton.contentTintColor = Theme.proCTAText
|
|
applyButton.contentTintColor = Theme.proCTAText
|
|
|
applyButton.focusRingType = .none
|
|
applyButton.focusRingType = .none
|
|
@@ -603,6 +819,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
savedButton.isBordered = false
|
|
savedButton.isBordered = false
|
|
|
savedButton.bezelStyle = .rounded
|
|
savedButton.bezelStyle = .rounded
|
|
|
savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
|
|
+ savedButton.image = NSImage(systemSymbolName: savedOn ? "heart.fill" : "heart", accessibilityDescription: nil)
|
|
|
|
|
+ savedButton.imagePosition = .imageLeading
|
|
|
|
|
+ savedButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
|
|
|
savedButton.focusRingType = .none
|
|
savedButton.focusRingType = .none
|
|
|
savedButton.state = savedOn ? .on : .off
|
|
savedButton.state = savedOn ? .on : .off
|
|
|
savedButton.pointerCursor = true
|
|
savedButton.pointerCursor = true
|
|
@@ -620,7 +839,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Dismiss")
|
|
dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Dismiss")
|
|
|
dismissButton.imagePosition = .imageOnly
|
|
dismissButton.imagePosition = .imageOnly
|
|
|
dismissButton.imageScaling = .scaleProportionallyDown
|
|
dismissButton.imageScaling = .scaleProportionallyDown
|
|
|
- dismissButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
|
|
|
|
|
|
|
+ dismissButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
|
dismissButton.isBordered = false
|
|
dismissButton.isBordered = false
|
|
|
dismissButton.bezelStyle = .rounded
|
|
dismissButton.bezelStyle = .rounded
|
|
|
dismissButton.contentTintColor = Theme.secondaryText
|
|
dismissButton.contentTintColor = Theme.secondaryText
|
|
@@ -629,7 +848,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
|
|
dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
|
|
|
dismissButton.focusRingType = .none
|
|
dismissButton.focusRingType = .none
|
|
|
dismissButton.wantsLayer = true
|
|
dismissButton.wantsLayer = true
|
|
|
- dismissButton.layer?.cornerRadius = 14
|
|
|
|
|
|
|
+ dismissButton.layer?.cornerRadius = 8
|
|
|
dismissButton.layer?.backgroundColor = NSColor.clear.cgColor
|
|
dismissButton.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
dismissButton.pointerCursor = true
|
|
dismissButton.pointerCursor = true
|
|
|
dismissButton.hoverHandler = { [weak dismissButton] hovering in
|
|
dismissButton.hoverHandler = { [weak dismissButton] hovering in
|
|
@@ -641,50 +860,64 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton])
|
|
let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton])
|
|
|
buttonRow.orientation = .horizontal
|
|
buttonRow.orientation = .horizontal
|
|
|
buttonRow.spacing = 8
|
|
buttonRow.spacing = 8
|
|
|
- buttonRow.alignment = .centerY
|
|
|
|
|
|
|
+ buttonRow.alignment = .top
|
|
|
buttonRow.translatesAutoresizingMaskIntoConstraints = false
|
|
buttonRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
buttonRow.setContentHuggingPriority(.required, for: .horizontal)
|
|
buttonRow.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
buttonRow.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
buttonRow.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
|
+ buttonRow.setContentHuggingPriority(.required, for: .vertical)
|
|
|
|
|
+ buttonRow.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
|
|
+ applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
|
+ savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
|
+ dismissButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
|
+
|
|
|
|
|
+ let middleColumn = NSStackView(views: [titleField, companyRow, descriptionField])
|
|
|
|
|
+ middleColumn.orientation = .vertical
|
|
|
|
|
+ middleColumn.spacing = 5
|
|
|
|
|
+ middleColumn.alignment = .leading
|
|
|
|
|
+ middleColumn.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ middleColumn.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ middleColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+
|
|
|
|
|
+ let contentRow = NSStackView(views: [iconBox, middleColumn])
|
|
|
|
|
+ contentRow.orientation = .horizontal
|
|
|
|
|
+ contentRow.spacing = 14
|
|
|
|
|
+ contentRow.alignment = .top
|
|
|
|
|
+ contentRow.distribution = .fill
|
|
|
|
|
+ contentRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ card.addSubview(contentRow)
|
|
|
|
|
+ card.addSubview(buttonRow)
|
|
|
|
|
+ let actionCornerInset: CGFloat = 8
|
|
|
|
|
+ let contentToActionsGap: CGFloat = 12
|
|
|
|
|
+ let bodyTrailingInset: CGFloat = 16
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ contentRow.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
|
|
|
|
|
+ contentRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -bodyTrailingInset),
|
|
|
|
|
+ contentRow.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
|
|
|
|
|
+ contentRow.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14),
|
|
|
|
|
|
|
|
- // Title hugs the leading edge; a low–hugging-priority spacer absorbs remaining width so buttons stay trailing.
|
|
|
|
|
- titleField.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
|
|
|
- titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
|
|
+ buttonRow.topAnchor.constraint(equalTo: card.topAnchor, constant: actionCornerInset),
|
|
|
|
|
+ buttonRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -actionCornerInset),
|
|
|
|
|
|
|
|
- let titleRowSpacer = NSView()
|
|
|
|
|
- titleRowSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- titleRowSpacer.setContentHuggingPriority(NSLayoutConstraint.Priority(1), for: .horizontal)
|
|
|
|
|
- titleRowSpacer.setContentCompressionResistancePriority(NSLayoutConstraint.Priority(1), for: .horizontal)
|
|
|
|
|
-
|
|
|
|
|
- let titleAndActionsRow = NSStackView(views: [titleField, titleRowSpacer, buttonRow])
|
|
|
|
|
- titleAndActionsRow.orientation = .horizontal
|
|
|
|
|
- titleAndActionsRow.spacing = 0
|
|
|
|
|
- titleAndActionsRow.setCustomSpacing(14, after: titleRowSpacer)
|
|
|
|
|
- titleAndActionsRow.alignment = .centerY
|
|
|
|
|
- titleAndActionsRow.distribution = .fill
|
|
|
|
|
- titleAndActionsRow.userInterfaceLayoutDirection = .leftToRight
|
|
|
|
|
- titleAndActionsRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
-
|
|
|
|
|
- let contentColumn = NSStackView(views: [titleAndActionsRow, descriptionField])
|
|
|
|
|
- contentColumn.orientation = .vertical
|
|
|
|
|
- contentColumn.spacing = 6
|
|
|
|
|
- contentColumn.alignment = .width
|
|
|
|
|
- contentColumn.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
-
|
|
|
|
|
- card.addSubview(contentColumn)
|
|
|
|
|
- NSLayoutConstraint.activate([
|
|
|
|
|
- contentColumn.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
|
|
|
|
|
- contentColumn.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
|
|
|
|
|
- contentColumn.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
|
|
|
|
|
- contentColumn.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14),
|
|
|
|
|
-
|
|
|
|
|
- applyButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 72),
|
|
|
|
|
- applyButton.heightAnchor.constraint(equalToConstant: 28),
|
|
|
|
|
- savedButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 72),
|
|
|
|
|
- savedButton.heightAnchor.constraint(equalToConstant: 28),
|
|
|
|
|
- dismissButton.widthAnchor.constraint(equalToConstant: 28),
|
|
|
|
|
- dismissButton.heightAnchor.constraint(equalToConstant: 28),
|
|
|
|
|
-
|
|
|
|
|
- descriptionField.widthAnchor.constraint(equalTo: contentColumn.widthAnchor)
|
|
|
|
|
|
|
+ middleColumn.trailingAnchor.constraint(lessThanOrEqualTo: buttonRow.leadingAnchor, constant: -contentToActionsGap),
|
|
|
|
|
+
|
|
|
|
|
+ iconBox.widthAnchor.constraint(equalToConstant: 58),
|
|
|
|
|
+ iconBox.heightAnchor.constraint(equalToConstant: 58),
|
|
|
|
|
+
|
|
|
|
|
+ categoryIcon.centerXAnchor.constraint(equalTo: iconBox.centerXAnchor),
|
|
|
|
|
+ categoryIcon.centerYAnchor.constraint(equalTo: iconBox.centerYAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ buildingIcon.widthAnchor.constraint(equalToConstant: 14),
|
|
|
|
|
+ buildingIcon.heightAnchor.constraint(equalToConstant: 14),
|
|
|
|
|
+
|
|
|
|
|
+ applyButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76),
|
|
|
|
|
+ applyButton.heightAnchor.constraint(equalToConstant: 32),
|
|
|
|
|
+ savedButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 84),
|
|
|
|
|
+ savedButton.heightAnchor.constraint(equalToConstant: 32),
|
|
|
|
|
+ dismissButton.widthAnchor.constraint(equalToConstant: 32),
|
|
|
|
|
+ dismissButton.heightAnchor.constraint(equalToConstant: 32),
|
|
|
|
|
+
|
|
|
|
|
+ descriptionField.widthAnchor.constraint(equalTo: middleColumn.widthAnchor)
|
|
|
])
|
|
])
|
|
|
|
|
|
|
|
return card
|
|
return card
|
|
@@ -692,7 +925,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
|
|
private func styleJobSavedButton(_ button: NSButton) {
|
|
private func styleJobSavedButton(_ button: NSButton) {
|
|
|
button.wantsLayer = true
|
|
button.wantsLayer = true
|
|
|
- button.layer?.cornerRadius = 6
|
|
|
|
|
|
|
+ button.layer?.cornerRadius = 8
|
|
|
let on = button.state == .on
|
|
let on = button.state == .on
|
|
|
let hovering = (button as? HoverableButton)?.isHovering ?? false
|
|
let hovering = (button as? HoverableButton)?.isHovering ?? false
|
|
|
if on {
|
|
if on {
|
|
@@ -701,10 +934,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
button.layer?.borderColor = Theme.brandBlue.cgColor
|
|
button.layer?.borderColor = Theme.brandBlue.cgColor
|
|
|
button.contentTintColor = Theme.brandBlue
|
|
button.contentTintColor = Theme.brandBlue
|
|
|
} else {
|
|
} else {
|
|
|
- button.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor
|
|
|
|
|
|
|
+ button.layer?.backgroundColor = (hovering ? Theme.proCardFill : Theme.cardBackground).cgColor
|
|
|
button.layer?.borderWidth = 1
|
|
button.layer?.borderWidth = 1
|
|
|
- button.layer?.borderColor = Theme.border.cgColor
|
|
|
|
|
- button.contentTintColor = Theme.primaryText
|
|
|
|
|
|
|
+ button.layer?.borderColor = Theme.brandBlue.cgColor
|
|
|
|
|
+ button.contentTintColor = Theme.brandBlue
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -726,6 +959,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
applySavedState(willSave, for: job)
|
|
applySavedState(willSave, for: job)
|
|
|
sender.state = willSave ? .on : .off
|
|
sender.state = willSave ? .on : .off
|
|
|
sender.title = willSave ? "Saved" : "Save"
|
|
sender.title = willSave ? "Saved" : "Save"
|
|
|
|
|
+ sender.image = NSImage(systemSymbolName: willSave ? "heart.fill" : "heart", accessibilityDescription: nil)
|
|
|
styleJobSavedButton(sender)
|
|
styleJobSavedButton(sender)
|
|
|
if isSavedJobsSidebarIndex(selectedSidebarIndex) {
|
|
if isSavedJobsSidebarIndex(selectedSidebarIndex) {
|
|
|
reloadSavedJobsListings()
|
|
reloadSavedJobsListings()
|
|
@@ -819,9 +1053,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
jobSearchIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
jobSearchIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- jobSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
|
|
|
|
|
- jobSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Job search")
|
|
|
|
|
- jobSearchIcon.contentTintColor = Theme.primaryText
|
|
|
|
|
|
|
+ jobSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
|
|
|
|
|
+ jobSearchIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Ask AI")
|
|
|
|
|
+ jobSearchIcon.contentTintColor = Theme.brandBlue
|
|
|
|
|
|
|
|
configureField(jobKeywordsField, placeholder: "Ask for roles, skills, salary, or job descriptions...")
|
|
configureField(jobKeywordsField, placeholder: "Ask for roles, skills, salary, or job descriptions...")
|
|
|
|
|
|
|
@@ -865,14 +1099,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
|
|
findJobsButton.translatesAutoresizingMaskIntoConstraints = false
|
|
findJobsButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
findJobsButton.title = ""
|
|
findJobsButton.title = ""
|
|
|
|
|
+ findJobsButton.image = NSImage(systemSymbolName: "paperplane.fill", accessibilityDescription: nil)
|
|
|
|
|
+ findJobsButton.imagePosition = .imageLeading
|
|
|
|
|
+ findJobsButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
|
|
|
findJobsButton.attributedTitle = NSAttributedString(
|
|
findJobsButton.attributedTitle = NSAttributedString(
|
|
|
- string: "Send",
|
|
|
|
|
|
|
+ string: " Send",
|
|
|
attributes: [
|
|
attributes: [
|
|
|
.font: NSFont.systemFont(ofSize: 14, weight: .semibold),
|
|
.font: NSFont.systemFont(ofSize: 14, weight: .semibold),
|
|
|
.foregroundColor: Theme.proCTAText,
|
|
.foregroundColor: Theme.proCTAText,
|
|
|
.kern: 0.35
|
|
.kern: 0.35
|
|
|
]
|
|
]
|
|
|
)
|
|
)
|
|
|
|
|
+ findJobsButton.contentTintColor = Theme.proCTAText
|
|
|
findJobsButton.isBordered = false
|
|
findJobsButton.isBordered = false
|
|
|
findJobsButton.bezelStyle = .rounded
|
|
findJobsButton.bezelStyle = .rounded
|
|
|
findJobsButton.wantsLayer = true
|
|
findJobsButton.wantsLayer = true
|
|
@@ -1324,6 +1562,26 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
window?.makeFirstResponder(nil)
|
|
window?.makeFirstResponder(nil)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @objc private func didTapFeatureRole() {
|
|
|
|
|
+ focusSearchField(seed: "Find roles similar to: ")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func didTapFeatureCompany() {
|
|
|
|
|
+ focusSearchField(seed: "Find jobs at company: ")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func didTapFeatureSkill() {
|
|
|
|
|
+ focusSearchField(seed: "Find jobs that require skill: ")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func focusSearchField(seed: String) {
|
|
|
|
|
+ jobKeywordsField.stringValue = seed
|
|
|
|
|
+ window?.makeFirstResponder(jobKeywordsField)
|
|
|
|
|
+ if let editor = jobKeywordsField.window?.fieldEditor(true, for: jobKeywordsField) as? NSTextView {
|
|
|
|
|
+ editor.moveToEndOfDocument(nil)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@objc private func didTapLoadMoreJobs() {
|
|
@objc private func didTapLoadMoreJobs() {
|
|
|
let prompt = "Show more jobs"
|
|
let prompt = "Show more jobs"
|
|
|
guard !isAwaitingResponse, isContinuationPrompt(prompt) else { return }
|
|
guard !isAwaitingResponse, isContinuationPrompt(prompt) else { return }
|
|
@@ -1364,7 +1622,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
)
|
|
)
|
|
|
self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
|
|
self.chatMessages.append(ChatMessage(role: "assistant", content: reply))
|
|
|
self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
|
|
self.appendChatBubble(text: reply, isUser: false, jobs: freshJobs)
|
|
|
- self.setChatStatusLabel("Ask for another role, company, or skill match")
|
|
|
|
|
|
|
+ self.setChatStatusLabel(reply)
|
|
|
case .failure(let error):
|
|
case .failure(let error):
|
|
|
self.appendChatBubble(text: error.localizedDescription, isUser: false)
|
|
self.appendChatBubble(text: error.localizedDescription, isUser: false)
|
|
|
if error is URLError {
|
|
if error is URLError {
|
|
@@ -2289,6 +2547,132 @@ private struct OpenAIAPIErrorResponse: Codable {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/// Home welcome row: three tappable shortcuts that seed the main search field (matches the reference dashboard tiles).
|
|
|
|
|
+private final class FeatureShortcutCardView: NSView {
|
|
|
|
|
+ private weak var actionTarget: AnyObject?
|
|
|
|
|
+ private var actionSelector: Selector
|
|
|
|
|
+
|
|
|
|
|
+ init(symbolName: String, title: String, subtitle: String, target: AnyObject?, action: Selector) {
|
|
|
|
|
+ self.actionTarget = target
|
|
|
|
|
+ self.actionSelector = action
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.cornerRadius = 14
|
|
|
|
|
+ if #available(macOS 11.0, *) {
|
|
|
|
|
+ layer?.cornerCurve = .continuous
|
|
|
|
|
+ }
|
|
|
|
|
+ layer?.backgroundColor = NSColor.white.cgColor
|
|
|
|
|
+ layer?.masksToBounds = false
|
|
|
|
|
+ layer?.shadowColor = NSColor.black.withAlphaComponent(0.12).cgColor
|
|
|
|
|
+ layer?.shadowOffset = CGSize(width: 0, height: 2)
|
|
|
|
|
+ layer?.shadowRadius = 10
|
|
|
|
|
+ layer?.shadowOpacity = 1
|
|
|
|
|
+
|
|
|
|
|
+ let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
|
|
|
|
|
+ let iconWellColor = NSColor(srgbRed: 220 / 255, green: 235 / 255, blue: 252 / 255, alpha: 1)
|
|
|
|
|
+ let secondary = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
|
|
|
|
|
+
|
|
|
|
|
+ let iconHost = NSView()
|
|
|
|
|
+ iconHost.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ iconHost.wantsLayer = true
|
|
|
|
|
+ iconHost.layer?.backgroundColor = iconWellColor.cgColor
|
|
|
|
|
+ iconHost.layer?.cornerRadius = 22
|
|
|
|
|
+
|
|
|
|
|
+ let icon = NSImageView()
|
|
|
|
|
+ icon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
|
|
|
|
|
+ icon.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
|
|
|
|
|
+ icon.contentTintColor = brandBlue
|
|
|
|
|
+ iconHost.addSubview(icon)
|
|
|
|
|
+
|
|
|
|
|
+ let titleField = NSTextField(wrappingLabelWithString: title)
|
|
|
|
|
+ titleField.font = .systemFont(ofSize: 15, weight: .bold)
|
|
|
|
|
+ titleField.textColor = brandBlue
|
|
|
|
|
+ titleField.maximumNumberOfLines = 1
|
|
|
|
|
+ titleField.isEditable = false
|
|
|
|
|
+ titleField.isBordered = false
|
|
|
|
|
+ titleField.drawsBackground = false
|
|
|
|
|
+ titleField.alignment = .left
|
|
|
|
|
+
|
|
|
|
|
+ let subtitleField = NSTextField(wrappingLabelWithString: subtitle)
|
|
|
|
|
+ subtitleField.font = .systemFont(ofSize: 11, weight: .regular)
|
|
|
|
|
+ subtitleField.textColor = secondary
|
|
|
|
|
+ subtitleField.maximumNumberOfLines = 2
|
|
|
|
|
+ subtitleField.isEditable = false
|
|
|
|
|
+ subtitleField.isBordered = false
|
|
|
|
|
+ subtitleField.drawsBackground = false
|
|
|
|
|
+ subtitleField.alignment = .left
|
|
|
|
|
+ subtitleField.preferredMaxLayoutWidth = 160
|
|
|
|
|
+
|
|
|
|
|
+ let textColumn = NSStackView(views: [titleField, subtitleField])
|
|
|
|
|
+ textColumn.orientation = .vertical
|
|
|
|
|
+ textColumn.spacing = 4
|
|
|
|
|
+ textColumn.alignment = .leading
|
|
|
|
|
+ textColumn.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ textColumn.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ textColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+
|
|
|
|
|
+ let chevron = NSImageView()
|
|
|
|
|
+ chevron.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ chevron.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
|
|
|
|
|
+ chevron.image = NSImage(systemSymbolName: "arrow.right", accessibilityDescription: nil)
|
|
|
|
|
+ chevron.contentTintColor = brandBlue
|
|
|
|
|
+ chevron.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
|
|
+ chevron.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
|
+
|
|
|
|
|
+ iconHost.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
|
|
+ iconHost.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
|
+
|
|
|
|
|
+ let row = NSStackView(views: [iconHost, textColumn, chevron])
|
|
|
|
|
+ row.orientation = .horizontal
|
|
|
|
|
+ row.spacing = 12
|
|
|
|
|
+ row.alignment = .centerY
|
|
|
|
|
+ row.distribution = .fill
|
|
|
|
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ addSubview(row)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
|
|
|
|
|
+ row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
|
|
|
|
|
+ row.topAnchor.constraint(equalTo: topAnchor, constant: 16),
|
|
|
|
|
+ row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16),
|
|
|
|
|
+
|
|
|
|
|
+ iconHost.widthAnchor.constraint(equalToConstant: 44),
|
|
|
|
|
+ iconHost.heightAnchor.constraint(equalToConstant: 44),
|
|
|
|
|
+ icon.centerXAnchor.constraint(equalTo: iconHost.centerXAnchor),
|
|
|
|
|
+ icon.centerYAnchor.constraint(equalTo: iconHost.centerYAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ setAccessibilityElement(true)
|
|
|
|
|
+ setAccessibilityRole(.button)
|
|
|
|
|
+ setAccessibilityLabel("\(title). \(subtitle)")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) {
|
|
|
|
|
+ fatalError("init(coder:) has not been implemented")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func layout() {
|
|
|
|
|
+ super.layout()
|
|
|
|
|
+ guard let layer = layer, bounds.width > 0, bounds.height > 0 else { return }
|
|
|
|
|
+ let r = bounds
|
|
|
|
|
+ layer.shadowPath = CGPath(roundedRect: r, cornerWidth: 14, cornerHeight: 14, transform: nil)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func mouseDown(with event: NSEvent) {
|
|
|
|
|
+ if let target = actionTarget {
|
|
|
|
|
+ _ = target.perform(actionSelector, with: nil)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func resetCursorRects() {
|
|
|
|
|
+ super.resetCursorRects()
|
|
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
|
|
/// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
|
|
|
private final class JobPayloadButton: HoverableButton {
|
|
private final class JobPayloadButton: HoverableButton {
|
|
|
var jobPayload: JobListing?
|
|
var jobPayload: JobListing?
|