|
|
@@ -183,11 +183,16 @@ final class CVProfileDocumentView: NSView {
|
|
|
private let profile: SavedProfile
|
|
|
private let template: CVTemplate
|
|
|
private let style: DocumentStyle
|
|
|
+ /// Matches `CVTemplatePreviewView` so the same template id + layout recipe renders the same silhouette as the gallery card.
|
|
|
+ private let variant: Int
|
|
|
+ private let isEditable: Bool
|
|
|
|
|
|
- init(profile: SavedProfile, template: CVTemplate) {
|
|
|
+ init(profile: SavedProfile, template: CVTemplate, isEditable: Bool = false) {
|
|
|
self.profile = profile
|
|
|
self.template = template
|
|
|
self.style = DocumentStyle.make(for: template)
|
|
|
+ self.variant = template.galleryLayoutVariant
|
|
|
+ self.isEditable = isEditable
|
|
|
super.init(frame: .zero)
|
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
|
wantsLayer = true
|
|
|
@@ -230,6 +235,498 @@ final class CVProfileDocumentView: NSView {
|
|
|
// MARK: - Composition
|
|
|
|
|
|
private func buildRoot() -> NSView {
|
|
|
+ switch template.family {
|
|
|
+ case .modern:
|
|
|
+ return buildModernFamilyDocument()
|
|
|
+ case .creative:
|
|
|
+ return buildCreativeFamilyDocument()
|
|
|
+ case .professional, .minimal, .executive:
|
|
|
+ return buildTraditionalFamilyDocument()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Modern (gallery uses three distinct silhouettes from `variant`)
|
|
|
+
|
|
|
+ private func buildModernFamilyDocument() -> NSView {
|
|
|
+ switch variant % 3 {
|
|
|
+ case 0: return modernClassicBandDocument()
|
|
|
+ case 1: return modernRailDocument()
|
|
|
+ default: return modernSplitHeaderDocument()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func modernClassicBandDocument() -> NSView {
|
|
|
+ let theme = template.themeColor
|
|
|
+ let white = NSColor.white
|
|
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
+
|
|
|
+ let header = NSView()
|
|
|
+ header.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ header.wantsLayer = true
|
|
|
+ header.layer?.backgroundColor = theme.cgColor
|
|
|
+ header.layer?.cornerRadius = variant % 2 == 0 ? 8 : 6
|
|
|
+
|
|
|
+ let name = label(nameText, font: .systemFont(ofSize: 22, weight: .bold), color: white, maxLines: 2)
|
|
|
+ let role = label(roleText, font: .systemFont(ofSize: 14, weight: .medium), color: white.withAlphaComponent(0.92), maxLines: 2)
|
|
|
+ let textCol = NSStackView(views: [name, role])
|
|
|
+ textCol.orientation = .vertical
|
|
|
+ textCol.spacing = 4
|
|
|
+ textCol.alignment = .leading
|
|
|
+ textCol.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ let iconRow = NSStackView()
|
|
|
+ iconRow.orientation = .horizontal
|
|
|
+ iconRow.spacing = 10
|
|
|
+ iconRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ for sym in ["mappin.and.ellipse", "phone.fill", "envelope.fill"] {
|
|
|
+ guard let img = NSImage(systemSymbolName: sym, accessibilityDescription: nil) else { continue }
|
|
|
+ let iv = NSImageView(image: img)
|
|
|
+ iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
|
|
|
+ iv.contentTintColor = white.withAlphaComponent(0.88)
|
|
|
+ iconRow.addArrangedSubview(iv)
|
|
|
+ }
|
|
|
+
|
|
|
+ let topRow = NSStackView()
|
|
|
+ topRow.orientation = .horizontal
|
|
|
+ topRow.spacing = 14
|
|
|
+ topRow.alignment = .centerY
|
|
|
+ topRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ topRow.addArrangedSubview(textCol)
|
|
|
+ topRow.addArrangedSubview(NSView())
|
|
|
+ topRow.addArrangedSubview(iconRow)
|
|
|
+
|
|
|
+ header.addSubview(topRow)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ topRow.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 18),
|
|
|
+ topRow.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -18),
|
|
|
+ topRow.topAnchor.constraint(equalTo: header.topAnchor, constant: 14),
|
|
|
+ topRow.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -14)
|
|
|
+ ])
|
|
|
+
|
|
|
+ let body: NSView
|
|
|
+ switch template.layout {
|
|
|
+ case .singleColumn:
|
|
|
+ body = modernMainContentColumn(compact: false, includeSummaryInMain: true)
|
|
|
+ case .twoColumn(let side, let tinted):
|
|
|
+ let main = modernMainContentColumn(compact: true, includeSummaryInMain: false)
|
|
|
+ let sideCol = modernAboutHighlightsSidebar(tinted: tinted)
|
|
|
+ let row = NSStackView()
|
|
|
+ row.orientation = .horizontal
|
|
|
+ row.spacing = 20
|
|
|
+ row.alignment = .top
|
|
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ let mult: CGFloat = (variant % 4 == 2) ? 0.36 : 0.32
|
|
|
+ if side == .leading {
|
|
|
+ row.addArrangedSubview(sideCol)
|
|
|
+ row.addArrangedSubview(main)
|
|
|
+ sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
|
|
|
+ } else {
|
|
|
+ row.addArrangedSubview(main)
|
|
|
+ row.addArrangedSubview(sideCol)
|
|
|
+ sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
|
|
|
+ }
|
|
|
+ body = row
|
|
|
+ }
|
|
|
+
|
|
|
+ let wrap = NSStackView(views: [header, body])
|
|
|
+ wrap.orientation = .vertical
|
|
|
+ wrap.spacing = 18
|
|
|
+ wrap.alignment = .leading
|
|
|
+ return wrap
|
|
|
+ }
|
|
|
+
|
|
|
+ private func modernRailDocument() -> NSView {
|
|
|
+ let theme = template.themeColor
|
|
|
+ let rail = NSView()
|
|
|
+ rail.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ rail.wantsLayer = true
|
|
|
+ rail.layer?.backgroundColor = theme.cgColor
|
|
|
+ rail.layer?.cornerRadius = 2
|
|
|
+ rail.widthAnchor.constraint(equalToConstant: 3 + CGFloat(variant % 2)).isActive = true
|
|
|
+
|
|
|
+ let inner = NSStackView()
|
|
|
+ inner.orientation = .vertical
|
|
|
+ inner.spacing = 10
|
|
|
+ inner.alignment = .leading
|
|
|
+ inner.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
+ let contactParts = [profile.personal.email, profile.personal.phone].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
|
+ let contactLine = contactParts.isEmpty ? "Add contact in your profile" : contactParts.joined(separator: " · ")
|
|
|
+
|
|
|
+ inner.addArrangedSubview(label(nameText, font: .systemFont(ofSize: 21, weight: .bold), color: style.ink, maxLines: 2))
|
|
|
+ inner.addArrangedSubview(label(roleText, font: .systemFont(ofSize: 14, weight: .semibold), color: theme, maxLines: 2))
|
|
|
+ inner.addArrangedSubview(label(contactLine, font: style.contactFont, color: style.muted, maxLines: 2))
|
|
|
+ inner.addArrangedSubview(hairline())
|
|
|
+ inner.addArrangedSubview(skillTagRow(theme: theme, maxTags: 5))
|
|
|
+ inner.addArrangedSubview(modernPrimaryBody(theme: theme))
|
|
|
+
|
|
|
+ let row = NSStackView(views: [rail, inner])
|
|
|
+ row.orientation = .horizontal
|
|
|
+ row.spacing = 14
|
|
|
+ row.alignment = .top
|
|
|
+ return row
|
|
|
+ }
|
|
|
+
|
|
|
+ private func modernSplitHeaderDocument() -> NSView {
|
|
|
+ let theme = template.themeColor
|
|
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
+ let loc = profile.personal.address.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+
|
|
|
+ let left = NSStackView()
|
|
|
+ left.orientation = .vertical
|
|
|
+ left.spacing = 5
|
|
|
+ left.alignment = .leading
|
|
|
+ left.addArrangedSubview(label(nameText, font: .systemFont(ofSize: 21, weight: .bold), color: style.ink, maxLines: 2))
|
|
|
+ left.addArrangedSubview(label(roleText, font: .systemFont(ofSize: 13.5, weight: .medium), color: style.muted, maxLines: 2))
|
|
|
+ if !loc.isEmpty {
|
|
|
+ left.addArrangedSubview(label(loc, font: style.contactFont, color: style.muted.withAlphaComponent(0.88), maxLines: 2))
|
|
|
+ }
|
|
|
+
|
|
|
+ let right = NSStackView()
|
|
|
+ right.orientation = .vertical
|
|
|
+ right.spacing = 8
|
|
|
+ right.alignment = .leading
|
|
|
+ right.wantsLayer = true
|
|
|
+ right.layer?.backgroundColor = theme.cgColor
|
|
|
+ right.layer?.cornerRadius = 8
|
|
|
+ right.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
|
|
|
+ let onW = NSColor.white
|
|
|
+ if !profile.personal.email.isEmpty {
|
|
|
+ right.addArrangedSubview(label(profile.personal.email, font: .systemFont(ofSize: 12, weight: .medium), color: onW.withAlphaComponent(0.95), maxLines: 2))
|
|
|
+ }
|
|
|
+ if !profile.personal.phone.isEmpty {
|
|
|
+ right.addArrangedSubview(label(profile.personal.phone, font: .systemFont(ofSize: 12, weight: .medium), color: onW.withAlphaComponent(0.92), maxLines: 1))
|
|
|
+ }
|
|
|
+ if !loc.isEmpty {
|
|
|
+ right.addArrangedSubview(label(loc, font: .systemFont(ofSize: 11.5, weight: .regular), color: onW.withAlphaComponent(0.8), maxLines: 2))
|
|
|
+ }
|
|
|
+
|
|
|
+ let top = NSStackView(views: [left, right])
|
|
|
+ top.orientation = .horizontal
|
|
|
+ top.spacing = 16
|
|
|
+ top.alignment = .top
|
|
|
+ left.widthAnchor.constraint(equalTo: top.widthAnchor, multiplier: 0.54).isActive = true
|
|
|
+
|
|
|
+ let col = NSStackView(views: [top, hairline(), modernPrimaryBody(theme: theme)])
|
|
|
+ col.orientation = .vertical
|
|
|
+ col.spacing = 16
|
|
|
+ col.alignment = .leading
|
|
|
+ return col
|
|
|
+ }
|
|
|
+
|
|
|
+ private func modernPrimaryBody(theme: NSColor) -> NSView {
|
|
|
+ switch template.layout {
|
|
|
+ case .singleColumn:
|
|
|
+ return modernMainContentColumn(compact: false, includeSummaryInMain: true)
|
|
|
+ case .twoColumn(let side, let tinted):
|
|
|
+ let main = modernMainContentColumn(compact: true, includeSummaryInMain: false)
|
|
|
+ let sideCol = modernAboutHighlightsSidebar(tinted: tinted)
|
|
|
+ let row = NSStackView()
|
|
|
+ row.orientation = .horizontal
|
|
|
+ row.spacing = 20
|
|
|
+ row.alignment = .top
|
|
|
+ let mult: CGFloat = (variant % 4 == 2) ? 0.36 : 0.32
|
|
|
+ if side == .leading {
|
|
|
+ row.addArrangedSubview(sideCol)
|
|
|
+ row.addArrangedSubview(main)
|
|
|
+ sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
|
|
|
+ } else {
|
|
|
+ row.addArrangedSubview(main)
|
|
|
+ row.addArrangedSubview(sideCol)
|
|
|
+ sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
|
|
|
+ }
|
|
|
+ return row
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func modernMainContentColumn(compact: Bool, includeSummaryInMain: Bool) -> NSView {
|
|
|
+ let theme = template.themeColor
|
|
|
+ let v = NSStackView()
|
|
|
+ v.orientation = .vertical
|
|
|
+ v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
|
|
|
+ v.alignment = .leading
|
|
|
+
|
|
|
+ if includeSummaryInMain, let summary = nonEmpty(profile.careerSummary) {
|
|
|
+ v.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
|
|
|
+ v.addArrangedSubview(paragraph(summary, compact: compact))
|
|
|
+ }
|
|
|
+
|
|
|
+ let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
|
+ if !jobs.isEmpty {
|
|
|
+ v.addArrangedSubview(modernSectionRow(symbol: "briefcase.fill", title: "Experience", theme: theme))
|
|
|
+ for (index, job) in jobs.enumerated() {
|
|
|
+ v.addArrangedSubview(experienceBlock(job: job, compact: compact))
|
|
|
+ if index == 0 {
|
|
|
+ v.addArrangedSubview(skillTagRow(theme: theme, maxTags: 5))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
|
|
|
+ if !schools.isEmpty {
|
|
|
+ v.addArrangedSubview(modernSectionRow(symbol: "graduationcap.fill", title: "Education", theme: theme))
|
|
|
+ for edu in schools {
|
|
|
+ v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ appendCertificatesInterestsReferrals(to: v, compact: compact)
|
|
|
+ return v
|
|
|
+ }
|
|
|
+
|
|
|
+ private func modernAboutHighlightsSidebar(tinted: Bool) -> NSView {
|
|
|
+ let theme = template.themeColor
|
|
|
+ let box = NSStackView()
|
|
|
+ box.orientation = .vertical
|
|
|
+ box.spacing = 12
|
|
|
+ box.alignment = .leading
|
|
|
+ if tinted {
|
|
|
+ box.wantsLayer = true
|
|
|
+ box.layer?.backgroundColor = theme.withAlphaComponent(0.1).cgColor
|
|
|
+ box.layer?.cornerRadius = 8
|
|
|
+ box.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
|
|
|
+ }
|
|
|
+
|
|
|
+ if let summary = nonEmpty(profile.careerSummary) {
|
|
|
+ box.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
|
|
|
+ box.addArrangedSubview(paragraph(summary, compact: true))
|
|
|
+ }
|
|
|
+ if let hi = highlightsBodyText() {
|
|
|
+ box.addArrangedSubview(modernSectionRow(symbol: "star.fill", title: "Highlights", theme: theme))
|
|
|
+ box.addArrangedSubview(paragraph(hi, compact: true))
|
|
|
+ }
|
|
|
+ if box.arrangedSubviews.isEmpty {
|
|
|
+ box.addArrangedSubview(modernSectionRow(symbol: "person.crop.circle", title: "About", theme: theme))
|
|
|
+ box.addArrangedSubview(paragraph("Add a career summary or interests in your profile to populate this column.", compact: true))
|
|
|
+ }
|
|
|
+ return box
|
|
|
+ }
|
|
|
+
|
|
|
+ private func modernSectionRow(symbol: String, title: String, theme: NSColor) -> NSView {
|
|
|
+ guard let img = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) else {
|
|
|
+ return sectionHeading(title)
|
|
|
+ }
|
|
|
+ let iv = NSImageView(image: img)
|
|
|
+ iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
|
+ iv.contentTintColor = theme
|
|
|
+ let t = label(title.uppercased(), font: style.sectionFont, color: style.sectionInk, maxLines: 1)
|
|
|
+ let r = NSStackView(views: [iv, t])
|
|
|
+ r.orientation = .horizontal
|
|
|
+ r.spacing = 8
|
|
|
+ r.alignment = .centerY
|
|
|
+ return r
|
|
|
+ }
|
|
|
+
|
|
|
+ private func skillTagRow(theme: NSColor, maxTags: Int) -> NSView {
|
|
|
+ let tags = skillTokensFromProfile(max: maxTags)
|
|
|
+ guard !tags.isEmpty else { return NSView() }
|
|
|
+ let row = NSStackView()
|
|
|
+ row.orientation = .horizontal
|
|
|
+ row.spacing = 8
|
|
|
+ row.alignment = .centerY
|
|
|
+ for s in tags {
|
|
|
+ let tag = NSView()
|
|
|
+ tag.wantsLayer = true
|
|
|
+ tag.layer?.backgroundColor = theme.withAlphaComponent(0.14).cgColor
|
|
|
+ tag.layer?.cornerRadius = 6
|
|
|
+ tag.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ let lab = label(s, font: .systemFont(ofSize: 11, weight: .semibold), color: theme.blended(withFraction: 0.35, of: style.ink) ?? style.ink, maxLines: 1)
|
|
|
+ lab.alignment = .center
|
|
|
+ lab.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ tag.addSubview(lab)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ lab.leadingAnchor.constraint(equalTo: tag.leadingAnchor, constant: 10),
|
|
|
+ lab.trailingAnchor.constraint(equalTo: tag.trailingAnchor, constant: -10),
|
|
|
+ lab.topAnchor.constraint(equalTo: tag.topAnchor, constant: 5),
|
|
|
+ lab.bottomAnchor.constraint(equalTo: tag.bottomAnchor, constant: -5)
|
|
|
+ ])
|
|
|
+ row.addArrangedSubview(tag)
|
|
|
+ }
|
|
|
+ return row
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Creative (dark sidebar in gallery — match filled page)
|
|
|
+
|
|
|
+ private func buildCreativeFamilyDocument() -> NSView {
|
|
|
+ switch template.layout {
|
|
|
+ case .singleColumn:
|
|
|
+ return creativeSingleColumnDocument()
|
|
|
+ case .twoColumn(let side, _):
|
|
|
+ return creativeTwoColumnDocument(sidebar: side)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func creativeDeepBackground() -> NSColor {
|
|
|
+ let theme = template.themeColor
|
|
|
+ let navy = NSColor(srgbRed: 0.08, green: 0.1, blue: 0.18, alpha: 1)
|
|
|
+ let plum = NSColor(srgbRed: 0.14, green: 0.07, blue: 0.24, alpha: 1)
|
|
|
+ switch variant % 4 {
|
|
|
+ case 0: return theme.blended(withFraction: 0.52, of: navy) ?? theme
|
|
|
+ case 1: return theme.blended(withFraction: 0.7, of: NSColor.black) ?? theme
|
|
|
+ case 2: return style.ink.blended(withFraction: 0.38, of: theme) ?? theme
|
|
|
+ default: return theme.blended(withFraction: 0.4, of: plum) ?? theme
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func creativeSingleColumnDocument() -> NSView {
|
|
|
+ let theme = template.themeColor
|
|
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
+
|
|
|
+ let banner = NSView()
|
|
|
+ banner.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ banner.wantsLayer = true
|
|
|
+ banner.layer?.backgroundColor = theme.cgColor
|
|
|
+ banner.layer?.cornerRadius = variant % 4 == 1 ? 8 : 6
|
|
|
+ let inner = label(" \(nameText) · \(roleText) ", font: .systemFont(ofSize: 14, weight: .bold), color: .white, maxLines: 2)
|
|
|
+ inner.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ banner.addSubview(inner)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ inner.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 14),
|
|
|
+ inner.trailingAnchor.constraint(lessThanOrEqualTo: banner.trailingAnchor, constant: -14),
|
|
|
+ inner.topAnchor.constraint(equalTo: banner.topAnchor, constant: 12),
|
|
|
+ inner.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -12)
|
|
|
+ ])
|
|
|
+
|
|
|
+ let main = creativeMainStack(theme: theme)
|
|
|
+ let col = NSStackView(views: [banner, main])
|
|
|
+ col.orientation = .vertical
|
|
|
+ col.spacing = 16
|
|
|
+ col.alignment = .leading
|
|
|
+ return col
|
|
|
+ }
|
|
|
+
|
|
|
+ private func creativeTwoColumnDocument(sidebar: CVTemplate.SidebarSide) -> NSView {
|
|
|
+ let theme = template.themeColor
|
|
|
+ let deep = creativeDeepBackground()
|
|
|
+ let onSidebar = NSColor.white.withAlphaComponent(0.95)
|
|
|
+ let skillPrefix = (variant % 3 == 0) ? "• " : "▸ "
|
|
|
+
|
|
|
+ let sidebarStack = NSStackView()
|
|
|
+ sidebarStack.orientation = .vertical
|
|
|
+ sidebarStack.spacing = 12
|
|
|
+ sidebarStack.alignment = .leading
|
|
|
+ sidebarStack.wantsLayer = true
|
|
|
+ sidebarStack.layer?.backgroundColor = deep.cgColor
|
|
|
+ sidebarStack.layer?.cornerRadius = variant % 2 == 0 ? 10 : 8
|
|
|
+ sidebarStack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
|
|
+
|
|
|
+ let nm = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
+ let role = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
+ sidebarStack.addArrangedSubview(label(nm, font: .systemFont(ofSize: 18, weight: .bold), color: onSidebar, maxLines: 2))
|
|
|
+ sidebarStack.addArrangedSubview(label(role, font: .systemFont(ofSize: 13, weight: .medium), color: onSidebar.withAlphaComponent(0.85), maxLines: 2))
|
|
|
+ if !profile.personal.email.isEmpty {
|
|
|
+ sidebarStack.addArrangedSubview(label(profile.personal.email, font: .systemFont(ofSize: 11.5), color: onSidebar.withAlphaComponent(0.82), maxLines: 2))
|
|
|
+ }
|
|
|
+ if !profile.personal.phone.isEmpty {
|
|
|
+ sidebarStack.addArrangedSubview(label(profile.personal.phone, font: .systemFont(ofSize: 11.5), color: onSidebar.withAlphaComponent(0.82), maxLines: 1))
|
|
|
+ }
|
|
|
+ sidebarStack.addArrangedSubview(creativeSidebarHeading("STRENGTHS", onSidebar: onSidebar, accent: theme))
|
|
|
+ for token in skillTokensFromProfile(max: 8) {
|
|
|
+ sidebarStack.addArrangedSubview(label("\(skillPrefix)\(token)", font: .systemFont(ofSize: 12, weight: .semibold), color: onSidebar.withAlphaComponent(0.92), maxLines: 2))
|
|
|
+ }
|
|
|
+
|
|
|
+ let main = creativeMainStack(theme: theme)
|
|
|
+ let row = NSStackView()
|
|
|
+ row.orientation = .horizontal
|
|
|
+ row.spacing = 18
|
|
|
+ row.alignment = .top
|
|
|
+ let sidebarMult = 0.32 + CGFloat(variant % 3) * 0.02
|
|
|
+ if sidebar == .leading {
|
|
|
+ row.addArrangedSubview(sidebarStack)
|
|
|
+ row.addArrangedSubview(main)
|
|
|
+ sidebarStack.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
|
|
|
+ } else {
|
|
|
+ row.addArrangedSubview(main)
|
|
|
+ row.addArrangedSubview(sidebarStack)
|
|
|
+ sidebarStack.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
|
|
|
+ }
|
|
|
+ return row
|
|
|
+ }
|
|
|
+
|
|
|
+ private func creativeSidebarHeading(_ raw: String, onSidebar: NSColor, accent: NSColor) -> NSView {
|
|
|
+ let t = label(raw, font: .systemFont(ofSize: 10.5, weight: .heavy), color: onSidebar, maxLines: 1)
|
|
|
+ let bar = NSView()
|
|
|
+ bar.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ bar.wantsLayer = true
|
|
|
+ bar.layer?.backgroundColor = accent.cgColor
|
|
|
+ bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
|
|
|
+ let c = NSStackView(views: [t, bar])
|
|
|
+ c.orientation = .vertical
|
|
|
+ c.spacing = 4
|
|
|
+ c.alignment = .leading
|
|
|
+ bar.leadingAnchor.constraint(equalTo: t.leadingAnchor).isActive = true
|
|
|
+ bar.widthAnchor.constraint(equalToConstant: 72).isActive = true
|
|
|
+ return c
|
|
|
+ }
|
|
|
+
|
|
|
+ private func creativeMainHeader(theme: NSColor) -> NSView {
|
|
|
+ let v = NSView()
|
|
|
+ v.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ let stripe = NSView()
|
|
|
+ stripe.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ stripe.wantsLayer = true
|
|
|
+ stripe.layer?.backgroundColor = theme.cgColor
|
|
|
+ v.addSubview(stripe)
|
|
|
+ let row = NSStackView()
|
|
|
+ row.orientation = .horizontal
|
|
|
+ row.spacing = 8
|
|
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ let lab = label("PORTFOLIO SNAPSHOT", font: .systemFont(ofSize: 12, weight: .heavy), color: style.ink, maxLines: 1)
|
|
|
+ row.addArrangedSubview(stripe)
|
|
|
+ row.addArrangedSubview(lab)
|
|
|
+ v.addSubview(row)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ stripe.widthAnchor.constraint(equalToConstant: 4),
|
|
|
+ stripe.heightAnchor.constraint(equalToConstant: 18),
|
|
|
+ row.leadingAnchor.constraint(equalTo: v.leadingAnchor),
|
|
|
+ row.topAnchor.constraint(equalTo: v.topAnchor),
|
|
|
+ row.bottomAnchor.constraint(equalTo: v.bottomAnchor)
|
|
|
+ ])
|
|
|
+ return v
|
|
|
+ }
|
|
|
+
|
|
|
+ private func creativeMainStack(theme: NSColor) -> NSView {
|
|
|
+ let stack = NSStackView()
|
|
|
+ stack.orientation = .vertical
|
|
|
+ stack.spacing = style.bodyBlockSpacing
|
|
|
+ stack.alignment = .leading
|
|
|
+ stack.addArrangedSubview(creativeMainHeader(theme: theme))
|
|
|
+ if let summary = nonEmpty(profile.careerSummary) {
|
|
|
+ stack.addArrangedSubview(sectionHeading("Profile"))
|
|
|
+ stack.addArrangedSubview(paragraph(summary, compact: false))
|
|
|
+ }
|
|
|
+ let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
|
+ if !jobs.isEmpty {
|
|
|
+ stack.addArrangedSubview(sectionHeading("Impact"))
|
|
|
+ for job in jobs {
|
|
|
+ let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
+ if !titleLine.isEmpty {
|
|
|
+ stack.addArrangedSubview(label(titleLine, font: .systemFont(ofSize: 13.5, weight: .heavy), color: style.ink, maxLines: 0))
|
|
|
+ }
|
|
|
+ for bullet in Self.bulletChunks(from: job.description) {
|
|
|
+ let mark = (variant % 2 == 0) ? "— " : "▸ "
|
|
|
+ stack.addArrangedSubview(label("\(mark)\(bullet)", font: style.bodyFont, color: style.muted, maxLines: 0))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
|
|
|
+ if !schools.isEmpty {
|
|
|
+ stack.addArrangedSubview(sectionHeading("Education"))
|
|
|
+ for edu in schools {
|
|
|
+ stack.addArrangedSubview(educationBlock(edu: edu, compact: false))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ appendCertificatesInterestsReferrals(to: stack, compact: false)
|
|
|
+ return stack
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Traditional families (professional / minimal / executive)
|
|
|
+
|
|
|
+ private func buildTraditionalFamilyDocument() -> NSView {
|
|
|
switch template.layout {
|
|
|
case .singleColumn:
|
|
|
return singleColumnLayout()
|
|
|
@@ -244,11 +741,56 @@ final class CVProfileDocumentView: NSView {
|
|
|
v.alignment = .leading
|
|
|
v.spacing = style.columnVerticalSpacing + 3
|
|
|
v.addArrangedSubview(headerBlock())
|
|
|
+ if template.family == .professional && (variant % 6) == 4 {
|
|
|
+ v.addArrangedSubview(professionalInlineSkillsRow())
|
|
|
+ }
|
|
|
v.addArrangedSubview(hairline())
|
|
|
- v.addArrangedSubview(bodyColumn(compact: false))
|
|
|
+ let body = bodyColumn(compact: false, experienceFirst: professionalExperienceFirst)
|
|
|
+ v.addArrangedSubview(usesProfessionalSingleColumnRail ? bodyWithLeadingAccentRail(body) : body)
|
|
|
return v
|
|
|
}
|
|
|
|
|
|
+ private var professionalExperienceFirst: Bool {
|
|
|
+ template.family == .professional && (variant % 3) == 1
|
|
|
+ }
|
|
|
+
|
|
|
+ private func professionalInlineSkillsRow() -> NSView {
|
|
|
+ let tokens = skillTokensFromProfile(max: 6)
|
|
|
+ guard !tokens.isEmpty else { return NSView() }
|
|
|
+ let joined = tokens.joined(separator: " · ")
|
|
|
+ return label(joined, font: .systemFont(ofSize: 11.5, weight: .medium), color: template.themeColor, maxLines: 0)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Matches the CV Maker thumbnail: professional ATS single-column layouts use a full-height theme rail.
|
|
|
+ private var usesProfessionalSingleColumnRail: Bool {
|
|
|
+ if case .singleColumn = template.layout, template.family == .professional { return true }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ private func bodyWithLeadingAccentRail(_ content: NSView) -> NSView {
|
|
|
+ let wrap = NSView()
|
|
|
+ wrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ let rail = NSView()
|
|
|
+ rail.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ rail.wantsLayer = true
|
|
|
+ rail.layer?.backgroundColor = template.themeColor.cgColor
|
|
|
+ rail.layer?.cornerRadius = 1
|
|
|
+ content.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ wrap.addSubview(rail)
|
|
|
+ wrap.addSubview(content)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ rail.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
|
|
|
+ rail.topAnchor.constraint(equalTo: content.topAnchor),
|
|
|
+ rail.bottomAnchor.constraint(equalTo: content.bottomAnchor),
|
|
|
+ rail.widthAnchor.constraint(equalToConstant: 3),
|
|
|
+ content.leadingAnchor.constraint(equalTo: rail.trailingAnchor, constant: 12),
|
|
|
+ content.trailingAnchor.constraint(equalTo: wrap.trailingAnchor),
|
|
|
+ content.topAnchor.constraint(equalTo: wrap.topAnchor),
|
|
|
+ content.bottomAnchor.constraint(equalTo: wrap.bottomAnchor)
|
|
|
+ ])
|
|
|
+ return wrap
|
|
|
+ }
|
|
|
+
|
|
|
private func twoColumnLayout(sidebar: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
|
|
|
let v = NSStackView()
|
|
|
v.orientation = .vertical
|
|
|
@@ -263,7 +805,7 @@ final class CVProfileDocumentView: NSView {
|
|
|
row.spacing = template.family == .minimal ? 18 : 22
|
|
|
|
|
|
let sidebarCol = sidebarColumn(tinted: tinted)
|
|
|
- let mainCol = bodyColumn(compact: true)
|
|
|
+ let mainCol = bodyColumn(compact: true, experienceFirst: professionalExperienceFirst)
|
|
|
|
|
|
if sidebar == .leading {
|
|
|
row.addArrangedSubview(sidebarCol)
|
|
|
@@ -272,7 +814,13 @@ final class CVProfileDocumentView: NSView {
|
|
|
row.addArrangedSubview(mainCol)
|
|
|
row.addArrangedSubview(sidebarCol)
|
|
|
}
|
|
|
- sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: template.family == .executive ? 0.34 : 0.32).isActive = true
|
|
|
+ let sidebarMult: CGFloat
|
|
|
+ if template.family == .professional {
|
|
|
+ sidebarMult = (variant % 5 == 2) ? 0.38 : 0.32
|
|
|
+ } else {
|
|
|
+ sidebarMult = template.family == .executive ? 0.34 : 0.32
|
|
|
+ }
|
|
|
+ sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
|
|
|
|
|
|
v.addArrangedSubview(row)
|
|
|
return v
|
|
|
@@ -390,8 +938,13 @@ final class CVProfileDocumentView: NSView {
|
|
|
return bar
|
|
|
case .blueBar:
|
|
|
bar.layer?.backgroundColor = template.themeColor.cgColor
|
|
|
- bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
|
|
- bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
|
|
|
+ if template.headline == .centered {
|
|
|
+ bar.heightAnchor.constraint(equalToConstant: 2.5).isActive = true
|
|
|
+ bar.widthAnchor.constraint(equalToConstant: 148).isActive = true
|
|
|
+ } else {
|
|
|
+ bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
|
|
+ bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
|
|
|
+ }
|
|
|
return bar
|
|
|
}
|
|
|
}
|
|
|
@@ -430,33 +983,62 @@ final class CVProfileDocumentView: NSView {
|
|
|
return box
|
|
|
}
|
|
|
|
|
|
- private func bodyColumn(compact: Bool) -> NSView {
|
|
|
+ private func bodyColumn(compact: Bool, experienceFirst: Bool = false) -> NSView {
|
|
|
let v = NSStackView()
|
|
|
v.orientation = .vertical
|
|
|
v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
|
|
|
v.alignment = .leading
|
|
|
|
|
|
- if let summary = nonEmpty(profile.careerSummary) {
|
|
|
- v.addArrangedSubview(sectionHeading("Summary"))
|
|
|
- v.addArrangedSubview(paragraph(summary, compact: compact))
|
|
|
- }
|
|
|
+ let summaryTitle = sectionHeading(summarySectionTitle)
|
|
|
+ let summaryBody: NSView? = nonEmpty(profile.careerSummary).map { paragraph($0, compact: compact) }
|
|
|
|
|
|
let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
|
- if !jobs.isEmpty {
|
|
|
- v.addArrangedSubview(sectionHeading("Experience"))
|
|
|
- for job in jobs {
|
|
|
- v.addArrangedSubview(experienceBlock(job: job, compact: compact))
|
|
|
- }
|
|
|
+ let experienceHeading = sectionHeading("Experience")
|
|
|
+ var experienceBlocks: [NSView] = []
|
|
|
+ for job in jobs {
|
|
|
+ experienceBlocks.append(experienceBlock(job: job, compact: compact))
|
|
|
}
|
|
|
|
|
|
let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
|
|
|
- if !schools.isEmpty {
|
|
|
- v.addArrangedSubview(sectionHeading("Education"))
|
|
|
- for edu in schools {
|
|
|
- v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
|
|
|
+ var educationBlocks: [NSView] = []
|
|
|
+ for edu in schools {
|
|
|
+ educationBlocks.append(educationBlock(edu: edu, compact: compact))
|
|
|
+ }
|
|
|
+
|
|
|
+ let appendSummary: () -> Void = { [self] in
|
|
|
+ if let body = summaryBody {
|
|
|
+ v.addArrangedSubview(summaryTitle)
|
|
|
+ v.addArrangedSubview(body)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let appendExperience: () -> Void = { [self] in
|
|
|
+ if !jobs.isEmpty {
|
|
|
+ v.addArrangedSubview(experienceHeading)
|
|
|
+ experienceBlocks.forEach { v.addArrangedSubview($0) }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let appendEducation: () -> Void = { [self] in
|
|
|
+ if !schools.isEmpty {
|
|
|
+ v.addArrangedSubview(sectionHeading("Education"))
|
|
|
+ educationBlocks.forEach { v.addArrangedSubview($0) }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ if experienceFirst {
|
|
|
+ appendExperience()
|
|
|
+ appendSummary()
|
|
|
+ appendEducation()
|
|
|
+ } else {
|
|
|
+ appendSummary()
|
|
|
+ appendExperience()
|
|
|
+ appendEducation()
|
|
|
+ }
|
|
|
+
|
|
|
+ appendCertificatesInterestsReferrals(to: v, compact: compact)
|
|
|
+ return v
|
|
|
+ }
|
|
|
+
|
|
|
+ private func appendCertificatesInterestsReferrals(to v: NSStackView, compact: Bool) {
|
|
|
if let cert = nonEmpty(profile.certificates) {
|
|
|
v.addArrangedSubview(sectionHeading("Certificates"))
|
|
|
v.addArrangedSubview(paragraph(cert, compact: compact))
|
|
|
@@ -469,8 +1051,27 @@ final class CVProfileDocumentView: NSView {
|
|
|
v.addArrangedSubview(sectionHeading("Referrals"))
|
|
|
v.addArrangedSubview(paragraph(ref, compact: compact))
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- return v
|
|
|
+ private func skillTokensFromProfile(max: Int) -> [String] {
|
|
|
+ let raw = profile.languages.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if raw.isEmpty { return [] }
|
|
|
+ let parts = raw.split(whereSeparator: { $0 == "," || $0 == "·" || $0 == "|" || $0 == ";" })
|
|
|
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
|
+ .filter { !$0.isEmpty }
|
|
|
+ if parts.count > 1 { return Array(parts.prefix(max)) }
|
|
|
+ return raw.split(separator: " ").map(String.init).filter { $0.count > 1 }.prefix(max).map { String($0) }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func highlightsBodyText() -> String? {
|
|
|
+ if let t = nonEmpty(profile.interests) { return t }
|
|
|
+ if let r = nonEmpty(profile.referral) { return r }
|
|
|
+ let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
|
+ if let first = jobs.first {
|
|
|
+ let bullets = Self.bulletChunks(from: first.description)
|
|
|
+ if let b = bullets.first { return b }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
}
|
|
|
|
|
|
private func ancillaryBlock(title: String, body: String?) -> NSStackView? {
|
|
|
@@ -499,17 +1100,37 @@ final class CVProfileDocumentView: NSView {
|
|
|
}
|
|
|
|
|
|
private func experienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
|
|
|
- let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
- let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
let v = NSStackView()
|
|
|
v.orientation = .vertical
|
|
|
v.spacing = template.family == .professional ? 4 : 6
|
|
|
v.alignment = .leading
|
|
|
- if !titleLine.isEmpty {
|
|
|
- v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
|
|
|
- }
|
|
|
- if !meta.isEmpty {
|
|
|
- v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
|
|
|
+
|
|
|
+ if template.family == .professional {
|
|
|
+ let title = job.jobTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let company = job.company.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let duration = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if !title.isEmpty {
|
|
|
+ v.addArrangedSubview(label(title, font: style.expTitleFont, color: style.ink, maxLines: 0))
|
|
|
+ }
|
|
|
+ let metaParts = [company, duration].filter { !$0.isEmpty }
|
|
|
+ if !metaParts.isEmpty {
|
|
|
+ let metaJoined = metaParts.joined(separator: " · ")
|
|
|
+ v.addArrangedSubview(label(metaJoined, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
|
|
|
+ } else if title.isEmpty {
|
|
|
+ let fallback = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
+ if !fallback.isEmpty {
|
|
|
+ v.addArrangedSubview(label(fallback, font: style.expTitleFont, color: style.ink, maxLines: 0))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
+ let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if !titleLine.isEmpty {
|
|
|
+ v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
|
|
|
+ }
|
|
|
+ if !meta.isEmpty {
|
|
|
+ v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
|
|
|
+ }
|
|
|
}
|
|
|
for bullet in Self.bulletChunks(from: job.description) {
|
|
|
v.addArrangedSubview(bulletRow(bullet, compact: compact))
|
|
|
@@ -522,12 +1143,27 @@ final class CVProfileDocumentView: NSView {
|
|
|
v.orientation = .vertical
|
|
|
v.spacing = 4
|
|
|
v.alignment = .leading
|
|
|
- let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
- if !head.isEmpty {
|
|
|
- v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
|
|
|
- }
|
|
|
- if !edu.year.isEmpty {
|
|
|
- v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
|
|
|
+ let institution = edu.institution.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let degree = edu.degree.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let year = edu.year.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+
|
|
|
+ if template.family == .professional {
|
|
|
+ if !institution.isEmpty {
|
|
|
+ v.addArrangedSubview(label(institution, font: style.eduTitleFont, color: style.ink, maxLines: 0))
|
|
|
+ }
|
|
|
+ let subParts = [degree, year].filter { !$0.isEmpty }
|
|
|
+ if !subParts.isEmpty {
|
|
|
+ let sub = subParts.joined(separator: " · ")
|
|
|
+ v.addArrangedSubview(label(sub, font: style.eduMetaFont, color: style.muted, maxLines: 0))
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
+ if !head.isEmpty {
|
|
|
+ v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
|
|
|
+ }
|
|
|
+ if !edu.year.isEmpty {
|
|
|
+ v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
|
|
|
+ }
|
|
|
}
|
|
|
return v
|
|
|
}
|
|
|
@@ -553,6 +1189,11 @@ final class CVProfileDocumentView: NSView {
|
|
|
return label(text, font: font, color: style.ink, maxLines: 0)
|
|
|
}
|
|
|
|
|
|
+ /// Gallery + ATS “Clear Path” style use “Profile”; other families keep the neutral résumé label.
|
|
|
+ private var summarySectionTitle: String {
|
|
|
+ template.family == .professional ? "Profile" : "Summary"
|
|
|
+ }
|
|
|
+
|
|
|
private func sectionHeading(_ raw: String) -> NSTextField {
|
|
|
let upper = raw.uppercased()
|
|
|
let s: String
|
|
|
@@ -581,6 +1222,13 @@ final class CVProfileDocumentView: NSView {
|
|
|
t.font = font
|
|
|
t.textColor = color
|
|
|
t.alignment = .left
|
|
|
+ if isEditable {
|
|
|
+ t.isEditable = true
|
|
|
+ t.isSelectable = true
|
|
|
+ t.isBordered = false
|
|
|
+ t.drawsBackground = false
|
|
|
+ t.focusRingType = .default
|
|
|
+ }
|
|
|
return t
|
|
|
}
|
|
|
|