|
|
@@ -183,11 +183,16 @@ final class CVProfileDocumentView: NSView {
|
|
183
|
183
|
private let profile: SavedProfile
|
|
184
|
184
|
private let template: CVTemplate
|
|
185
|
185
|
private let style: DocumentStyle
|
|
|
186
|
+ /// Matches `CVTemplatePreviewView` so the same template id + layout recipe renders the same silhouette as the gallery card.
|
|
|
187
|
+ private let variant: Int
|
|
|
188
|
+ private let isEditable: Bool
|
|
186
|
189
|
|
|
187
|
|
- init(profile: SavedProfile, template: CVTemplate) {
|
|
|
190
|
+ init(profile: SavedProfile, template: CVTemplate, isEditable: Bool = false) {
|
|
188
|
191
|
self.profile = profile
|
|
189
|
192
|
self.template = template
|
|
190
|
193
|
self.style = DocumentStyle.make(for: template)
|
|
|
194
|
+ self.variant = template.galleryLayoutVariant
|
|
|
195
|
+ self.isEditable = isEditable
|
|
191
|
196
|
super.init(frame: .zero)
|
|
192
|
197
|
translatesAutoresizingMaskIntoConstraints = false
|
|
193
|
198
|
wantsLayer = true
|
|
|
@@ -230,6 +235,498 @@ final class CVProfileDocumentView: NSView {
|
|
230
|
235
|
// MARK: - Composition
|
|
231
|
236
|
|
|
232
|
237
|
private func buildRoot() -> NSView {
|
|
|
238
|
+ switch template.family {
|
|
|
239
|
+ case .modern:
|
|
|
240
|
+ return buildModernFamilyDocument()
|
|
|
241
|
+ case .creative:
|
|
|
242
|
+ return buildCreativeFamilyDocument()
|
|
|
243
|
+ case .professional, .minimal, .executive:
|
|
|
244
|
+ return buildTraditionalFamilyDocument()
|
|
|
245
|
+ }
|
|
|
246
|
+ }
|
|
|
247
|
+
|
|
|
248
|
+ // MARK: - Modern (gallery uses three distinct silhouettes from `variant`)
|
|
|
249
|
+
|
|
|
250
|
+ private func buildModernFamilyDocument() -> NSView {
|
|
|
251
|
+ switch variant % 3 {
|
|
|
252
|
+ case 0: return modernClassicBandDocument()
|
|
|
253
|
+ case 1: return modernRailDocument()
|
|
|
254
|
+ default: return modernSplitHeaderDocument()
|
|
|
255
|
+ }
|
|
|
256
|
+ }
|
|
|
257
|
+
|
|
|
258
|
+ private func modernClassicBandDocument() -> NSView {
|
|
|
259
|
+ let theme = template.themeColor
|
|
|
260
|
+ let white = NSColor.white
|
|
|
261
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
262
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
263
|
+
|
|
|
264
|
+ let header = NSView()
|
|
|
265
|
+ header.translatesAutoresizingMaskIntoConstraints = false
|
|
|
266
|
+ header.wantsLayer = true
|
|
|
267
|
+ header.layer?.backgroundColor = theme.cgColor
|
|
|
268
|
+ header.layer?.cornerRadius = variant % 2 == 0 ? 8 : 6
|
|
|
269
|
+
|
|
|
270
|
+ let name = label(nameText, font: .systemFont(ofSize: 22, weight: .bold), color: white, maxLines: 2)
|
|
|
271
|
+ let role = label(roleText, font: .systemFont(ofSize: 14, weight: .medium), color: white.withAlphaComponent(0.92), maxLines: 2)
|
|
|
272
|
+ let textCol = NSStackView(views: [name, role])
|
|
|
273
|
+ textCol.orientation = .vertical
|
|
|
274
|
+ textCol.spacing = 4
|
|
|
275
|
+ textCol.alignment = .leading
|
|
|
276
|
+ textCol.translatesAutoresizingMaskIntoConstraints = false
|
|
|
277
|
+
|
|
|
278
|
+ let iconRow = NSStackView()
|
|
|
279
|
+ iconRow.orientation = .horizontal
|
|
|
280
|
+ iconRow.spacing = 10
|
|
|
281
|
+ iconRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
282
|
+ for sym in ["mappin.and.ellipse", "phone.fill", "envelope.fill"] {
|
|
|
283
|
+ guard let img = NSImage(systemSymbolName: sym, accessibilityDescription: nil) else { continue }
|
|
|
284
|
+ let iv = NSImageView(image: img)
|
|
|
285
|
+ iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
|
|
|
286
|
+ iv.contentTintColor = white.withAlphaComponent(0.88)
|
|
|
287
|
+ iconRow.addArrangedSubview(iv)
|
|
|
288
|
+ }
|
|
|
289
|
+
|
|
|
290
|
+ let topRow = NSStackView()
|
|
|
291
|
+ topRow.orientation = .horizontal
|
|
|
292
|
+ topRow.spacing = 14
|
|
|
293
|
+ topRow.alignment = .centerY
|
|
|
294
|
+ topRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
295
|
+ topRow.addArrangedSubview(textCol)
|
|
|
296
|
+ topRow.addArrangedSubview(NSView())
|
|
|
297
|
+ topRow.addArrangedSubview(iconRow)
|
|
|
298
|
+
|
|
|
299
|
+ header.addSubview(topRow)
|
|
|
300
|
+ NSLayoutConstraint.activate([
|
|
|
301
|
+ topRow.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 18),
|
|
|
302
|
+ topRow.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -18),
|
|
|
303
|
+ topRow.topAnchor.constraint(equalTo: header.topAnchor, constant: 14),
|
|
|
304
|
+ topRow.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -14)
|
|
|
305
|
+ ])
|
|
|
306
|
+
|
|
|
307
|
+ let body: NSView
|
|
|
308
|
+ switch template.layout {
|
|
|
309
|
+ case .singleColumn:
|
|
|
310
|
+ body = modernMainContentColumn(compact: false, includeSummaryInMain: true)
|
|
|
311
|
+ case .twoColumn(let side, let tinted):
|
|
|
312
|
+ let main = modernMainContentColumn(compact: true, includeSummaryInMain: false)
|
|
|
313
|
+ let sideCol = modernAboutHighlightsSidebar(tinted: tinted)
|
|
|
314
|
+ let row = NSStackView()
|
|
|
315
|
+ row.orientation = .horizontal
|
|
|
316
|
+ row.spacing = 20
|
|
|
317
|
+ row.alignment = .top
|
|
|
318
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
319
|
+ let mult: CGFloat = (variant % 4 == 2) ? 0.36 : 0.32
|
|
|
320
|
+ if side == .leading {
|
|
|
321
|
+ row.addArrangedSubview(sideCol)
|
|
|
322
|
+ row.addArrangedSubview(main)
|
|
|
323
|
+ sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
|
|
|
324
|
+ } else {
|
|
|
325
|
+ row.addArrangedSubview(main)
|
|
|
326
|
+ row.addArrangedSubview(sideCol)
|
|
|
327
|
+ sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
|
|
|
328
|
+ }
|
|
|
329
|
+ body = row
|
|
|
330
|
+ }
|
|
|
331
|
+
|
|
|
332
|
+ let wrap = NSStackView(views: [header, body])
|
|
|
333
|
+ wrap.orientation = .vertical
|
|
|
334
|
+ wrap.spacing = 18
|
|
|
335
|
+ wrap.alignment = .leading
|
|
|
336
|
+ return wrap
|
|
|
337
|
+ }
|
|
|
338
|
+
|
|
|
339
|
+ private func modernRailDocument() -> NSView {
|
|
|
340
|
+ let theme = template.themeColor
|
|
|
341
|
+ let rail = NSView()
|
|
|
342
|
+ rail.translatesAutoresizingMaskIntoConstraints = false
|
|
|
343
|
+ rail.wantsLayer = true
|
|
|
344
|
+ rail.layer?.backgroundColor = theme.cgColor
|
|
|
345
|
+ rail.layer?.cornerRadius = 2
|
|
|
346
|
+ rail.widthAnchor.constraint(equalToConstant: 3 + CGFloat(variant % 2)).isActive = true
|
|
|
347
|
+
|
|
|
348
|
+ let inner = NSStackView()
|
|
|
349
|
+ inner.orientation = .vertical
|
|
|
350
|
+ inner.spacing = 10
|
|
|
351
|
+ inner.alignment = .leading
|
|
|
352
|
+ inner.translatesAutoresizingMaskIntoConstraints = false
|
|
|
353
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
354
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
355
|
+ let contactParts = [profile.personal.email, profile.personal.phone].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
|
356
|
+ let contactLine = contactParts.isEmpty ? "Add contact in your profile" : contactParts.joined(separator: " · ")
|
|
|
357
|
+
|
|
|
358
|
+ inner.addArrangedSubview(label(nameText, font: .systemFont(ofSize: 21, weight: .bold), color: style.ink, maxLines: 2))
|
|
|
359
|
+ inner.addArrangedSubview(label(roleText, font: .systemFont(ofSize: 14, weight: .semibold), color: theme, maxLines: 2))
|
|
|
360
|
+ inner.addArrangedSubview(label(contactLine, font: style.contactFont, color: style.muted, maxLines: 2))
|
|
|
361
|
+ inner.addArrangedSubview(hairline())
|
|
|
362
|
+ inner.addArrangedSubview(skillTagRow(theme: theme, maxTags: 5))
|
|
|
363
|
+ inner.addArrangedSubview(modernPrimaryBody(theme: theme))
|
|
|
364
|
+
|
|
|
365
|
+ let row = NSStackView(views: [rail, inner])
|
|
|
366
|
+ row.orientation = .horizontal
|
|
|
367
|
+ row.spacing = 14
|
|
|
368
|
+ row.alignment = .top
|
|
|
369
|
+ return row
|
|
|
370
|
+ }
|
|
|
371
|
+
|
|
|
372
|
+ private func modernSplitHeaderDocument() -> NSView {
|
|
|
373
|
+ let theme = template.themeColor
|
|
|
374
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
375
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
376
|
+ let loc = profile.personal.address.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
377
|
+
|
|
|
378
|
+ let left = NSStackView()
|
|
|
379
|
+ left.orientation = .vertical
|
|
|
380
|
+ left.spacing = 5
|
|
|
381
|
+ left.alignment = .leading
|
|
|
382
|
+ left.addArrangedSubview(label(nameText, font: .systemFont(ofSize: 21, weight: .bold), color: style.ink, maxLines: 2))
|
|
|
383
|
+ left.addArrangedSubview(label(roleText, font: .systemFont(ofSize: 13.5, weight: .medium), color: style.muted, maxLines: 2))
|
|
|
384
|
+ if !loc.isEmpty {
|
|
|
385
|
+ left.addArrangedSubview(label(loc, font: style.contactFont, color: style.muted.withAlphaComponent(0.88), maxLines: 2))
|
|
|
386
|
+ }
|
|
|
387
|
+
|
|
|
388
|
+ let right = NSStackView()
|
|
|
389
|
+ right.orientation = .vertical
|
|
|
390
|
+ right.spacing = 8
|
|
|
391
|
+ right.alignment = .leading
|
|
|
392
|
+ right.wantsLayer = true
|
|
|
393
|
+ right.layer?.backgroundColor = theme.cgColor
|
|
|
394
|
+ right.layer?.cornerRadius = 8
|
|
|
395
|
+ right.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
|
|
|
396
|
+ let onW = NSColor.white
|
|
|
397
|
+ if !profile.personal.email.isEmpty {
|
|
|
398
|
+ right.addArrangedSubview(label(profile.personal.email, font: .systemFont(ofSize: 12, weight: .medium), color: onW.withAlphaComponent(0.95), maxLines: 2))
|
|
|
399
|
+ }
|
|
|
400
|
+ if !profile.personal.phone.isEmpty {
|
|
|
401
|
+ right.addArrangedSubview(label(profile.personal.phone, font: .systemFont(ofSize: 12, weight: .medium), color: onW.withAlphaComponent(0.92), maxLines: 1))
|
|
|
402
|
+ }
|
|
|
403
|
+ if !loc.isEmpty {
|
|
|
404
|
+ right.addArrangedSubview(label(loc, font: .systemFont(ofSize: 11.5, weight: .regular), color: onW.withAlphaComponent(0.8), maxLines: 2))
|
|
|
405
|
+ }
|
|
|
406
|
+
|
|
|
407
|
+ let top = NSStackView(views: [left, right])
|
|
|
408
|
+ top.orientation = .horizontal
|
|
|
409
|
+ top.spacing = 16
|
|
|
410
|
+ top.alignment = .top
|
|
|
411
|
+ left.widthAnchor.constraint(equalTo: top.widthAnchor, multiplier: 0.54).isActive = true
|
|
|
412
|
+
|
|
|
413
|
+ let col = NSStackView(views: [top, hairline(), modernPrimaryBody(theme: theme)])
|
|
|
414
|
+ col.orientation = .vertical
|
|
|
415
|
+ col.spacing = 16
|
|
|
416
|
+ col.alignment = .leading
|
|
|
417
|
+ return col
|
|
|
418
|
+ }
|
|
|
419
|
+
|
|
|
420
|
+ private func modernPrimaryBody(theme: NSColor) -> NSView {
|
|
|
421
|
+ switch template.layout {
|
|
|
422
|
+ case .singleColumn:
|
|
|
423
|
+ return modernMainContentColumn(compact: false, includeSummaryInMain: true)
|
|
|
424
|
+ case .twoColumn(let side, let tinted):
|
|
|
425
|
+ let main = modernMainContentColumn(compact: true, includeSummaryInMain: false)
|
|
|
426
|
+ let sideCol = modernAboutHighlightsSidebar(tinted: tinted)
|
|
|
427
|
+ let row = NSStackView()
|
|
|
428
|
+ row.orientation = .horizontal
|
|
|
429
|
+ row.spacing = 20
|
|
|
430
|
+ row.alignment = .top
|
|
|
431
|
+ let mult: CGFloat = (variant % 4 == 2) ? 0.36 : 0.32
|
|
|
432
|
+ if side == .leading {
|
|
|
433
|
+ row.addArrangedSubview(sideCol)
|
|
|
434
|
+ row.addArrangedSubview(main)
|
|
|
435
|
+ sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
|
|
|
436
|
+ } else {
|
|
|
437
|
+ row.addArrangedSubview(main)
|
|
|
438
|
+ row.addArrangedSubview(sideCol)
|
|
|
439
|
+ sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
|
|
|
440
|
+ }
|
|
|
441
|
+ return row
|
|
|
442
|
+ }
|
|
|
443
|
+ }
|
|
|
444
|
+
|
|
|
445
|
+ private func modernMainContentColumn(compact: Bool, includeSummaryInMain: Bool) -> NSView {
|
|
|
446
|
+ let theme = template.themeColor
|
|
|
447
|
+ let v = NSStackView()
|
|
|
448
|
+ v.orientation = .vertical
|
|
|
449
|
+ v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
|
|
|
450
|
+ v.alignment = .leading
|
|
|
451
|
+
|
|
|
452
|
+ if includeSummaryInMain, let summary = nonEmpty(profile.careerSummary) {
|
|
|
453
|
+ v.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
|
|
|
454
|
+ v.addArrangedSubview(paragraph(summary, compact: compact))
|
|
|
455
|
+ }
|
|
|
456
|
+
|
|
|
457
|
+ let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
|
458
|
+ if !jobs.isEmpty {
|
|
|
459
|
+ v.addArrangedSubview(modernSectionRow(symbol: "briefcase.fill", title: "Experience", theme: theme))
|
|
|
460
|
+ for (index, job) in jobs.enumerated() {
|
|
|
461
|
+ v.addArrangedSubview(experienceBlock(job: job, compact: compact))
|
|
|
462
|
+ if index == 0 {
|
|
|
463
|
+ v.addArrangedSubview(skillTagRow(theme: theme, maxTags: 5))
|
|
|
464
|
+ }
|
|
|
465
|
+ }
|
|
|
466
|
+ }
|
|
|
467
|
+
|
|
|
468
|
+ let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
|
|
|
469
|
+ if !schools.isEmpty {
|
|
|
470
|
+ v.addArrangedSubview(modernSectionRow(symbol: "graduationcap.fill", title: "Education", theme: theme))
|
|
|
471
|
+ for edu in schools {
|
|
|
472
|
+ v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
|
|
|
473
|
+ }
|
|
|
474
|
+ }
|
|
|
475
|
+
|
|
|
476
|
+ appendCertificatesInterestsReferrals(to: v, compact: compact)
|
|
|
477
|
+ return v
|
|
|
478
|
+ }
|
|
|
479
|
+
|
|
|
480
|
+ private func modernAboutHighlightsSidebar(tinted: Bool) -> NSView {
|
|
|
481
|
+ let theme = template.themeColor
|
|
|
482
|
+ let box = NSStackView()
|
|
|
483
|
+ box.orientation = .vertical
|
|
|
484
|
+ box.spacing = 12
|
|
|
485
|
+ box.alignment = .leading
|
|
|
486
|
+ if tinted {
|
|
|
487
|
+ box.wantsLayer = true
|
|
|
488
|
+ box.layer?.backgroundColor = theme.withAlphaComponent(0.1).cgColor
|
|
|
489
|
+ box.layer?.cornerRadius = 8
|
|
|
490
|
+ box.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
|
|
|
491
|
+ }
|
|
|
492
|
+
|
|
|
493
|
+ if let summary = nonEmpty(profile.careerSummary) {
|
|
|
494
|
+ box.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
|
|
|
495
|
+ box.addArrangedSubview(paragraph(summary, compact: true))
|
|
|
496
|
+ }
|
|
|
497
|
+ if let hi = highlightsBodyText() {
|
|
|
498
|
+ box.addArrangedSubview(modernSectionRow(symbol: "star.fill", title: "Highlights", theme: theme))
|
|
|
499
|
+ box.addArrangedSubview(paragraph(hi, compact: true))
|
|
|
500
|
+ }
|
|
|
501
|
+ if box.arrangedSubviews.isEmpty {
|
|
|
502
|
+ box.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
|
|
|
503
|
+ box.addArrangedSubview(paragraph("Add a career summary or interests in your profile to populate this column.", compact: true))
|
|
|
504
|
+ }
|
|
|
505
|
+ return box
|
|
|
506
|
+ }
|
|
|
507
|
+
|
|
|
508
|
+ private func modernSectionRow(symbol: String, title: String, theme: NSColor) -> NSView {
|
|
|
509
|
+ guard let img = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) else {
|
|
|
510
|
+ return sectionHeading(title)
|
|
|
511
|
+ }
|
|
|
512
|
+ let iv = NSImageView(image: img)
|
|
|
513
|
+ iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
|
514
|
+ iv.contentTintColor = theme
|
|
|
515
|
+ let t = label(title.uppercased(), font: style.sectionFont, color: style.sectionInk, maxLines: 1)
|
|
|
516
|
+ let r = NSStackView(views: [iv, t])
|
|
|
517
|
+ r.orientation = .horizontal
|
|
|
518
|
+ r.spacing = 8
|
|
|
519
|
+ r.alignment = .centerY
|
|
|
520
|
+ return r
|
|
|
521
|
+ }
|
|
|
522
|
+
|
|
|
523
|
+ private func skillTagRow(theme: NSColor, maxTags: Int) -> NSView {
|
|
|
524
|
+ let tags = skillTokensFromProfile(max: maxTags)
|
|
|
525
|
+ guard !tags.isEmpty else { return NSView() }
|
|
|
526
|
+ let row = NSStackView()
|
|
|
527
|
+ row.orientation = .horizontal
|
|
|
528
|
+ row.spacing = 8
|
|
|
529
|
+ row.alignment = .centerY
|
|
|
530
|
+ for s in tags {
|
|
|
531
|
+ let tag = NSView()
|
|
|
532
|
+ tag.wantsLayer = true
|
|
|
533
|
+ tag.layer?.backgroundColor = theme.withAlphaComponent(0.14).cgColor
|
|
|
534
|
+ tag.layer?.cornerRadius = 6
|
|
|
535
|
+ tag.translatesAutoresizingMaskIntoConstraints = false
|
|
|
536
|
+ let lab = label(s, font: .systemFont(ofSize: 11, weight: .semibold), color: theme.blended(withFraction: 0.35, of: style.ink) ?? style.ink, maxLines: 1)
|
|
|
537
|
+ lab.alignment = .center
|
|
|
538
|
+ lab.translatesAutoresizingMaskIntoConstraints = false
|
|
|
539
|
+ tag.addSubview(lab)
|
|
|
540
|
+ NSLayoutConstraint.activate([
|
|
|
541
|
+ lab.leadingAnchor.constraint(equalTo: tag.leadingAnchor, constant: 10),
|
|
|
542
|
+ lab.trailingAnchor.constraint(equalTo: tag.trailingAnchor, constant: -10),
|
|
|
543
|
+ lab.topAnchor.constraint(equalTo: tag.topAnchor, constant: 5),
|
|
|
544
|
+ lab.bottomAnchor.constraint(equalTo: tag.bottomAnchor, constant: -5)
|
|
|
545
|
+ ])
|
|
|
546
|
+ row.addArrangedSubview(tag)
|
|
|
547
|
+ }
|
|
|
548
|
+ return row
|
|
|
549
|
+ }
|
|
|
550
|
+
|
|
|
551
|
+ // MARK: - Creative (dark sidebar in gallery — match filled page)
|
|
|
552
|
+
|
|
|
553
|
+ private func buildCreativeFamilyDocument() -> NSView {
|
|
|
554
|
+ switch template.layout {
|
|
|
555
|
+ case .singleColumn:
|
|
|
556
|
+ return creativeSingleColumnDocument()
|
|
|
557
|
+ case .twoColumn(let side, _):
|
|
|
558
|
+ return creativeTwoColumnDocument(sidebar: side)
|
|
|
559
|
+ }
|
|
|
560
|
+ }
|
|
|
561
|
+
|
|
|
562
|
+ private func creativeDeepBackground() -> NSColor {
|
|
|
563
|
+ let theme = template.themeColor
|
|
|
564
|
+ let navy = NSColor(srgbRed: 0.08, green: 0.1, blue: 0.18, alpha: 1)
|
|
|
565
|
+ let plum = NSColor(srgbRed: 0.14, green: 0.07, blue: 0.24, alpha: 1)
|
|
|
566
|
+ switch variant % 4 {
|
|
|
567
|
+ case 0: return theme.blended(withFraction: 0.52, of: navy) ?? theme
|
|
|
568
|
+ case 1: return theme.blended(withFraction: 0.7, of: NSColor.black) ?? theme
|
|
|
569
|
+ case 2: return style.ink.blended(withFraction: 0.38, of: theme) ?? theme
|
|
|
570
|
+ default: return theme.blended(withFraction: 0.4, of: plum) ?? theme
|
|
|
571
|
+ }
|
|
|
572
|
+ }
|
|
|
573
|
+
|
|
|
574
|
+ private func creativeSingleColumnDocument() -> NSView {
|
|
|
575
|
+ let theme = template.themeColor
|
|
|
576
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
577
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
578
|
+
|
|
|
579
|
+ let banner = NSView()
|
|
|
580
|
+ banner.translatesAutoresizingMaskIntoConstraints = false
|
|
|
581
|
+ banner.wantsLayer = true
|
|
|
582
|
+ banner.layer?.backgroundColor = theme.cgColor
|
|
|
583
|
+ banner.layer?.cornerRadius = variant % 4 == 1 ? 8 : 6
|
|
|
584
|
+ let inner = label(" \(nameText) · \(roleText) ", font: .systemFont(ofSize: 14, weight: .bold), color: .white, maxLines: 2)
|
|
|
585
|
+ inner.translatesAutoresizingMaskIntoConstraints = false
|
|
|
586
|
+ banner.addSubview(inner)
|
|
|
587
|
+ NSLayoutConstraint.activate([
|
|
|
588
|
+ inner.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 14),
|
|
|
589
|
+ inner.trailingAnchor.constraint(lessThanOrEqualTo: banner.trailingAnchor, constant: -14),
|
|
|
590
|
+ inner.topAnchor.constraint(equalTo: banner.topAnchor, constant: 12),
|
|
|
591
|
+ inner.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -12)
|
|
|
592
|
+ ])
|
|
|
593
|
+
|
|
|
594
|
+ let main = creativeMainStack(theme: theme)
|
|
|
595
|
+ let col = NSStackView(views: [banner, main])
|
|
|
596
|
+ col.orientation = .vertical
|
|
|
597
|
+ col.spacing = 16
|
|
|
598
|
+ col.alignment = .leading
|
|
|
599
|
+ return col
|
|
|
600
|
+ }
|
|
|
601
|
+
|
|
|
602
|
+ private func creativeTwoColumnDocument(sidebar: CVTemplate.SidebarSide) -> NSView {
|
|
|
603
|
+ let theme = template.themeColor
|
|
|
604
|
+ let deep = creativeDeepBackground()
|
|
|
605
|
+ let onSidebar = NSColor.white.withAlphaComponent(0.95)
|
|
|
606
|
+ let skillPrefix = (variant % 3 == 0) ? "• " : "▸ "
|
|
|
607
|
+
|
|
|
608
|
+ let sidebarStack = NSStackView()
|
|
|
609
|
+ sidebarStack.orientation = .vertical
|
|
|
610
|
+ sidebarStack.spacing = 12
|
|
|
611
|
+ sidebarStack.alignment = .leading
|
|
|
612
|
+ sidebarStack.wantsLayer = true
|
|
|
613
|
+ sidebarStack.layer?.backgroundColor = deep.cgColor
|
|
|
614
|
+ sidebarStack.layer?.cornerRadius = variant % 2 == 0 ? 10 : 8
|
|
|
615
|
+ sidebarStack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
|
|
616
|
+
|
|
|
617
|
+ let nm = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
618
|
+ let role = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
619
|
+ sidebarStack.addArrangedSubview(label(nm, font: .systemFont(ofSize: 18, weight: .bold), color: onSidebar, maxLines: 2))
|
|
|
620
|
+ sidebarStack.addArrangedSubview(label(role, font: .systemFont(ofSize: 13, weight: .medium), color: onSidebar.withAlphaComponent(0.85), maxLines: 2))
|
|
|
621
|
+ if !profile.personal.email.isEmpty {
|
|
|
622
|
+ sidebarStack.addArrangedSubview(label(profile.personal.email, font: .systemFont(ofSize: 11.5), color: onSidebar.withAlphaComponent(0.82), maxLines: 2))
|
|
|
623
|
+ }
|
|
|
624
|
+ if !profile.personal.phone.isEmpty {
|
|
|
625
|
+ sidebarStack.addArrangedSubview(label(profile.personal.phone, font: .systemFont(ofSize: 11.5), color: onSidebar.withAlphaComponent(0.82), maxLines: 1))
|
|
|
626
|
+ }
|
|
|
627
|
+ sidebarStack.addArrangedSubview(creativeSidebarHeading("STRENGTHS", onSidebar: onSidebar, accent: theme))
|
|
|
628
|
+ for token in skillTokensFromProfile(max: 8) {
|
|
|
629
|
+ sidebarStack.addArrangedSubview(label("\(skillPrefix)\(token)", font: .systemFont(ofSize: 12, weight: .semibold), color: onSidebar.withAlphaComponent(0.92), maxLines: 2))
|
|
|
630
|
+ }
|
|
|
631
|
+
|
|
|
632
|
+ let main = creativeMainStack(theme: theme)
|
|
|
633
|
+ let row = NSStackView()
|
|
|
634
|
+ row.orientation = .horizontal
|
|
|
635
|
+ row.spacing = 18
|
|
|
636
|
+ row.alignment = .top
|
|
|
637
|
+ let sidebarMult = 0.32 + CGFloat(variant % 3) * 0.02
|
|
|
638
|
+ if sidebar == .leading {
|
|
|
639
|
+ row.addArrangedSubview(sidebarStack)
|
|
|
640
|
+ row.addArrangedSubview(main)
|
|
|
641
|
+ sidebarStack.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
|
|
|
642
|
+ } else {
|
|
|
643
|
+ row.addArrangedSubview(main)
|
|
|
644
|
+ row.addArrangedSubview(sidebarStack)
|
|
|
645
|
+ sidebarStack.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
|
|
|
646
|
+ }
|
|
|
647
|
+ return row
|
|
|
648
|
+ }
|
|
|
649
|
+
|
|
|
650
|
+ private func creativeSidebarHeading(_ raw: String, onSidebar: NSColor, accent: NSColor) -> NSView {
|
|
|
651
|
+ let t = label(raw, font: .systemFont(ofSize: 10.5, weight: .heavy), color: onSidebar, maxLines: 1)
|
|
|
652
|
+ let bar = NSView()
|
|
|
653
|
+ bar.translatesAutoresizingMaskIntoConstraints = false
|
|
|
654
|
+ bar.wantsLayer = true
|
|
|
655
|
+ bar.layer?.backgroundColor = accent.cgColor
|
|
|
656
|
+ bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
|
|
|
657
|
+ let c = NSStackView(views: [t, bar])
|
|
|
658
|
+ c.orientation = .vertical
|
|
|
659
|
+ c.spacing = 4
|
|
|
660
|
+ c.alignment = .leading
|
|
|
661
|
+ bar.leadingAnchor.constraint(equalTo: t.leadingAnchor).isActive = true
|
|
|
662
|
+ bar.widthAnchor.constraint(equalToConstant: 72).isActive = true
|
|
|
663
|
+ return c
|
|
|
664
|
+ }
|
|
|
665
|
+
|
|
|
666
|
+ private func creativeMainHeader(theme: NSColor) -> NSView {
|
|
|
667
|
+ let v = NSView()
|
|
|
668
|
+ v.translatesAutoresizingMaskIntoConstraints = false
|
|
|
669
|
+ let stripe = NSView()
|
|
|
670
|
+ stripe.translatesAutoresizingMaskIntoConstraints = false
|
|
|
671
|
+ stripe.wantsLayer = true
|
|
|
672
|
+ stripe.layer?.backgroundColor = theme.cgColor
|
|
|
673
|
+ v.addSubview(stripe)
|
|
|
674
|
+ let row = NSStackView()
|
|
|
675
|
+ row.orientation = .horizontal
|
|
|
676
|
+ row.spacing = 8
|
|
|
677
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
678
|
+ let lab = label("PORTFOLIO SNAPSHOT", font: .systemFont(ofSize: 12, weight: .heavy), color: style.ink, maxLines: 1)
|
|
|
679
|
+ row.addArrangedSubview(stripe)
|
|
|
680
|
+ row.addArrangedSubview(lab)
|
|
|
681
|
+ v.addSubview(row)
|
|
|
682
|
+ NSLayoutConstraint.activate([
|
|
|
683
|
+ stripe.widthAnchor.constraint(equalToConstant: 4),
|
|
|
684
|
+ stripe.heightAnchor.constraint(equalToConstant: 18),
|
|
|
685
|
+ row.leadingAnchor.constraint(equalTo: v.leadingAnchor),
|
|
|
686
|
+ row.topAnchor.constraint(equalTo: v.topAnchor),
|
|
|
687
|
+ row.bottomAnchor.constraint(equalTo: v.bottomAnchor)
|
|
|
688
|
+ ])
|
|
|
689
|
+ return v
|
|
|
690
|
+ }
|
|
|
691
|
+
|
|
|
692
|
+ private func creativeMainStack(theme: NSColor) -> NSView {
|
|
|
693
|
+ let stack = NSStackView()
|
|
|
694
|
+ stack.orientation = .vertical
|
|
|
695
|
+ stack.spacing = style.bodyBlockSpacing
|
|
|
696
|
+ stack.alignment = .leading
|
|
|
697
|
+ stack.addArrangedSubview(creativeMainHeader(theme: theme))
|
|
|
698
|
+ if let summary = nonEmpty(profile.careerSummary) {
|
|
|
699
|
+ stack.addArrangedSubview(sectionHeading("Profile"))
|
|
|
700
|
+ stack.addArrangedSubview(paragraph(summary, compact: false))
|
|
|
701
|
+ }
|
|
|
702
|
+ let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
|
703
|
+ if !jobs.isEmpty {
|
|
|
704
|
+ stack.addArrangedSubview(sectionHeading("Impact"))
|
|
|
705
|
+ for job in jobs {
|
|
|
706
|
+ let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
707
|
+ if !titleLine.isEmpty {
|
|
|
708
|
+ stack.addArrangedSubview(label(titleLine, font: .systemFont(ofSize: 13.5, weight: .heavy), color: style.ink, maxLines: 0))
|
|
|
709
|
+ }
|
|
|
710
|
+ for bullet in Self.bulletChunks(from: job.description) {
|
|
|
711
|
+ let mark = (variant % 2 == 0) ? "— " : "▸ "
|
|
|
712
|
+ stack.addArrangedSubview(label("\(mark)\(bullet)", font: style.bodyFont, color: style.muted, maxLines: 0))
|
|
|
713
|
+ }
|
|
|
714
|
+ }
|
|
|
715
|
+ }
|
|
|
716
|
+ let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
|
|
|
717
|
+ if !schools.isEmpty {
|
|
|
718
|
+ stack.addArrangedSubview(sectionHeading("Education"))
|
|
|
719
|
+ for edu in schools {
|
|
|
720
|
+ stack.addArrangedSubview(educationBlock(edu: edu, compact: false))
|
|
|
721
|
+ }
|
|
|
722
|
+ }
|
|
|
723
|
+ appendCertificatesInterestsReferrals(to: stack, compact: false)
|
|
|
724
|
+ return stack
|
|
|
725
|
+ }
|
|
|
726
|
+
|
|
|
727
|
+ // MARK: - Traditional families (professional / minimal / executive)
|
|
|
728
|
+
|
|
|
729
|
+ private func buildTraditionalFamilyDocument() -> NSView {
|
|
233
|
730
|
switch template.layout {
|
|
234
|
731
|
case .singleColumn:
|
|
235
|
732
|
return singleColumnLayout()
|
|
|
@@ -244,11 +741,56 @@ final class CVProfileDocumentView: NSView {
|
|
244
|
741
|
v.alignment = .leading
|
|
245
|
742
|
v.spacing = style.columnVerticalSpacing + 3
|
|
246
|
743
|
v.addArrangedSubview(headerBlock())
|
|
|
744
|
+ if template.family == .professional && (variant % 6) == 4 {
|
|
|
745
|
+ v.addArrangedSubview(professionalInlineSkillsRow())
|
|
|
746
|
+ }
|
|
247
|
747
|
v.addArrangedSubview(hairline())
|
|
248
|
|
- v.addArrangedSubview(bodyColumn(compact: false))
|
|
|
748
|
+ let body = bodyColumn(compact: false, experienceFirst: professionalExperienceFirst)
|
|
|
749
|
+ v.addArrangedSubview(usesProfessionalSingleColumnRail ? bodyWithLeadingAccentRail(body) : body)
|
|
249
|
750
|
return v
|
|
250
|
751
|
}
|
|
251
|
752
|
|
|
|
753
|
+ private var professionalExperienceFirst: Bool {
|
|
|
754
|
+ template.family == .professional && (variant % 3) == 1
|
|
|
755
|
+ }
|
|
|
756
|
+
|
|
|
757
|
+ private func professionalInlineSkillsRow() -> NSView {
|
|
|
758
|
+ let tokens = skillTokensFromProfile(max: 6)
|
|
|
759
|
+ guard !tokens.isEmpty else { return NSView() }
|
|
|
760
|
+ let joined = tokens.joined(separator: " · ")
|
|
|
761
|
+ return label(joined, font: .systemFont(ofSize: 11.5, weight: .medium), color: template.themeColor, maxLines: 0)
|
|
|
762
|
+ }
|
|
|
763
|
+
|
|
|
764
|
+ /// Matches the CV Maker thumbnail: professional ATS single-column layouts use a full-height theme rail.
|
|
|
765
|
+ private var usesProfessionalSingleColumnRail: Bool {
|
|
|
766
|
+ if case .singleColumn = template.layout, template.family == .professional { return true }
|
|
|
767
|
+ return false
|
|
|
768
|
+ }
|
|
|
769
|
+
|
|
|
770
|
+ private func bodyWithLeadingAccentRail(_ content: NSView) -> NSView {
|
|
|
771
|
+ let wrap = NSView()
|
|
|
772
|
+ wrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
773
|
+ let rail = NSView()
|
|
|
774
|
+ rail.translatesAutoresizingMaskIntoConstraints = false
|
|
|
775
|
+ rail.wantsLayer = true
|
|
|
776
|
+ rail.layer?.backgroundColor = template.themeColor.cgColor
|
|
|
777
|
+ rail.layer?.cornerRadius = 1
|
|
|
778
|
+ content.translatesAutoresizingMaskIntoConstraints = false
|
|
|
779
|
+ wrap.addSubview(rail)
|
|
|
780
|
+ wrap.addSubview(content)
|
|
|
781
|
+ NSLayoutConstraint.activate([
|
|
|
782
|
+ rail.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
|
|
|
783
|
+ rail.topAnchor.constraint(equalTo: content.topAnchor),
|
|
|
784
|
+ rail.bottomAnchor.constraint(equalTo: content.bottomAnchor),
|
|
|
785
|
+ rail.widthAnchor.constraint(equalToConstant: 3),
|
|
|
786
|
+ content.leadingAnchor.constraint(equalTo: rail.trailingAnchor, constant: 12),
|
|
|
787
|
+ content.trailingAnchor.constraint(equalTo: wrap.trailingAnchor),
|
|
|
788
|
+ content.topAnchor.constraint(equalTo: wrap.topAnchor),
|
|
|
789
|
+ content.bottomAnchor.constraint(equalTo: wrap.bottomAnchor)
|
|
|
790
|
+ ])
|
|
|
791
|
+ return wrap
|
|
|
792
|
+ }
|
|
|
793
|
+
|
|
252
|
794
|
private func twoColumnLayout(sidebar: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
|
|
253
|
795
|
let v = NSStackView()
|
|
254
|
796
|
v.orientation = .vertical
|
|
|
@@ -263,7 +805,7 @@ final class CVProfileDocumentView: NSView {
|
|
263
|
805
|
row.spacing = template.family == .minimal ? 18 : 22
|
|
264
|
806
|
|
|
265
|
807
|
let sidebarCol = sidebarColumn(tinted: tinted)
|
|
266
|
|
- let mainCol = bodyColumn(compact: true)
|
|
|
808
|
+ let mainCol = bodyColumn(compact: true, experienceFirst: professionalExperienceFirst)
|
|
267
|
809
|
|
|
268
|
810
|
if sidebar == .leading {
|
|
269
|
811
|
row.addArrangedSubview(sidebarCol)
|
|
|
@@ -272,7 +814,13 @@ final class CVProfileDocumentView: NSView {
|
|
272
|
814
|
row.addArrangedSubview(mainCol)
|
|
273
|
815
|
row.addArrangedSubview(sidebarCol)
|
|
274
|
816
|
}
|
|
275
|
|
- sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: template.family == .executive ? 0.34 : 0.32).isActive = true
|
|
|
817
|
+ let sidebarMult: CGFloat
|
|
|
818
|
+ if template.family == .professional {
|
|
|
819
|
+ sidebarMult = (variant % 5 == 2) ? 0.38 : 0.32
|
|
|
820
|
+ } else {
|
|
|
821
|
+ sidebarMult = template.family == .executive ? 0.34 : 0.32
|
|
|
822
|
+ }
|
|
|
823
|
+ sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
|
|
276
|
824
|
|
|
277
|
825
|
v.addArrangedSubview(row)
|
|
278
|
826
|
return v
|
|
|
@@ -390,8 +938,13 @@ final class CVProfileDocumentView: NSView {
|
|
390
|
938
|
return bar
|
|
391
|
939
|
case .blueBar:
|
|
392
|
940
|
bar.layer?.backgroundColor = template.themeColor.cgColor
|
|
393
|
|
- bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
|
394
|
|
- bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
|
|
|
941
|
+ if template.headline == .centered {
|
|
|
942
|
+ bar.heightAnchor.constraint(equalToConstant: 2.5).isActive = true
|
|
|
943
|
+ bar.widthAnchor.constraint(equalToConstant: 148).isActive = true
|
|
|
944
|
+ } else {
|
|
|
945
|
+ bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
|
|
946
|
+ bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
|
|
|
947
|
+ }
|
|
395
|
948
|
return bar
|
|
396
|
949
|
}
|
|
397
|
950
|
}
|
|
|
@@ -430,33 +983,62 @@ final class CVProfileDocumentView: NSView {
|
|
430
|
983
|
return box
|
|
431
|
984
|
}
|
|
432
|
985
|
|
|
433
|
|
- private func bodyColumn(compact: Bool) -> NSView {
|
|
|
986
|
+ private func bodyColumn(compact: Bool, experienceFirst: Bool = false) -> NSView {
|
|
434
|
987
|
let v = NSStackView()
|
|
435
|
988
|
v.orientation = .vertical
|
|
436
|
989
|
v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
|
|
437
|
990
|
v.alignment = .leading
|
|
438
|
991
|
|
|
439
|
|
- if let summary = nonEmpty(profile.careerSummary) {
|
|
440
|
|
- v.addArrangedSubview(sectionHeading("Summary"))
|
|
441
|
|
- v.addArrangedSubview(paragraph(summary, compact: compact))
|
|
442
|
|
- }
|
|
|
992
|
+ let summaryTitle = sectionHeading(summarySectionTitle)
|
|
|
993
|
+ let summaryBody: NSView? = nonEmpty(profile.careerSummary).map { paragraph($0, compact: compact) }
|
|
443
|
994
|
|
|
444
|
995
|
let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
445
|
|
- if !jobs.isEmpty {
|
|
446
|
|
- v.addArrangedSubview(sectionHeading("Experience"))
|
|
447
|
|
- for job in jobs {
|
|
448
|
|
- v.addArrangedSubview(experienceBlock(job: job, compact: compact))
|
|
449
|
|
- }
|
|
|
996
|
+ let experienceHeading = sectionHeading("Experience")
|
|
|
997
|
+ var experienceBlocks: [NSView] = []
|
|
|
998
|
+ for job in jobs {
|
|
|
999
|
+ experienceBlocks.append(experienceBlock(job: job, compact: compact))
|
|
450
|
1000
|
}
|
|
451
|
1001
|
|
|
452
|
1002
|
let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
|
|
453
|
|
- if !schools.isEmpty {
|
|
454
|
|
- v.addArrangedSubview(sectionHeading("Education"))
|
|
455
|
|
- for edu in schools {
|
|
456
|
|
- v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
|
|
|
1003
|
+ var educationBlocks: [NSView] = []
|
|
|
1004
|
+ for edu in schools {
|
|
|
1005
|
+ educationBlocks.append(educationBlock(edu: edu, compact: compact))
|
|
|
1006
|
+ }
|
|
|
1007
|
+
|
|
|
1008
|
+ let appendSummary: () -> Void = { [self] in
|
|
|
1009
|
+ if let body = summaryBody {
|
|
|
1010
|
+ v.addArrangedSubview(summaryTitle)
|
|
|
1011
|
+ v.addArrangedSubview(body)
|
|
|
1012
|
+ }
|
|
|
1013
|
+ }
|
|
|
1014
|
+ let appendExperience: () -> Void = { [self] in
|
|
|
1015
|
+ if !jobs.isEmpty {
|
|
|
1016
|
+ v.addArrangedSubview(experienceHeading)
|
|
|
1017
|
+ experienceBlocks.forEach { v.addArrangedSubview($0) }
|
|
|
1018
|
+ }
|
|
|
1019
|
+ }
|
|
|
1020
|
+ let appendEducation: () -> Void = { [self] in
|
|
|
1021
|
+ if !schools.isEmpty {
|
|
|
1022
|
+ v.addArrangedSubview(sectionHeading("Education"))
|
|
|
1023
|
+ educationBlocks.forEach { v.addArrangedSubview($0) }
|
|
457
|
1024
|
}
|
|
458
|
1025
|
}
|
|
459
|
1026
|
|
|
|
1027
|
+ if experienceFirst {
|
|
|
1028
|
+ appendExperience()
|
|
|
1029
|
+ appendSummary()
|
|
|
1030
|
+ appendEducation()
|
|
|
1031
|
+ } else {
|
|
|
1032
|
+ appendSummary()
|
|
|
1033
|
+ appendExperience()
|
|
|
1034
|
+ appendEducation()
|
|
|
1035
|
+ }
|
|
|
1036
|
+
|
|
|
1037
|
+ appendCertificatesInterestsReferrals(to: v, compact: compact)
|
|
|
1038
|
+ return v
|
|
|
1039
|
+ }
|
|
|
1040
|
+
|
|
|
1041
|
+ private func appendCertificatesInterestsReferrals(to v: NSStackView, compact: Bool) {
|
|
460
|
1042
|
if let cert = nonEmpty(profile.certificates) {
|
|
461
|
1043
|
v.addArrangedSubview(sectionHeading("Certificates"))
|
|
462
|
1044
|
v.addArrangedSubview(paragraph(cert, compact: compact))
|
|
|
@@ -469,8 +1051,27 @@ final class CVProfileDocumentView: NSView {
|
|
469
|
1051
|
v.addArrangedSubview(sectionHeading("Referrals"))
|
|
470
|
1052
|
v.addArrangedSubview(paragraph(ref, compact: compact))
|
|
471
|
1053
|
}
|
|
|
1054
|
+ }
|
|
472
|
1055
|
|
|
473
|
|
- return v
|
|
|
1056
|
+ private func skillTokensFromProfile(max: Int) -> [String] {
|
|
|
1057
|
+ let raw = profile.languages.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1058
|
+ if raw.isEmpty { return [] }
|
|
|
1059
|
+ let parts = raw.split(whereSeparator: { $0 == "," || $0 == "·" || $0 == "|" || $0 == ";" })
|
|
|
1060
|
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
|
1061
|
+ .filter { !$0.isEmpty }
|
|
|
1062
|
+ if parts.count > 1 { return Array(parts.prefix(max)) }
|
|
|
1063
|
+ return raw.split(separator: " ").map(String.init).filter { $0.count > 1 }.prefix(max).map { String($0) }
|
|
|
1064
|
+ }
|
|
|
1065
|
+
|
|
|
1066
|
+ private func highlightsBodyText() -> String? {
|
|
|
1067
|
+ if let t = nonEmpty(profile.interests) { return t }
|
|
|
1068
|
+ if let r = nonEmpty(profile.referral) { return r }
|
|
|
1069
|
+ let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
|
1070
|
+ if let first = jobs.first {
|
|
|
1071
|
+ let bullets = Self.bulletChunks(from: first.description)
|
|
|
1072
|
+ if let b = bullets.first { return b }
|
|
|
1073
|
+ }
|
|
|
1074
|
+ return nil
|
|
474
|
1075
|
}
|
|
475
|
1076
|
|
|
476
|
1077
|
private func ancillaryBlock(title: String, body: String?) -> NSStackView? {
|
|
|
@@ -499,17 +1100,37 @@ final class CVProfileDocumentView: NSView {
|
|
499
|
1100
|
}
|
|
500
|
1101
|
|
|
501
|
1102
|
private func experienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
|
|
502
|
|
- let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
503
|
|
- let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
504
|
1103
|
let v = NSStackView()
|
|
505
|
1104
|
v.orientation = .vertical
|
|
506
|
1105
|
v.spacing = template.family == .professional ? 4 : 6
|
|
507
|
1106
|
v.alignment = .leading
|
|
508
|
|
- if !titleLine.isEmpty {
|
|
509
|
|
- v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
|
|
510
|
|
- }
|
|
511
|
|
- if !meta.isEmpty {
|
|
512
|
|
- v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
|
|
|
1107
|
+
|
|
|
1108
|
+ if template.family == .professional {
|
|
|
1109
|
+ let title = job.jobTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1110
|
+ let company = job.company.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1111
|
+ let duration = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1112
|
+ if !title.isEmpty {
|
|
|
1113
|
+ v.addArrangedSubview(label(title, font: style.expTitleFont, color: style.ink, maxLines: 0))
|
|
|
1114
|
+ }
|
|
|
1115
|
+ let metaParts = [company, duration].filter { !$0.isEmpty }
|
|
|
1116
|
+ if !metaParts.isEmpty {
|
|
|
1117
|
+ let metaJoined = metaParts.joined(separator: " · ")
|
|
|
1118
|
+ v.addArrangedSubview(label(metaJoined, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
|
|
|
1119
|
+ } else if title.isEmpty {
|
|
|
1120
|
+ let fallback = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
1121
|
+ if !fallback.isEmpty {
|
|
|
1122
|
+ v.addArrangedSubview(label(fallback, font: style.expTitleFont, color: style.ink, maxLines: 0))
|
|
|
1123
|
+ }
|
|
|
1124
|
+ }
|
|
|
1125
|
+ } else {
|
|
|
1126
|
+ let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
1127
|
+ let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1128
|
+ if !titleLine.isEmpty {
|
|
|
1129
|
+ v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
|
|
|
1130
|
+ }
|
|
|
1131
|
+ if !meta.isEmpty {
|
|
|
1132
|
+ v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
|
|
|
1133
|
+ }
|
|
513
|
1134
|
}
|
|
514
|
1135
|
for bullet in Self.bulletChunks(from: job.description) {
|
|
515
|
1136
|
v.addArrangedSubview(bulletRow(bullet, compact: compact))
|
|
|
@@ -522,12 +1143,27 @@ final class CVProfileDocumentView: NSView {
|
|
522
|
1143
|
v.orientation = .vertical
|
|
523
|
1144
|
v.spacing = 4
|
|
524
|
1145
|
v.alignment = .leading
|
|
525
|
|
- let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
526
|
|
- if !head.isEmpty {
|
|
527
|
|
- v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
|
|
528
|
|
- }
|
|
529
|
|
- if !edu.year.isEmpty {
|
|
530
|
|
- v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
|
|
|
1146
|
+ let institution = edu.institution.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1147
|
+ let degree = edu.degree.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1148
|
+ let year = edu.year.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
1149
|
+
|
|
|
1150
|
+ if template.family == .professional {
|
|
|
1151
|
+ if !institution.isEmpty {
|
|
|
1152
|
+ v.addArrangedSubview(label(institution, font: style.eduTitleFont, color: style.ink, maxLines: 0))
|
|
|
1153
|
+ }
|
|
|
1154
|
+ let subParts = [degree, year].filter { !$0.isEmpty }
|
|
|
1155
|
+ if !subParts.isEmpty {
|
|
|
1156
|
+ let sub = subParts.joined(separator: " · ")
|
|
|
1157
|
+ v.addArrangedSubview(label(sub, font: style.eduMetaFont, color: style.muted, maxLines: 0))
|
|
|
1158
|
+ }
|
|
|
1159
|
+ } else {
|
|
|
1160
|
+ let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
1161
|
+ if !head.isEmpty {
|
|
|
1162
|
+ v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
|
|
|
1163
|
+ }
|
|
|
1164
|
+ if !edu.year.isEmpty {
|
|
|
1165
|
+ v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
|
|
|
1166
|
+ }
|
|
531
|
1167
|
}
|
|
532
|
1168
|
return v
|
|
533
|
1169
|
}
|
|
|
@@ -553,6 +1189,11 @@ final class CVProfileDocumentView: NSView {
|
|
553
|
1189
|
return label(text, font: font, color: style.ink, maxLines: 0)
|
|
554
|
1190
|
}
|
|
555
|
1191
|
|
|
|
1192
|
+ /// Gallery + ATS “Clear Path” style use “Profile”; other families keep the neutral résumé label.
|
|
|
1193
|
+ private var summarySectionTitle: String {
|
|
|
1194
|
+ template.family == .professional ? "Profile" : "Summary"
|
|
|
1195
|
+ }
|
|
|
1196
|
+
|
|
556
|
1197
|
private func sectionHeading(_ raw: String) -> NSTextField {
|
|
557
|
1198
|
let upper = raw.uppercased()
|
|
558
|
1199
|
let s: String
|
|
|
@@ -581,6 +1222,13 @@ final class CVProfileDocumentView: NSView {
|
|
581
|
1222
|
t.font = font
|
|
582
|
1223
|
t.textColor = color
|
|
583
|
1224
|
t.alignment = .left
|
|
|
1225
|
+ if isEditable {
|
|
|
1226
|
+ t.isEditable = true
|
|
|
1227
|
+ t.isSelectable = true
|
|
|
1228
|
+ t.isBordered = false
|
|
|
1229
|
+ t.drawsBackground = false
|
|
|
1230
|
+ t.focusRingType = .default
|
|
|
1231
|
+ }
|
|
584
|
1232
|
return t
|
|
585
|
1233
|
}
|
|
586
|
1234
|
|