Brak opisu

CVMakerPageView.swift 62KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579
  1. //
  2. // CVMakerPageView.swift
  3. // App for Indeed
  4. //
  5. // Template gallery for the CV Maker sidebar destination. Light-theme rendering
  6. // inspired by a dark reference UI: page header, category toggle, style chips,
  7. // 4-column thumbnail grid with hover Preview overlay, and a sticky bottom CTA.
  8. //
  9. import Cocoa
  10. // MARK: - Data model
  11. enum CVCategoryGroup: Hashable {
  12. case designBased
  13. case professionBased
  14. var title: String {
  15. switch self {
  16. case .designBased: return "Design-Based"
  17. case .professionBased: return "Profession-Based"
  18. }
  19. }
  20. }
  21. enum CVDesignFamily: String, CaseIterable, Hashable {
  22. case professional, modern, creative, minimal, executive
  23. var title: String {
  24. switch self {
  25. case .professional: return "Professional"
  26. case .modern: return "Modern"
  27. case .creative: return "Creative"
  28. case .minimal: return "Minimal"
  29. case .executive: return "Executive"
  30. }
  31. }
  32. }
  33. /// Visual recipe used by the mini preview renderer so every template can vary
  34. /// the headline style, accent line, and sidebar layout without bespoke views.
  35. struct CVTemplate: Hashable {
  36. enum Headline: Hashable {
  37. /// Big name centered above the body.
  38. case centered
  39. /// Name aligned to the leading edge, role beneath it.
  40. case leftAligned
  41. /// Name on the leading edge with circular initials avatar on the trailing edge.
  42. case leftWithInitials
  43. /// Initials avatar above a centered name (single column).
  44. case avatarStacked
  45. }
  46. enum Accent: Hashable {
  47. case none
  48. case redUnderline
  49. case redBar
  50. case blueBar
  51. }
  52. enum SidebarSide: Hashable { case leading, trailing }
  53. enum Layout: Hashable {
  54. case singleColumn
  55. case twoColumn(sidebar: SidebarSide, tinted: Bool)
  56. }
  57. enum SectionLabelStyle: Hashable {
  58. case uppercase
  59. case slashed // "// EXPERIENCE"
  60. case bracketed // "[ EXPERIENCE ]"
  61. }
  62. let id: String
  63. let name: String
  64. let family: CVDesignFamily
  65. let headline: Headline
  66. let accent: Accent
  67. let layout: Layout
  68. let sectionLabelStyle: SectionLabelStyle
  69. }
  70. // MARK: - Catalog
  71. enum CVTemplateCatalog {
  72. static let all: [CVTemplate] = [
  73. // Minimal family (matches the reference screenshot)
  74. CVTemplate(
  75. id: "paper-white",
  76. name: "Paper White",
  77. family: .minimal,
  78. headline: .centered,
  79. accent: .none,
  80. layout: .singleColumn,
  81. sectionLabelStyle: .uppercase
  82. ),
  83. CVTemplate(
  84. id: "swiss",
  85. name: "Swiss",
  86. family: .minimal,
  87. headline: .centered,
  88. accent: .redUnderline,
  89. layout: .twoColumn(sidebar: .leading, tinted: false),
  90. sectionLabelStyle: .uppercase
  91. ),
  92. CVTemplate(
  93. id: "mono",
  94. name: "Mono",
  95. family: .minimal,
  96. headline: .leftAligned,
  97. accent: .redUnderline,
  98. layout: .singleColumn,
  99. sectionLabelStyle: .slashed
  100. ),
  101. CVTemplate(
  102. id: "airy",
  103. name: "Airy",
  104. family: .minimal,
  105. headline: .leftWithInitials,
  106. accent: .none,
  107. layout: .twoColumn(sidebar: .trailing, tinted: false),
  108. sectionLabelStyle: .uppercase
  109. ),
  110. CVTemplate(
  111. id: "tabular",
  112. name: "Tabular",
  113. family: .minimal,
  114. headline: .leftAligned,
  115. accent: .none,
  116. layout: .singleColumn,
  117. sectionLabelStyle: .bracketed
  118. ),
  119. CVTemplate(
  120. id: "facet",
  121. name: "Facet",
  122. family: .minimal,
  123. headline: .avatarStacked,
  124. accent: .none,
  125. layout: .twoColumn(sidebar: .leading, tinted: true),
  126. sectionLabelStyle: .uppercase
  127. ),
  128. // Professional family
  129. CVTemplate(
  130. id: "corporate",
  131. name: "Corporate",
  132. family: .professional,
  133. headline: .leftAligned,
  134. accent: .blueBar,
  135. layout: .singleColumn,
  136. sectionLabelStyle: .uppercase
  137. ),
  138. CVTemplate(
  139. id: "atlas",
  140. name: "Atlas",
  141. family: .professional,
  142. headline: .centered,
  143. accent: .blueBar,
  144. layout: .twoColumn(sidebar: .leading, tinted: true),
  145. sectionLabelStyle: .uppercase
  146. ),
  147. CVTemplate(
  148. id: "ledger",
  149. name: "Ledger",
  150. family: .professional,
  151. headline: .leftAligned,
  152. accent: .blueBar,
  153. layout: .twoColumn(sidebar: .trailing, tinted: false),
  154. sectionLabelStyle: .uppercase
  155. ),
  156. CVTemplate(
  157. id: "harbor",
  158. name: "Harbor",
  159. family: .professional,
  160. headline: .leftWithInitials,
  161. accent: .none,
  162. layout: .twoColumn(sidebar: .leading, tinted: true),
  163. sectionLabelStyle: .uppercase
  164. ),
  165. CVTemplate(
  166. id: "metro",
  167. name: "Metro",
  168. family: .professional,
  169. headline: .centered,
  170. accent: .blueBar,
  171. layout: .singleColumn,
  172. sectionLabelStyle: .uppercase
  173. ),
  174. CVTemplate(
  175. id: "pinstripe",
  176. name: "Pinstripe",
  177. family: .professional,
  178. headline: .leftAligned,
  179. accent: .blueBar,
  180. layout: .twoColumn(sidebar: .leading, tinted: false),
  181. sectionLabelStyle: .uppercase
  182. ),
  183. // Modern family
  184. CVTemplate(
  185. id: "vertex",
  186. name: "Vertex",
  187. family: .modern,
  188. headline: .leftWithInitials,
  189. accent: .blueBar,
  190. layout: .twoColumn(sidebar: .leading, tinted: true),
  191. sectionLabelStyle: .slashed
  192. ),
  193. CVTemplate(
  194. id: "linea",
  195. name: "Linea",
  196. family: .modern,
  197. headline: .leftAligned,
  198. accent: .blueBar,
  199. layout: .singleColumn,
  200. sectionLabelStyle: .slashed
  201. ),
  202. CVTemplate(
  203. id: "prism",
  204. name: "Prism",
  205. family: .modern,
  206. headline: .avatarStacked,
  207. accent: .blueBar,
  208. layout: .twoColumn(sidebar: .trailing, tinted: true),
  209. sectionLabelStyle: .uppercase
  210. ),
  211. CVTemplate(
  212. id: "circuit",
  213. name: "Circuit",
  214. family: .modern,
  215. headline: .leftAligned,
  216. accent: .blueBar,
  217. layout: .twoColumn(sidebar: .trailing, tinted: false),
  218. sectionLabelStyle: .slashed
  219. ),
  220. CVTemplate(
  221. id: "north",
  222. name: "North",
  223. family: .modern,
  224. headline: .leftWithInitials,
  225. accent: .none,
  226. layout: .twoColumn(sidebar: .leading, tinted: false),
  227. sectionLabelStyle: .uppercase
  228. ),
  229. CVTemplate(
  230. id: "axis",
  231. name: "Axis",
  232. family: .modern,
  233. headline: .centered,
  234. accent: .blueBar,
  235. layout: .singleColumn,
  236. sectionLabelStyle: .bracketed
  237. ),
  238. // Creative family
  239. CVTemplate(
  240. id: "marigold",
  241. name: "Marigold",
  242. family: .creative,
  243. headline: .avatarStacked,
  244. accent: .redBar,
  245. layout: .twoColumn(sidebar: .leading, tinted: true),
  246. sectionLabelStyle: .slashed
  247. ),
  248. CVTemplate(
  249. id: "ember",
  250. name: "Ember",
  251. family: .creative,
  252. headline: .leftWithInitials,
  253. accent: .redBar,
  254. layout: .twoColumn(sidebar: .trailing, tinted: true),
  255. sectionLabelStyle: .slashed
  256. ),
  257. CVTemplate(
  258. id: "lattice",
  259. name: "Lattice",
  260. family: .creative,
  261. headline: .leftAligned,
  262. accent: .redUnderline,
  263. layout: .singleColumn,
  264. sectionLabelStyle: .bracketed
  265. ),
  266. CVTemplate(
  267. id: "bloom",
  268. name: "Bloom",
  269. family: .creative,
  270. headline: .avatarStacked,
  271. accent: .redBar,
  272. layout: .singleColumn,
  273. sectionLabelStyle: .slashed
  274. ),
  275. CVTemplate(
  276. id: "studio",
  277. name: "Studio",
  278. family: .creative,
  279. headline: .leftWithInitials,
  280. accent: .redUnderline,
  281. layout: .twoColumn(sidebar: .leading, tinted: true),
  282. sectionLabelStyle: .uppercase
  283. ),
  284. CVTemplate(
  285. id: "kite",
  286. name: "Kite",
  287. family: .creative,
  288. headline: .centered,
  289. accent: .redBar,
  290. layout: .twoColumn(sidebar: .trailing, tinted: false),
  291. sectionLabelStyle: .slashed
  292. ),
  293. // Executive family
  294. CVTemplate(
  295. id: "regent",
  296. name: "Regent",
  297. family: .executive,
  298. headline: .centered,
  299. accent: .blueBar,
  300. layout: .twoColumn(sidebar: .leading, tinted: true),
  301. sectionLabelStyle: .uppercase
  302. ),
  303. CVTemplate(
  304. id: "monarch",
  305. name: "Monarch",
  306. family: .executive,
  307. headline: .centered,
  308. accent: .blueBar,
  309. layout: .singleColumn,
  310. sectionLabelStyle: .uppercase
  311. ),
  312. CVTemplate(
  313. id: "sterling",
  314. name: "Sterling",
  315. family: .executive,
  316. headline: .leftAligned,
  317. accent: .blueBar,
  318. layout: .twoColumn(sidebar: .trailing, tinted: false),
  319. sectionLabelStyle: .uppercase
  320. ),
  321. CVTemplate(
  322. id: "summit",
  323. name: "Summit",
  324. family: .executive,
  325. headline: .centered,
  326. accent: .redUnderline,
  327. layout: .twoColumn(sidebar: .leading, tinted: false),
  328. sectionLabelStyle: .uppercase
  329. ),
  330. CVTemplate(
  331. id: "estate",
  332. name: "Estate",
  333. family: .executive,
  334. headline: .leftWithInitials,
  335. accent: .blueBar,
  336. layout: .twoColumn(sidebar: .trailing, tinted: true),
  337. sectionLabelStyle: .uppercase
  338. ),
  339. CVTemplate(
  340. id: "chairman",
  341. name: "Chairman",
  342. family: .executive,
  343. headline: .leftAligned,
  344. accent: .blueBar,
  345. layout: .singleColumn,
  346. sectionLabelStyle: .uppercase
  347. )
  348. ]
  349. }
  350. // MARK: - View
  351. /// Standalone NSView for the CV Maker route. Renders the template gallery with
  352. /// header, segmented category groups, family chips, a thumbnail grid, and a
  353. /// bottom CTA. Hosts inside the same `nonHomeHost` slot as Saved Jobs/Settings.
  354. final class CVMakerPageView: NSView {
  355. /// Light-theme palette aligned with the rest of the dashboard (brand blue + neutral grays on white).
  356. private enum Palette {
  357. static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  358. static let mutedSurface = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
  359. static let chipRestFill = NSColor(srgbRed: 244 / 255, green: 246 / 255, blue: 250 / 255, alpha: 1)
  360. static let chipBorder = NSColor(srgbRed: 222 / 255, green: 226 / 255, blue: 233 / 255, alpha: 1)
  361. static let chipHoverFill = NSColor(srgbRed: 236 / 255, green: 240 / 255, blue: 246 / 255, alpha: 1)
  362. static let chipBadgeBackground = NSColor(srgbRed: 233 / 255, green: 236 / 255, blue: 241 / 255, alpha: 1)
  363. static let chipBadgeText = NSColor(srgbRed: 90 / 255, green: 102 / 255, blue: 121 / 255, alpha: 1)
  364. static let activeChipBackground = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  365. static let activeChipHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
  366. static let activeChipText = NSColor.white
  367. static let activeChipBadgeBackground = NSColor.white.withAlphaComponent(0.22)
  368. static let activeChipBadgeText = NSColor.white
  369. static let primaryText = NSColor(srgbRed: 31 / 255, green: 41 / 255, blue: 55 / 255, alpha: 1)
  370. static let secondaryText = NSColor(srgbRed: 100 / 255, green: 116 / 255, blue: 139 / 255, alpha: 1)
  371. static let cardBorder = NSColor(srgbRed: 216 / 255, green: 223 / 255, blue: 233 / 255, alpha: 1)
  372. static let cardBorderHover = NSColor(srgbRed: 178 / 255, green: 196 / 255, blue: 225 / 255, alpha: 1)
  373. static let cardBorderSelected = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  374. static let cardFooter = NSColor(srgbRed: 250 / 255, green: 251 / 255, blue: 253 / 255, alpha: 1)
  375. static let previewSurface = NSColor(srgbRed: 252 / 255, green: 252 / 255, blue: 252 / 255, alpha: 1)
  376. static let previewPaper = NSColor.white
  377. static let previewSidebarTint = NSColor(srgbRed: 244 / 255, green: 246 / 255, blue: 250 / 255, alpha: 1)
  378. static let previewInk = NSColor(srgbRed: 38 / 255, green: 50 / 255, blue: 71 / 255, alpha: 1)
  379. static let previewMuted = NSColor(srgbRed: 165 / 255, green: 175 / 255, blue: 192 / 255, alpha: 1)
  380. static let previewAccentRed = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1)
  381. static let previewAccentBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  382. static let ctaBackground = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  383. static let ctaHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
  384. static let ctaText = NSColor.white
  385. static let overlayTint = NSColor.black.withAlphaComponent(0.45)
  386. }
  387. private static let columns: Int = 4
  388. private let titleLabel = NSTextField(labelWithString: "Templates")
  389. private let subtitleLabel = NSTextField(labelWithString: "Browse and select a template for your CV")
  390. private let groupTabsRow = NSStackView()
  391. private let familyChipsRow = NSStackView()
  392. private let scrollView = NSScrollView()
  393. private let gridDocument = TopFlippedView()
  394. private let gridStack = NSStackView()
  395. private let ctaButton = CVHoverableButton(title: "Use Template & Select Profile →", target: nil, action: nil)
  396. private var selectedGroup: CVCategoryGroup = .designBased
  397. private var selectedFamily: CVDesignFamily? = nil // nil == "All"
  398. private var selectedTemplateID: String? = "paper-white"
  399. private var groupTabButtons: [CVCategoryGroup: CVChipButton] = [:]
  400. private var familyChipButtons: [CVDesignFamily?: CVChipButton] = [:]
  401. private var templateCardsByID: [String: CVTemplateCard] = [:]
  402. override init(frame frameRect: NSRect) {
  403. super.init(frame: frameRect)
  404. configureLayout()
  405. reloadFamilyChips()
  406. reloadTemplateGrid()
  407. updateSelectedChipStates()
  408. }
  409. @available(*, unavailable)
  410. required init?(coder: NSCoder) {
  411. fatalError("init(coder:) has not been implemented")
  412. }
  413. override func layout() {
  414. super.layout()
  415. // Re-measure the grid in case the container width changed (window resize).
  416. layoutGridCardsIfNeeded()
  417. }
  418. // MARK: Setup
  419. private func configureLayout() {
  420. wantsLayer = true
  421. layer?.backgroundColor = Palette.pageBackground.cgColor
  422. titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
  423. titleLabel.textColor = Palette.primaryText
  424. titleLabel.alignment = .left
  425. subtitleLabel.font = .systemFont(ofSize: 13, weight: .regular)
  426. subtitleLabel.textColor = Palette.secondaryText
  427. subtitleLabel.alignment = .left
  428. subtitleLabel.maximumNumberOfLines = 1
  429. let headerStack = NSStackView(views: [titleLabel, subtitleLabel])
  430. headerStack.orientation = .vertical
  431. headerStack.spacing = 4
  432. headerStack.alignment = .leading
  433. headerStack.translatesAutoresizingMaskIntoConstraints = false
  434. groupTabsRow.orientation = .horizontal
  435. groupTabsRow.spacing = 8
  436. groupTabsRow.alignment = .centerY
  437. groupTabsRow.translatesAutoresizingMaskIntoConstraints = false
  438. configureGroupTabs()
  439. familyChipsRow.orientation = .horizontal
  440. familyChipsRow.spacing = 8
  441. familyChipsRow.alignment = .centerY
  442. familyChipsRow.translatesAutoresizingMaskIntoConstraints = false
  443. gridStack.orientation = .vertical
  444. gridStack.spacing = 16
  445. gridStack.alignment = .leading
  446. gridStack.distribution = .fill
  447. gridStack.translatesAutoresizingMaskIntoConstraints = false
  448. gridDocument.translatesAutoresizingMaskIntoConstraints = false
  449. gridDocument.addSubview(gridStack)
  450. NSLayoutConstraint.activate([
  451. gridStack.leadingAnchor.constraint(equalTo: gridDocument.leadingAnchor),
  452. gridStack.trailingAnchor.constraint(equalTo: gridDocument.trailingAnchor),
  453. gridStack.topAnchor.constraint(equalTo: gridDocument.topAnchor),
  454. gridStack.bottomAnchor.constraint(equalTo: gridDocument.bottomAnchor)
  455. ])
  456. scrollView.translatesAutoresizingMaskIntoConstraints = false
  457. scrollView.hasVerticalScroller = true
  458. scrollView.hasHorizontalScroller = false
  459. scrollView.scrollerStyle = .legacy
  460. scrollView.autohidesScrollers = true
  461. scrollView.drawsBackground = false
  462. scrollView.borderType = .noBorder
  463. scrollView.documentView = gridDocument
  464. NSLayoutConstraint.activate([
  465. gridDocument.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  466. gridDocument.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
  467. gridDocument.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor)
  468. ])
  469. ctaButton.translatesAutoresizingMaskIntoConstraints = false
  470. styleCTAButton(ctaButton)
  471. ctaButton.target = self
  472. ctaButton.action = #selector(didTapUseTemplate)
  473. addSubview(headerStack)
  474. addSubview(groupTabsRow)
  475. addSubview(familyChipsRow)
  476. addSubview(scrollView)
  477. addSubview(ctaButton)
  478. let horizontalInset: CGFloat = 32
  479. NSLayoutConstraint.activate([
  480. headerStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
  481. headerStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset),
  482. headerStack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  483. groupTabsRow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
  484. groupTabsRow.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset),
  485. groupTabsRow.topAnchor.constraint(equalTo: headerStack.bottomAnchor, constant: 18),
  486. familyChipsRow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
  487. familyChipsRow.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset),
  488. familyChipsRow.topAnchor.constraint(equalTo: groupTabsRow.bottomAnchor, constant: 14),
  489. scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
  490. scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
  491. scrollView.topAnchor.constraint(equalTo: familyChipsRow.bottomAnchor, constant: 16),
  492. scrollView.bottomAnchor.constraint(equalTo: ctaButton.topAnchor, constant: -16),
  493. ctaButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
  494. ctaButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
  495. ctaButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24),
  496. ctaButton.heightAnchor.constraint(equalToConstant: 50)
  497. ])
  498. }
  499. private func configureGroupTabs() {
  500. groupTabsRow.arrangedSubviews.forEach {
  501. groupTabsRow.removeArrangedSubview($0)
  502. $0.removeFromSuperview()
  503. }
  504. groupTabButtons.removeAll()
  505. let groups: [(CVCategoryGroup, String)] = [
  506. (.designBased, "rectangle.3.group"),
  507. (.professionBased, "person.2")
  508. ]
  509. for (group, symbolName) in groups {
  510. let count = templates(forGroup: group, family: nil).count
  511. let chip = CVChipButton(
  512. title: group.title,
  513. badgeText: "\(count)",
  514. leadingSymbol: symbolName,
  515. style: .pillLarge
  516. )
  517. chip.translatesAutoresizingMaskIntoConstraints = false
  518. chip.onSelect = { [weak self] in self?.didSelectGroup(group) }
  519. groupTabsRow.addArrangedSubview(chip)
  520. groupTabButtons[group] = chip
  521. }
  522. }
  523. private func reloadFamilyChips() {
  524. familyChipsRow.arrangedSubviews.forEach {
  525. familyChipsRow.removeArrangedSubview($0)
  526. $0.removeFromSuperview()
  527. }
  528. familyChipButtons.removeAll()
  529. let allCount = templates(forGroup: selectedGroup, family: nil).count
  530. let allChip = CVChipButton(title: "All", badgeText: "\(allCount)", leadingSymbol: nil, style: .pillSmall)
  531. allChip.onSelect = { [weak self] in self?.didSelectFamily(nil) }
  532. familyChipsRow.addArrangedSubview(allChip)
  533. familyChipButtons[nil] = allChip
  534. for family in CVDesignFamily.allCases {
  535. let count = templates(forGroup: selectedGroup, family: family).count
  536. let chip = CVChipButton(title: family.title, badgeText: "\(count)", leadingSymbol: nil, style: .pillSmall)
  537. chip.onSelect = { [weak self] in self?.didSelectFamily(family) }
  538. familyChipsRow.addArrangedSubview(chip)
  539. familyChipButtons[family] = chip
  540. }
  541. }
  542. // MARK: Data filtering
  543. private func templates(forGroup group: CVCategoryGroup, family: CVDesignFamily?) -> [CVTemplate] {
  544. // The catalog is design-driven; profession-based reuses the same templates
  545. // so the gallery is fully populated for both groups in this preview build.
  546. let base = CVTemplateCatalog.all
  547. guard let family else { return base }
  548. _ = group
  549. return base.filter { $0.family == family }
  550. }
  551. private var visibleTemplates: [CVTemplate] {
  552. templates(forGroup: selectedGroup, family: selectedFamily)
  553. }
  554. // MARK: Grid
  555. private func reloadTemplateGrid() {
  556. gridStack.arrangedSubviews.forEach {
  557. gridStack.removeArrangedSubview($0)
  558. $0.removeFromSuperview()
  559. }
  560. templateCardsByID.removeAll()
  561. let templates = visibleTemplates
  562. if templates.isEmpty {
  563. let empty = NSTextField(labelWithString: "No templates yet for this category.")
  564. empty.font = .systemFont(ofSize: 13)
  565. empty.textColor = Palette.secondaryText
  566. gridStack.addArrangedSubview(empty)
  567. return
  568. }
  569. let columns = Self.columns
  570. var index = 0
  571. while index < templates.count {
  572. let row = NSStackView()
  573. row.orientation = .horizontal
  574. row.spacing = 16
  575. row.distribution = .fillEqually
  576. row.alignment = .top
  577. row.translatesAutoresizingMaskIntoConstraints = false
  578. row.setHuggingPriority(.defaultLow, for: .horizontal)
  579. for column in 0..<columns {
  580. let position = index + column
  581. if position < templates.count {
  582. let template = templates[position]
  583. let card = CVTemplateCard(template: template, palette: palette())
  584. card.translatesAutoresizingMaskIntoConstraints = false
  585. card.onSelect = { [weak self] in self?.didSelectTemplate(template.id) }
  586. card.onPreview = { [weak self] in self?.didPreviewTemplate(template.id) }
  587. row.addArrangedSubview(card)
  588. templateCardsByID[template.id] = card
  589. } else {
  590. let filler = NSView()
  591. filler.translatesAutoresizingMaskIntoConstraints = false
  592. row.addArrangedSubview(filler)
  593. }
  594. }
  595. gridStack.addArrangedSubview(row)
  596. row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
  597. index += columns
  598. }
  599. if selectedTemplateID == nil || templateCardsByID[selectedTemplateID ?? ""] == nil {
  600. selectedTemplateID = templates.first?.id
  601. }
  602. applySelectionToCards()
  603. }
  604. private func layoutGridCardsIfNeeded() {
  605. // Stack will resize the rows; nothing else to do — kept as a hook for future enhancements.
  606. }
  607. private func applySelectionToCards() {
  608. for (id, card) in templateCardsByID {
  609. card.isSelected = (id == selectedTemplateID)
  610. }
  611. }
  612. private func palette() -> CVTemplateCard.Palette {
  613. CVTemplateCard.Palette(
  614. border: Palette.cardBorder,
  615. borderHover: Palette.cardBorderHover,
  616. borderSelected: Palette.cardBorderSelected,
  617. footerBackground: Palette.cardFooter,
  618. previewSurface: Palette.previewSurface,
  619. previewPaper: Palette.previewPaper,
  620. previewSidebarTint: Palette.previewSidebarTint,
  621. previewInk: Palette.previewInk,
  622. previewMuted: Palette.previewMuted,
  623. previewAccentRed: Palette.previewAccentRed,
  624. previewAccentBlue: Palette.previewAccentBlue,
  625. primaryText: Palette.primaryText,
  626. secondaryText: Palette.secondaryText,
  627. overlayTint: Palette.overlayTint
  628. )
  629. }
  630. // MARK: Selection
  631. private func didSelectGroup(_ group: CVCategoryGroup) {
  632. guard selectedGroup != group else { return }
  633. selectedGroup = group
  634. selectedFamily = nil
  635. reloadFamilyChips()
  636. reloadTemplateGrid()
  637. updateSelectedChipStates()
  638. }
  639. private func didSelectFamily(_ family: CVDesignFamily?) {
  640. guard selectedFamily != family else { return }
  641. selectedFamily = family
  642. reloadTemplateGrid()
  643. updateSelectedChipStates()
  644. }
  645. private func didSelectTemplate(_ id: String) {
  646. selectedTemplateID = id
  647. applySelectionToCards()
  648. }
  649. private func didPreviewTemplate(_ id: String) {
  650. selectedTemplateID = id
  651. applySelectionToCards()
  652. guard let template = CVTemplateCatalog.all.first(where: { $0.id == id }) else { return }
  653. presentPlaceholderAlert(
  654. title: "Preview \"\(template.name)\"",
  655. message: "Full-page previews and PDF export are coming soon."
  656. )
  657. }
  658. @objc private func didTapUseTemplate() {
  659. guard let id = selectedTemplateID,
  660. let template = CVTemplateCatalog.all.first(where: { $0.id == id }) else {
  661. presentPlaceholderAlert(title: "Pick a template", message: "Select a template first, then choose a profile to continue.")
  662. return
  663. }
  664. presentPlaceholderAlert(
  665. title: "Use \"\(template.name)\"",
  666. message: "Profile selection and CV editing are not available in this preview build yet."
  667. )
  668. }
  669. private func updateSelectedChipStates() {
  670. for (group, chip) in groupTabButtons {
  671. chip.isSelected = (group == selectedGroup)
  672. }
  673. for (family, chip) in familyChipButtons {
  674. chip.isSelected = (family == selectedFamily)
  675. }
  676. }
  677. private func styleCTAButton(_ button: CVHoverableButton) {
  678. button.title = "Use Template & Select Profile →"
  679. button.font = .systemFont(ofSize: 14, weight: .semibold)
  680. button.isBordered = false
  681. button.bezelStyle = .rounded
  682. button.focusRingType = .none
  683. button.contentTintColor = Palette.ctaText
  684. button.wantsLayer = true
  685. button.layer?.cornerRadius = 12
  686. button.layer?.backgroundColor = Palette.ctaBackground.cgColor
  687. button.pointerCursor = true
  688. let attrs: [NSAttributedString.Key: Any] = [
  689. .foregroundColor: Palette.ctaText,
  690. .font: NSFont.systemFont(ofSize: 14, weight: .semibold)
  691. ]
  692. button.attributedTitle = NSAttributedString(string: button.title, attributes: attrs)
  693. button.hoverHandler = { [weak button] hovering in
  694. button?.layer?.backgroundColor = (hovering ? Palette.ctaHover : Palette.ctaBackground).cgColor
  695. }
  696. }
  697. private func presentPlaceholderAlert(title: String, message: String) {
  698. let alert = NSAlert()
  699. alert.messageText = title
  700. alert.informativeText = message
  701. alert.alertStyle = .informational
  702. alert.addButton(withTitle: "OK")
  703. if let window {
  704. alert.beginSheetModal(for: window)
  705. } else {
  706. alert.runModal()
  707. }
  708. }
  709. }
  710. // MARK: - Chip / pill button
  711. /// Reusable pill button used for both the top section toggle ("Design-Based"
  712. /// vs "Profession-Based") and the family filter chips. Switches between an
  713. /// active brand-blue state and a soft neutral state, with an inline count badge.
  714. private final class CVChipButton: NSView {
  715. enum Style { case pillLarge, pillSmall }
  716. var onSelect: (() -> Void)?
  717. var isSelected: Bool = false { didSet { applyState() } }
  718. private let style: Style
  719. private let titleLabel = NSTextField(labelWithString: "")
  720. private let badgeLabel = NSTextField(labelWithString: "")
  721. private let badgePill = NSView()
  722. private let symbolView = NSImageView()
  723. private let stack = NSStackView()
  724. private var isHovering: Bool = false
  725. private var didPushCursor: Bool = false
  726. private var trackingArea: NSTrackingArea?
  727. private enum Palette {
  728. static let restFill = NSColor(srgbRed: 247 / 255, green: 249 / 255, blue: 252 / 255, alpha: 1)
  729. static let restBorder = NSColor(srgbRed: 222 / 255, green: 226 / 255, blue: 233 / 255, alpha: 1)
  730. static let hoverFill = NSColor(srgbRed: 238 / 255, green: 241 / 255, blue: 247 / 255, alpha: 1)
  731. static let activeFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  732. static let activeFillHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
  733. static let restText = NSColor(srgbRed: 71 / 255, green: 85 / 255, blue: 105 / 255, alpha: 1)
  734. static let activeText = NSColor.white
  735. static let restBadge = NSColor(srgbRed: 230 / 255, green: 234 / 255, blue: 240 / 255, alpha: 1)
  736. static let restBadgeText = NSColor(srgbRed: 100 / 255, green: 116 / 255, blue: 139 / 255, alpha: 1)
  737. static let activeBadge = NSColor.white.withAlphaComponent(0.22)
  738. static let activeBadgeText = NSColor.white
  739. }
  740. init(title: String, badgeText: String, leadingSymbol: String?, style: Style) {
  741. self.style = style
  742. super.init(frame: .zero)
  743. wantsLayer = true
  744. translatesAutoresizingMaskIntoConstraints = false
  745. let height: CGFloat = style == .pillLarge ? 38 : 30
  746. layer?.cornerRadius = height / 2
  747. layer?.borderWidth = 1
  748. heightAnchor.constraint(equalToConstant: height).isActive = true
  749. titleLabel.stringValue = title
  750. titleLabel.font = .systemFont(ofSize: style == .pillLarge ? 13 : 12, weight: .semibold)
  751. titleLabel.isBordered = false
  752. titleLabel.drawsBackground = false
  753. titleLabel.isEditable = false
  754. titleLabel.isSelectable = false
  755. badgeLabel.stringValue = badgeText
  756. badgeLabel.font = .systemFont(ofSize: 10.5, weight: .semibold)
  757. badgeLabel.alignment = .center
  758. badgeLabel.isBordered = false
  759. badgeLabel.drawsBackground = false
  760. badgeLabel.isEditable = false
  761. badgeLabel.isSelectable = false
  762. badgePill.translatesAutoresizingMaskIntoConstraints = false
  763. badgePill.wantsLayer = true
  764. badgePill.layer?.cornerRadius = 9
  765. badgePill.addSubview(badgeLabel)
  766. badgeLabel.translatesAutoresizingMaskIntoConstraints = false
  767. NSLayoutConstraint.activate([
  768. badgeLabel.leadingAnchor.constraint(equalTo: badgePill.leadingAnchor, constant: 7),
  769. badgeLabel.trailingAnchor.constraint(equalTo: badgePill.trailingAnchor, constant: -7),
  770. badgeLabel.centerYAnchor.constraint(equalTo: badgePill.centerYAnchor),
  771. badgePill.heightAnchor.constraint(equalToConstant: 18),
  772. badgePill.widthAnchor.constraint(greaterThanOrEqualToConstant: 22)
  773. ])
  774. stack.orientation = .horizontal
  775. stack.spacing = 8
  776. stack.alignment = .centerY
  777. stack.translatesAutoresizingMaskIntoConstraints = false
  778. if let symbol = leadingSymbol, style == .pillLarge {
  779. symbolView.translatesAutoresizingMaskIntoConstraints = false
  780. symbolView.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)
  781. symbolView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  782. stack.addArrangedSubview(symbolView)
  783. }
  784. stack.addArrangedSubview(titleLabel)
  785. stack.addArrangedSubview(badgePill)
  786. addSubview(stack)
  787. let horizontalInset: CGFloat = style == .pillLarge ? 16 : 12
  788. NSLayoutConstraint.activate([
  789. stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
  790. stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
  791. stack.centerYAnchor.constraint(equalTo: centerYAnchor)
  792. ])
  793. setContentHuggingPriority(.required, for: .horizontal)
  794. applyState()
  795. }
  796. @available(*, unavailable)
  797. required init?(coder: NSCoder) {
  798. fatalError("init(coder:) has not been implemented")
  799. }
  800. override func hitTest(_ point: NSPoint) -> NSView? {
  801. guard let superview else { return super.hitTest(point) }
  802. let local = convert(point, from: superview)
  803. return bounds.contains(local) ? self : nil
  804. }
  805. override func mouseDown(with event: NSEvent) {
  806. onSelect?()
  807. }
  808. override func updateTrackingAreas() {
  809. super.updateTrackingAreas()
  810. if let area = trackingArea { removeTrackingArea(area) }
  811. let area = NSTrackingArea(
  812. rect: bounds,
  813. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  814. owner: self,
  815. userInfo: nil
  816. )
  817. addTrackingArea(area)
  818. trackingArea = area
  819. }
  820. override func mouseEntered(with event: NSEvent) {
  821. super.mouseEntered(with: event)
  822. isHovering = true
  823. applyState()
  824. if !didPushCursor {
  825. NSCursor.pointingHand.push()
  826. didPushCursor = true
  827. }
  828. }
  829. override func mouseExited(with event: NSEvent) {
  830. super.mouseExited(with: event)
  831. isHovering = false
  832. applyState()
  833. if didPushCursor {
  834. NSCursor.pop()
  835. didPushCursor = false
  836. }
  837. }
  838. override func viewWillMove(toWindow newWindow: NSWindow?) {
  839. super.viewWillMove(toWindow: newWindow)
  840. if newWindow == nil, didPushCursor {
  841. NSCursor.pop()
  842. didPushCursor = false
  843. isHovering = false
  844. }
  845. }
  846. private func applyState() {
  847. let fill: NSColor
  848. let border: NSColor
  849. let textColor: NSColor
  850. let badgeFill: NSColor
  851. let badgeText: NSColor
  852. if isSelected {
  853. fill = isHovering ? Palette.activeFillHover : Palette.activeFill
  854. border = fill
  855. textColor = Palette.activeText
  856. badgeFill = Palette.activeBadge
  857. badgeText = Palette.activeBadgeText
  858. } else {
  859. fill = isHovering ? Palette.hoverFill : Palette.restFill
  860. border = Palette.restBorder
  861. textColor = Palette.restText
  862. badgeFill = Palette.restBadge
  863. badgeText = Palette.restBadgeText
  864. }
  865. layer?.backgroundColor = fill.cgColor
  866. layer?.borderColor = border.cgColor
  867. titleLabel.textColor = textColor
  868. symbolView.contentTintColor = textColor
  869. badgePill.layer?.backgroundColor = badgeFill.cgColor
  870. badgeLabel.textColor = badgeText
  871. }
  872. }
  873. // MARK: - Template card
  874. /// Bordered card holding the mini CV preview, a hover "Preview" overlay, and a
  875. /// footer with the template name + family. Maintains its own hover/selected
  876. /// states so the parent can stay declarative.
  877. private final class CVTemplateCard: NSView {
  878. struct Palette {
  879. let border: NSColor
  880. let borderHover: NSColor
  881. let borderSelected: NSColor
  882. let footerBackground: NSColor
  883. let previewSurface: NSColor
  884. let previewPaper: NSColor
  885. let previewSidebarTint: NSColor
  886. let previewInk: NSColor
  887. let previewMuted: NSColor
  888. let previewAccentRed: NSColor
  889. let previewAccentBlue: NSColor
  890. let primaryText: NSColor
  891. let secondaryText: NSColor
  892. let overlayTint: NSColor
  893. }
  894. var onSelect: (() -> Void)?
  895. var onPreview: (() -> Void)?
  896. var isSelected: Bool = false { didSet { applyBorder() } }
  897. private let template: CVTemplate
  898. private let palette: Palette
  899. private let previewSurface = NSView()
  900. private let preview: CVTemplatePreviewView
  901. private let nameLabel = NSTextField(labelWithString: "")
  902. private let categoryLabel = NSTextField(labelWithString: "")
  903. private let overlay = NSView()
  904. private let overlayBadge = NSView()
  905. private let overlayBadgeLabel = NSTextField(labelWithString: "Preview")
  906. private let overlayBadgeIcon = NSImageView()
  907. private var trackingArea: NSTrackingArea?
  908. private var isHovering: Bool = false
  909. private var didPushCursor: Bool = false
  910. init(template: CVTemplate, palette: Palette) {
  911. self.template = template
  912. self.palette = palette
  913. self.preview = CVTemplatePreviewView(template: template, palette: palette)
  914. super.init(frame: .zero)
  915. wantsLayer = true
  916. layer?.cornerRadius = 14
  917. layer?.borderWidth = 1
  918. layer?.masksToBounds = true
  919. translatesAutoresizingMaskIntoConstraints = false
  920. heightAnchor.constraint(greaterThanOrEqualToConstant: 280).isActive = true
  921. previewSurface.translatesAutoresizingMaskIntoConstraints = false
  922. previewSurface.wantsLayer = true
  923. previewSurface.layer?.backgroundColor = palette.previewSurface.cgColor
  924. preview.translatesAutoresizingMaskIntoConstraints = false
  925. previewSurface.addSubview(preview)
  926. nameLabel.stringValue = template.name
  927. nameLabel.font = .systemFont(ofSize: 13, weight: .semibold)
  928. nameLabel.textColor = palette.primaryText
  929. nameLabel.isBordered = false
  930. nameLabel.drawsBackground = false
  931. nameLabel.isEditable = false
  932. nameLabel.isSelectable = false
  933. categoryLabel.stringValue = template.family.title
  934. categoryLabel.font = .systemFont(ofSize: 11.5, weight: .regular)
  935. categoryLabel.textColor = palette.secondaryText
  936. categoryLabel.isBordered = false
  937. categoryLabel.drawsBackground = false
  938. categoryLabel.isEditable = false
  939. categoryLabel.isSelectable = false
  940. let footerStack = NSStackView(views: [nameLabel, categoryLabel])
  941. footerStack.orientation = .vertical
  942. footerStack.spacing = 2
  943. footerStack.alignment = .leading
  944. footerStack.translatesAutoresizingMaskIntoConstraints = false
  945. let footer = NSView()
  946. footer.translatesAutoresizingMaskIntoConstraints = false
  947. footer.wantsLayer = true
  948. footer.layer?.backgroundColor = palette.footerBackground.cgColor
  949. footer.addSubview(footerStack)
  950. NSLayoutConstraint.activate([
  951. footerStack.leadingAnchor.constraint(equalTo: footer.leadingAnchor, constant: 14),
  952. footerStack.trailingAnchor.constraint(lessThanOrEqualTo: footer.trailingAnchor, constant: -14),
  953. footerStack.topAnchor.constraint(equalTo: footer.topAnchor, constant: 12),
  954. footerStack.bottomAnchor.constraint(equalTo: footer.bottomAnchor, constant: -12)
  955. ])
  956. addSubview(previewSurface)
  957. addSubview(footer)
  958. addSubview(overlay)
  959. configureOverlay()
  960. NSLayoutConstraint.activate([
  961. previewSurface.topAnchor.constraint(equalTo: topAnchor),
  962. previewSurface.leadingAnchor.constraint(equalTo: leadingAnchor),
  963. previewSurface.trailingAnchor.constraint(equalTo: trailingAnchor),
  964. previewSurface.heightAnchor.constraint(greaterThanOrEqualToConstant: 230),
  965. preview.topAnchor.constraint(equalTo: previewSurface.topAnchor, constant: 16),
  966. preview.leadingAnchor.constraint(equalTo: previewSurface.leadingAnchor, constant: 18),
  967. preview.trailingAnchor.constraint(equalTo: previewSurface.trailingAnchor, constant: -18),
  968. preview.bottomAnchor.constraint(equalTo: previewSurface.bottomAnchor, constant: -16),
  969. footer.topAnchor.constraint(equalTo: previewSurface.bottomAnchor),
  970. footer.leadingAnchor.constraint(equalTo: leadingAnchor),
  971. footer.trailingAnchor.constraint(equalTo: trailingAnchor),
  972. footer.bottomAnchor.constraint(equalTo: bottomAnchor),
  973. overlay.topAnchor.constraint(equalTo: previewSurface.topAnchor),
  974. overlay.leadingAnchor.constraint(equalTo: previewSurface.leadingAnchor),
  975. overlay.trailingAnchor.constraint(equalTo: previewSurface.trailingAnchor),
  976. overlay.bottomAnchor.constraint(equalTo: previewSurface.bottomAnchor)
  977. ])
  978. applyBorder()
  979. }
  980. @available(*, unavailable)
  981. required init?(coder: NSCoder) {
  982. fatalError("init(coder:) has not been implemented")
  983. }
  984. private func configureOverlay() {
  985. overlay.translatesAutoresizingMaskIntoConstraints = false
  986. overlay.wantsLayer = true
  987. overlay.layer?.backgroundColor = palette.overlayTint.cgColor
  988. overlay.alphaValue = 0
  989. overlay.isHidden = false
  990. overlayBadge.translatesAutoresizingMaskIntoConstraints = false
  991. overlayBadge.wantsLayer = true
  992. overlayBadge.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.78).cgColor
  993. overlayBadge.layer?.cornerRadius = 16
  994. overlayBadgeIcon.translatesAutoresizingMaskIntoConstraints = false
  995. overlayBadgeIcon.image = NSImage(systemSymbolName: "eye", accessibilityDescription: nil)
  996. overlayBadgeIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  997. overlayBadgeIcon.contentTintColor = .white
  998. overlayBadgeLabel.font = .systemFont(ofSize: 12, weight: .semibold)
  999. overlayBadgeLabel.textColor = .white
  1000. overlayBadgeLabel.isBordered = false
  1001. overlayBadgeLabel.drawsBackground = false
  1002. overlayBadgeLabel.isEditable = false
  1003. overlayBadgeLabel.isSelectable = false
  1004. let badgeStack = NSStackView(views: [overlayBadgeIcon, overlayBadgeLabel])
  1005. badgeStack.orientation = .horizontal
  1006. badgeStack.spacing = 6
  1007. badgeStack.alignment = .centerY
  1008. badgeStack.translatesAutoresizingMaskIntoConstraints = false
  1009. overlayBadge.addSubview(badgeStack)
  1010. overlay.addSubview(overlayBadge)
  1011. NSLayoutConstraint.activate([
  1012. badgeStack.leadingAnchor.constraint(equalTo: overlayBadge.leadingAnchor, constant: 14),
  1013. badgeStack.trailingAnchor.constraint(equalTo: overlayBadge.trailingAnchor, constant: -14),
  1014. badgeStack.topAnchor.constraint(equalTo: overlayBadge.topAnchor, constant: 8),
  1015. badgeStack.bottomAnchor.constraint(equalTo: overlayBadge.bottomAnchor, constant: -8),
  1016. overlayBadge.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
  1017. overlayBadge.centerYAnchor.constraint(equalTo: overlay.centerYAnchor)
  1018. ])
  1019. }
  1020. override func hitTest(_ point: NSPoint) -> NSView? {
  1021. guard let superview else { return super.hitTest(point) }
  1022. let local = convert(point, from: superview)
  1023. return bounds.contains(local) ? self : nil
  1024. }
  1025. override func mouseDown(with event: NSEvent) {
  1026. let local = convert(event.locationInWindow, from: nil)
  1027. if overlay.frame.contains(local) {
  1028. onPreview?()
  1029. } else {
  1030. onSelect?()
  1031. }
  1032. }
  1033. override func updateTrackingAreas() {
  1034. super.updateTrackingAreas()
  1035. if let area = trackingArea { removeTrackingArea(area) }
  1036. let area = NSTrackingArea(
  1037. rect: bounds,
  1038. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  1039. owner: self,
  1040. userInfo: nil
  1041. )
  1042. addTrackingArea(area)
  1043. trackingArea = area
  1044. }
  1045. override func mouseEntered(with event: NSEvent) {
  1046. super.mouseEntered(with: event)
  1047. isHovering = true
  1048. animateOverlay(visible: true)
  1049. applyBorder()
  1050. if !didPushCursor {
  1051. NSCursor.pointingHand.push()
  1052. didPushCursor = true
  1053. }
  1054. }
  1055. override func mouseExited(with event: NSEvent) {
  1056. super.mouseExited(with: event)
  1057. isHovering = false
  1058. animateOverlay(visible: false)
  1059. applyBorder()
  1060. if didPushCursor {
  1061. NSCursor.pop()
  1062. didPushCursor = false
  1063. }
  1064. }
  1065. override func viewWillMove(toWindow newWindow: NSWindow?) {
  1066. super.viewWillMove(toWindow: newWindow)
  1067. if newWindow == nil, didPushCursor {
  1068. NSCursor.pop()
  1069. didPushCursor = false
  1070. isHovering = false
  1071. }
  1072. }
  1073. private func animateOverlay(visible: Bool) {
  1074. let target: CGFloat = visible ? 1 : 0
  1075. NSAnimationContext.runAnimationGroup { context in
  1076. context.duration = 0.12
  1077. context.allowsImplicitAnimation = true
  1078. overlay.animator().alphaValue = target
  1079. }
  1080. }
  1081. private func applyBorder() {
  1082. if isSelected {
  1083. layer?.borderColor = palette.borderSelected.cgColor
  1084. layer?.borderWidth = 2
  1085. } else if isHovering {
  1086. layer?.borderColor = palette.borderHover.cgColor
  1087. layer?.borderWidth = 1
  1088. } else {
  1089. layer?.borderColor = palette.border.cgColor
  1090. layer?.borderWidth = 1
  1091. }
  1092. }
  1093. }
  1094. // MARK: - Mini preview renderer
  1095. /// Tiny stylized representation of a finished CV — accent strip, headline area,
  1096. /// faux paragraph + section lines. Adapts to the template's headline, accent,
  1097. /// sidebar, and section-label style so the grid feels varied at a glance.
  1098. private final class CVTemplatePreviewView: NSView {
  1099. private let template: CVTemplate
  1100. private let palette: CVTemplateCard.Palette
  1101. private let paper = NSView()
  1102. init(template: CVTemplate, palette: CVTemplateCard.Palette) {
  1103. self.template = template
  1104. self.palette = palette
  1105. super.init(frame: .zero)
  1106. wantsLayer = true
  1107. translatesAutoresizingMaskIntoConstraints = false
  1108. configurePaper()
  1109. }
  1110. @available(*, unavailable)
  1111. required init?(coder: NSCoder) {
  1112. fatalError("init(coder:) has not been implemented")
  1113. }
  1114. private func configurePaper() {
  1115. paper.translatesAutoresizingMaskIntoConstraints = false
  1116. paper.wantsLayer = true
  1117. paper.layer?.backgroundColor = palette.previewPaper.cgColor
  1118. paper.layer?.cornerRadius = 4
  1119. paper.layer?.borderColor = NSColor(srgbRed: 232 / 255, green: 235 / 255, blue: 241 / 255, alpha: 1).cgColor
  1120. paper.layer?.borderWidth = 1
  1121. paper.layer?.masksToBounds = true
  1122. addSubview(paper)
  1123. NSLayoutConstraint.activate([
  1124. paper.topAnchor.constraint(equalTo: topAnchor),
  1125. paper.bottomAnchor.constraint(equalTo: bottomAnchor),
  1126. paper.centerXAnchor.constraint(equalTo: centerXAnchor),
  1127. paper.widthAnchor.constraint(equalTo: heightAnchor, multiplier: 0.78),
  1128. paper.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor),
  1129. paper.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor)
  1130. ])
  1131. let header = makeHeader()
  1132. let body = makeBody()
  1133. let stack = NSStackView(views: [header, body])
  1134. stack.orientation = .vertical
  1135. stack.alignment = .leading
  1136. stack.spacing = 6
  1137. stack.distribution = .fill
  1138. stack.translatesAutoresizingMaskIntoConstraints = false
  1139. paper.addSubview(stack)
  1140. NSLayoutConstraint.activate([
  1141. stack.leadingAnchor.constraint(equalTo: paper.leadingAnchor, constant: 8),
  1142. stack.trailingAnchor.constraint(equalTo: paper.trailingAnchor, constant: -8),
  1143. stack.topAnchor.constraint(equalTo: paper.topAnchor, constant: 8),
  1144. stack.bottomAnchor.constraint(lessThanOrEqualTo: paper.bottomAnchor, constant: -8),
  1145. header.widthAnchor.constraint(equalTo: stack.widthAnchor),
  1146. body.widthAnchor.constraint(equalTo: stack.widthAnchor)
  1147. ])
  1148. }
  1149. // MARK: Header
  1150. private func makeHeader() -> NSView {
  1151. let container = NSView()
  1152. container.translatesAutoresizingMaskIntoConstraints = false
  1153. let nameStrip = makeLine(color: palette.previewInk, height: 5.5, widthFraction: 0.6)
  1154. let roleStrip = makeLine(color: palette.previewMuted, height: 3, widthFraction: 0.42)
  1155. let contactStrip = makeLine(color: palette.previewMuted.withAlphaComponent(0.7), height: 2, widthFraction: 0.55)
  1156. let textStack = NSStackView(views: [nameStrip, roleStrip, contactStrip])
  1157. textStack.orientation = .vertical
  1158. textStack.spacing = 4
  1159. textStack.translatesAutoresizingMaskIntoConstraints = false
  1160. switch template.headline {
  1161. case .centered:
  1162. textStack.alignment = .centerX
  1163. case .leftAligned, .leftWithInitials:
  1164. textStack.alignment = .leading
  1165. case .avatarStacked:
  1166. textStack.alignment = .leading
  1167. }
  1168. container.addSubview(textStack)
  1169. switch template.headline {
  1170. case .leftWithInitials:
  1171. let avatar = makeAvatar()
  1172. container.addSubview(avatar)
  1173. NSLayoutConstraint.activate([
  1174. textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1175. textStack.trailingAnchor.constraint(lessThanOrEqualTo: avatar.leadingAnchor, constant: -6),
  1176. textStack.topAnchor.constraint(equalTo: container.topAnchor),
  1177. textStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  1178. avatar.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  1179. avatar.centerYAnchor.constraint(equalTo: container.centerYAnchor),
  1180. avatar.widthAnchor.constraint(equalToConstant: 20),
  1181. avatar.heightAnchor.constraint(equalToConstant: 20)
  1182. ])
  1183. case .avatarStacked:
  1184. let avatar = makeAvatar()
  1185. container.addSubview(avatar)
  1186. NSLayoutConstraint.activate([
  1187. avatar.topAnchor.constraint(equalTo: container.topAnchor),
  1188. avatar.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1189. avatar.widthAnchor.constraint(equalToConstant: 22),
  1190. avatar.heightAnchor.constraint(equalToConstant: 22),
  1191. textStack.topAnchor.constraint(equalTo: avatar.bottomAnchor, constant: 4),
  1192. textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1193. textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  1194. textStack.bottomAnchor.constraint(equalTo: container.bottomAnchor)
  1195. ])
  1196. case .centered:
  1197. NSLayoutConstraint.activate([
  1198. textStack.topAnchor.constraint(equalTo: container.topAnchor),
  1199. textStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  1200. textStack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  1201. textStack.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor),
  1202. textStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor)
  1203. ])
  1204. case .leftAligned:
  1205. NSLayoutConstraint.activate([
  1206. textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1207. textStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor),
  1208. textStack.topAnchor.constraint(equalTo: container.topAnchor),
  1209. textStack.bottomAnchor.constraint(equalTo: container.bottomAnchor)
  1210. ])
  1211. }
  1212. // Accent decorations
  1213. switch template.accent {
  1214. case .none:
  1215. break
  1216. case .redUnderline, .redBar:
  1217. let accent = NSView()
  1218. accent.translatesAutoresizingMaskIntoConstraints = false
  1219. accent.wantsLayer = true
  1220. accent.layer?.backgroundColor = palette.previewAccentRed.cgColor
  1221. container.addSubview(accent)
  1222. NSLayoutConstraint.activate([
  1223. accent.heightAnchor.constraint(equalToConstant: template.accent == .redBar ? 2.5 : 1.5),
  1224. accent.topAnchor.constraint(equalTo: textStack.bottomAnchor, constant: 5),
  1225. accent.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1226. accent.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: template.accent == .redBar ? 0.32 : 0.9),
  1227. accent.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor)
  1228. ])
  1229. case .blueBar:
  1230. let accent = NSView()
  1231. accent.translatesAutoresizingMaskIntoConstraints = false
  1232. accent.wantsLayer = true
  1233. accent.layer?.backgroundColor = palette.previewAccentBlue.cgColor
  1234. container.addSubview(accent)
  1235. NSLayoutConstraint.activate([
  1236. accent.heightAnchor.constraint(equalToConstant: 2.5),
  1237. accent.topAnchor.constraint(equalTo: textStack.bottomAnchor, constant: 5),
  1238. accent.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1239. accent.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.32),
  1240. accent.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor)
  1241. ])
  1242. }
  1243. return container
  1244. }
  1245. private func makeAvatar() -> NSView {
  1246. let avatar = NSView()
  1247. avatar.translatesAutoresizingMaskIntoConstraints = false
  1248. avatar.wantsLayer = true
  1249. avatar.layer?.backgroundColor = palette.previewSidebarTint.cgColor
  1250. avatar.layer?.borderColor = palette.previewMuted.withAlphaComponent(0.4).cgColor
  1251. avatar.layer?.borderWidth = 1
  1252. avatar.layer?.cornerRadius = 11
  1253. let initials = NSTextField(labelWithString: "SJ")
  1254. initials.font = .systemFont(ofSize: 7, weight: .bold)
  1255. initials.textColor = palette.previewInk
  1256. initials.alignment = .center
  1257. initials.translatesAutoresizingMaskIntoConstraints = false
  1258. avatar.addSubview(initials)
  1259. NSLayoutConstraint.activate([
  1260. initials.centerXAnchor.constraint(equalTo: avatar.centerXAnchor),
  1261. initials.centerYAnchor.constraint(equalTo: avatar.centerYAnchor)
  1262. ])
  1263. return avatar
  1264. }
  1265. // MARK: Body
  1266. private func makeBody() -> NSView {
  1267. switch template.layout {
  1268. case .singleColumn:
  1269. return makeColumn(width: nil, isSidebar: false)
  1270. case .twoColumn(let side, let tinted):
  1271. return makeTwoColumnLayout(sidebarSide: side, tinted: tinted)
  1272. }
  1273. }
  1274. private func makeTwoColumnLayout(sidebarSide: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
  1275. let container = NSView()
  1276. container.translatesAutoresizingMaskIntoConstraints = false
  1277. let sidebar = makeColumn(width: nil, isSidebar: true)
  1278. if tinted {
  1279. sidebar.wantsLayer = true
  1280. sidebar.layer?.backgroundColor = palette.previewSidebarTint.cgColor
  1281. sidebar.layer?.cornerRadius = 3
  1282. }
  1283. let main = makeColumn(width: nil, isSidebar: false)
  1284. container.addSubview(sidebar)
  1285. container.addSubview(main)
  1286. let leadingItem: NSView = (sidebarSide == .leading) ? sidebar : main
  1287. let trailingItem: NSView = (sidebarSide == .leading) ? main : sidebar
  1288. NSLayoutConstraint.activate([
  1289. leadingItem.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1290. leadingItem.topAnchor.constraint(equalTo: container.topAnchor, constant: 2),
  1291. leadingItem.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor),
  1292. trailingItem.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  1293. trailingItem.topAnchor.constraint(equalTo: container.topAnchor, constant: 2),
  1294. trailingItem.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor),
  1295. trailingItem.leadingAnchor.constraint(equalTo: leadingItem.trailingAnchor, constant: 8),
  1296. sidebar.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.32),
  1297. main.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.6)
  1298. ])
  1299. return container
  1300. }
  1301. private func makeColumn(width: CGFloat?, isSidebar: Bool) -> NSView {
  1302. let stack = NSStackView()
  1303. stack.orientation = .vertical
  1304. stack.alignment = .leading
  1305. stack.spacing = 6
  1306. stack.translatesAutoresizingMaskIntoConstraints = false
  1307. let sectionNames: [String] = isSidebar
  1308. ? ["CONTACT", "SKILLS", "LANGUAGES", "INTERESTS"]
  1309. : ["PROFILE", "EXPERIENCE", "EDUCATION", "SKILLS"]
  1310. for (i, section) in sectionNames.enumerated() {
  1311. let block = makeSectionBlock(title: section, lineCount: isSidebar ? 3 : (i == 1 ? 4 : 2), narrow: isSidebar)
  1312. stack.addArrangedSubview(block)
  1313. block.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1314. }
  1315. return stack
  1316. }
  1317. private func makeSectionBlock(title: String, lineCount: Int, narrow: Bool) -> NSView {
  1318. let label = NSTextField(labelWithString: formattedSectionLabel(title))
  1319. label.font = .systemFont(ofSize: narrow ? 5 : 5.5, weight: .bold)
  1320. label.textColor = palette.previewAccentBlue.blended(withFraction: 0.4, of: palette.previewInk) ?? palette.previewInk
  1321. label.maximumNumberOfLines = 1
  1322. label.isBordered = false
  1323. label.drawsBackground = false
  1324. label.isEditable = false
  1325. label.isSelectable = false
  1326. label.translatesAutoresizingMaskIntoConstraints = false
  1327. let lines = NSStackView()
  1328. lines.orientation = .vertical
  1329. lines.alignment = .leading
  1330. lines.spacing = 2
  1331. lines.translatesAutoresizingMaskIntoConstraints = false
  1332. for index in 0..<lineCount {
  1333. let widthFraction: CGFloat = max(0.4, 0.95 - CGFloat(index) * 0.13)
  1334. let line = makeLine(color: palette.previewMuted.withAlphaComponent(0.65), height: 1.6, widthFraction: widthFraction)
  1335. lines.addArrangedSubview(line)
  1336. line.widthAnchor.constraint(equalTo: lines.widthAnchor, multiplier: widthFraction).isActive = true
  1337. }
  1338. let block = NSStackView(views: [label, lines])
  1339. block.orientation = .vertical
  1340. block.alignment = .leading
  1341. block.spacing = 2
  1342. block.translatesAutoresizingMaskIntoConstraints = false
  1343. let blockWidth = block.widthAnchor
  1344. lines.widthAnchor.constraint(equalTo: blockWidth).isActive = true
  1345. return block
  1346. }
  1347. private func formattedSectionLabel(_ raw: String) -> String {
  1348. switch template.sectionLabelStyle {
  1349. case .uppercase: return raw
  1350. case .slashed: return "// \(raw.capitalized)"
  1351. case .bracketed: return "[ \(raw) ]"
  1352. }
  1353. }
  1354. private func makeLine(color: NSColor, height: CGFloat, widthFraction: CGFloat) -> NSView {
  1355. let line = LineView()
  1356. line.translatesAutoresizingMaskIntoConstraints = false
  1357. line.wantsLayer = true
  1358. line.layer?.backgroundColor = color.cgColor
  1359. line.layer?.cornerRadius = max(height / 2, 1)
  1360. line.heightAnchor.constraint(equalToConstant: height).isActive = true
  1361. line.widthFraction = widthFraction
  1362. return line
  1363. }
  1364. }
  1365. private final class LineView: NSView {
  1366. var widthFraction: CGFloat = 1
  1367. }
  1368. // MARK: - Helpers
  1369. /// Flipped origin so the grid stacks fill from the top.
  1370. private final class TopFlippedView: NSView {
  1371. override var isFlipped: Bool { true }
  1372. }
  1373. /// Local copy of the dashboard's hoverable button so this file stays
  1374. /// self-contained without exposing the existing private classes.
  1375. private final class CVHoverableButton: NSButton {
  1376. var hoverHandler: ((Bool) -> Void)?
  1377. var pointerCursor: Bool = false
  1378. private(set) var isHovering: Bool = false
  1379. private var trackingArea: NSTrackingArea?
  1380. private var didPushCursor: Bool = false
  1381. override func updateTrackingAreas() {
  1382. super.updateTrackingAreas()
  1383. if let area = trackingArea { removeTrackingArea(area) }
  1384. let area = NSTrackingArea(
  1385. rect: bounds,
  1386. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  1387. owner: self,
  1388. userInfo: nil
  1389. )
  1390. addTrackingArea(area)
  1391. trackingArea = area
  1392. }
  1393. override func mouseEntered(with event: NSEvent) {
  1394. super.mouseEntered(with: event)
  1395. isHovering = true
  1396. hoverHandler?(true)
  1397. if pointerCursor, !didPushCursor {
  1398. NSCursor.pointingHand.push()
  1399. didPushCursor = true
  1400. }
  1401. }
  1402. override func mouseExited(with event: NSEvent) {
  1403. super.mouseExited(with: event)
  1404. isHovering = false
  1405. hoverHandler?(false)
  1406. if didPushCursor {
  1407. NSCursor.pop()
  1408. didPushCursor = false
  1409. }
  1410. }
  1411. override func viewWillMove(toWindow newWindow: NSWindow?) {
  1412. super.viewWillMove(toWindow: newWindow)
  1413. if newWindow == nil, didPushCursor {
  1414. NSCursor.pop()
  1415. didPushCursor = false
  1416. isHovering = false
  1417. }
  1418. }
  1419. }