Nenhuma descrição

CVMakerPageView.swift 61KB

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