Без опису

CVMakerPageView.swift 57KB

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