|
|
@@ -8,6 +8,7 @@
|
|
8
|
8
|
//
|
|
9
|
9
|
|
|
10
|
10
|
import Cocoa
|
|
|
11
|
+import QuartzCore
|
|
11
|
12
|
|
|
12
|
13
|
// MARK: - Data model
|
|
13
|
14
|
|
|
|
@@ -37,6 +38,21 @@ enum CVDesignFamily: String, CaseIterable, Hashable {
|
|
37
|
38
|
}
|
|
38
|
39
|
}
|
|
39
|
40
|
|
|
|
41
|
+/// High-level layout bucket for catalog metadata and filtering.
|
|
|
42
|
+enum CVTemplateLayoutType: String, Hashable {
|
|
|
43
|
+ case atsSingleColumn
|
|
|
44
|
+ case twoColumnSidebarLeading
|
|
|
45
|
+ case twoColumnSidebarTrailing
|
|
|
46
|
+
|
|
|
47
|
+ var gallerySubtitle: String {
|
|
|
48
|
+ switch self {
|
|
|
49
|
+ case .atsSingleColumn: return "ATS layout"
|
|
|
50
|
+ case .twoColumnSidebarLeading: return "Sidebar left"
|
|
|
51
|
+ case .twoColumnSidebarTrailing: return "Sidebar right"
|
|
|
52
|
+ }
|
|
|
53
|
+ }
|
|
|
54
|
+}
|
|
|
55
|
+
|
|
40
|
56
|
/// Visual recipe used by the mini preview renderer so every template can vary
|
|
41
|
57
|
/// the headline style, accent line, and sidebar layout without bespoke views.
|
|
42
|
58
|
struct CVTemplate: Hashable {
|
|
|
@@ -77,6 +93,92 @@ struct CVTemplate: Hashable {
|
|
77
|
93
|
let accent: Accent
|
|
78
|
94
|
let layout: Layout
|
|
79
|
95
|
let sectionLabelStyle: SectionLabelStyle
|
|
|
96
|
+ /// sRGB accent used for headers, tags, and sidebar tints in the mini preview.
|
|
|
97
|
+ let themeRed: CGFloat
|
|
|
98
|
+ let themeGreen: CGFloat
|
|
|
99
|
+ let themeBlue: CGFloat
|
|
|
100
|
+
|
|
|
101
|
+ /// Shown on cards; mirrors the design family in this build.
|
|
|
102
|
+ var category: String { family.title }
|
|
|
103
|
+
|
|
|
104
|
+ var layoutType: CVTemplateLayoutType {
|
|
|
105
|
+ switch layout {
|
|
|
106
|
+ case .singleColumn: return .atsSingleColumn
|
|
|
107
|
+ case .twoColumn(sidebar: .leading, _): return .twoColumnSidebarLeading
|
|
|
108
|
+ case .twoColumn(sidebar: .trailing, _): return .twoColumnSidebarTrailing
|
|
|
109
|
+ }
|
|
|
110
|
+ }
|
|
|
111
|
+
|
|
|
112
|
+ var themeColor: NSColor {
|
|
|
113
|
+ NSColor(srgbRed: themeRed, green: themeGreen, blue: themeBlue, alpha: 1)
|
|
|
114
|
+ }
|
|
|
115
|
+
|
|
|
116
|
+ /// Optional bundle image name; `nil` means render a live vector/text preview.
|
|
|
117
|
+ var previewImageAssetName: String? { nil }
|
|
|
118
|
+
|
|
|
119
|
+ init(
|
|
|
120
|
+ id: String,
|
|
|
121
|
+ name: String,
|
|
|
122
|
+ family: CVDesignFamily,
|
|
|
123
|
+ headline: Headline,
|
|
|
124
|
+ accent: Accent,
|
|
|
125
|
+ layout: Layout,
|
|
|
126
|
+ sectionLabelStyle: SectionLabelStyle,
|
|
|
127
|
+ themeRed: CGFloat? = nil,
|
|
|
128
|
+ themeGreen: CGFloat? = nil,
|
|
|
129
|
+ themeBlue: CGFloat? = nil
|
|
|
130
|
+ ) {
|
|
|
131
|
+ self.id = id
|
|
|
132
|
+ self.name = name
|
|
|
133
|
+ self.family = family
|
|
|
134
|
+ self.headline = headline
|
|
|
135
|
+ self.accent = accent
|
|
|
136
|
+ self.layout = layout
|
|
|
137
|
+ self.sectionLabelStyle = sectionLabelStyle
|
|
|
138
|
+ if let tr = themeRed, let tg = themeGreen, let tb = themeBlue {
|
|
|
139
|
+ self.themeRed = tr
|
|
|
140
|
+ self.themeGreen = tg
|
|
|
141
|
+ self.themeBlue = tb
|
|
|
142
|
+ } else {
|
|
|
143
|
+ let rgb = Self.resolvedThemeRGB(family: family, id: id)
|
|
|
144
|
+ self.themeRed = rgb.0
|
|
|
145
|
+ self.themeGreen = rgb.1
|
|
|
146
|
+ self.themeBlue = rgb.2
|
|
|
147
|
+ }
|
|
|
148
|
+ }
|
|
|
149
|
+
|
|
|
150
|
+ private static func resolvedThemeRGB(family: CVDesignFamily, id: String) -> (CGFloat, CGFloat, CGFloat) {
|
|
|
151
|
+ var hash: UInt64 = 1469598103934665603
|
|
|
152
|
+ for b in id.utf8 {
|
|
|
153
|
+ hash ^= UInt64(b)
|
|
|
154
|
+ hash &*= 1_099_511_628_211
|
|
|
155
|
+ }
|
|
|
156
|
+ let t = Double(hash % 1000) / 1000.0
|
|
|
157
|
+ switch family {
|
|
|
158
|
+ case .professional:
|
|
|
159
|
+ let r = 0.12 + t * 0.06
|
|
|
160
|
+ let g = 0.32 + t * 0.08
|
|
|
161
|
+ let b = 0.58 + t * 0.12
|
|
|
162
|
+ return (r, g, b)
|
|
|
163
|
+ case .modern:
|
|
|
164
|
+ let r = 0.0 + t * 0.08
|
|
|
165
|
+ let g = 0.45 + t * 0.12
|
|
|
166
|
+ let bl = 0.85 + t * 0.1
|
|
|
167
|
+ return (min(r, 1), min(g, 1), min(bl, 1))
|
|
|
168
|
+ case .minimal:
|
|
|
169
|
+ return (0.45 + t * 0.05, 0.48 + t * 0.04, 0.55 + t * 0.06)
|
|
|
170
|
+ case .executive:
|
|
|
171
|
+ let r = 0.08 + t * 0.06
|
|
|
172
|
+ let g = 0.12 + t * 0.05
|
|
|
173
|
+ let b = 0.22 + t * 0.08
|
|
|
174
|
+ return (r, g, b)
|
|
|
175
|
+ case .creative:
|
|
|
176
|
+ let r = 0.25 + t * 0.2
|
|
|
177
|
+ let g = 0.35 + t * 0.15
|
|
|
178
|
+ let b = 0.72 + t * 0.15
|
|
|
179
|
+ return (min(r, 1), min(g, 1), min(b, 1))
|
|
|
180
|
+ }
|
|
|
181
|
+ }
|
|
80
|
182
|
}
|
|
81
|
183
|
|
|
82
|
184
|
// MARK: - Catalog
|
|
|
@@ -403,12 +505,17 @@ final class CVMakerPageView: NSView {
|
|
403
|
505
|
static let ctaHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
|
|
404
|
506
|
static let ctaText = NSColor.white
|
|
405
|
507
|
static let overlayTint = NSColor.black.withAlphaComponent(0.45)
|
|
|
508
|
+ static let selectionGlow = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55)
|
|
|
509
|
+ static let gradientTop = NSColor(srgbRed: 250 / 255, green: 252 / 255, blue: 1, alpha: 1)
|
|
|
510
|
+ static let gradientBottom = NSColor(srgbRed: 236 / 255, green: 244 / 255, blue: 1, alpha: 1)
|
|
406
|
511
|
}
|
|
407
|
512
|
|
|
408
|
|
- private static let columns: Int = 4
|
|
|
513
|
+ private let pageGradientLayer = CAGradientLayer()
|
|
|
514
|
+ private let filterChrome = NSVisualEffectView()
|
|
|
515
|
+ private let filterStack = NSStackView()
|
|
409
|
516
|
|
|
410
|
517
|
private let titleLabel = NSTextField(labelWithString: "Templates")
|
|
411
|
|
- private let subtitleLabel = NSTextField(labelWithString: "Browse and select a template for your CV")
|
|
|
518
|
+ private let subtitleLabel = NSTextField(labelWithString: "Polished layouts with live previews — pick a style that fits your story.")
|
|
412
|
519
|
private let groupTabsRow = NSStackView()
|
|
413
|
520
|
private let familyChipsRow = NSStackView()
|
|
414
|
521
|
private let scrollView = NSScrollView()
|
|
|
@@ -425,6 +532,8 @@ final class CVMakerPageView: NSView {
|
|
425
|
532
|
private var familyChipButtons: [CVDesignFamily?: CVChipButton] = [:]
|
|
426
|
533
|
private var templateCardsByID: [String: CVTemplateCard] = [:]
|
|
427
|
534
|
|
|
|
535
|
+ private var appliedGridColumnCount: Int = 0
|
|
|
536
|
+
|
|
428
|
537
|
override init(frame frameRect: NSRect) {
|
|
429
|
538
|
super.init(frame: frameRect)
|
|
430
|
539
|
configureLayout()
|
|
|
@@ -441,7 +550,7 @@ final class CVMakerPageView: NSView {
|
|
441
|
550
|
|
|
442
|
551
|
override func layout() {
|
|
443
|
552
|
super.layout()
|
|
444
|
|
- // Re-measure the grid in case the container width changed (window resize).
|
|
|
553
|
+ pageGradientLayer.frame = bounds
|
|
445
|
554
|
layoutGridCardsIfNeeded()
|
|
446
|
555
|
}
|
|
447
|
556
|
|
|
|
@@ -449,7 +558,36 @@ final class CVMakerPageView: NSView {
|
|
449
|
558
|
|
|
450
|
559
|
private func configureLayout() {
|
|
451
|
560
|
wantsLayer = true
|
|
452
|
|
- layer?.backgroundColor = Palette.pageBackground.cgColor
|
|
|
561
|
+ layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
562
|
+
|
|
|
563
|
+ pageGradientLayer.colors = [Palette.gradientBottom.cgColor, Palette.gradientTop.cgColor]
|
|
|
564
|
+ pageGradientLayer.locations = [0, 1] as [NSNumber]
|
|
|
565
|
+ pageGradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
|
|
|
566
|
+ pageGradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
|
|
|
567
|
+ layer?.insertSublayer(pageGradientLayer, at: 0)
|
|
|
568
|
+
|
|
|
569
|
+ filterChrome.translatesAutoresizingMaskIntoConstraints = false
|
|
|
570
|
+ filterChrome.material = .sidebar
|
|
|
571
|
+ filterChrome.blendingMode = .withinWindow
|
|
|
572
|
+ filterChrome.state = .active
|
|
|
573
|
+ filterChrome.wantsLayer = true
|
|
|
574
|
+ filterChrome.layer?.cornerRadius = 18
|
|
|
575
|
+ filterChrome.layer?.borderWidth = 1
|
|
|
576
|
+ filterChrome.layer?.borderColor = NSColor.white.withAlphaComponent(0.65).cgColor
|
|
|
577
|
+
|
|
|
578
|
+ filterStack.orientation = .vertical
|
|
|
579
|
+ filterStack.spacing = 12
|
|
|
580
|
+ filterStack.alignment = .leading
|
|
|
581
|
+ filterStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
582
|
+ filterStack.addArrangedSubview(groupTabsRow)
|
|
|
583
|
+ filterStack.addArrangedSubview(familyChipsRow)
|
|
|
584
|
+ filterChrome.addSubview(filterStack)
|
|
|
585
|
+ NSLayoutConstraint.activate([
|
|
|
586
|
+ filterStack.leadingAnchor.constraint(equalTo: filterChrome.leadingAnchor, constant: 14),
|
|
|
587
|
+ filterStack.trailingAnchor.constraint(equalTo: filterChrome.trailingAnchor, constant: -14),
|
|
|
588
|
+ filterStack.topAnchor.constraint(equalTo: filterChrome.topAnchor, constant: 12),
|
|
|
589
|
+ filterStack.bottomAnchor.constraint(equalTo: filterChrome.bottomAnchor, constant: -12)
|
|
|
590
|
+ ])
|
|
453
|
591
|
|
|
454
|
592
|
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
|
|
455
|
593
|
titleLabel.textColor = Palette.primaryText
|
|
|
@@ -478,7 +616,7 @@ final class CVMakerPageView: NSView {
|
|
478
|
616
|
familyChipsRow.translatesAutoresizingMaskIntoConstraints = false
|
|
479
|
617
|
|
|
480
|
618
|
gridStack.orientation = .vertical
|
|
481
|
|
- gridStack.spacing = 16
|
|
|
619
|
+ gridStack.spacing = 26
|
|
482
|
620
|
gridStack.alignment = .leading
|
|
483
|
621
|
gridStack.distribution = .fill
|
|
484
|
622
|
gridStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -512,8 +650,7 @@ final class CVMakerPageView: NSView {
|
|
512
|
650
|
ctaButton.action = #selector(didTapUseTemplate)
|
|
513
|
651
|
|
|
514
|
652
|
addSubview(headerStack)
|
|
515
|
|
- addSubview(groupTabsRow)
|
|
516
|
|
- addSubview(familyChipsRow)
|
|
|
653
|
+ addSubview(filterChrome)
|
|
517
|
654
|
addSubview(scrollView)
|
|
518
|
655
|
addSubview(ctaButton)
|
|
519
|
656
|
|
|
|
@@ -524,23 +661,19 @@ final class CVMakerPageView: NSView {
|
|
524
|
661
|
headerStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset),
|
|
525
|
662
|
headerStack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
|
526
|
663
|
|
|
527
|
|
- groupTabsRow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
|
528
|
|
- groupTabsRow.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset),
|
|
529
|
|
- groupTabsRow.topAnchor.constraint(equalTo: headerStack.bottomAnchor, constant: 18),
|
|
530
|
|
-
|
|
531
|
|
- familyChipsRow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
|
532
|
|
- familyChipsRow.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset),
|
|
533
|
|
- familyChipsRow.topAnchor.constraint(equalTo: groupTabsRow.bottomAnchor, constant: 14),
|
|
|
664
|
+ filterChrome.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
|
|
665
|
+ filterChrome.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset),
|
|
|
666
|
+ filterChrome.topAnchor.constraint(equalTo: headerStack.bottomAnchor, constant: 16),
|
|
534
|
667
|
|
|
535
|
668
|
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
|
536
|
669
|
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
|
|
537
|
|
- scrollView.topAnchor.constraint(equalTo: familyChipsRow.bottomAnchor, constant: 16),
|
|
538
|
|
- scrollView.bottomAnchor.constraint(equalTo: ctaButton.topAnchor, constant: -16),
|
|
|
670
|
+ scrollView.topAnchor.constraint(equalTo: filterChrome.bottomAnchor, constant: 18),
|
|
|
671
|
+ scrollView.bottomAnchor.constraint(equalTo: ctaButton.topAnchor, constant: -18),
|
|
539
|
672
|
|
|
540
|
673
|
ctaButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
|
541
|
674
|
ctaButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
|
|
542
|
675
|
ctaButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24),
|
|
543
|
|
- ctaButton.heightAnchor.constraint(equalToConstant: 50)
|
|
|
676
|
+ ctaButton.heightAnchor.constraint(equalToConstant: 52)
|
|
544
|
677
|
])
|
|
545
|
678
|
}
|
|
546
|
679
|
|
|
|
@@ -624,12 +757,12 @@ final class CVMakerPageView: NSView {
|
|
624
|
757
|
return
|
|
625
|
758
|
}
|
|
626
|
759
|
|
|
627
|
|
- let columns = Self.columns
|
|
|
760
|
+ let columns = resolvedGridColumnCount()
|
|
628
|
761
|
var index = 0
|
|
629
|
762
|
while index < templates.count {
|
|
630
|
763
|
let row = NSStackView()
|
|
631
|
764
|
row.orientation = .horizontal
|
|
632
|
|
- row.spacing = 16
|
|
|
765
|
+ row.spacing = 22
|
|
633
|
766
|
row.distribution = .fillEqually
|
|
634
|
767
|
row.alignment = .top
|
|
635
|
768
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -662,8 +795,29 @@ final class CVMakerPageView: NSView {
|
|
662
|
795
|
applySelectionToCards()
|
|
663
|
796
|
}
|
|
664
|
797
|
|
|
|
798
|
+ private func galleryLayoutWidth() -> CGFloat {
|
|
|
799
|
+ if bounds.width > 8 { return bounds.width }
|
|
|
800
|
+ if let s = superview, s.bounds.width > 8 { return max(s.bounds.width - 64, 400) }
|
|
|
801
|
+ return 900
|
|
|
802
|
+ }
|
|
|
803
|
+
|
|
665
|
804
|
private func layoutGridCardsIfNeeded() {
|
|
666
|
|
- // Stack will resize the rows; nothing else to do — kept as a hook for future enhancements.
|
|
|
805
|
+ let w = max(galleryLayoutWidth(), 400)
|
|
|
806
|
+ let cols: Int
|
|
|
807
|
+ if w < 780 { cols = 2 }
|
|
|
808
|
+ else if w < 1080 { cols = 3 }
|
|
|
809
|
+ else { cols = 4 }
|
|
|
810
|
+ guard cols != appliedGridColumnCount else { return }
|
|
|
811
|
+ appliedGridColumnCount = cols
|
|
|
812
|
+ reloadTemplateGrid()
|
|
|
813
|
+ updateSelectedChipStates()
|
|
|
814
|
+ }
|
|
|
815
|
+
|
|
|
816
|
+ private func resolvedGridColumnCount() -> Int {
|
|
|
817
|
+ let w = max(galleryLayoutWidth(), 400)
|
|
|
818
|
+ if w < 780 { return 2 }
|
|
|
819
|
+ if w < 1080 { return 3 }
|
|
|
820
|
+ return 4
|
|
667
|
821
|
}
|
|
668
|
822
|
|
|
669
|
823
|
private func applySelectionToCards() {
|
|
|
@@ -672,11 +826,12 @@ final class CVMakerPageView: NSView {
|
|
672
|
826
|
}
|
|
673
|
827
|
}
|
|
674
|
828
|
|
|
675
|
|
- private func palette() -> CVTemplateCard.Palette {
|
|
676
|
|
- CVTemplateCard.Palette(
|
|
|
829
|
+ private func palette() -> CVTemplateCardPalette {
|
|
|
830
|
+ CVTemplateCardPalette(
|
|
677
|
831
|
border: Palette.cardBorder,
|
|
678
|
832
|
borderHover: Palette.cardBorderHover,
|
|
679
|
833
|
borderSelected: Palette.cardBorderSelected,
|
|
|
834
|
+ selectionGlow: Palette.selectionGlow,
|
|
680
|
835
|
footerBackground: Palette.cardFooter,
|
|
681
|
836
|
previewSurface: Palette.previewSurface,
|
|
682
|
837
|
previewPaper: Palette.previewPaper,
|
|
|
@@ -753,7 +908,7 @@ final class CVMakerPageView: NSView {
|
|
753
|
908
|
button.focusRingType = .none
|
|
754
|
909
|
button.contentTintColor = Palette.ctaText
|
|
755
|
910
|
button.wantsLayer = true
|
|
756
|
|
- button.layer?.cornerRadius = 12
|
|
|
911
|
+ button.layer?.cornerRadius = 14
|
|
757
|
912
|
button.layer?.backgroundColor = Palette.ctaBackground.cgColor
|
|
758
|
913
|
button.pointerCursor = true
|
|
759
|
914
|
let attrs: [NSAttributedString.Key: Any] = [
|
|
|
@@ -992,32 +1147,14 @@ private final class CVChipButton: NSView {
|
|
992
|
1147
|
|
|
993
|
1148
|
// MARK: - Template card
|
|
994
|
1149
|
|
|
995
|
|
-/// Bordered card holding the mini CV preview, a hover "Preview" overlay, and a
|
|
996
|
|
-/// footer with the template name + family. Maintains its own hover/selected
|
|
997
|
|
-/// states so the parent can stay declarative.
|
|
|
1150
|
+/// Premium gallery card: live résumé thumbnail, glass-style preview overlay, soft
|
|
|
1151
|
+/// shadow, and an animated brand border when selected.
|
|
998
|
1152
|
private final class CVTemplateCard: NSView {
|
|
999
|
|
- struct Palette {
|
|
1000
|
|
- let border: NSColor
|
|
1001
|
|
- let borderHover: NSColor
|
|
1002
|
|
- let borderSelected: NSColor
|
|
1003
|
|
- let footerBackground: NSColor
|
|
1004
|
|
- let previewSurface: NSColor
|
|
1005
|
|
- let previewPaper: NSColor
|
|
1006
|
|
- let previewSidebarTint: NSColor
|
|
1007
|
|
- let previewInk: NSColor
|
|
1008
|
|
- let previewMuted: NSColor
|
|
1009
|
|
- let previewAccentRed: NSColor
|
|
1010
|
|
- let previewAccentBlue: NSColor
|
|
1011
|
|
- let primaryText: NSColor
|
|
1012
|
|
- let secondaryText: NSColor
|
|
1013
|
|
- let overlayTint: NSColor
|
|
1014
|
|
- }
|
|
1015
|
|
-
|
|
1016
|
1153
|
var onSelect: (() -> Void)?
|
|
1017
|
1154
|
var onPreview: (() -> Void)?
|
|
1018
|
|
- var isSelected: Bool = false { didSet { applyBorder() } }
|
|
|
1155
|
+ var isSelected: Bool = false { didSet { applyChrome() } }
|
|
1019
|
1156
|
private let template: CVTemplate
|
|
1020
|
|
- private let palette: Palette
|
|
|
1157
|
+ private let palette: CVTemplateCardPalette
|
|
1021
|
1158
|
private let previewSurface = NSView()
|
|
1022
|
1159
|
private let preview: CVTemplatePreviewView
|
|
1023
|
1160
|
private let nameLabel = NSTextField(labelWithString: "")
|
|
|
@@ -1030,17 +1167,17 @@ private final class CVTemplateCard: NSView {
|
|
1030
|
1167
|
private var isHovering: Bool = false
|
|
1031
|
1168
|
private var didPushCursor: Bool = false
|
|
1032
|
1169
|
|
|
1033
|
|
- init(template: CVTemplate, palette: Palette) {
|
|
|
1170
|
+ init(template: CVTemplate, palette: CVTemplateCardPalette) {
|
|
1034
|
1171
|
self.template = template
|
|
1035
|
1172
|
self.palette = palette
|
|
1036
|
1173
|
self.preview = CVTemplatePreviewView(template: template, palette: palette)
|
|
1037
|
1174
|
super.init(frame: .zero)
|
|
1038
|
1175
|
wantsLayer = true
|
|
1039
|
|
- layer?.cornerRadius = 14
|
|
1040
|
|
- layer?.borderWidth = 1
|
|
1041
|
|
- layer?.masksToBounds = true
|
|
|
1176
|
+ layer?.masksToBounds = false
|
|
|
1177
|
+ layer?.cornerRadius = 24
|
|
|
1178
|
+ layer?.backgroundColor = NSColor.white.cgColor
|
|
1042
|
1179
|
translatesAutoresizingMaskIntoConstraints = false
|
|
1043
|
|
- heightAnchor.constraint(greaterThanOrEqualToConstant: 280).isActive = true
|
|
|
1180
|
+ heightAnchor.constraint(greaterThanOrEqualToConstant: 292).isActive = true
|
|
1044
|
1181
|
|
|
1045
|
1182
|
previewSurface.translatesAutoresizingMaskIntoConstraints = false
|
|
1046
|
1183
|
previewSurface.wantsLayer = true
|
|
|
@@ -1050,14 +1187,14 @@ private final class CVTemplateCard: NSView {
|
|
1050
|
1187
|
previewSurface.addSubview(preview)
|
|
1051
|
1188
|
|
|
1052
|
1189
|
nameLabel.stringValue = template.name
|
|
1053
|
|
- nameLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
1190
|
+ nameLabel.font = .systemFont(ofSize: 14, weight: .semibold)
|
|
1054
|
1191
|
nameLabel.textColor = palette.primaryText
|
|
1055
|
1192
|
nameLabel.isBordered = false
|
|
1056
|
1193
|
nameLabel.drawsBackground = false
|
|
1057
|
1194
|
nameLabel.isEditable = false
|
|
1058
|
1195
|
nameLabel.isSelectable = false
|
|
1059
|
1196
|
|
|
1060
|
|
- categoryLabel.stringValue = template.family.title
|
|
|
1197
|
+ categoryLabel.stringValue = "\(template.category) · \(template.layoutType.gallerySubtitle)"
|
|
1061
|
1198
|
categoryLabel.font = .systemFont(ofSize: 11.5, weight: .regular)
|
|
1062
|
1199
|
categoryLabel.textColor = palette.secondaryText
|
|
1063
|
1200
|
categoryLabel.isBordered = false
|
|
|
@@ -1067,7 +1204,7 @@ private final class CVTemplateCard: NSView {
|
|
1067
|
1204
|
|
|
1068
|
1205
|
let footerStack = NSStackView(views: [nameLabel, categoryLabel])
|
|
1069
|
1206
|
footerStack.orientation = .vertical
|
|
1070
|
|
- footerStack.spacing = 2
|
|
|
1207
|
+ footerStack.spacing = 3
|
|
1071
|
1208
|
footerStack.alignment = .leading
|
|
1072
|
1209
|
footerStack.translatesAutoresizingMaskIntoConstraints = false
|
|
1073
|
1210
|
|
|
|
@@ -1077,10 +1214,10 @@ private final class CVTemplateCard: NSView {
|
|
1077
|
1214
|
footer.layer?.backgroundColor = palette.footerBackground.cgColor
|
|
1078
|
1215
|
footer.addSubview(footerStack)
|
|
1079
|
1216
|
NSLayoutConstraint.activate([
|
|
1080
|
|
- footerStack.leadingAnchor.constraint(equalTo: footer.leadingAnchor, constant: 14),
|
|
1081
|
|
- footerStack.trailingAnchor.constraint(lessThanOrEqualTo: footer.trailingAnchor, constant: -14),
|
|
1082
|
|
- footerStack.topAnchor.constraint(equalTo: footer.topAnchor, constant: 12),
|
|
1083
|
|
- footerStack.bottomAnchor.constraint(equalTo: footer.bottomAnchor, constant: -12)
|
|
|
1217
|
+ footerStack.leadingAnchor.constraint(equalTo: footer.leadingAnchor, constant: 16),
|
|
|
1218
|
+ footerStack.trailingAnchor.constraint(lessThanOrEqualTo: footer.trailingAnchor, constant: -16),
|
|
|
1219
|
+ footerStack.topAnchor.constraint(equalTo: footer.topAnchor, constant: 13),
|
|
|
1220
|
+ footerStack.bottomAnchor.constraint(equalTo: footer.bottomAnchor, constant: -13)
|
|
1084
|
1221
|
])
|
|
1085
|
1222
|
|
|
1086
|
1223
|
addSubview(previewSurface)
|
|
|
@@ -1092,12 +1229,12 @@ private final class CVTemplateCard: NSView {
|
|
1092
|
1229
|
previewSurface.topAnchor.constraint(equalTo: topAnchor),
|
|
1093
|
1230
|
previewSurface.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
1094
|
1231
|
previewSurface.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
1095
|
|
- previewSurface.heightAnchor.constraint(greaterThanOrEqualToConstant: 230),
|
|
|
1232
|
+ previewSurface.heightAnchor.constraint(greaterThanOrEqualToConstant: 236),
|
|
1096
|
1233
|
|
|
1097
|
|
- preview.topAnchor.constraint(equalTo: previewSurface.topAnchor, constant: 16),
|
|
1098
|
|
- preview.leadingAnchor.constraint(equalTo: previewSurface.leadingAnchor, constant: 18),
|
|
1099
|
|
- preview.trailingAnchor.constraint(equalTo: previewSurface.trailingAnchor, constant: -18),
|
|
1100
|
|
- preview.bottomAnchor.constraint(equalTo: previewSurface.bottomAnchor, constant: -16),
|
|
|
1234
|
+ preview.topAnchor.constraint(equalTo: previewSurface.topAnchor, constant: 14),
|
|
|
1235
|
+ preview.leadingAnchor.constraint(equalTo: previewSurface.leadingAnchor, constant: 16),
|
|
|
1236
|
+ preview.trailingAnchor.constraint(equalTo: previewSurface.trailingAnchor, constant: -16),
|
|
|
1237
|
+ preview.bottomAnchor.constraint(equalTo: previewSurface.bottomAnchor, constant: -14),
|
|
1101
|
1238
|
|
|
1102
|
1239
|
footer.topAnchor.constraint(equalTo: previewSurface.bottomAnchor),
|
|
1103
|
1240
|
footer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
@@ -1109,7 +1246,7 @@ private final class CVTemplateCard: NSView {
|
|
1109
|
1246
|
overlay.trailingAnchor.constraint(equalTo: previewSurface.trailingAnchor),
|
|
1110
|
1247
|
overlay.bottomAnchor.constraint(equalTo: previewSurface.bottomAnchor)
|
|
1111
|
1248
|
])
|
|
1112
|
|
- applyBorder()
|
|
|
1249
|
+ applyChrome()
|
|
1113
|
1250
|
}
|
|
1114
|
1251
|
|
|
1115
|
1252
|
@available(*, unavailable)
|
|
|
@@ -1117,6 +1254,11 @@ private final class CVTemplateCard: NSView {
|
|
1117
|
1254
|
fatalError("init(coder:) has not been implemented")
|
|
1118
|
1255
|
}
|
|
1119
|
1256
|
|
|
|
1257
|
+ override func layout() {
|
|
|
1258
|
+ super.layout()
|
|
|
1259
|
+ layer?.shadowPath = CGPath(roundedRect: bounds, cornerWidth: 24, cornerHeight: 24, transform: nil)
|
|
|
1260
|
+ }
|
|
|
1261
|
+
|
|
1120
|
1262
|
private func configureOverlay() {
|
|
1121
|
1263
|
overlay.translatesAutoresizingMaskIntoConstraints = false
|
|
1122
|
1264
|
overlay.wantsLayer = true
|
|
|
@@ -1126,15 +1268,17 @@ private final class CVTemplateCard: NSView {
|
|
1126
|
1268
|
|
|
1127
|
1269
|
overlayBadge.translatesAutoresizingMaskIntoConstraints = false
|
|
1128
|
1270
|
overlayBadge.wantsLayer = true
|
|
1129
|
|
- overlayBadge.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.78).cgColor
|
|
1130
|
|
- overlayBadge.layer?.cornerRadius = 16
|
|
|
1271
|
+ overlayBadge.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.22).cgColor
|
|
|
1272
|
+ overlayBadge.layer?.cornerRadius = 20
|
|
|
1273
|
+ overlayBadge.layer?.borderWidth = 1
|
|
|
1274
|
+ overlayBadge.layer?.borderColor = NSColor.white.withAlphaComponent(0.45).cgColor
|
|
1131
|
1275
|
|
|
1132
|
1276
|
overlayBadgeIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
1133
|
1277
|
overlayBadgeIcon.image = NSImage(systemSymbolName: "eye", accessibilityDescription: nil)
|
|
1134
|
|
- overlayBadgeIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
|
|
|
1278
|
+ overlayBadgeIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
1135
|
1279
|
overlayBadgeIcon.contentTintColor = .white
|
|
1136
|
1280
|
|
|
1137
|
|
- overlayBadgeLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
|
1281
|
+ overlayBadgeLabel.font = .systemFont(ofSize: 12.5, weight: .semibold)
|
|
1138
|
1282
|
overlayBadgeLabel.textColor = .white
|
|
1139
|
1283
|
overlayBadgeLabel.isBordered = false
|
|
1140
|
1284
|
overlayBadgeLabel.drawsBackground = false
|
|
|
@@ -1143,17 +1287,17 @@ private final class CVTemplateCard: NSView {
|
|
1143
|
1287
|
|
|
1144
|
1288
|
let badgeStack = NSStackView(views: [overlayBadgeIcon, overlayBadgeLabel])
|
|
1145
|
1289
|
badgeStack.orientation = .horizontal
|
|
1146
|
|
- badgeStack.spacing = 6
|
|
|
1290
|
+ badgeStack.spacing = 7
|
|
1147
|
1291
|
badgeStack.alignment = .centerY
|
|
1148
|
1292
|
badgeStack.translatesAutoresizingMaskIntoConstraints = false
|
|
1149
|
1293
|
|
|
1150
|
1294
|
overlayBadge.addSubview(badgeStack)
|
|
1151
|
1295
|
overlay.addSubview(overlayBadge)
|
|
1152
|
1296
|
NSLayoutConstraint.activate([
|
|
1153
|
|
- badgeStack.leadingAnchor.constraint(equalTo: overlayBadge.leadingAnchor, constant: 14),
|
|
1154
|
|
- badgeStack.trailingAnchor.constraint(equalTo: overlayBadge.trailingAnchor, constant: -14),
|
|
1155
|
|
- badgeStack.topAnchor.constraint(equalTo: overlayBadge.topAnchor, constant: 8),
|
|
1156
|
|
- badgeStack.bottomAnchor.constraint(equalTo: overlayBadge.bottomAnchor, constant: -8),
|
|
|
1297
|
+ badgeStack.leadingAnchor.constraint(equalTo: overlayBadge.leadingAnchor, constant: 16),
|
|
|
1298
|
+ badgeStack.trailingAnchor.constraint(equalTo: overlayBadge.trailingAnchor, constant: -16),
|
|
|
1299
|
+ badgeStack.topAnchor.constraint(equalTo: overlayBadge.topAnchor, constant: 9),
|
|
|
1300
|
+ badgeStack.bottomAnchor.constraint(equalTo: overlayBadge.bottomAnchor, constant: -9),
|
|
1157
|
1301
|
overlayBadge.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
|
1158
|
1302
|
overlayBadge.centerYAnchor.constraint(equalTo: overlay.centerYAnchor)
|
|
1159
|
1303
|
])
|
|
|
@@ -1170,10 +1314,22 @@ private final class CVTemplateCard: NSView {
|
|
1170
|
1314
|
if overlay.frame.contains(local) {
|
|
1171
|
1315
|
onPreview?()
|
|
1172
|
1316
|
} else {
|
|
|
1317
|
+ playTapPulse()
|
|
1173
|
1318
|
onSelect?()
|
|
1174
|
1319
|
}
|
|
1175
|
1320
|
}
|
|
1176
|
1321
|
|
|
|
1322
|
+ private func playTapPulse() {
|
|
|
1323
|
+ guard let l = layer else { return }
|
|
|
1324
|
+ let a = CABasicAnimation(keyPath: "transform")
|
|
|
1325
|
+ a.fromValue = NSValue(caTransform3D: CATransform3DIdentity)
|
|
|
1326
|
+ a.toValue = NSValue(caTransform3D: CATransform3DMakeScale(0.985, 0.985, 1))
|
|
|
1327
|
+ a.duration = 0.1
|
|
|
1328
|
+ a.autoreverses = true
|
|
|
1329
|
+ a.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
|
1330
|
+ l.add(a, forKey: "tapPulse")
|
|
|
1331
|
+ }
|
|
|
1332
|
+
|
|
1177
|
1333
|
override func updateTrackingAreas() {
|
|
1178
|
1334
|
super.updateTrackingAreas()
|
|
1179
|
1335
|
if let area = trackingArea { removeTrackingArea(area) }
|
|
|
@@ -1191,7 +1347,7 @@ private final class CVTemplateCard: NSView {
|
|
1191
|
1347
|
super.mouseEntered(with: event)
|
|
1192
|
1348
|
isHovering = true
|
|
1193
|
1349
|
animateOverlay(visible: true)
|
|
1194
|
|
- applyBorder()
|
|
|
1350
|
+ applyChrome()
|
|
1195
|
1351
|
if !didPushCursor {
|
|
1196
|
1352
|
NSCursor.pointingHand.push()
|
|
1197
|
1353
|
didPushCursor = true
|
|
|
@@ -1202,7 +1358,7 @@ private final class CVTemplateCard: NSView {
|
|
1202
|
1358
|
super.mouseExited(with: event)
|
|
1203
|
1359
|
isHovering = false
|
|
1204
|
1360
|
animateOverlay(visible: false)
|
|
1205
|
|
- applyBorder()
|
|
|
1361
|
+ applyChrome()
|
|
1206
|
1362
|
if didPushCursor {
|
|
1207
|
1363
|
NSCursor.pop()
|
|
1208
|
1364
|
didPushCursor = false
|
|
|
@@ -1221,346 +1377,39 @@ private final class CVTemplateCard: NSView {
|
|
1221
|
1377
|
private func animateOverlay(visible: Bool) {
|
|
1222
|
1378
|
let target: CGFloat = visible ? 1 : 0
|
|
1223
|
1379
|
NSAnimationContext.runAnimationGroup { context in
|
|
1224
|
|
- context.duration = 0.12
|
|
|
1380
|
+ context.duration = 0.18
|
|
|
1381
|
+ context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
1225
|
1382
|
context.allowsImplicitAnimation = true
|
|
1226
|
1383
|
overlay.animator().alphaValue = target
|
|
1227
|
1384
|
}
|
|
1228
|
1385
|
}
|
|
1229
|
1386
|
|
|
1230
|
|
- private func applyBorder() {
|
|
|
1387
|
+ private func applyChrome() {
|
|
1231
|
1388
|
if isSelected {
|
|
1232
|
1389
|
layer?.borderColor = palette.borderSelected.cgColor
|
|
1233
|
|
- layer?.borderWidth = 2
|
|
|
1390
|
+ layer?.borderWidth = 2.5
|
|
|
1391
|
+ layer?.shadowColor = palette.borderSelected.cgColor
|
|
|
1392
|
+ layer?.shadowOpacity = 0.42
|
|
|
1393
|
+ layer?.shadowRadius = 22
|
|
|
1394
|
+ layer?.shadowOffset = CGSize(width: 0, height: 10)
|
|
1234
|
1395
|
} else if isHovering {
|
|
1235
|
1396
|
layer?.borderColor = palette.borderHover.cgColor
|
|
1236
|
|
- layer?.borderWidth = 1
|
|
|
1397
|
+ layer?.borderWidth = 1.5
|
|
|
1398
|
+ layer?.shadowColor = NSColor.black.cgColor
|
|
|
1399
|
+ layer?.shadowOpacity = 0.14
|
|
|
1400
|
+ layer?.shadowRadius = 18
|
|
|
1401
|
+ layer?.shadowOffset = CGSize(width: 0, height: 10)
|
|
1237
|
1402
|
} else {
|
|
1238
|
1403
|
layer?.borderColor = palette.border.cgColor
|
|
1239
|
1404
|
layer?.borderWidth = 1
|
|
|
1405
|
+ layer?.shadowColor = NSColor.black.cgColor
|
|
|
1406
|
+ layer?.shadowOpacity = 0.08
|
|
|
1407
|
+ layer?.shadowRadius = 12
|
|
|
1408
|
+ layer?.shadowOffset = CGSize(width: 0, height: 6)
|
|
1240
|
1409
|
}
|
|
1241
|
1410
|
}
|
|
1242
|
1411
|
}
|
|
1243
|
1412
|
|
|
1244
|
|
-// MARK: - Mini preview renderer
|
|
1245
|
|
-
|
|
1246
|
|
-/// Tiny stylized representation of a finished CV — accent strip, headline area,
|
|
1247
|
|
-/// faux paragraph + section lines. Adapts to the template's headline, accent,
|
|
1248
|
|
-/// sidebar, and section-label style so the grid feels varied at a glance.
|
|
1249
|
|
-private final class CVTemplatePreviewView: NSView {
|
|
1250
|
|
- private let template: CVTemplate
|
|
1251
|
|
- private let palette: CVTemplateCard.Palette
|
|
1252
|
|
- private let paper = NSView()
|
|
1253
|
|
-
|
|
1254
|
|
- init(template: CVTemplate, palette: CVTemplateCard.Palette) {
|
|
1255
|
|
- self.template = template
|
|
1256
|
|
- self.palette = palette
|
|
1257
|
|
- super.init(frame: .zero)
|
|
1258
|
|
- wantsLayer = true
|
|
1259
|
|
- translatesAutoresizingMaskIntoConstraints = false
|
|
1260
|
|
- configurePaper()
|
|
1261
|
|
- }
|
|
1262
|
|
-
|
|
1263
|
|
- @available(*, unavailable)
|
|
1264
|
|
- required init?(coder: NSCoder) {
|
|
1265
|
|
- fatalError("init(coder:) has not been implemented")
|
|
1266
|
|
- }
|
|
1267
|
|
-
|
|
1268
|
|
- private func configurePaper() {
|
|
1269
|
|
- paper.translatesAutoresizingMaskIntoConstraints = false
|
|
1270
|
|
- paper.wantsLayer = true
|
|
1271
|
|
- paper.layer?.backgroundColor = palette.previewPaper.cgColor
|
|
1272
|
|
- paper.layer?.cornerRadius = 4
|
|
1273
|
|
- paper.layer?.borderColor = NSColor(srgbRed: 232 / 255, green: 235 / 255, blue: 241 / 255, alpha: 1).cgColor
|
|
1274
|
|
- paper.layer?.borderWidth = 1
|
|
1275
|
|
- paper.layer?.masksToBounds = true
|
|
1276
|
|
- addSubview(paper)
|
|
1277
|
|
-
|
|
1278
|
|
- NSLayoutConstraint.activate([
|
|
1279
|
|
- paper.topAnchor.constraint(equalTo: topAnchor),
|
|
1280
|
|
- paper.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
1281
|
|
- paper.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
1282
|
|
- paper.widthAnchor.constraint(equalTo: heightAnchor, multiplier: 0.78),
|
|
1283
|
|
- paper.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor),
|
|
1284
|
|
- paper.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor)
|
|
1285
|
|
- ])
|
|
1286
|
|
-
|
|
1287
|
|
- let header = makeHeader()
|
|
1288
|
|
- let body = makeBody()
|
|
1289
|
|
- let stack = NSStackView(views: [header, body])
|
|
1290
|
|
- stack.orientation = .vertical
|
|
1291
|
|
- stack.alignment = .leading
|
|
1292
|
|
- stack.spacing = 6
|
|
1293
|
|
- stack.distribution = .fill
|
|
1294
|
|
- stack.translatesAutoresizingMaskIntoConstraints = false
|
|
1295
|
|
- paper.addSubview(stack)
|
|
1296
|
|
- NSLayoutConstraint.activate([
|
|
1297
|
|
- stack.leadingAnchor.constraint(equalTo: paper.leadingAnchor, constant: 8),
|
|
1298
|
|
- stack.trailingAnchor.constraint(equalTo: paper.trailingAnchor, constant: -8),
|
|
1299
|
|
- stack.topAnchor.constraint(equalTo: paper.topAnchor, constant: 8),
|
|
1300
|
|
- stack.bottomAnchor.constraint(lessThanOrEqualTo: paper.bottomAnchor, constant: -8),
|
|
1301
|
|
- header.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
|
1302
|
|
- body.widthAnchor.constraint(equalTo: stack.widthAnchor)
|
|
1303
|
|
- ])
|
|
1304
|
|
- }
|
|
1305
|
|
-
|
|
1306
|
|
- // MARK: Header
|
|
1307
|
|
-
|
|
1308
|
|
- private func makeHeader() -> NSView {
|
|
1309
|
|
- let container = NSView()
|
|
1310
|
|
- container.translatesAutoresizingMaskIntoConstraints = false
|
|
1311
|
|
-
|
|
1312
|
|
- let jitterA = templateVisualJitter(template.id)
|
|
1313
|
|
- let jitterB = templateVisualJitter(template.id + "-b")
|
|
1314
|
|
- let nameInk = palette.previewInk.blended(withFraction: CGFloat(jitterA * 0.14), of: palette.previewAccentBlue) ?? palette.previewInk
|
|
1315
|
|
- let nameStrip = makeLine(color: nameInk, height: 5.2 + CGFloat(jitterB * 0.9), widthFraction: 0.52 + CGFloat(jitterA * 0.14))
|
|
1316
|
|
- let roleStrip = makeLine(color: palette.previewMuted, height: 3, widthFraction: 0.36 + CGFloat(jitterB * 0.12))
|
|
1317
|
|
- let contactStrip = makeLine(color: palette.previewMuted.withAlphaComponent(0.7), height: 2, widthFraction: 0.48 + CGFloat(jitterA * 0.1))
|
|
1318
|
|
-
|
|
1319
|
|
- let textStack = NSStackView(views: [nameStrip, roleStrip, contactStrip])
|
|
1320
|
|
- textStack.orientation = .vertical
|
|
1321
|
|
- textStack.spacing = 4
|
|
1322
|
|
- textStack.translatesAutoresizingMaskIntoConstraints = false
|
|
1323
|
|
-
|
|
1324
|
|
- switch template.headline {
|
|
1325
|
|
- case .centered:
|
|
1326
|
|
- textStack.alignment = .centerX
|
|
1327
|
|
- case .leftAligned, .leftWithInitials:
|
|
1328
|
|
- textStack.alignment = .leading
|
|
1329
|
|
- case .avatarStacked:
|
|
1330
|
|
- textStack.alignment = .leading
|
|
1331
|
|
- }
|
|
1332
|
|
-
|
|
1333
|
|
- container.addSubview(textStack)
|
|
1334
|
|
-
|
|
1335
|
|
- switch template.headline {
|
|
1336
|
|
- case .leftWithInitials:
|
|
1337
|
|
- let avatar = makeAvatar()
|
|
1338
|
|
- container.addSubview(avatar)
|
|
1339
|
|
- NSLayoutConstraint.activate([
|
|
1340
|
|
- textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
1341
|
|
- textStack.trailingAnchor.constraint(lessThanOrEqualTo: avatar.leadingAnchor, constant: -6),
|
|
1342
|
|
- textStack.topAnchor.constraint(equalTo: container.topAnchor),
|
|
1343
|
|
- textStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
1344
|
|
- avatar.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
1345
|
|
- avatar.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
|
1346
|
|
- avatar.widthAnchor.constraint(equalToConstant: 20),
|
|
1347
|
|
- avatar.heightAnchor.constraint(equalToConstant: 20)
|
|
1348
|
|
- ])
|
|
1349
|
|
- case .avatarStacked:
|
|
1350
|
|
- let avatar = makeAvatar()
|
|
1351
|
|
- container.addSubview(avatar)
|
|
1352
|
|
- NSLayoutConstraint.activate([
|
|
1353
|
|
- avatar.topAnchor.constraint(equalTo: container.topAnchor),
|
|
1354
|
|
- avatar.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
1355
|
|
- avatar.widthAnchor.constraint(equalToConstant: 22),
|
|
1356
|
|
- avatar.heightAnchor.constraint(equalToConstant: 22),
|
|
1357
|
|
- textStack.topAnchor.constraint(equalTo: avatar.bottomAnchor, constant: 4),
|
|
1358
|
|
- textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
1359
|
|
- textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
1360
|
|
- textStack.bottomAnchor.constraint(equalTo: container.bottomAnchor)
|
|
1361
|
|
- ])
|
|
1362
|
|
- case .centered:
|
|
1363
|
|
- NSLayoutConstraint.activate([
|
|
1364
|
|
- textStack.topAnchor.constraint(equalTo: container.topAnchor),
|
|
1365
|
|
- textStack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
1366
|
|
- textStack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
|
1367
|
|
- textStack.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor),
|
|
1368
|
|
- textStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor)
|
|
1369
|
|
- ])
|
|
1370
|
|
- case .leftAligned:
|
|
1371
|
|
- NSLayoutConstraint.activate([
|
|
1372
|
|
- textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
1373
|
|
- textStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor),
|
|
1374
|
|
- textStack.topAnchor.constraint(equalTo: container.topAnchor),
|
|
1375
|
|
- textStack.bottomAnchor.constraint(equalTo: container.bottomAnchor)
|
|
1376
|
|
- ])
|
|
1377
|
|
- }
|
|
1378
|
|
-
|
|
1379
|
|
- // Accent decorations
|
|
1380
|
|
- switch template.accent {
|
|
1381
|
|
- case .none:
|
|
1382
|
|
- break
|
|
1383
|
|
- case .redUnderline, .redBar:
|
|
1384
|
|
- let accent = NSView()
|
|
1385
|
|
- accent.translatesAutoresizingMaskIntoConstraints = false
|
|
1386
|
|
- accent.wantsLayer = true
|
|
1387
|
|
- accent.layer?.backgroundColor = palette.previewAccentRed.cgColor
|
|
1388
|
|
- container.addSubview(accent)
|
|
1389
|
|
- NSLayoutConstraint.activate([
|
|
1390
|
|
- accent.heightAnchor.constraint(equalToConstant: template.accent == .redBar ? 2.5 : 1.5),
|
|
1391
|
|
- accent.topAnchor.constraint(equalTo: textStack.bottomAnchor, constant: 5),
|
|
1392
|
|
- accent.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
1393
|
|
- accent.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: template.accent == .redBar ? 0.32 : 0.9),
|
|
1394
|
|
- accent.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor)
|
|
1395
|
|
- ])
|
|
1396
|
|
- case .blueBar:
|
|
1397
|
|
- let accent = NSView()
|
|
1398
|
|
- accent.translatesAutoresizingMaskIntoConstraints = false
|
|
1399
|
|
- accent.wantsLayer = true
|
|
1400
|
|
- accent.layer?.backgroundColor = palette.previewAccentBlue.cgColor
|
|
1401
|
|
- container.addSubview(accent)
|
|
1402
|
|
- NSLayoutConstraint.activate([
|
|
1403
|
|
- accent.heightAnchor.constraint(equalToConstant: 2.5),
|
|
1404
|
|
- accent.topAnchor.constraint(equalTo: textStack.bottomAnchor, constant: 5),
|
|
1405
|
|
- accent.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
1406
|
|
- accent.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.32),
|
|
1407
|
|
- accent.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor)
|
|
1408
|
|
- ])
|
|
1409
|
|
- }
|
|
1410
|
|
-
|
|
1411
|
|
- return container
|
|
1412
|
|
- }
|
|
1413
|
|
-
|
|
1414
|
|
- /// Stable 0…1 value per template so thumbnails are not visually identical when metadata is similar.
|
|
1415
|
|
- private func templateVisualJitter(_ salt: String) -> Double {
|
|
1416
|
|
- var hash: UInt64 = 1469598103934665603
|
|
1417
|
|
- for b in salt.utf8 {
|
|
1418
|
|
- hash ^= UInt64(b)
|
|
1419
|
|
- hash &*= 1_099_511_628_211
|
|
1420
|
|
- }
|
|
1421
|
|
- return Double(hash % 10_007) / 10_006.0
|
|
1422
|
|
- }
|
|
1423
|
|
-
|
|
1424
|
|
- private func makeAvatar() -> NSView {
|
|
1425
|
|
- let avatar = NSView()
|
|
1426
|
|
- avatar.translatesAutoresizingMaskIntoConstraints = false
|
|
1427
|
|
- avatar.wantsLayer = true
|
|
1428
|
|
- avatar.layer?.backgroundColor = palette.previewSidebarTint.cgColor
|
|
1429
|
|
- avatar.layer?.borderColor = palette.previewMuted.withAlphaComponent(0.4).cgColor
|
|
1430
|
|
- avatar.layer?.borderWidth = 1
|
|
1431
|
|
- avatar.layer?.cornerRadius = 11
|
|
1432
|
|
- let initials = NSTextField(labelWithString: "SJ")
|
|
1433
|
|
- initials.font = .systemFont(ofSize: 7, weight: .bold)
|
|
1434
|
|
- initials.textColor = palette.previewInk
|
|
1435
|
|
- initials.alignment = .center
|
|
1436
|
|
- initials.translatesAutoresizingMaskIntoConstraints = false
|
|
1437
|
|
- avatar.addSubview(initials)
|
|
1438
|
|
- NSLayoutConstraint.activate([
|
|
1439
|
|
- initials.centerXAnchor.constraint(equalTo: avatar.centerXAnchor),
|
|
1440
|
|
- initials.centerYAnchor.constraint(equalTo: avatar.centerYAnchor)
|
|
1441
|
|
- ])
|
|
1442
|
|
- return avatar
|
|
1443
|
|
- }
|
|
1444
|
|
-
|
|
1445
|
|
- // MARK: Body
|
|
1446
|
|
-
|
|
1447
|
|
- private func makeBody() -> NSView {
|
|
1448
|
|
- switch template.layout {
|
|
1449
|
|
- case .singleColumn:
|
|
1450
|
|
- return makeColumn(width: nil, isSidebar: false)
|
|
1451
|
|
- case .twoColumn(let side, let tinted):
|
|
1452
|
|
- return makeTwoColumnLayout(sidebarSide: side, tinted: tinted)
|
|
1453
|
|
- }
|
|
1454
|
|
- }
|
|
1455
|
|
-
|
|
1456
|
|
- private func makeTwoColumnLayout(sidebarSide: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
|
|
1457
|
|
- let container = NSView()
|
|
1458
|
|
- container.translatesAutoresizingMaskIntoConstraints = false
|
|
1459
|
|
-
|
|
1460
|
|
- let sidebar = makeColumn(width: nil, isSidebar: true)
|
|
1461
|
|
- if tinted {
|
|
1462
|
|
- sidebar.wantsLayer = true
|
|
1463
|
|
- sidebar.layer?.backgroundColor = palette.previewSidebarTint.cgColor
|
|
1464
|
|
- sidebar.layer?.cornerRadius = 3
|
|
1465
|
|
- }
|
|
1466
|
|
- let main = makeColumn(width: nil, isSidebar: false)
|
|
1467
|
|
-
|
|
1468
|
|
- container.addSubview(sidebar)
|
|
1469
|
|
- container.addSubview(main)
|
|
1470
|
|
-
|
|
1471
|
|
- let leadingItem: NSView = (sidebarSide == .leading) ? sidebar : main
|
|
1472
|
|
- let trailingItem: NSView = (sidebarSide == .leading) ? main : sidebar
|
|
1473
|
|
-
|
|
1474
|
|
- NSLayoutConstraint.activate([
|
|
1475
|
|
- leadingItem.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
1476
|
|
- leadingItem.topAnchor.constraint(equalTo: container.topAnchor, constant: 2),
|
|
1477
|
|
- leadingItem.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor),
|
|
1478
|
|
- trailingItem.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
1479
|
|
- trailingItem.topAnchor.constraint(equalTo: container.topAnchor, constant: 2),
|
|
1480
|
|
- trailingItem.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor),
|
|
1481
|
|
- trailingItem.leadingAnchor.constraint(equalTo: leadingItem.trailingAnchor, constant: 8),
|
|
1482
|
|
- sidebar.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.32),
|
|
1483
|
|
- main.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.6)
|
|
1484
|
|
- ])
|
|
1485
|
|
- return container
|
|
1486
|
|
- }
|
|
1487
|
|
-
|
|
1488
|
|
- private func makeColumn(width: CGFloat?, isSidebar: Bool) -> NSView {
|
|
1489
|
|
- let stack = NSStackView()
|
|
1490
|
|
- stack.orientation = .vertical
|
|
1491
|
|
- stack.alignment = .leading
|
|
1492
|
|
- stack.spacing = 6
|
|
1493
|
|
- stack.translatesAutoresizingMaskIntoConstraints = false
|
|
1494
|
|
-
|
|
1495
|
|
- let sectionNames: [String] = isSidebar
|
|
1496
|
|
- ? ["CONTACT", "SKILLS", "LANGUAGES", "INTERESTS"]
|
|
1497
|
|
- : ["PROFILE", "EXPERIENCE", "EDUCATION", "SKILLS"]
|
|
1498
|
|
-
|
|
1499
|
|
- for (i, section) in sectionNames.enumerated() {
|
|
1500
|
|
- let block = makeSectionBlock(title: section, lineCount: isSidebar ? 3 : (i == 1 ? 4 : 2), narrow: isSidebar)
|
|
1501
|
|
- stack.addArrangedSubview(block)
|
|
1502
|
|
- block.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
1503
|
|
- }
|
|
1504
|
|
- return stack
|
|
1505
|
|
- }
|
|
1506
|
|
-
|
|
1507
|
|
- private func makeSectionBlock(title: String, lineCount: Int, narrow: Bool) -> NSView {
|
|
1508
|
|
- let label = NSTextField(labelWithString: formattedSectionLabel(title))
|
|
1509
|
|
- label.font = .systemFont(ofSize: narrow ? 5 : 5.5, weight: .bold)
|
|
1510
|
|
- label.textColor = palette.previewAccentBlue.blended(withFraction: 0.4, of: palette.previewInk) ?? palette.previewInk
|
|
1511
|
|
- label.maximumNumberOfLines = 1
|
|
1512
|
|
- label.isBordered = false
|
|
1513
|
|
- label.drawsBackground = false
|
|
1514
|
|
- label.isEditable = false
|
|
1515
|
|
- label.isSelectable = false
|
|
1516
|
|
- label.translatesAutoresizingMaskIntoConstraints = false
|
|
1517
|
|
-
|
|
1518
|
|
- let lines = NSStackView()
|
|
1519
|
|
- lines.orientation = .vertical
|
|
1520
|
|
- lines.alignment = .leading
|
|
1521
|
|
- lines.spacing = 2
|
|
1522
|
|
- lines.translatesAutoresizingMaskIntoConstraints = false
|
|
1523
|
|
- for index in 0..<lineCount {
|
|
1524
|
|
- let widthFraction: CGFloat = max(0.4, 0.95 - CGFloat(index) * 0.13)
|
|
1525
|
|
- let line = makeLine(color: palette.previewMuted.withAlphaComponent(0.65), height: 1.6, widthFraction: widthFraction)
|
|
1526
|
|
- lines.addArrangedSubview(line)
|
|
1527
|
|
- line.widthAnchor.constraint(equalTo: lines.widthAnchor, multiplier: widthFraction).isActive = true
|
|
1528
|
|
- }
|
|
1529
|
|
-
|
|
1530
|
|
- let block = NSStackView(views: [label, lines])
|
|
1531
|
|
- block.orientation = .vertical
|
|
1532
|
|
- block.alignment = .leading
|
|
1533
|
|
- block.spacing = 2
|
|
1534
|
|
- block.translatesAutoresizingMaskIntoConstraints = false
|
|
1535
|
|
- let blockWidth = block.widthAnchor
|
|
1536
|
|
- lines.widthAnchor.constraint(equalTo: blockWidth).isActive = true
|
|
1537
|
|
- return block
|
|
1538
|
|
- }
|
|
1539
|
|
-
|
|
1540
|
|
- private func formattedSectionLabel(_ raw: String) -> String {
|
|
1541
|
|
- switch template.sectionLabelStyle {
|
|
1542
|
|
- case .uppercase: return raw
|
|
1543
|
|
- case .slashed: return "// \(raw.capitalized)"
|
|
1544
|
|
- case .bracketed: return "[ \(raw) ]"
|
|
1545
|
|
- }
|
|
1546
|
|
- }
|
|
1547
|
|
-
|
|
1548
|
|
- private func makeLine(color: NSColor, height: CGFloat, widthFraction: CGFloat) -> NSView {
|
|
1549
|
|
- let line = LineView()
|
|
1550
|
|
- line.translatesAutoresizingMaskIntoConstraints = false
|
|
1551
|
|
- line.wantsLayer = true
|
|
1552
|
|
- line.layer?.backgroundColor = color.cgColor
|
|
1553
|
|
- line.layer?.cornerRadius = max(height / 2, 1)
|
|
1554
|
|
- line.heightAnchor.constraint(equalToConstant: height).isActive = true
|
|
1555
|
|
- line.widthFraction = widthFraction
|
|
1556
|
|
- return line
|
|
1557
|
|
- }
|
|
1558
|
|
-}
|
|
1559
|
|
-
|
|
1560
|
|
-private final class LineView: NSView {
|
|
1561
|
|
- var widthFraction: CGFloat = 1
|
|
1562
|
|
-}
|
|
1563
|
|
-
|
|
1564
|
1413
|
// MARK: - Helpers
|
|
1565
|
1414
|
|
|
1566
|
1415
|
/// Flipped origin so the grid stacks fill from the top.
|