Kaynağa Gözat

Complete CV Maker flow with profile-filled previews.

Route template selection to Profile, add Build CV with family-aware layout and built-in catalog fallback after AI gallery refresh, and add six professional catalog entries.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 ay önce
ebeveyn
işleme
6446b8b007

+ 118 - 0
App for Indeed/Views/CVFilledPreviewPageView.swift

@@ -0,0 +1,118 @@
+//
+//  CVFilledPreviewPageView.swift
+//  App for Indeed
+//
+//  Full-screen preview of a profile merged into the selected CV template.
+//
+
+import Cocoa
+
+private final class CVPreviewFlippedDocumentView: NSView {
+    override var isFlipped: Bool { true }
+}
+
+/// Hosts a scrollable `CVProfileDocumentView` with a simple chrome header and back navigation.
+final class CVFilledPreviewPageView: NSView {
+
+    var onDismiss: (() -> Void)?
+
+    private let backButton = NSButton(title: "← Profiles", target: nil, action: nil)
+    private let titleLabel = NSTextField(labelWithString: "CV preview")
+    private let scrollView = NSScrollView()
+    private let documentView = CVPreviewFlippedDocumentView()
+    private let contentStack = NSStackView()
+
+    private static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
+    private static let secondaryText = NSColor(srgbRed: 100 / 255, green: 116 / 255, blue: 139 / 255, alpha: 1)
+    private static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
+
+    override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        wantsLayer = true
+        layer?.backgroundColor = Self.pageBackground.cgColor
+        userInterfaceLayoutDirection = .leftToRight
+
+        backButton.translatesAutoresizingMaskIntoConstraints = false
+        backButton.bezelStyle = .rounded
+        backButton.isBordered = false
+        backButton.font = .systemFont(ofSize: 13, weight: .semibold)
+        backButton.contentTintColor = Self.brandBlue
+        backButton.target = self
+        backButton.action = #selector(didTapBack)
+
+        titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
+        titleLabel.textColor = NSColor(srgbRed: 31 / 255, green: 41 / 255, blue: 55 / 255, alpha: 1)
+
+        let subtitle = NSTextField(wrappingLabelWithString: "Your profile fields are laid out using the template you chose in CV Maker.")
+        subtitle.font = .systemFont(ofSize: 12, weight: .regular)
+        subtitle.textColor = Self.secondaryText
+        subtitle.maximumNumberOfLines = 0
+
+        let headerCol = NSStackView(views: [backButton, titleLabel, subtitle])
+        headerCol.orientation = .vertical
+        headerCol.alignment = .leading
+        headerCol.spacing = 6
+        headerCol.setCustomSpacing(14, after: backButton)
+        headerCol.translatesAutoresizingMaskIntoConstraints = false
+
+        contentStack.orientation = .vertical
+        contentStack.alignment = .leading
+        contentStack.spacing = 20
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
+
+        documentView.translatesAutoresizingMaskIntoConstraints = false
+        documentView.addSubview(contentStack)
+
+        scrollView.translatesAutoresizingMaskIntoConstraints = false
+        scrollView.drawsBackground = false
+        scrollView.hasVerticalScroller = true
+        scrollView.hasHorizontalScroller = false
+        scrollView.autohidesScrollers = true
+        scrollView.borderType = .noBorder
+        scrollView.documentView = documentView
+
+        addSubview(headerCol)
+        addSubview(scrollView)
+
+        NSLayoutConstraint.activate([
+            headerCol.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 32),
+            headerCol.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -32),
+            headerCol.topAnchor.constraint(equalTo: topAnchor, constant: 16),
+
+            scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
+            scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
+            scrollView.topAnchor.constraint(equalTo: headerCol.bottomAnchor, constant: 16),
+            scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+            documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
+            documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
+            documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
+            documentView.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 40),
+
+            contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
+            contentStack.trailingAnchor.constraint(lessThanOrEqualTo: documentView.trailingAnchor, constant: -32),
+            contentStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 8),
+            contentStack.widthAnchor.constraint(lessThanOrEqualTo: documentView.widthAnchor, constant: -64)
+        ])
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    func configure(profile: SavedProfile, template: CVTemplate) {
+        for v in contentStack.arrangedSubviews {
+            contentStack.removeArrangedSubview(v)
+            v.removeFromSuperview()
+        }
+        let doc = CVProfileDocumentView(profile: profile, template: template)
+        contentStack.addArrangedSubview(doc)
+        let profileTitle = profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName
+        titleLabel.stringValue = "\(template.name) · \(profileTitle)"
+    }
+
+    @objc private func didTapBack() {
+        onDismiss?()
+    }
+}

+ 69 - 5
App for Indeed/Views/CVMakerPageView.swift

@@ -304,6 +304,60 @@ enum CVTemplateCatalog {
             layout: .twoColumn(sidebar: .leading, tinted: false),
             sectionLabelStyle: .uppercase
         ),
+        CVTemplate(
+            id: "briefing",
+            name: "Briefing",
+            family: .professional,
+            headline: .leftAligned,
+            accent: .blueBar,
+            layout: .twoColumn(sidebar: .leading, tinted: true),
+            sectionLabelStyle: .uppercase
+        ),
+        CVTemplate(
+            id: "quorum",
+            name: "Quorum",
+            family: .professional,
+            headline: .leftWithInitials,
+            accent: .none,
+            layout: .singleColumn,
+            sectionLabelStyle: .bracketed
+        ),
+        CVTemplate(
+            id: "docket",
+            name: "Docket",
+            family: .professional,
+            headline: .centered,
+            accent: .blueBar,
+            layout: .twoColumn(sidebar: .trailing, tinted: false),
+            sectionLabelStyle: .uppercase
+        ),
+        CVTemplate(
+            id: "conduit",
+            name: "Conduit",
+            family: .professional,
+            headline: .leftAligned,
+            accent: .blueBar,
+            layout: .singleColumn,
+            sectionLabelStyle: .slashed
+        ),
+        CVTemplate(
+            id: "principal",
+            name: "Principal",
+            family: .professional,
+            headline: .leftWithInitials,
+            accent: .blueBar,
+            layout: .twoColumn(sidebar: .trailing, tinted: true),
+            sectionLabelStyle: .uppercase
+        ),
+        CVTemplate(
+            id: "charter",
+            name: "Charter",
+            family: .professional,
+            headline: .leftAligned,
+            accent: .none,
+            layout: .twoColumn(sidebar: .leading, tinted: false),
+            sectionLabelStyle: .uppercase
+        ),
 
         // Modern family
         CVTemplate(
@@ -542,6 +596,19 @@ final class CVMakerPageView: NSView {
     /// Every visible gallery card (not keyed by id — duplicate AI ids would collapse in a dictionary and break single-selection visuals).
     private var templateCardsInGrid: [CVTemplateCard] = []
 
+    /// Invoked when the user taps **Use Template & Select Profile** with a valid gallery selection. Delivers the selected template’s catalog id so the host can route to profile pickers or a future editor.
+    var onContinueToProfileSelection: ((String) -> Void)?
+
+    func templateInGallery(withID id: String) -> CVTemplate? {
+        resolvedTemplate(withID: id)
+    }
+
+    /// Resolves a template from the live gallery, then falls back to the built-in catalog when AI fetch replaces `activeCatalog` (so the user’s selection still previews correctly).
+    func resolvedTemplate(withID id: String) -> CVTemplate? {
+        if let match = activeCatalog.first(where: { $0.id == id }) { return match }
+        return CVTemplateCatalog.all.first { $0.id == id }
+    }
+
     private var appliedGridColumnCount: Int = 0
 
     override init(frame frameRect: NSRect) {
@@ -898,14 +965,11 @@ final class CVMakerPageView: NSView {
 
     @objc private func didTapUseTemplate() {
         guard let id = selectedTemplateID,
-              let template = activeCatalog.first(where: { $0.id == id }) else {
+              activeCatalog.contains(where: { $0.id == id }) else {
             presentPlaceholderAlert(title: "Pick a template", message: "Select a template first, then choose a profile to continue.")
             return
         }
-        presentPlaceholderAlert(
-            title: "Use \"\(template.name)\"",
-            message: "Profile selection and CV editing are not available in this preview build yet."
-        )
+        onContinueToProfileSelection?(id)
     }
 
     private func updateSelectedChipStates() {

+ 624 - 0
App for Indeed/Views/CVProfileDocumentView.swift

@@ -0,0 +1,624 @@
+//
+//  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
+        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: NSColor(srgbRed: 42 / 255, green: 48 / 255, blue: 56 / 255, alpha: 1),
+                muted: NSColor(srgbRed: 110 / 255, green: 118 / 255, blue: 128 / 255, alpha: 1),
+                rule: NSColor(srgbRed: 228 / 255, green: 230 / 255, blue: 234 / 255, alpha: 1),
+                cardBackground: NSColor(srgbRed: 0.998, green: 0.998, blue: 0.998, alpha: 1),
+                columnVerticalSpacing: 15,
+                bodyBlockSpacing: 15,
+                roleUsesThemeColor: false,
+                sectionInk: theme.withAlphaComponent(0.92)
+            )
+
+        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: NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55),
+                ink: NSColor(srgbRed: 28 / 255, green: 36 / 255, blue: 48 / 255, alpha: 1),
+                muted: NSColor(srgbRed: 88 / 255, green: 98 / 255, blue: 118 / 255, alpha: 1),
+                rule: NSColor(srgbRed: 210 / 255, green: 218 / 255, blue: 232 / 255, alpha: 1),
+                cardBackground: NSColor.white,
+                columnVerticalSpacing: 13,
+                bodyBlockSpacing: 13,
+                roleUsesThemeColor: false,
+                sectionInk: theme
+            )
+
+        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: NSColor(srgbRed: 24 / 255, green: 34 / 255, blue: 52 / 255, alpha: 1),
+                muted: NSColor(srgbRed: 96 / 255, green: 110 / 255, blue: 132 / 255, alpha: 1),
+                rule: NSColor(srgbRed: 200 / 255, green: 214 / 255, blue: 236 / 255, alpha: 1),
+                cardBackground: NSColor(srgbRed: 0.99, green: 0.995, blue: 1, alpha: 1),
+                columnVerticalSpacing: 17,
+                bodyBlockSpacing: 16,
+                roleUsesThemeColor: true,
+                sectionInk: theme
+            )
+
+        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)
+            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: NSColor(srgbRed: 55 / 255, green: 55 / 255, blue: 62 / 255, alpha: 1),
+                ink: NSColor(srgbRed: 22 / 255, green: 22 / 255, blue: 28 / 255, alpha: 1),
+                muted: NSColor(srgbRed: 82 / 255, green: 82 / 255, blue: 90 / 255, alpha: 1),
+                rule: NSColor(srgbRed: 72 / 255, green: 72 / 255, blue: 78 / 255, alpha: 0.35),
+                cardBackground: NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1),
+                columnVerticalSpacing: 18,
+                bodyBlockSpacing: 17,
+                roleUsesThemeColor: false,
+                sectionInk: NSColor(srgbRed: 32 / 255, green: 32 / 255, blue: 38 / 255, alpha: 1)
+            )
+
+        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: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1),
+                ink: NSColor(srgbRed: 32 / 255, green: 26 / 255, blue: 52 / 255, alpha: 1),
+                muted: NSColor(srgbRed: 108 / 255, green: 96 / 255, blue: 130 / 255, alpha: 1),
+                rule: theme.withAlphaComponent(0.22),
+                cardBackground: NSColor(srgbRed: 0.995, green: 0.993, blue: 1, alpha: 1),
+                columnVerticalSpacing: 18,
+                bodyBlockSpacing: 17,
+                roleUsesThemeColor: true,
+                sectionInk: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1)
+            )
+        }
+    }
+}
+
+/// Full-width résumé layout that injects `SavedProfile` into the visual language of `CVTemplate`.
+final class CVProfileDocumentView: NSView {
+
+    private let profile: SavedProfile
+    private let template: CVTemplate
+    private let style: DocumentStyle
+
+    init(profile: SavedProfile, template: CVTemplate) {
+        self.profile = profile
+        self.template = template
+        self.style = DocumentStyle.make(for: template)
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        wantsLayer = true
+        layer?.backgroundColor = NSColor.clear.cgColor
+        userInterfaceLayoutDirection = .leftToRight
+
+        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
+
+        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: 640),
+
+            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")
+    }
+
+    // MARK: - Composition
+
+    private func buildRoot() -> 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())
+        v.addArrangedSubview(hairline())
+        v.addArrangedSubview(bodyColumn(compact: false))
+        return v
+    }
+
+    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 = template.family == .minimal ? 18 : 22
+
+        let sidebarCol = sidebarColumn(tinted: tinted)
+        let mainCol = bodyColumn(compact: true)
+
+        if sidebar == .leading {
+            row.addArrangedSubview(sidebarCol)
+            row.addArrangedSubview(mainCol)
+        } else {
+            row.addArrangedSubview(mainCol)
+            row.addArrangedSubview(sidebarCol)
+        }
+        sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: template.family == .executive ? 0.34 : 0.32).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 = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).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 = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).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
+            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
+            box.layer?.backgroundColor = template.themeColor.withAlphaComponent(template.family == .creative ? 0.12 : 0.08).cgColor
+            box.layer?.cornerRadius = 8
+        }
+        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))
+        }
+
+        if let skillsBlock = ancillaryBlock(title: "Languages & more", body: combinedAncillaryText()) {
+            box.addArrangedSubview(skillsBlock)
+        }
+
+        return box
+    }
+
+    private func bodyColumn(compact: Bool) -> 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 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 schools = profile.educations.filter { !$0.isEffectivelyEmpty }
+        if !schools.isEmpty {
+            v.addArrangedSubview(sectionHeading("Education"))
+            for edu in schools {
+                v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
+            }
+        }
+
+        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))
+        }
+
+        return v
+    }
+
+    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 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))
+        }
+        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 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)
+    }
+
+    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 }
+    }
+}

+ 72 - 4
App for Indeed/Views/DashboardView.swift

@@ -134,6 +134,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     }()
     /// When true, `myProfilePageView` is visible instead of the profiles list.
     private var isProfileEditorPresented = false
+    /// When true, the merged CV preview is visible instead of the profiles list or editor.
+    private var isCVDocumentPreviewPresented = false
+    /// Template id chosen in CV Maker until the user leaves Profile or starts a new CV Maker hand-off.
+    private var pendingCVTemplateID: String?
+    private let cvFilledPreviewPageView = CVFilledPreviewPageView()
 
     private var currentSidebarItems: [SidebarItem] = []
     private var selectedSidebarIndex: Int = 0
@@ -1338,6 +1343,20 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             cvMakerPageView.topAnchor.constraint(equalTo: cvMakerPageContainer.topAnchor),
             cvMakerPageView.bottomAnchor.constraint(equalTo: cvMakerPageContainer.bottomAnchor)
         ])
+
+        cvMakerPageView.onContinueToProfileSelection = { [weak self] templateID in
+            guard let self else { return }
+            self.pendingCVTemplateID = templateID
+            let name = self.cvMakerPageView.resolvedTemplate(withID: templateID)?.name ?? "Selected template"
+            self.profilesListPageView.setPendingCVTemplateDisplayName(name)
+            self.selectProfileSidebarForCVMakerFlow()
+        }
+    }
+
+    /// Switches the main panel to **Profile** so the user can pick a saved CV profile after choosing a template in CV Maker.
+    private func selectProfileSidebarForCVMakerFlow() {
+        guard let index = currentSidebarItems.firstIndex(where: { $0.title == "Profile" }) else { return }
+        selectSidebarItem(at: index)
     }
 
     private func configureProfilePage() {
@@ -1348,8 +1367,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
         profilesListPageView.translatesAutoresizingMaskIntoConstraints = false
         myProfilePageView.translatesAutoresizingMaskIntoConstraints = false
+        cvFilledPreviewPageView.translatesAutoresizingMaskIntoConstraints = false
         profilePageContainer.addSubview(profilesListPageView)
         profilePageContainer.addSubview(myProfilePageView)
+        profilePageContainer.addSubview(cvFilledPreviewPageView)
 
         NSLayoutConstraint.activate([
             profilesListPageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
@@ -1360,7 +1381,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             myProfilePageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
             myProfilePageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
             myProfilePageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
-            myProfilePageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor)
+            myProfilePageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor),
+
+            cvFilledPreviewPageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
+            cvFilledPreviewPageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
+            cvFilledPreviewPageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
+            cvFilledPreviewPageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor)
         ])
 
         profilesListPageView.onAddProfile = { [weak self] in
@@ -1372,17 +1398,48 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         profilesListPageView.onDeleteProfile = { [weak self] id in
             self?.confirmDeleteProfile(id: id)
         }
+        profilesListPageView.onBuildCVWithProfile = { [weak self] profileID in
+            guard let self,
+                  let tid = self.pendingCVTemplateID,
+                  let template = self.cvMakerPageView.resolvedTemplate(withID: tid),
+                  let profile = SavedProfilesStore.profile(id: profileID) else { return }
+            self.presentCVDocumentPreview(profile: profile, template: template)
+        }
+        cvFilledPreviewPageView.onDismiss = { [weak self] in
+            self?.dismissCVDocumentPreview()
+        }
         myProfilePageView.onDismiss = { [weak self] in
             self?.dismissProfileEditor()
         }
 
         isProfileEditorPresented = false
+        isCVDocumentPreviewPresented = false
         profilesListPageView.isHidden = false
         myProfilePageView.isHidden = true
+        cvFilledPreviewPageView.isHidden = true
         profilesListPageView.reloadFromStore()
     }
 
+    private func presentCVDocumentPreview(profile: SavedProfile, template: CVTemplate) {
+        isCVDocumentPreviewPresented = true
+        cvFilledPreviewPageView.configure(profile: profile, template: template)
+        cvFilledPreviewPageView.isHidden = false
+        profilesListPageView.isHidden = true
+        myProfilePageView.isHidden = true
+    }
+
+    private func dismissCVDocumentPreview() {
+        isCVDocumentPreviewPresented = false
+        cvFilledPreviewPageView.isHidden = true
+        profilesListPageView.reloadFromStore()
+        profilesListPageView.isHidden = false
+        myProfilePageView.isHidden = true
+    }
+
     private func presentProfileEditor(existingID: UUID?) {
+        if isCVDocumentPreviewPresented {
+            dismissCVDocumentPreview()
+        }
         isProfileEditorPresented = true
         if let id = existingID, let profile = SavedProfilesStore.profile(id: id) {
             myProfilePageView.loadSavedProfile(profile)
@@ -1691,13 +1748,24 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         profilePageContainer.isHidden = !profile
         if !profile {
             isProfileEditorPresented = false
+            isCVDocumentPreviewPresented = false
+            pendingCVTemplateID = nil
+            profilesListPageView.setPendingCVTemplateDisplayName(nil)
+            cvFilledPreviewPageView.isHidden = true
             profilesListPageView.isHidden = false
             myProfilePageView.isHidden = true
         }
         if profile, !isProfileEditorPresented {
-            profilesListPageView.reloadFromStore()
-            profilesListPageView.isHidden = false
-            myProfilePageView.isHidden = true
+            if isCVDocumentPreviewPresented {
+                profilesListPageView.isHidden = true
+                myProfilePageView.isHidden = true
+                cvFilledPreviewPageView.isHidden = false
+            } else {
+                profilesListPageView.reloadFromStore()
+                profilesListPageView.isHidden = false
+                myProfilePageView.isHidden = true
+                cvFilledPreviewPageView.isHidden = true
+            }
         }
         if !home, selectedSidebarIndex < currentSidebarItems.count {
             if savedJobs {

+ 55 - 9
App for Indeed/Views/ProfilesListPageView.swift

@@ -26,12 +26,17 @@ final class ProfilesListPageView: NSView {
     var onAddProfile: (() -> Void)?
     var onEditProfile: ((UUID) -> Void)?
     var onDeleteProfile: ((UUID) -> Void)?
+    /// Fired when the user taps **Build CV** on a row while a CV Maker template is pending.
+    var onBuildCVWithProfile: ((UUID) -> Void)?
 
     private let scrollView = NSScrollView()
     private let documentView = ProfilesListDocumentView()
     private let contentStack = NSStackView()
     private let emptyStateLabel = NSTextField(wrappingLabelWithString: "")
     private let addButton = ProfilesPrimaryButton(title: "Add new profile", target: nil, action: nil)
+    private let pendingFlowLabel = NSTextField(wrappingLabelWithString: "")
+    /// Non-`nil` after **Use Template & Select Profile** until the dashboard clears the flow (e.g. leaving Profile).
+    private var pendingCVTemplateDisplayName: String?
 
     override var userInterfaceLayoutDirection: NSUserInterfaceLayoutDirection {
         get { .leftToRight }
@@ -57,16 +62,30 @@ final class ProfilesListPageView: NSView {
         let profiles = SavedProfilesStore.loadAll()
         emptyStateLabel.isHidden = !profiles.isEmpty
 
+        let showBuildCV = pendingCVTemplateDisplayName != nil
         for profile in profiles {
-            let row = ProfileListRowView(profile: profile)
+            let row = ProfileListRowView(profile: profile, showBuildCV: showBuildCV)
             row.translatesAutoresizingMaskIntoConstraints = false
             row.onEdit = { [weak self] id in self?.onEditProfile?(id) }
             row.onDelete = { [weak self] id in self?.onDeleteProfile?(id) }
+            row.onBuildCV = { [weak self] id in self?.onBuildCVWithProfile?(id) }
             contentStack.addArrangedSubview(row)
             row.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
         }
     }
 
+    /// Shows the CV Maker hand-off banner and per-profile **Build CV** actions, or clears them when `nil`.
+    func setPendingCVTemplateDisplayName(_ name: String?) {
+        pendingCVTemplateDisplayName = name
+        if let name, !name.isEmpty {
+            pendingFlowLabel.stringValue = "You chose the “\(name)” template. Tap Build CV on a profile to preview your résumé with that layout."
+            pendingFlowLabel.isHidden = false
+        } else {
+            pendingFlowLabel.isHidden = true
+        }
+        reloadFromStore()
+    }
+
     private func setup() {
         wantsLayer = true
         layer?.backgroundColor = ProfilesListPalette.pageBackground.cgColor
@@ -99,6 +118,17 @@ final class ProfilesListPageView: NSView {
         titleSubtitleStack.spacing = 10
         titleSubtitleStack.translatesAutoresizingMaskIntoConstraints = false
 
+        pendingFlowLabel.font = .systemFont(ofSize: 13, weight: .medium)
+        pendingFlowLabel.textColor = ProfilesListPalette.brandBlue
+        pendingFlowLabel.maximumNumberOfLines = 0
+        pendingFlowLabel.isHidden = true
+
+        let pageHeaderStack = NSStackView(views: [titleSubtitleStack, pendingFlowLabel])
+        pageHeaderStack.orientation = .vertical
+        pageHeaderStack.alignment = .leading
+        pageHeaderStack.spacing = 12
+        pageHeaderStack.translatesAutoresizingMaskIntoConstraints = false
+
         let footerStack = NSStackView(views: [emptyStateLabel, addButton])
         footerStack.orientation = .vertical
         footerStack.alignment = .leading
@@ -112,7 +142,7 @@ final class ProfilesListPageView: NSView {
         contentStack.translatesAutoresizingMaskIntoConstraints = false
 
         documentView.translatesAutoresizingMaskIntoConstraints = false
-        documentView.addSubview(titleSubtitleStack)
+        documentView.addSubview(pageHeaderStack)
         documentView.addSubview(contentStack)
         documentView.addSubview(footerStack)
 
@@ -137,19 +167,19 @@ final class ProfilesListPageView: NSView {
             documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
             documentView.bottomAnchor.constraint(equalTo: footerStack.bottomAnchor, constant: 32),
 
-            titleSubtitleStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
-            titleSubtitleStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
-            titleSubtitleStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 24),
+            pageHeaderStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
+            pageHeaderStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
+            pageHeaderStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 24),
 
             contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
             contentStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
-            contentStack.topAnchor.constraint(equalTo: titleSubtitleStack.bottomAnchor, constant: 24),
+            contentStack.topAnchor.constraint(equalTo: pageHeaderStack.bottomAnchor, constant: 24),
 
             footerStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
             footerStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
             footerStack.topAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 24),
 
-            titleSubtitleStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
+            pageHeaderStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
             contentStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
             footerStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64)
         ])
@@ -167,12 +197,14 @@ final class ProfilesListPageView: NSView {
 private final class ProfileListRowView: NSView {
     var onEdit: ((UUID) -> Void)?
     var onDelete: ((UUID) -> Void)?
+    var onBuildCV: ((UUID) -> Void)?
 
     private let profileID: UUID
+    private let buildCVButton = NSButton(title: "Build CV", target: nil, action: nil)
     private let editButton = NSButton(title: "Edit", target: nil, action: nil)
     private let deleteButton = NSButton(title: "Delete", target: nil, action: nil)
 
-    init(profile: SavedProfile) {
+    init(profile: SavedProfile, showBuildCV: Bool) {
         self.profileID = profile.id
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
@@ -201,6 +233,15 @@ private final class ProfileListRowView: NSView {
         textStack.spacing = 4
         textStack.translatesAutoresizingMaskIntoConstraints = false
 
+        buildCVButton.translatesAutoresizingMaskIntoConstraints = false
+        buildCVButton.bezelStyle = .rounded
+        buildCVButton.isBordered = true
+        buildCVButton.font = .systemFont(ofSize: 12, weight: .semibold)
+        buildCVButton.contentTintColor = ProfilesListPalette.brandBlue
+        buildCVButton.target = self
+        buildCVButton.action = #selector(didTapBuildCV)
+        buildCVButton.isHidden = !showBuildCV
+
         editButton.translatesAutoresizingMaskIntoConstraints = false
         editButton.bezelStyle = .rounded
         editButton.isBordered = true
@@ -219,7 +260,8 @@ private final class ProfileListRowView: NSView {
         let spacer = NSView()
         spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
 
-        let actions = NSStackView(views: [editButton, deleteButton])
+        let actionViews: [NSView] = showBuildCV ? [buildCVButton, editButton, deleteButton] : [editButton, deleteButton]
+        let actions = NSStackView(views: actionViews)
         actions.orientation = .horizontal
         actions.spacing = 8
         actions.alignment = .centerY
@@ -248,6 +290,10 @@ private final class ProfileListRowView: NSView {
         onEdit?(profileID)
     }
 
+    @objc private func didTapBuildCV() {
+        onBuildCV?(profileID)
+    }
+
     @objc private func didTapDelete() {
         onDelete?(profileID)
     }