|
|
@@ -38,6 +38,12 @@ private struct DocumentStyle {
|
|
38
|
38
|
|
|
39
|
39
|
static func make(for template: CVTemplate) -> DocumentStyle {
|
|
40
|
40
|
let theme = template.themeColor
|
|
|
41
|
+ let colors = CVResumeAppearance.colors()
|
|
|
42
|
+ let sectionInk = CVResumeAppearance.sectionHeadingColor(for: template)
|
|
|
43
|
+ let cardBG = CVResumeAppearance.paperBackground(
|
|
|
44
|
+ variant: template.galleryLayoutVariant,
|
|
|
45
|
+ base: colors.cardBackground
|
|
|
46
|
+ )
|
|
41
|
47
|
switch template.family {
|
|
42
|
48
|
case .minimal:
|
|
43
|
49
|
return DocumentStyle(
|
|
|
@@ -54,14 +60,14 @@ private struct DocumentStyle {
|
|
54
|
60
|
bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
|
|
55
|
61
|
bulletMarkerFont: .systemFont(ofSize: 11, weight: .light),
|
|
56
|
62
|
bulletMarkerColor: theme.withAlphaComponent(0.55),
|
|
57
|
|
- ink: NSColor(srgbRed: 42 / 255, green: 48 / 255, blue: 56 / 255, alpha: 1),
|
|
58
|
|
- muted: NSColor(srgbRed: 110 / 255, green: 118 / 255, blue: 128 / 255, alpha: 1),
|
|
59
|
|
- rule: NSColor(srgbRed: 228 / 255, green: 230 / 255, blue: 234 / 255, alpha: 1),
|
|
60
|
|
- cardBackground: NSColor(srgbRed: 0.998, green: 0.998, blue: 0.998, alpha: 1),
|
|
|
63
|
+ ink: colors.ink,
|
|
|
64
|
+ muted: colors.muted,
|
|
|
65
|
+ rule: colors.rule,
|
|
|
66
|
+ cardBackground: cardBG,
|
|
61
|
67
|
columnVerticalSpacing: 15,
|
|
62
|
68
|
bodyBlockSpacing: 15,
|
|
63
|
69
|
roleUsesThemeColor: false,
|
|
64
|
|
- sectionInk: theme.withAlphaComponent(0.92)
|
|
|
70
|
+ sectionInk: sectionInk
|
|
65
|
71
|
)
|
|
66
|
72
|
|
|
67
|
73
|
case .professional:
|
|
|
@@ -78,15 +84,15 @@ private struct DocumentStyle {
|
|
78
|
84
|
eduMetaFont: .systemFont(ofSize: 11.5, weight: .medium),
|
|
79
|
85
|
bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
|
|
80
|
86
|
bulletMarkerFont: .systemFont(ofSize: 12, weight: .bold),
|
|
81
|
|
- bulletMarkerColor: NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55),
|
|
82
|
|
- ink: NSColor(srgbRed: 28 / 255, green: 36 / 255, blue: 48 / 255, alpha: 1),
|
|
83
|
|
- muted: NSColor(srgbRed: 88 / 255, green: 98 / 255, blue: 118 / 255, alpha: 1),
|
|
84
|
|
- rule: NSColor(srgbRed: 210 / 255, green: 218 / 255, blue: 232 / 255, alpha: 1),
|
|
85
|
|
- cardBackground: NSColor.white,
|
|
|
87
|
+ bulletMarkerColor: theme,
|
|
|
88
|
+ ink: colors.ink,
|
|
|
89
|
+ muted: colors.muted,
|
|
|
90
|
+ rule: colors.rule,
|
|
|
91
|
+ cardBackground: cardBG,
|
|
86
|
92
|
columnVerticalSpacing: 13,
|
|
87
|
93
|
bodyBlockSpacing: 13,
|
|
88
|
94
|
roleUsesThemeColor: false,
|
|
89
|
|
- sectionInk: theme
|
|
|
95
|
+ sectionInk: sectionInk
|
|
90
|
96
|
)
|
|
91
|
97
|
|
|
92
|
98
|
case .modern:
|
|
|
@@ -104,14 +110,14 @@ private struct DocumentStyle {
|
|
104
|
110
|
bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
105
|
111
|
bulletMarkerFont: .systemFont(ofSize: 13, weight: .bold),
|
|
106
|
112
|
bulletMarkerColor: theme,
|
|
107
|
|
- ink: NSColor(srgbRed: 24 / 255, green: 34 / 255, blue: 52 / 255, alpha: 1),
|
|
108
|
|
- muted: NSColor(srgbRed: 96 / 255, green: 110 / 255, blue: 132 / 255, alpha: 1),
|
|
109
|
|
- rule: NSColor(srgbRed: 200 / 255, green: 214 / 255, blue: 236 / 255, alpha: 1),
|
|
110
|
|
- cardBackground: NSColor(srgbRed: 0.99, green: 0.995, blue: 1, alpha: 1),
|
|
|
113
|
+ ink: colors.ink,
|
|
|
114
|
+ muted: colors.muted,
|
|
|
115
|
+ rule: colors.rule,
|
|
|
116
|
+ cardBackground: cardBG,
|
|
111
|
117
|
columnVerticalSpacing: 17,
|
|
112
|
118
|
bodyBlockSpacing: 16,
|
|
113
|
119
|
roleUsesThemeColor: true,
|
|
114
|
|
- sectionInk: theme
|
|
|
120
|
+ sectionInk: sectionInk
|
|
115
|
121
|
)
|
|
116
|
122
|
|
|
117
|
123
|
case .executive:
|
|
|
@@ -125,6 +131,9 @@ private struct DocumentStyle {
|
|
125
|
131
|
?? NSFontManager.shared.convert(georgia12, toHaveTrait: .italicFontMask)
|
|
126
|
132
|
let eduMeta = NSFont(name: "Georgia-Italic", size: 11.5)
|
|
127
|
133
|
?? NSFontManager.shared.convert(georgia115, toHaveTrait: .italicFontMask)
|
|
|
134
|
+ let execCard = CVResumeAppearance.isDark
|
|
|
135
|
+ ? cardBG
|
|
|
136
|
+ : NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1)
|
|
128
|
137
|
return DocumentStyle(
|
|
129
|
138
|
nameFont: serifName,
|
|
130
|
139
|
roleFont: serifRole,
|
|
|
@@ -138,15 +147,15 @@ private struct DocumentStyle {
|
|
138
|
147
|
eduMetaFont: eduMeta,
|
|
139
|
148
|
bulletBodyFont: serifCompact,
|
|
140
|
149
|
bulletMarkerFont: .systemFont(ofSize: 11, weight: .bold),
|
|
141
|
|
- bulletMarkerColor: NSColor(srgbRed: 55 / 255, green: 55 / 255, blue: 62 / 255, alpha: 1),
|
|
142
|
|
- ink: NSColor(srgbRed: 22 / 255, green: 22 / 255, blue: 28 / 255, alpha: 1),
|
|
143
|
|
- muted: NSColor(srgbRed: 82 / 255, green: 82 / 255, blue: 90 / 255, alpha: 1),
|
|
144
|
|
- rule: NSColor(srgbRed: 72 / 255, green: 72 / 255, blue: 78 / 255, alpha: 0.35),
|
|
145
|
|
- cardBackground: NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1),
|
|
|
150
|
+ bulletMarkerColor: colors.ink.withAlphaComponent(0.75),
|
|
|
151
|
+ ink: colors.ink,
|
|
|
152
|
+ muted: colors.muted,
|
|
|
153
|
+ rule: colors.rule,
|
|
|
154
|
+ cardBackground: execCard,
|
|
146
|
155
|
columnVerticalSpacing: 18,
|
|
147
|
156
|
bodyBlockSpacing: 17,
|
|
148
|
157
|
roleUsesThemeColor: false,
|
|
149
|
|
- sectionInk: NSColor(srgbRed: 32 / 255, green: 32 / 255, blue: 38 / 255, alpha: 1)
|
|
|
158
|
+ sectionInk: sectionInk
|
|
150
|
159
|
)
|
|
151
|
160
|
|
|
152
|
161
|
case .creative:
|
|
|
@@ -163,15 +172,15 @@ private struct DocumentStyle {
|
|
163
|
172
|
eduMetaFont: .systemFont(ofSize: 12, weight: .medium),
|
|
164
|
173
|
bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
165
|
174
|
bulletMarkerFont: .systemFont(ofSize: 13, weight: .heavy),
|
|
166
|
|
- bulletMarkerColor: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1),
|
|
167
|
|
- ink: NSColor(srgbRed: 32 / 255, green: 26 / 255, blue: 52 / 255, alpha: 1),
|
|
168
|
|
- muted: NSColor(srgbRed: 108 / 255, green: 96 / 255, blue: 130 / 255, alpha: 1),
|
|
|
175
|
+ bulletMarkerColor: CVResumeAppearance.accentColor(for: template),
|
|
|
176
|
+ ink: colors.ink,
|
|
|
177
|
+ muted: colors.muted,
|
|
169
|
178
|
rule: theme.withAlphaComponent(0.22),
|
|
170
|
|
- cardBackground: NSColor(srgbRed: 0.995, green: 0.993, blue: 1, alpha: 1),
|
|
|
179
|
+ cardBackground: cardBG,
|
|
171
|
180
|
columnVerticalSpacing: 18,
|
|
172
|
181
|
bodyBlockSpacing: 17,
|
|
173
|
182
|
roleUsesThemeColor: true,
|
|
174
|
|
- sectionInk: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1)
|
|
|
183
|
+ sectionInk: sectionInk
|
|
175
|
184
|
)
|
|
176
|
185
|
}
|
|
177
|
186
|
}
|
|
|
@@ -187,9 +196,12 @@ final class CVProfileDocumentView: NSView {
|
|
187
|
196
|
|
|
188
|
197
|
private let profile: SavedProfile
|
|
189
|
198
|
private let template: CVTemplate
|
|
190
|
|
- private let style: DocumentStyle
|
|
|
199
|
+ private var style: DocumentStyle
|
|
191
|
200
|
/// Matches `CVTemplatePreviewView` so the same template id + layout recipe renders the same silhouette as the gallery card.
|
|
192
|
201
|
private let variant: Int
|
|
|
202
|
+ private var appearanceObserver: NSObjectProtocol?
|
|
|
203
|
+ private weak var cardView: NSView?
|
|
|
204
|
+
|
|
193
|
205
|
init(profile: SavedProfile, template: CVTemplate) {
|
|
194
|
206
|
self.profile = profile
|
|
195
|
207
|
self.template = template
|
|
|
@@ -200,9 +212,35 @@ final class CVProfileDocumentView: NSView {
|
|
200
|
212
|
wantsLayer = true
|
|
201
|
213
|
layer?.backgroundColor = NSColor.clear.cgColor
|
|
202
|
214
|
userInterfaceLayoutDirection = .leftToRight
|
|
203
|
|
- // Let the preview stack stretch us to the scroll view width; don’t shrink to label intrinsic widths.
|
|
204
|
215
|
setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
216
|
+ installCardContent()
|
|
|
217
|
+ appearanceObserver = NotificationCenter.default.addObserver(
|
|
|
218
|
+ forName: AppAppearanceManager.didChangeNotification,
|
|
|
219
|
+ object: nil,
|
|
|
220
|
+ queue: .main
|
|
|
221
|
+ ) { [weak self] _ in
|
|
|
222
|
+ self?.refreshForAppearanceChange()
|
|
|
223
|
+ }
|
|
|
224
|
+ }
|
|
|
225
|
+
|
|
|
226
|
+ deinit {
|
|
|
227
|
+ if let appearanceObserver {
|
|
|
228
|
+ NotificationCenter.default.removeObserver(appearanceObserver)
|
|
|
229
|
+ }
|
|
|
230
|
+ }
|
|
|
231
|
+
|
|
|
232
|
+ override func viewDidChangeEffectiveAppearance() {
|
|
|
233
|
+ super.viewDidChangeEffectiveAppearance()
|
|
|
234
|
+ refreshForAppearanceChange()
|
|
|
235
|
+ }
|
|
205
|
236
|
|
|
|
237
|
+ private func refreshForAppearanceChange() {
|
|
|
238
|
+ style = DocumentStyle.make(for: template)
|
|
|
239
|
+ installCardContent()
|
|
|
240
|
+ }
|
|
|
241
|
+
|
|
|
242
|
+ private func installCardContent() {
|
|
|
243
|
+ subviews.forEach { $0.removeFromSuperview() }
|
|
206
|
244
|
let card = NSView()
|
|
207
|
245
|
card.translatesAutoresizingMaskIntoConstraints = false
|
|
208
|
246
|
card.wantsLayer = true
|
|
|
@@ -211,6 +249,7 @@ final class CVProfileDocumentView: NSView {
|
|
211
|
249
|
card.layer?.borderWidth = 1
|
|
212
|
250
|
card.layer?.borderColor = style.rule.cgColor
|
|
213
|
251
|
card.layer?.masksToBounds = true
|
|
|
252
|
+ cardView = card
|
|
214
|
253
|
|
|
215
|
254
|
let root = buildRoot()
|
|
216
|
255
|
root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -278,8 +317,12 @@ final class CVProfileDocumentView: NSView {
|
|
278
|
317
|
return buildModernFamilyDocument()
|
|
279
|
318
|
case .creative:
|
|
280
|
319
|
return buildCreativeFamilyDocument()
|
|
281
|
|
- case .professional, .minimal, .executive:
|
|
282
|
|
- return buildTraditionalFamilyDocument()
|
|
|
320
|
+ case .executive:
|
|
|
321
|
+ return buildExecutiveDocument()
|
|
|
322
|
+ case .minimal:
|
|
|
323
|
+ return buildMinimalDocument()
|
|
|
324
|
+ case .professional:
|
|
|
325
|
+ return buildProfessionalDocument()
|
|
283
|
326
|
}
|
|
284
|
327
|
}
|
|
285
|
328
|
|
|
|
@@ -550,7 +593,7 @@ final class CVProfileDocumentView: NSView {
|
|
550
|
593
|
let iv = NSImageView(image: img)
|
|
551
|
594
|
iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
552
|
595
|
iv.contentTintColor = theme
|
|
553
|
|
- let t = label(title.uppercased(), font: style.sectionFont, color: style.sectionInk, maxLines: 1)
|
|
|
596
|
+ let t = label(title.uppercased(), font: style.sectionFont, color: style.ink, maxLines: 1)
|
|
554
|
597
|
let r = NSStackView(views: [iv, t])
|
|
555
|
598
|
r.orientation = .horizontal
|
|
556
|
599
|
r.spacing = 8
|
|
|
@@ -762,9 +805,300 @@ final class CVProfileDocumentView: NSView {
|
|
762
|
805
|
return stack
|
|
763
|
806
|
}
|
|
764
|
807
|
|
|
765
|
|
- // MARK: - Traditional families (professional / minimal / executive)
|
|
|
808
|
+ // MARK: - Executive (matches gallery serif layout)
|
|
|
809
|
+
|
|
|
810
|
+ private func buildExecutiveDocument() -> NSView {
|
|
|
811
|
+ let centeredHead = (variant % 2) == 0
|
|
|
812
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
813
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
814
|
+ let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter {
|
|
|
815
|
+ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
|
816
|
+ }
|
|
|
817
|
+ let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ")
|
|
|
818
|
+
|
|
|
819
|
+ let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
|
|
|
820
|
+ let role = label(roleText, font: style.roleFont, color: style.muted, maxLines: 2)
|
|
|
821
|
+ let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.9), maxLines: 3)
|
|
|
822
|
+ if centeredHead {
|
|
|
823
|
+ name.alignment = .center
|
|
|
824
|
+ role.alignment = .center
|
|
|
825
|
+ contact.alignment = .center
|
|
|
826
|
+ }
|
|
|
827
|
+
|
|
|
828
|
+ let rule = executiveHeaderRule(wide: variant % 3 == 0)
|
|
|
829
|
+ let head = NSStackView(views: [name, role, contact, rule])
|
|
|
830
|
+ head.orientation = .vertical
|
|
|
831
|
+ head.spacing = 8
|
|
|
832
|
+ head.alignment = centeredHead ? .centerX : .leading
|
|
|
833
|
+
|
|
|
834
|
+ let body: NSView
|
|
|
835
|
+ switch template.layout {
|
|
|
836
|
+ case .singleColumn:
|
|
|
837
|
+ body = executiveMainColumn(compact: false, tightLeading: variant % 5 == 2)
|
|
|
838
|
+ case .twoColumn(let side, let tinted):
|
|
|
839
|
+ let main = executiveMainColumn(compact: true, tightLeading: variant % 5 == 2)
|
|
|
840
|
+ let sideCol = executiveSidebarColumn(tinted: tinted, showMetrics: variant % 4 == 1)
|
|
|
841
|
+ let row = NSStackView()
|
|
|
842
|
+ row.orientation = .horizontal
|
|
|
843
|
+ row.spacing = 20
|
|
|
844
|
+ row.alignment = .top
|
|
|
845
|
+ if side == .leading {
|
|
|
846
|
+ row.addArrangedSubview(sideCol)
|
|
|
847
|
+ row.addArrangedSubview(main)
|
|
|
848
|
+ } else {
|
|
|
849
|
+ row.addArrangedSubview(main)
|
|
|
850
|
+ row.addArrangedSubview(sideCol)
|
|
|
851
|
+ }
|
|
|
852
|
+ let mult: CGFloat = (variant % 5 == 3) ? 0.38 : 0.33
|
|
|
853
|
+ sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
|
|
|
854
|
+ body = row
|
|
|
855
|
+ }
|
|
|
856
|
+
|
|
|
857
|
+ let wrap = NSStackView(views: [head, body])
|
|
|
858
|
+ wrap.orientation = .vertical
|
|
|
859
|
+ wrap.spacing = style.columnVerticalSpacing
|
|
|
860
|
+ wrap.alignment = .leading
|
|
|
861
|
+ return wrap
|
|
|
862
|
+ }
|
|
|
863
|
+
|
|
|
864
|
+ private func executiveHeaderRule(wide: Bool) -> NSView {
|
|
|
865
|
+ let theme = template.themeColor
|
|
|
866
|
+ let v = NSView()
|
|
|
867
|
+ v.translatesAutoresizingMaskIntoConstraints = false
|
|
|
868
|
+ v.wantsLayer = true
|
|
|
869
|
+ v.layer?.backgroundColor = theme.withAlphaComponent(0.45).cgColor
|
|
|
870
|
+ v.heightAnchor.constraint(equalToConstant: wide ? 2 : 1.5).isActive = true
|
|
|
871
|
+ v.widthAnchor.constraint(equalToConstant: wide ? 160 : 110).isActive = true
|
|
|
872
|
+ return v
|
|
|
873
|
+ }
|
|
|
874
|
+
|
|
|
875
|
+ private func executiveMainColumn(compact: Bool, tightLeading: Bool) -> NSView {
|
|
|
876
|
+ let stack = NSStackView()
|
|
|
877
|
+ stack.orientation = .vertical
|
|
|
878
|
+ stack.spacing = (compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2) - (tightLeading ? 2 : 0)
|
|
|
879
|
+ stack.alignment = .leading
|
|
|
880
|
+
|
|
|
881
|
+ let summaryTitle = (variant % 6 == 3) ? "Summary" : "Professional Summary"
|
|
|
882
|
+ if let summary = nonEmpty(profile.careerSummary) {
|
|
|
883
|
+ stack.addArrangedSubview(sectionHeading(summaryTitle))
|
|
|
884
|
+ stack.addArrangedSubview(paragraph(summary, compact: compact))
|
|
|
885
|
+ }
|
|
|
886
|
+
|
|
|
887
|
+ let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
|
888
|
+ if !jobs.isEmpty {
|
|
|
889
|
+ stack.addArrangedSubview(sectionHeading("Selected Experience"))
|
|
|
890
|
+ for job in jobs {
|
|
|
891
|
+ stack.addArrangedSubview(executiveExperienceBlock(job: job, compact: compact))
|
|
|
892
|
+ }
|
|
|
893
|
+ }
|
|
|
894
|
+
|
|
|
895
|
+ let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
|
|
|
896
|
+ if !schools.isEmpty {
|
|
|
897
|
+ stack.addArrangedSubview(sectionHeading("Education"))
|
|
|
898
|
+ for edu in schools {
|
|
|
899
|
+ stack.addArrangedSubview(educationBlock(edu: edu, compact: compact))
|
|
|
900
|
+ }
|
|
|
901
|
+ }
|
|
|
902
|
+ appendCertificatesInterestsReferrals(to: stack, compact: compact)
|
|
|
903
|
+ return stack
|
|
|
904
|
+ }
|
|
|
905
|
+
|
|
|
906
|
+ private func executiveExperienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
|
|
|
907
|
+ let v = NSStackView()
|
|
|
908
|
+ v.orientation = .vertical
|
|
|
909
|
+ v.spacing = 4
|
|
|
910
|
+ v.alignment = .leading
|
|
|
911
|
+ let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: ", ")
|
|
|
912
|
+ if !titleLine.isEmpty {
|
|
|
913
|
+ v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
|
|
|
914
|
+ }
|
|
|
915
|
+ let duration = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
916
|
+ if !duration.isEmpty {
|
|
|
917
|
+ v.addArrangedSubview(label(duration, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
|
|
|
918
|
+ }
|
|
|
919
|
+ for bullet in Self.bulletChunks(from: job.description) {
|
|
|
920
|
+ v.addArrangedSubview(label(bullet, font: style.bodyFont, color: style.muted, maxLines: 0))
|
|
|
921
|
+ }
|
|
|
922
|
+ return v
|
|
|
923
|
+ }
|
|
|
924
|
+
|
|
|
925
|
+ private func executiveSidebarColumn(tinted: Bool, showMetrics: Bool) -> NSView {
|
|
|
926
|
+ let stack = NSStackView()
|
|
|
927
|
+ stack.orientation = .vertical
|
|
|
928
|
+ stack.spacing = 10
|
|
|
929
|
+ stack.alignment = .leading
|
|
|
930
|
+ if tinted {
|
|
|
931
|
+ stack.wantsLayer = true
|
|
|
932
|
+ let fill = (variant % 3 == 0)
|
|
|
933
|
+ ? CVResumeAppearance.colors().sidebarTint
|
|
|
934
|
+ : template.themeColor.withAlphaComponent(0.07)
|
|
|
935
|
+ stack.layer?.backgroundColor = fill.cgColor
|
|
|
936
|
+ stack.layer?.cornerRadius = variant % 4 == 2 ? 8 : 6
|
|
|
937
|
+ stack.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
|
|
|
938
|
+ }
|
|
|
939
|
+
|
|
|
940
|
+ let skills = skillTokensFromProfile(max: 12)
|
|
|
941
|
+ if !skills.isEmpty {
|
|
|
942
|
+ stack.addArrangedSubview(sectionHeading("Core Competencies"))
|
|
|
943
|
+ for s in skills {
|
|
|
944
|
+ stack.addArrangedSubview(label("· \(s)", font: style.bodyFont, color: style.ink, maxLines: 0))
|
|
|
945
|
+ }
|
|
|
946
|
+ }
|
|
|
947
|
+
|
|
|
948
|
+ if let cert = nonEmpty(profile.certificates) {
|
|
|
949
|
+ stack.addArrangedSubview(sectionHeading("Tools"))
|
|
|
950
|
+ stack.addArrangedSubview(paragraph(cert, compact: true))
|
|
|
951
|
+ }
|
|
|
952
|
+
|
|
|
953
|
+ if showMetrics, let hi = highlightsBodyText() {
|
|
|
954
|
+ stack.addArrangedSubview(sectionHeading("Impact"))
|
|
|
955
|
+ stack.addArrangedSubview(paragraph(hi, compact: true))
|
|
|
956
|
+ }
|
|
|
957
|
+
|
|
|
958
|
+ if stack.arrangedSubviews.isEmpty {
|
|
|
959
|
+ stack.addArrangedSubview(sectionHeading("Contact"))
|
|
|
960
|
+ for line in contactLines() {
|
|
|
961
|
+ stack.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
|
|
|
962
|
+ }
|
|
|
963
|
+ }
|
|
|
964
|
+ return stack
|
|
|
965
|
+ }
|
|
|
966
|
+
|
|
|
967
|
+ // MARK: - Minimal (matches gallery light typography)
|
|
|
968
|
+
|
|
|
969
|
+ private func buildMinimalDocument() -> NSView {
|
|
|
970
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
971
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
972
|
+ let contactParts = [profile.personal.email, profile.personal.phone].filter {
|
|
|
973
|
+ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
|
974
|
+ }
|
|
|
975
|
+ let contactText = contactParts.isEmpty ? "Add contact in your profile" : contactParts.joined(separator: " ")
|
|
|
976
|
+
|
|
|
977
|
+ let nameWeight: NSFont.Weight = (variant % 3 == 0) ? .ultraLight : .light
|
|
|
978
|
+ let nameSize: CGFloat = template.headline == .centered ? 22 + CGFloat(variant % 2) : 20 + CGFloat(variant % 3)
|
|
|
979
|
+ let name = label(nameText, font: .systemFont(ofSize: nameSize, weight: nameWeight), color: style.ink, maxLines: 2)
|
|
|
980
|
+ let role = label(roleText.uppercased(), font: .systemFont(ofSize: 12.5, weight: .medium), color: style.muted, maxLines: 2)
|
|
|
981
|
+ let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.75), maxLines: 2)
|
|
|
982
|
+
|
|
|
983
|
+ let head = NSStackView()
|
|
|
984
|
+ head.orientation = .vertical
|
|
|
985
|
+ head.spacing = template.headline == .avatarStacked ? 10 : 6 + CGFloat(variant % 4)
|
|
|
986
|
+ head.alignment = template.headline == .centered ? .centerX : .leading
|
|
|
987
|
+ if template.headline == .avatarStacked {
|
|
|
988
|
+ head.addArrangedSubview(initialsBadge(for: nameText))
|
|
|
989
|
+ }
|
|
|
990
|
+ if template.headline == .centered {
|
|
|
991
|
+ name.alignment = .center
|
|
|
992
|
+ role.alignment = .center
|
|
|
993
|
+ contact.alignment = .center
|
|
|
994
|
+ }
|
|
|
995
|
+ head.addArrangedSubview(name)
|
|
|
996
|
+ head.addArrangedSubview(role)
|
|
|
997
|
+ head.addArrangedSubview(contact)
|
|
|
998
|
+ head.addArrangedSubview(hairline())
|
|
|
999
|
+ if variant % 5 == 1 {
|
|
|
1000
|
+ head.addArrangedSubview(hairline())
|
|
|
1001
|
+ }
|
|
|
1002
|
+
|
|
|
1003
|
+ let swapEdu = (variant % 4) == 2
|
|
|
1004
|
+ let body: NSView
|
|
|
1005
|
+ switch template.layout {
|
|
|
1006
|
+ case .singleColumn:
|
|
|
1007
|
+ body = minimalMainColumn(spacing: style.bodyBlockSpacing, educationBeforeExperience: swapEdu)
|
|
|
1008
|
+ case .twoColumn(let side, _):
|
|
|
1009
|
+ let main = minimalMainColumn(spacing: style.bodyBlockSpacing - 2, educationBeforeExperience: swapEdu)
|
|
|
1010
|
+ let aside = minimalSkillsAside(numbered: variant % 3 == 1)
|
|
|
1011
|
+ let row = NSStackView()
|
|
|
1012
|
+ row.orientation = .horizontal
|
|
|
1013
|
+ row.spacing = 18 + CGFloat(variant % 3)
|
|
|
1014
|
+ row.alignment = .top
|
|
|
1015
|
+ if side == .leading {
|
|
|
1016
|
+ row.addArrangedSubview(aside)
|
|
|
1017
|
+ row.addArrangedSubview(main)
|
|
|
1018
|
+ } else {
|
|
|
1019
|
+ row.addArrangedSubview(main)
|
|
|
1020
|
+ row.addArrangedSubview(aside)
|
|
|
1021
|
+ }
|
|
|
1022
|
+ let mult: CGFloat = (variant % 5 == 0) ? 0.34 : 0.30
|
|
|
1023
|
+ aside.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
|
|
|
1024
|
+ body = row
|
|
|
1025
|
+ }
|
|
|
1026
|
+
|
|
|
1027
|
+ let wrap = NSStackView(views: [head, body])
|
|
|
1028
|
+ wrap.orientation = .vertical
|
|
|
1029
|
+ wrap.spacing = style.columnVerticalSpacing
|
|
|
1030
|
+ wrap.alignment = .leading
|
|
|
1031
|
+ return wrap
|
|
|
1032
|
+ }
|
|
|
1033
|
+
|
|
|
1034
|
+ private func minimalMainColumn(spacing: CGFloat, educationBeforeExperience: Bool) -> NSView {
|
|
|
1035
|
+ let stack = NSStackView()
|
|
|
1036
|
+ stack.orientation = .vertical
|
|
|
1037
|
+ stack.spacing = spacing
|
|
|
1038
|
+ stack.alignment = .leading
|
|
|
1039
|
+
|
|
|
1040
|
+ let appendSummary: () -> Void = { [self] in
|
|
|
1041
|
+ if let summary = nonEmpty(profile.careerSummary) {
|
|
|
1042
|
+ stack.addArrangedSubview(sectionHeading("Profile"))
|
|
|
1043
|
+ stack.addArrangedSubview(paragraph(summary, compact: false))
|
|
|
1044
|
+ }
|
|
|
1045
|
+ }
|
|
|
1046
|
+ let appendExperience: () -> Void = { [self] in
|
|
|
1047
|
+ let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
|
1048
|
+ if !jobs.isEmpty {
|
|
|
1049
|
+ stack.addArrangedSubview(sectionHeading("Experience"))
|
|
|
1050
|
+ for job in jobs {
|
|
|
1051
|
+ stack.addArrangedSubview(experienceBlock(job: job, compact: false))
|
|
|
1052
|
+ }
|
|
|
1053
|
+ }
|
|
|
1054
|
+ }
|
|
|
1055
|
+ let appendEducation: () -> Void = { [self] in
|
|
|
1056
|
+ let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
|
|
|
1057
|
+ if !schools.isEmpty {
|
|
|
1058
|
+ stack.addArrangedSubview(sectionHeading("Education"))
|
|
|
1059
|
+ for edu in schools {
|
|
|
1060
|
+ stack.addArrangedSubview(educationBlock(edu: edu, compact: false))
|
|
|
1061
|
+ }
|
|
|
1062
|
+ }
|
|
|
1063
|
+ }
|
|
|
1064
|
+
|
|
|
1065
|
+ if educationBeforeExperience {
|
|
|
1066
|
+ appendEducation()
|
|
|
1067
|
+ appendSummary()
|
|
|
1068
|
+ appendExperience()
|
|
|
1069
|
+ } else {
|
|
|
1070
|
+ appendSummary()
|
|
|
1071
|
+ appendExperience()
|
|
|
1072
|
+ appendEducation()
|
|
|
1073
|
+ }
|
|
|
1074
|
+ appendCertificatesInterestsReferrals(to: stack, compact: false)
|
|
|
1075
|
+ return stack
|
|
|
1076
|
+ }
|
|
|
1077
|
+
|
|
|
1078
|
+ private func minimalSkillsAside(numbered: Bool) -> NSView {
|
|
|
1079
|
+ let stack = NSStackView()
|
|
|
1080
|
+ stack.orientation = .vertical
|
|
|
1081
|
+ stack.spacing = 8
|
|
|
1082
|
+ stack.alignment = .leading
|
|
|
1083
|
+ let skills = skillTokensFromProfile(max: 12)
|
|
|
1084
|
+ if skills.isEmpty {
|
|
|
1085
|
+ stack.addArrangedSubview(sectionHeading("Contact"))
|
|
|
1086
|
+ for line in contactLines() {
|
|
|
1087
|
+ stack.addArrangedSubview(label(line, font: style.contactFont, color: style.muted, maxLines: 0))
|
|
|
1088
|
+ }
|
|
|
1089
|
+ return stack
|
|
|
1090
|
+ }
|
|
|
1091
|
+ stack.addArrangedSubview(sectionHeading("Skills"))
|
|
|
1092
|
+ for (i, s) in skills.enumerated() {
|
|
|
1093
|
+ let prefix = numbered ? "\(i + 1). " : "· "
|
|
|
1094
|
+ stack.addArrangedSubview(label("\(prefix)\(s)", font: style.bodyCompactFont, color: style.muted, maxLines: 0))
|
|
|
1095
|
+ }
|
|
|
1096
|
+ return stack
|
|
|
1097
|
+ }
|
|
|
1098
|
+
|
|
|
1099
|
+ // MARK: - Professional
|
|
766
|
1100
|
|
|
767
|
|
- private func buildTraditionalFamilyDocument() -> NSView {
|
|
|
1101
|
+ private func buildProfessionalDocument() -> NSView {
|
|
768
|
1102
|
switch template.layout {
|
|
769
|
1103
|
case .singleColumn:
|
|
770
|
1104
|
return singleColumnLayout()
|
|
|
@@ -840,7 +1174,7 @@ final class CVProfileDocumentView: NSView {
|
|
840
|
1174
|
let row = NSStackView()
|
|
841
|
1175
|
row.orientation = .horizontal
|
|
842
|
1176
|
row.alignment = .top
|
|
843
|
|
- row.spacing = template.family == .minimal ? 18 : 22
|
|
|
1177
|
+ row.spacing = 22
|
|
844
|
1178
|
|
|
845
|
1179
|
let sidebarCol = sidebarColumn(tinted: tinted)
|
|
846
|
1180
|
let mainCol = bodyColumn(compact: true, experienceFirst: professionalExperienceFirst)
|
|
|
@@ -852,12 +1186,7 @@ final class CVProfileDocumentView: NSView {
|
|
852
|
1186
|
row.addArrangedSubview(mainCol)
|
|
853
|
1187
|
row.addArrangedSubview(sidebarCol)
|
|
854
|
1188
|
}
|
|
855
|
|
- let sidebarMult: CGFloat
|
|
856
|
|
- if template.family == .professional {
|
|
857
|
|
- sidebarMult = (variant % 5 == 2) ? 0.38 : 0.32
|
|
858
|
|
- } else {
|
|
859
|
|
- sidebarMult = template.family == .executive ? 0.34 : 0.32
|
|
860
|
|
- }
|
|
|
1189
|
+ let sidebarMult: CGFloat = (variant % 5 == 2) ? 0.38 : 0.32
|
|
861
|
1190
|
sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
|
|
862
|
1191
|
|
|
863
|
1192
|
v.addArrangedSubview(row)
|
|
|
@@ -965,12 +1294,12 @@ final class CVProfileDocumentView: NSView {
|
|
965
|
1294
|
bar.heightAnchor.constraint(equalToConstant: 1).isActive = true
|
|
966
|
1295
|
return bar
|
|
967
|
1296
|
case .redUnderline:
|
|
968
|
|
- bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
|
|
|
1297
|
+ bar.layer?.backgroundColor = CVResumeAppearance.accentColor(for: template).cgColor
|
|
969
|
1298
|
bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
|
|
970
|
1299
|
bar.widthAnchor.constraint(equalToConstant: template.family == .minimal ? 140 : 168).isActive = true
|
|
971
|
1300
|
return bar
|
|
972
|
1301
|
case .redBar:
|
|
973
|
|
- bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
|
|
|
1302
|
+ bar.layer?.backgroundColor = CVResumeAppearance.accentColor(for: template).cgColor
|
|
974
|
1303
|
bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
|
975
|
1304
|
bar.widthAnchor.constraint(equalToConstant: 56).isActive = true
|
|
976
|
1305
|
return bar
|
|
|
@@ -1004,8 +1333,11 @@ final class CVProfileDocumentView: NSView {
|
|
1004
|
1333
|
box.alignment = .leading
|
|
1005
|
1334
|
if tinted {
|
|
1006
|
1335
|
box.wantsLayer = true
|
|
1007
|
|
- box.layer?.backgroundColor = template.themeColor.withAlphaComponent(template.family == .creative ? 0.12 : 0.08).cgColor
|
|
1008
|
|
- box.layer?.cornerRadius = 8
|
|
|
1336
|
+ let tint = (variant % 4 == 1)
|
|
|
1337
|
+ ? template.themeColor.withAlphaComponent(0.08)
|
|
|
1338
|
+ : CVResumeAppearance.colors().sidebarTint
|
|
|
1339
|
+ box.layer?.backgroundColor = tint.cgColor
|
|
|
1340
|
+ box.layer?.cornerRadius = variant % 3 == 0 ? 8 : 6
|
|
1009
|
1341
|
}
|
|
1010
|
1342
|
box.edgeInsets = NSEdgeInsets(top: tinted ? 14 : 0, left: tinted ? 14 : 0, bottom: tinted ? 14 : 0, right: tinted ? 14 : 0)
|
|
1011
|
1343
|
|
|
|
@@ -1014,8 +1346,24 @@ final class CVProfileDocumentView: NSView {
|
|
1014
|
1346
|
box.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
|
|
1015
|
1347
|
}
|
|
1016
|
1348
|
|
|
1017
|
|
- if let skillsBlock = ancillaryBlock(title: "Languages & more", body: combinedAncillaryText()) {
|
|
1018
|
|
- box.addArrangedSubview(skillsBlock)
|
|
|
1349
|
+ let skills = skillTokensFromProfile(max: 8)
|
|
|
1350
|
+ if !skills.isEmpty {
|
|
|
1351
|
+ box.addArrangedSubview(sectionHeading("Skills"))
|
|
|
1352
|
+ if variant % 5 == 2 {
|
|
|
1353
|
+ box.addArrangedSubview(skillTagRow(theme: template.themeColor, maxTags: 5))
|
|
|
1354
|
+ } else {
|
|
|
1355
|
+ for token in skills {
|
|
|
1356
|
+ box.addArrangedSubview(label("· \(token)", font: style.contactFont, color: style.ink, maxLines: 0))
|
|
|
1357
|
+ }
|
|
|
1358
|
+ }
|
|
|
1359
|
+ }
|
|
|
1360
|
+
|
|
|
1361
|
+ if variant % 7 == 3, let tools = nonEmpty(profile.certificates) {
|
|
|
1362
|
+ box.addArrangedSubview(sectionHeading("Tools"))
|
|
|
1363
|
+ box.addArrangedSubview(paragraph(tools, compact: true))
|
|
|
1364
|
+ } else if let ancillary = combinedAncillaryText(), !ancillary.isEmpty {
|
|
|
1365
|
+ box.addArrangedSubview(sectionHeading("Languages & more"))
|
|
|
1366
|
+ box.addArrangedSubview(paragraph(ancillary, compact: true))
|
|
1019
|
1367
|
}
|
|
1020
|
1368
|
|
|
1021
|
1369
|
return box
|