// // CVProfileDocumentView.swift // App for Indeed // // Renders saved profile data in a layout that follows the selected CV template’s // family (professional / modern / minimal / executive / creative), headline, // accent, section labels, and column structure. // import Cocoa /// Typography and chrome derived from `CVTemplate.family` so the filled résumé /// visibly matches the gallery card the user picked—not a single generic layout. private struct DocumentStyle { let nameFont: NSFont let roleFont: NSFont let contactFont: NSFont let sectionFont: NSFont let bodyFont: NSFont let bodyCompactFont: NSFont let expTitleFont: NSFont let expMetaFont: NSFont let eduTitleFont: NSFont let eduMetaFont: NSFont let bulletBodyFont: NSFont let bulletMarkerFont: NSFont let bulletMarkerColor: NSColor let ink: NSColor let muted: NSColor let rule: NSColor let cardBackground: NSColor let columnVerticalSpacing: CGFloat let bodyBlockSpacing: CGFloat /// When true, the headline job title uses the template theme color. let roleUsesThemeColor: Bool /// Section heading text color (often theme; executive stays conservative). let sectionInk: NSColor static func make(for template: CVTemplate) -> DocumentStyle { let theme = template.themeColor let colors = CVResumeAppearance.colors() let sectionInk = CVResumeAppearance.sectionHeadingColor(for: template) let cardBG = CVResumeAppearance.paperBackground( variant: template.galleryLayoutVariant, base: colors.cardBackground ) switch template.family { case .minimal: return DocumentStyle( nameFont: .systemFont(ofSize: 20, weight: .regular), roleFont: .systemFont(ofSize: 13.5, weight: .regular), contactFont: .systemFont(ofSize: 11.5, weight: .regular), sectionFont: .systemFont(ofSize: 10.5, weight: .semibold), bodyFont: .systemFont(ofSize: 12.5, weight: .regular), bodyCompactFont: .systemFont(ofSize: 12, weight: .regular), expTitleFont: .systemFont(ofSize: 13, weight: .medium), expMetaFont: .systemFont(ofSize: 11.5, weight: .regular), eduTitleFont: .systemFont(ofSize: 13, weight: .medium), eduMetaFont: .systemFont(ofSize: 11.5, weight: .regular), bulletBodyFont: .systemFont(ofSize: 12, weight: .regular), bulletMarkerFont: .systemFont(ofSize: 11, weight: .light), bulletMarkerColor: theme.withAlphaComponent(0.55), ink: colors.ink, muted: colors.muted, rule: colors.rule, cardBackground: cardBG, columnVerticalSpacing: 15, bodyBlockSpacing: 15, roleUsesThemeColor: false, sectionInk: sectionInk ) case .professional: return DocumentStyle( nameFont: .systemFont(ofSize: 21, weight: .semibold), roleFont: .systemFont(ofSize: 13.5, weight: .medium), contactFont: .systemFont(ofSize: 11.5, weight: .regular), sectionFont: .systemFont(ofSize: 10.5, weight: .heavy), bodyFont: .systemFont(ofSize: 12, weight: .regular), bodyCompactFont: .systemFont(ofSize: 11.5, weight: .regular), expTitleFont: .systemFont(ofSize: 13.5, weight: .semibold), expMetaFont: .systemFont(ofSize: 11.5, weight: .semibold), eduTitleFont: .systemFont(ofSize: 13, weight: .semibold), eduMetaFont: .systemFont(ofSize: 11.5, weight: .medium), bulletBodyFont: .systemFont(ofSize: 12, weight: .regular), bulletMarkerFont: .systemFont(ofSize: 12, weight: .bold), bulletMarkerColor: theme, ink: colors.ink, muted: colors.muted, rule: colors.rule, cardBackground: cardBG, columnVerticalSpacing: 13, bodyBlockSpacing: 13, roleUsesThemeColor: false, sectionInk: sectionInk ) case .modern: return DocumentStyle( nameFont: .systemFont(ofSize: 22, weight: .bold), roleFont: .systemFont(ofSize: 14, weight: .semibold), contactFont: .systemFont(ofSize: 12, weight: .regular), sectionFont: .systemFont(ofSize: 11, weight: .heavy), bodyFont: .systemFont(ofSize: 12.5, weight: .regular), bodyCompactFont: .systemFont(ofSize: 12, weight: .regular), expTitleFont: .systemFont(ofSize: 14, weight: .bold), expMetaFont: .systemFont(ofSize: 12, weight: .medium), eduTitleFont: .systemFont(ofSize: 13.5, weight: .bold), eduMetaFont: .systemFont(ofSize: 12, weight: .regular), bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular), bulletMarkerFont: .systemFont(ofSize: 13, weight: .bold), bulletMarkerColor: theme, ink: colors.ink, muted: colors.muted, rule: colors.rule, cardBackground: cardBG, columnVerticalSpacing: 17, bodyBlockSpacing: 16, roleUsesThemeColor: true, sectionInk: sectionInk ) case .executive: let serifName = NSFont(name: "Georgia-Bold", size: 23) ?? .systemFont(ofSize: 23, weight: .semibold) let serifRole = NSFont(name: "Georgia", size: 14) ?? .systemFont(ofSize: 14, weight: .regular) let serifBody = NSFont(name: "Georgia", size: 12.5) ?? .systemFont(ofSize: 12.5, weight: .regular) let serifCompact = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12, weight: .regular) let georgia12 = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12) let georgia115 = NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5) let expMeta = NSFont(name: "Georgia-Italic", size: 12) ?? NSFontManager.shared.convert(georgia12, toHaveTrait: .italicFontMask) let eduMeta = NSFont(name: "Georgia-Italic", size: 11.5) ?? NSFontManager.shared.convert(georgia115, toHaveTrait: .italicFontMask) let execCard = CVResumeAppearance.isDark ? cardBG : NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1) return DocumentStyle( nameFont: serifName, roleFont: serifRole, contactFont: NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5), sectionFont: .systemFont(ofSize: 10.5, weight: .heavy), bodyFont: serifBody, bodyCompactFont: serifCompact, expTitleFont: NSFont(name: "Georgia-Bold", size: 14) ?? .systemFont(ofSize: 14, weight: .semibold), expMetaFont: expMeta, eduTitleFont: NSFont(name: "Georgia-Bold", size: 13.5) ?? .systemFont(ofSize: 13.5, weight: .semibold), eduMetaFont: eduMeta, bulletBodyFont: serifCompact, bulletMarkerFont: .systemFont(ofSize: 11, weight: .bold), bulletMarkerColor: colors.ink.withAlphaComponent(0.75), ink: colors.ink, muted: colors.muted, rule: colors.rule, cardBackground: execCard, columnVerticalSpacing: 18, bodyBlockSpacing: 17, roleUsesThemeColor: false, sectionInk: sectionInk ) case .creative: return DocumentStyle( nameFont: .systemFont(ofSize: 23, weight: .heavy), roleFont: .systemFont(ofSize: 14, weight: .semibold), contactFont: .systemFont(ofSize: 11.5, weight: .medium), sectionFont: .systemFont(ofSize: 11.5, weight: .heavy), bodyFont: .systemFont(ofSize: 12.5, weight: .regular), bodyCompactFont: .systemFont(ofSize: 12, weight: .regular), expTitleFont: .systemFont(ofSize: 14, weight: .heavy), expMetaFont: .systemFont(ofSize: 12, weight: .semibold), eduTitleFont: .systemFont(ofSize: 13.5, weight: .heavy), eduMetaFont: .systemFont(ofSize: 12, weight: .medium), bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular), bulletMarkerFont: .systemFont(ofSize: 13, weight: .heavy), bulletMarkerColor: CVResumeAppearance.accentColor(for: template), ink: colors.ink, muted: colors.muted, rule: theme.withAlphaComponent(0.22), cardBackground: cardBG, columnVerticalSpacing: 18, bodyBlockSpacing: 17, roleUsesThemeColor: true, sectionInk: sectionInk ) } } } /// Full-width résumé layout that injects `SavedProfile` into the visual language of `CVTemplate`. final class CVProfileDocumentView: NSView { /// Card width used in the CV preview; also the horizontal fitting size for this view. /// Without this, a parent `NSStackView` that only pins `width ≤ …` can size the document from /// wrapping `NSTextField` intrinsic widths (~0) and the whole page collapses to a thin strip. private static let cardWidth: CGFloat = 640 private let profile: SavedProfile private let template: CVTemplate private var style: DocumentStyle /// Matches `CVTemplatePreviewView` so the same template id + layout recipe renders the same silhouette as the gallery card. private let variant: Int private var appearanceObserver: NSObjectProtocol? private weak var cardView: NSView? init(profile: SavedProfile, template: CVTemplate) { self.profile = profile self.template = template self.style = DocumentStyle.make(for: template) self.variant = template.galleryLayoutVariant super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false wantsLayer = true layer?.backgroundColor = NSColor.clear.cgColor userInterfaceLayoutDirection = .leftToRight setContentHuggingPriority(.defaultLow, for: .horizontal) installCardContent() appearanceObserver = NotificationCenter.default.addObserver( forName: AppAppearanceManager.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in self?.refreshForAppearanceChange() } } deinit { if let appearanceObserver { NotificationCenter.default.removeObserver(appearanceObserver) } } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() refreshForAppearanceChange() } private func refreshForAppearanceChange() { style = DocumentStyle.make(for: template) installCardContent() } private func installCardContent() { subviews.forEach { $0.removeFromSuperview() } let card = NSView() card.translatesAutoresizingMaskIntoConstraints = false card.wantsLayer = true card.layer?.backgroundColor = style.cardBackground.cgColor card.layer?.cornerRadius = template.family == .executive ? 6 : 10 card.layer?.borderWidth = 1 card.layer?.borderColor = style.rule.cgColor card.layer?.masksToBounds = true cardView = card let root = buildRoot() root.translatesAutoresizingMaskIntoConstraints = false card.addSubview(root) addSubview(card) NSLayoutConstraint.activate([ card.leadingAnchor.constraint(equalTo: leadingAnchor), card.trailingAnchor.constraint(equalTo: trailingAnchor), card.topAnchor.constraint(equalTo: topAnchor), card.bottomAnchor.constraint(equalTo: bottomAnchor), card.widthAnchor.constraint(equalToConstant: Self.cardWidth), root.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 36), root.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -36), root.topAnchor.constraint(equalTo: card.topAnchor, constant: 32), root.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -36) ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var intrinsicContentSize: NSSize { NSSize(width: Self.cardWidth, height: NSView.noIntrinsicMetric) } override func layout() { super.layout() // Wrapping `NSTextField`s default to a very small intrinsic width until // `preferredMaxLayoutWidth` tracks the column width — stacks then collapse and text reflows like a narrow strip. updateWrappingTextPreferredWidths() } /// Any wrapping body (`maximumNumberOfLines == 0`) needs a concrete wrap width inside stack-driven layout. private func updateWrappingTextPreferredWidths() { for field in Self.collectWrappingTextFields(in: self) { guard let parent = field.superview, parent.bounds.width > 2 else { continue } let w = parent.bounds.width if abs(field.preferredMaxLayoutWidth - w) > 0.5 { field.preferredMaxLayoutWidth = w } } } private static func collectWrappingTextFields(in root: NSView) -> [NSTextField] { var out: [NSTextField] = [] func visit(_ v: NSView) { if let tf = v as? NSTextField, tf.maximumNumberOfLines == 0 { out.append(tf) } for c in v.subviews { visit(c) } } visit(root) return out } // MARK: - Composition private func buildRoot() -> NSView { switch template.family { case .modern: return buildModernFamilyDocument() case .creative: return buildCreativeFamilyDocument() case .executive: return buildExecutiveDocument() case .minimal: return buildMinimalDocument() case .professional: return buildProfessionalDocument() } } // 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.ink, 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: - Executive (matches gallery serif layout) private func buildExecutiveDocument() -> NSView { let centeredHead = (variant % 2) == 0 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, profile.personal.address].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ") let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2) let role = label(roleText, font: style.roleFont, color: style.muted, maxLines: 2) let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.9), maxLines: 3) if centeredHead { name.alignment = .center role.alignment = .center contact.alignment = .center } let rule = executiveHeaderRule(wide: variant % 3 == 0) let head = NSStackView(views: [name, role, contact, rule]) head.orientation = .vertical head.spacing = 8 head.alignment = centeredHead ? .centerX : .leading let body: NSView switch template.layout { case .singleColumn: body = executiveMainColumn(compact: false, tightLeading: variant % 5 == 2) case .twoColumn(let side, let tinted): let main = executiveMainColumn(compact: true, tightLeading: variant % 5 == 2) let sideCol = executiveSidebarColumn(tinted: tinted, showMetrics: variant % 4 == 1) let row = NSStackView() row.orientation = .horizontal row.spacing = 20 row.alignment = .top if side == .leading { row.addArrangedSubview(sideCol) row.addArrangedSubview(main) } else { row.addArrangedSubview(main) row.addArrangedSubview(sideCol) } let mult: CGFloat = (variant % 5 == 3) ? 0.38 : 0.33 sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true body = row } let wrap = NSStackView(views: [head, body]) wrap.orientation = .vertical wrap.spacing = style.columnVerticalSpacing wrap.alignment = .leading return wrap } private func executiveHeaderRule(wide: Bool) -> NSView { let theme = template.themeColor let v = NSView() v.translatesAutoresizingMaskIntoConstraints = false v.wantsLayer = true v.layer?.backgroundColor = theme.withAlphaComponent(0.45).cgColor v.heightAnchor.constraint(equalToConstant: wide ? 2 : 1.5).isActive = true v.widthAnchor.constraint(equalToConstant: wide ? 160 : 110).isActive = true return v } private func executiveMainColumn(compact: Bool, tightLeading: Bool) -> NSView { let stack = NSStackView() stack.orientation = .vertical stack.spacing = (compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2) - (tightLeading ? 2 : 0) stack.alignment = .leading let summaryTitle = (variant % 6 == 3) ? "Summary" : "Professional Summary" if let summary = nonEmpty(profile.careerSummary) { stack.addArrangedSubview(sectionHeading(summaryTitle)) stack.addArrangedSubview(paragraph(summary, compact: compact)) } let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty } if !jobs.isEmpty { stack.addArrangedSubview(sectionHeading("Selected Experience")) for job in jobs { stack.addArrangedSubview(executiveExperienceBlock(job: job, compact: compact)) } } let schools = profile.educations.filter { !$0.isEffectivelyEmpty } if !schools.isEmpty { stack.addArrangedSubview(sectionHeading("Education")) for edu in schools { stack.addArrangedSubview(educationBlock(edu: edu, compact: compact)) } } appendCertificatesInterestsReferrals(to: stack, compact: compact) return stack } private func executiveExperienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView { let v = NSStackView() v.orientation = .vertical v.spacing = 4 v.alignment = .leading let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: ", ") if !titleLine.isEmpty { v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0)) } let duration = job.duration.trimmingCharacters(in: .whitespacesAndNewlines) if !duration.isEmpty { v.addArrangedSubview(label(duration, font: style.expMetaFont, color: template.themeColor, maxLines: 0)) } for bullet in Self.bulletChunks(from: job.description) { v.addArrangedSubview(label(bullet, font: style.bodyFont, color: style.muted, maxLines: 0)) } return v } private func executiveSidebarColumn(tinted: Bool, showMetrics: Bool) -> NSView { let stack = NSStackView() stack.orientation = .vertical stack.spacing = 10 stack.alignment = .leading if tinted { stack.wantsLayer = true let fill = (variant % 3 == 0) ? CVResumeAppearance.colors().sidebarTint : template.themeColor.withAlphaComponent(0.07) stack.layer?.backgroundColor = fill.cgColor stack.layer?.cornerRadius = variant % 4 == 2 ? 8 : 6 stack.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14) } let skills = skillTokensFromProfile(max: 12) if !skills.isEmpty { stack.addArrangedSubview(sectionHeading("Core Competencies")) for s in skills { stack.addArrangedSubview(label("· \(s)", font: style.bodyFont, color: style.ink, maxLines: 0)) } } if let cert = nonEmpty(profile.certificates) { stack.addArrangedSubview(sectionHeading("Tools")) stack.addArrangedSubview(paragraph(cert, compact: true)) } if showMetrics, let hi = highlightsBodyText() { stack.addArrangedSubview(sectionHeading("Impact")) stack.addArrangedSubview(paragraph(hi, compact: true)) } if stack.arrangedSubviews.isEmpty { stack.addArrangedSubview(sectionHeading("Contact")) for line in contactLines() { stack.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0)) } } return stack } // MARK: - Minimal (matches gallery light typography) private func buildMinimalDocument() -> NSView { 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 contactText = contactParts.isEmpty ? "Add contact in your profile" : contactParts.joined(separator: " ") let nameWeight: NSFont.Weight = (variant % 3 == 0) ? .ultraLight : .light let nameSize: CGFloat = template.headline == .centered ? 22 + CGFloat(variant % 2) : 20 + CGFloat(variant % 3) let name = label(nameText, font: .systemFont(ofSize: nameSize, weight: nameWeight), color: style.ink, maxLines: 2) let role = label(roleText.uppercased(), font: .systemFont(ofSize: 12.5, weight: .medium), color: style.muted, maxLines: 2) let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.75), maxLines: 2) let head = NSStackView() head.orientation = .vertical head.spacing = template.headline == .avatarStacked ? 10 : 6 + CGFloat(variant % 4) head.alignment = template.headline == .centered ? .centerX : .leading if template.headline == .avatarStacked { head.addArrangedSubview(initialsBadge(for: nameText)) } if template.headline == .centered { name.alignment = .center role.alignment = .center contact.alignment = .center } head.addArrangedSubview(name) head.addArrangedSubview(role) head.addArrangedSubview(contact) head.addArrangedSubview(hairline()) if variant % 5 == 1 { head.addArrangedSubview(hairline()) } let swapEdu = (variant % 4) == 2 let body: NSView switch template.layout { case .singleColumn: body = minimalMainColumn(spacing: style.bodyBlockSpacing, educationBeforeExperience: swapEdu) case .twoColumn(let side, _): let main = minimalMainColumn(spacing: style.bodyBlockSpacing - 2, educationBeforeExperience: swapEdu) let aside = minimalSkillsAside(numbered: variant % 3 == 1) let row = NSStackView() row.orientation = .horizontal row.spacing = 18 + CGFloat(variant % 3) row.alignment = .top if side == .leading { row.addArrangedSubview(aside) row.addArrangedSubview(main) } else { row.addArrangedSubview(main) row.addArrangedSubview(aside) } let mult: CGFloat = (variant % 5 == 0) ? 0.34 : 0.30 aside.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true body = row } let wrap = NSStackView(views: [head, body]) wrap.orientation = .vertical wrap.spacing = style.columnVerticalSpacing wrap.alignment = .leading return wrap } private func minimalMainColumn(spacing: CGFloat, educationBeforeExperience: Bool) -> NSView { let stack = NSStackView() stack.orientation = .vertical stack.spacing = spacing stack.alignment = .leading let appendSummary: () -> Void = { [self] in if let summary = nonEmpty(profile.careerSummary) { stack.addArrangedSubview(sectionHeading("Profile")) stack.addArrangedSubview(paragraph(summary, compact: false)) } } let appendExperience: () -> Void = { [self] in let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty } if !jobs.isEmpty { stack.addArrangedSubview(sectionHeading("Experience")) for job in jobs { stack.addArrangedSubview(experienceBlock(job: job, compact: false)) } } } let appendEducation: () -> Void = { [self] in 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)) } } } if educationBeforeExperience { appendEducation() appendSummary() appendExperience() } else { appendSummary() appendExperience() appendEducation() } appendCertificatesInterestsReferrals(to: stack, compact: false) return stack } private func minimalSkillsAside(numbered: Bool) -> NSView { let stack = NSStackView() stack.orientation = .vertical stack.spacing = 8 stack.alignment = .leading let skills = skillTokensFromProfile(max: 12) if skills.isEmpty { stack.addArrangedSubview(sectionHeading("Contact")) for line in contactLines() { stack.addArrangedSubview(label(line, font: style.contactFont, color: style.muted, maxLines: 0)) } return stack } stack.addArrangedSubview(sectionHeading("Skills")) for (i, s) in skills.enumerated() { let prefix = numbered ? "\(i + 1). " : "· " stack.addArrangedSubview(label("\(prefix)\(s)", font: style.bodyCompactFont, color: style.muted, maxLines: 0)) } return stack } // MARK: - Professional private func buildProfessionalDocument() -> NSView { switch template.layout { case .singleColumn: return singleColumnLayout() case .twoColumn(let side, let tinted): return twoColumnLayout(sidebar: side, tinted: tinted) } } private func singleColumnLayout() -> NSView { let v = NSStackView() v.orientation = .vertical v.alignment = .leading v.spacing = style.columnVerticalSpacing + 3 v.addArrangedSubview(headerBlock()) if template.family == .professional && (variant % 6) == 4 { v.addArrangedSubview(professionalInlineSkillsRow()) } v.addArrangedSubview(hairline()) 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 v.alignment = .leading v.spacing = style.columnVerticalSpacing + 2 v.addArrangedSubview(headerBlock()) v.addArrangedSubview(hairline()) let row = NSStackView() row.orientation = .horizontal row.alignment = .top row.spacing = 22 let sidebarCol = sidebarColumn(tinted: tinted) let mainCol = bodyColumn(compact: true, experienceFirst: professionalExperienceFirst) if sidebar == .leading { row.addArrangedSubview(sidebarCol) row.addArrangedSubview(mainCol) } else { row.addArrangedSubview(mainCol) row.addArrangedSubview(sidebarCol) } let sidebarMult: CGFloat = (variant % 5 == 2) ? 0.38 : 0.32 sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true v.addArrangedSubview(row) return v } private func headerBlock() -> NSView { 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, profile.personal.address].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ") let roleColor = style.roleUsesThemeColor ? template.themeColor : style.muted let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2) let role = label(roleText, font: style.roleFont, color: roleColor, maxLines: 2) let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.92), maxLines: 3) let textCol = NSStackView(views: [name, role, contact]) textCol.orientation = .vertical textCol.spacing = template.family == .professional ? 3 : 4 textCol.alignment = .leading switch template.headline { case .centered: textCol.alignment = .centerX name.alignment = .center role.alignment = .center contact.alignment = .center let accent = headlineAccent() let stack = NSStackView(views: [textCol, accent]) stack.orientation = .vertical stack.spacing = 8 stack.alignment = .centerX return stack case .avatarStacked: textCol.alignment = .centerX name.alignment = .center role.alignment = .center contact.alignment = .center let accent = headlineAccent() let avatar = initialsBadge(for: nameText) let stack = NSStackView(views: [avatar, textCol, accent]) stack.orientation = .vertical stack.spacing = 8 stack.alignment = .centerX return stack case .leftAligned, .leftWithInitials: let row = NSStackView() row.orientation = .horizontal row.spacing = 14 row.alignment = .centerY row.addArrangedSubview(textCol) if template.headline == .leftWithInitials { row.addArrangedSubview(NSView()) row.addArrangedSubview(initialsBadge(for: nameText)) } let col = NSStackView(views: [row, headlineAccent()]) col.orientation = .vertical col.spacing = 8 col.alignment = .leading return col } } private func initialsBadge(for fullName: String) -> NSView { let initials = Self.initials(from: fullName) let t = NSTextField(labelWithString: initials) t.font = .systemFont(ofSize: 13, weight: .bold) t.textColor = template.themeColor t.alignment = .center t.translatesAutoresizingMaskIntoConstraints = false let wrap = NSView() wrap.translatesAutoresizingMaskIntoConstraints = false wrap.wantsLayer = true wrap.layer?.cornerRadius = 22 wrap.layer?.borderWidth = 1.5 wrap.layer?.borderColor = template.themeColor.withAlphaComponent(0.35).cgColor wrap.addSubview(t) NSLayoutConstraint.activate([ wrap.widthAnchor.constraint(equalToConstant: 44), wrap.heightAnchor.constraint(equalToConstant: 44), t.centerXAnchor.constraint(equalTo: wrap.centerXAnchor), t.centerYAnchor.constraint(equalTo: wrap.centerYAnchor) ]) return wrap } private static func initials(from fullName: String) -> String { let parts = fullName.split(separator: " ").filter { !$0.isEmpty } if parts.count >= 2 { let a = parts[0].prefix(1) let b = parts[1].prefix(1) return "\(a)\(b)".uppercased() } if let first = parts.first { return String(first.prefix(2)).uppercased() } return "CV" } private func headlineAccent() -> NSView { let bar = NSView() bar.translatesAutoresizingMaskIntoConstraints = false bar.wantsLayer = true switch template.accent { case .none: bar.heightAnchor.constraint(equalToConstant: 1).isActive = true return bar case .redUnderline: bar.layer?.backgroundColor = CVResumeAppearance.accentColor(for: template).cgColor bar.heightAnchor.constraint(equalToConstant: 2).isActive = true bar.widthAnchor.constraint(equalToConstant: template.family == .minimal ? 140 : 168).isActive = true return bar case .redBar: bar.layer?.backgroundColor = CVResumeAppearance.accentColor(for: template).cgColor bar.heightAnchor.constraint(equalToConstant: 4).isActive = true bar.widthAnchor.constraint(equalToConstant: 56).isActive = true return bar case .blueBar: bar.layer?.backgroundColor = template.themeColor.cgColor 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 } } private func hairline() -> NSView { let v = NSView() v.translatesAutoresizingMaskIntoConstraints = false v.wantsLayer = true v.layer?.backgroundColor = style.rule.cgColor let h: CGFloat = template.family == .executive ? 1.5 : 1 v.heightAnchor.constraint(equalToConstant: h).isActive = true return v } private func sidebarColumn(tinted: Bool) -> NSView { let box = NSStackView() box.orientation = .vertical box.spacing = 12 box.alignment = .leading if tinted { box.wantsLayer = true let tint = (variant % 4 == 1) ? template.themeColor.withAlphaComponent(0.08) : CVResumeAppearance.colors().sidebarTint box.layer?.backgroundColor = tint.cgColor box.layer?.cornerRadius = variant % 3 == 0 ? 8 : 6 } box.edgeInsets = NSEdgeInsets(top: tinted ? 14 : 0, left: tinted ? 14 : 0, bottom: tinted ? 14 : 0, right: tinted ? 14 : 0) box.addArrangedSubview(sectionHeading("Contact")) for line in contactLines() { box.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0)) } let skills = skillTokensFromProfile(max: 8) if !skills.isEmpty { box.addArrangedSubview(sectionHeading("Skills")) if variant % 5 == 2 { box.addArrangedSubview(skillTagRow(theme: template.themeColor, maxTags: 5)) } else { for token in skills { box.addArrangedSubview(label("· \(token)", font: style.contactFont, color: style.ink, maxLines: 0)) } } } if variant % 7 == 3, let tools = nonEmpty(profile.certificates) { box.addArrangedSubview(sectionHeading("Tools")) box.addArrangedSubview(paragraph(tools, compact: true)) } else if let ancillary = combinedAncillaryText(), !ancillary.isEmpty { box.addArrangedSubview(sectionHeading("Languages & more")) box.addArrangedSubview(paragraph(ancillary, compact: true)) } return box } 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 let summaryTitle = sectionHeading(summarySectionTitle) let summaryBody: NSView? = nonEmpty(profile.careerSummary).map { paragraph($0, compact: compact) } let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty } 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 } 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)) } if let interests = nonEmpty(profile.interests) { v.addArrangedSubview(sectionHeading("Interests")) v.addArrangedSubview(paragraph(interests, compact: compact)) } if let ref = nonEmpty(profile.referral) { v.addArrangedSubview(sectionHeading("Referrals")) v.addArrangedSubview(paragraph(ref, compact: compact)) } } 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? { guard let body, !body.isEmpty else { return nil } let s = NSStackView() s.orientation = .vertical s.spacing = 6 s.alignment = .leading s.addArrangedSubview(sectionHeading(title)) s.addArrangedSubview(paragraph(body, compact: true)) return s } private func contactLines() -> [String] { var lines: [String] = [] let p = profile.personal if !p.email.isEmpty { lines.append(p.email) } if !p.phone.isEmpty { lines.append(p.phone) } if !p.address.isEmpty { lines.append(p.address) } return lines.isEmpty ? ["—"] : lines } private func combinedAncillaryText() -> String? { let chunks = [profile.languages, profile.interests].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } return chunks.isEmpty ? nil : chunks.joined(separator: "\n\n") } private func experienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView { let v = NSStackView() v.orientation = .vertical v.spacing = template.family == .professional ? 4 : 6 v.alignment = .leading 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)) } return v } private func educationBlock(edu: EducationPayload, compact: Bool) -> NSView { let v = NSStackView() v.orientation = .vertical v.spacing = 4 v.alignment = .leading 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 } private func bulletRow(_ text: String, compact: Bool) -> NSView { let marker: String = template.family == .minimal ? "·" : "•" let dot = NSTextField(labelWithString: marker) dot.font = style.bulletMarkerFont dot.textColor = style.bulletMarkerColor dot.translatesAutoresizingMaskIntoConstraints = false let bodyFont = compact ? style.bodyCompactFont : style.bulletBodyFont let body = label(text, font: bodyFont, color: style.ink, maxLines: 0) let row = NSStackView(views: [dot, body]) row.orientation = .horizontal row.spacing = template.family == .creative ? 10 : 8 row.alignment = .top dot.setContentHuggingPriority(.required, for: .horizontal) return row } private func paragraph(_ text: String, compact: Bool) -> NSTextField { let font = compact ? style.bodyCompactFont : style.bodyFont 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 switch template.sectionLabelStyle { case .uppercase: s = upper case .slashed: s = "// \(upper)" case .bracketed: s = "[ \(upper) ]" } let t = NSTextField(labelWithString: s) t.font = style.sectionFont t.textColor = style.sectionInk t.alignment = .left return t } private func label(_ string: String, font: NSFont, color: NSColor, maxLines: Int) -> NSTextField { let isWrapping = maxLines == 0 let t: NSTextField if isWrapping { t = NSTextField(wrappingLabelWithString: string) t.maximumNumberOfLines = 0 } else { t = NSTextField(labelWithString: string) t.maximumNumberOfLines = maxLines } t.font = font t.textColor = color t.alignment = .left return t } private func displayable(_ value: String, placeholder: String) -> String { let t = value.trimmingCharacters(in: .whitespacesAndNewlines) return t.isEmpty ? placeholder : t } private func nonEmpty(_ value: String) -> String? { let t = value.trimmingCharacters(in: .whitespacesAndNewlines) return t.isEmpty ? nil : t } private static func bulletChunks(from text: String) -> [String] { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return [] } let byNewline = trimmed.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } if byNewline.count > 1 { return byNewline } let byBullet = trimmed.split(separator: "•") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } if byBullet.count > 1 { return byBullet.map { String($0) } } return [trimmed] } } // MARK: - Payload helpers private extension WorkExperiencePayload { var isEffectivelyEmpty: Bool { [jobTitle, company, duration, description].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } } private extension EducationPayload { var isEffectivelyEmpty: Bool { [degree, institution, year].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } }