Просмотр исходного кода

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 недель назад: 3
Родитель
Сommit
6446b8b007

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

@@ -0,0 +1,118 @@
1
+//
2
+//  CVFilledPreviewPageView.swift
3
+//  App for Indeed
4
+//
5
+//  Full-screen preview of a profile merged into the selected CV template.
6
+//
7
+
8
+import Cocoa
9
+
10
+private final class CVPreviewFlippedDocumentView: NSView {
11
+    override var isFlipped: Bool { true }
12
+}
13
+
14
+/// Hosts a scrollable `CVProfileDocumentView` with a simple chrome header and back navigation.
15
+final class CVFilledPreviewPageView: NSView {
16
+
17
+    var onDismiss: (() -> Void)?
18
+
19
+    private let backButton = NSButton(title: "← Profiles", target: nil, action: nil)
20
+    private let titleLabel = NSTextField(labelWithString: "CV preview")
21
+    private let scrollView = NSScrollView()
22
+    private let documentView = CVPreviewFlippedDocumentView()
23
+    private let contentStack = NSStackView()
24
+
25
+    private static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
26
+    private static let secondaryText = NSColor(srgbRed: 100 / 255, green: 116 / 255, blue: 139 / 255, alpha: 1)
27
+    private static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
28
+
29
+    override init(frame frameRect: NSRect) {
30
+        super.init(frame: frameRect)
31
+        wantsLayer = true
32
+        layer?.backgroundColor = Self.pageBackground.cgColor
33
+        userInterfaceLayoutDirection = .leftToRight
34
+
35
+        backButton.translatesAutoresizingMaskIntoConstraints = false
36
+        backButton.bezelStyle = .rounded
37
+        backButton.isBordered = false
38
+        backButton.font = .systemFont(ofSize: 13, weight: .semibold)
39
+        backButton.contentTintColor = Self.brandBlue
40
+        backButton.target = self
41
+        backButton.action = #selector(didTapBack)
42
+
43
+        titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
44
+        titleLabel.textColor = NSColor(srgbRed: 31 / 255, green: 41 / 255, blue: 55 / 255, alpha: 1)
45
+
46
+        let subtitle = NSTextField(wrappingLabelWithString: "Your profile fields are laid out using the template you chose in CV Maker.")
47
+        subtitle.font = .systemFont(ofSize: 12, weight: .regular)
48
+        subtitle.textColor = Self.secondaryText
49
+        subtitle.maximumNumberOfLines = 0
50
+
51
+        let headerCol = NSStackView(views: [backButton, titleLabel, subtitle])
52
+        headerCol.orientation = .vertical
53
+        headerCol.alignment = .leading
54
+        headerCol.spacing = 6
55
+        headerCol.setCustomSpacing(14, after: backButton)
56
+        headerCol.translatesAutoresizingMaskIntoConstraints = false
57
+
58
+        contentStack.orientation = .vertical
59
+        contentStack.alignment = .leading
60
+        contentStack.spacing = 20
61
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
62
+
63
+        documentView.translatesAutoresizingMaskIntoConstraints = false
64
+        documentView.addSubview(contentStack)
65
+
66
+        scrollView.translatesAutoresizingMaskIntoConstraints = false
67
+        scrollView.drawsBackground = false
68
+        scrollView.hasVerticalScroller = true
69
+        scrollView.hasHorizontalScroller = false
70
+        scrollView.autohidesScrollers = true
71
+        scrollView.borderType = .noBorder
72
+        scrollView.documentView = documentView
73
+
74
+        addSubview(headerCol)
75
+        addSubview(scrollView)
76
+
77
+        NSLayoutConstraint.activate([
78
+            headerCol.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 32),
79
+            headerCol.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -32),
80
+            headerCol.topAnchor.constraint(equalTo: topAnchor, constant: 16),
81
+
82
+            scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
83
+            scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
84
+            scrollView.topAnchor.constraint(equalTo: headerCol.bottomAnchor, constant: 16),
85
+            scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
86
+
87
+            documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
88
+            documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
89
+            documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
90
+            documentView.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 40),
91
+
92
+            contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
93
+            contentStack.trailingAnchor.constraint(lessThanOrEqualTo: documentView.trailingAnchor, constant: -32),
94
+            contentStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 8),
95
+            contentStack.widthAnchor.constraint(lessThanOrEqualTo: documentView.widthAnchor, constant: -64)
96
+        ])
97
+    }
98
+
99
+    @available(*, unavailable)
100
+    required init?(coder: NSCoder) {
101
+        fatalError("init(coder:) has not been implemented")
102
+    }
103
+
104
+    func configure(profile: SavedProfile, template: CVTemplate) {
105
+        for v in contentStack.arrangedSubviews {
106
+            contentStack.removeArrangedSubview(v)
107
+            v.removeFromSuperview()
108
+        }
109
+        let doc = CVProfileDocumentView(profile: profile, template: template)
110
+        contentStack.addArrangedSubview(doc)
111
+        let profileTitle = profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName
112
+        titleLabel.stringValue = "\(template.name) · \(profileTitle)"
113
+    }
114
+
115
+    @objc private func didTapBack() {
116
+        onDismiss?()
117
+    }
118
+}

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

@@ -304,6 +304,60 @@ enum CVTemplateCatalog {
304 304
             layout: .twoColumn(sidebar: .leading, tinted: false),
305 305
             sectionLabelStyle: .uppercase
306 306
         ),
307
+        CVTemplate(
308
+            id: "briefing",
309
+            name: "Briefing",
310
+            family: .professional,
311
+            headline: .leftAligned,
312
+            accent: .blueBar,
313
+            layout: .twoColumn(sidebar: .leading, tinted: true),
314
+            sectionLabelStyle: .uppercase
315
+        ),
316
+        CVTemplate(
317
+            id: "quorum",
318
+            name: "Quorum",
319
+            family: .professional,
320
+            headline: .leftWithInitials,
321
+            accent: .none,
322
+            layout: .singleColumn,
323
+            sectionLabelStyle: .bracketed
324
+        ),
325
+        CVTemplate(
326
+            id: "docket",
327
+            name: "Docket",
328
+            family: .professional,
329
+            headline: .centered,
330
+            accent: .blueBar,
331
+            layout: .twoColumn(sidebar: .trailing, tinted: false),
332
+            sectionLabelStyle: .uppercase
333
+        ),
334
+        CVTemplate(
335
+            id: "conduit",
336
+            name: "Conduit",
337
+            family: .professional,
338
+            headline: .leftAligned,
339
+            accent: .blueBar,
340
+            layout: .singleColumn,
341
+            sectionLabelStyle: .slashed
342
+        ),
343
+        CVTemplate(
344
+            id: "principal",
345
+            name: "Principal",
346
+            family: .professional,
347
+            headline: .leftWithInitials,
348
+            accent: .blueBar,
349
+            layout: .twoColumn(sidebar: .trailing, tinted: true),
350
+            sectionLabelStyle: .uppercase
351
+        ),
352
+        CVTemplate(
353
+            id: "charter",
354
+            name: "Charter",
355
+            family: .professional,
356
+            headline: .leftAligned,
357
+            accent: .none,
358
+            layout: .twoColumn(sidebar: .leading, tinted: false),
359
+            sectionLabelStyle: .uppercase
360
+        ),
307 361
 
308 362
         // Modern family
309 363
         CVTemplate(
@@ -542,6 +596,19 @@ final class CVMakerPageView: NSView {
542 596
     /// Every visible gallery card (not keyed by id — duplicate AI ids would collapse in a dictionary and break single-selection visuals).
543 597
     private var templateCardsInGrid: [CVTemplateCard] = []
544 598
 
599
+    /// 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.
600
+    var onContinueToProfileSelection: ((String) -> Void)?
601
+
602
+    func templateInGallery(withID id: String) -> CVTemplate? {
603
+        resolvedTemplate(withID: id)
604
+    }
605
+
606
+    /// 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).
607
+    func resolvedTemplate(withID id: String) -> CVTemplate? {
608
+        if let match = activeCatalog.first(where: { $0.id == id }) { return match }
609
+        return CVTemplateCatalog.all.first { $0.id == id }
610
+    }
611
+
545 612
     private var appliedGridColumnCount: Int = 0
546 613
 
547 614
     override init(frame frameRect: NSRect) {
@@ -898,14 +965,11 @@ final class CVMakerPageView: NSView {
898 965
 
899 966
     @objc private func didTapUseTemplate() {
900 967
         guard let id = selectedTemplateID,
901
-              let template = activeCatalog.first(where: { $0.id == id }) else {
968
+              activeCatalog.contains(where: { $0.id == id }) else {
902 969
             presentPlaceholderAlert(title: "Pick a template", message: "Select a template first, then choose a profile to continue.")
903 970
             return
904 971
         }
905
-        presentPlaceholderAlert(
906
-            title: "Use \"\(template.name)\"",
907
-            message: "Profile selection and CV editing are not available in this preview build yet."
908
-        )
972
+        onContinueToProfileSelection?(id)
909 973
     }
910 974
 
911 975
     private func updateSelectedChipStates() {

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

@@ -0,0 +1,624 @@
1
+//
2
+//  CVProfileDocumentView.swift
3
+//  App for Indeed
4
+//
5
+//  Renders saved profile data in a layout that follows the selected CV template’s
6
+//  family (professional / modern / minimal / executive / creative), headline,
7
+//  accent, section labels, and column structure.
8
+//
9
+
10
+import Cocoa
11
+
12
+/// Typography and chrome derived from `CVTemplate.family` so the filled résumé
13
+/// visibly matches the gallery card the user picked—not a single generic layout.
14
+private struct DocumentStyle {
15
+    let nameFont: NSFont
16
+    let roleFont: NSFont
17
+    let contactFont: NSFont
18
+    let sectionFont: NSFont
19
+    let bodyFont: NSFont
20
+    let bodyCompactFont: NSFont
21
+    let expTitleFont: NSFont
22
+    let expMetaFont: NSFont
23
+    let eduTitleFont: NSFont
24
+    let eduMetaFont: NSFont
25
+    let bulletBodyFont: NSFont
26
+    let bulletMarkerFont: NSFont
27
+    let bulletMarkerColor: NSColor
28
+    let ink: NSColor
29
+    let muted: NSColor
30
+    let rule: NSColor
31
+    let cardBackground: NSColor
32
+    let columnVerticalSpacing: CGFloat
33
+    let bodyBlockSpacing: CGFloat
34
+    /// When true, the headline job title uses the template theme color.
35
+    let roleUsesThemeColor: Bool
36
+    /// Section heading text color (often theme; executive stays conservative).
37
+    let sectionInk: NSColor
38
+
39
+    static func make(for template: CVTemplate) -> DocumentStyle {
40
+        let theme = template.themeColor
41
+        switch template.family {
42
+        case .minimal:
43
+            return DocumentStyle(
44
+                nameFont: .systemFont(ofSize: 20, weight: .regular),
45
+                roleFont: .systemFont(ofSize: 13.5, weight: .regular),
46
+                contactFont: .systemFont(ofSize: 11.5, weight: .regular),
47
+                sectionFont: .systemFont(ofSize: 10.5, weight: .semibold),
48
+                bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
49
+                bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
50
+                expTitleFont: .systemFont(ofSize: 13, weight: .medium),
51
+                expMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
52
+                eduTitleFont: .systemFont(ofSize: 13, weight: .medium),
53
+                eduMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
54
+                bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
55
+                bulletMarkerFont: .systemFont(ofSize: 11, weight: .light),
56
+                bulletMarkerColor: theme.withAlphaComponent(0.55),
57
+                ink: NSColor(srgbRed: 42 / 255, green: 48 / 255, blue: 56 / 255, alpha: 1),
58
+                muted: NSColor(srgbRed: 110 / 255, green: 118 / 255, blue: 128 / 255, alpha: 1),
59
+                rule: NSColor(srgbRed: 228 / 255, green: 230 / 255, blue: 234 / 255, alpha: 1),
60
+                cardBackground: NSColor(srgbRed: 0.998, green: 0.998, blue: 0.998, alpha: 1),
61
+                columnVerticalSpacing: 15,
62
+                bodyBlockSpacing: 15,
63
+                roleUsesThemeColor: false,
64
+                sectionInk: theme.withAlphaComponent(0.92)
65
+            )
66
+
67
+        case .professional:
68
+            return DocumentStyle(
69
+                nameFont: .systemFont(ofSize: 21, weight: .semibold),
70
+                roleFont: .systemFont(ofSize: 13.5, weight: .medium),
71
+                contactFont: .systemFont(ofSize: 11.5, weight: .regular),
72
+                sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
73
+                bodyFont: .systemFont(ofSize: 12, weight: .regular),
74
+                bodyCompactFont: .systemFont(ofSize: 11.5, weight: .regular),
75
+                expTitleFont: .systemFont(ofSize: 13.5, weight: .semibold),
76
+                expMetaFont: .systemFont(ofSize: 11.5, weight: .semibold),
77
+                eduTitleFont: .systemFont(ofSize: 13, weight: .semibold),
78
+                eduMetaFont: .systemFont(ofSize: 11.5, weight: .medium),
79
+                bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
80
+                bulletMarkerFont: .systemFont(ofSize: 12, weight: .bold),
81
+                bulletMarkerColor: NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55),
82
+                ink: NSColor(srgbRed: 28 / 255, green: 36 / 255, blue: 48 / 255, alpha: 1),
83
+                muted: NSColor(srgbRed: 88 / 255, green: 98 / 255, blue: 118 / 255, alpha: 1),
84
+                rule: NSColor(srgbRed: 210 / 255, green: 218 / 255, blue: 232 / 255, alpha: 1),
85
+                cardBackground: NSColor.white,
86
+                columnVerticalSpacing: 13,
87
+                bodyBlockSpacing: 13,
88
+                roleUsesThemeColor: false,
89
+                sectionInk: theme
90
+            )
91
+
92
+        case .modern:
93
+            return DocumentStyle(
94
+                nameFont: .systemFont(ofSize: 22, weight: .bold),
95
+                roleFont: .systemFont(ofSize: 14, weight: .semibold),
96
+                contactFont: .systemFont(ofSize: 12, weight: .regular),
97
+                sectionFont: .systemFont(ofSize: 11, weight: .heavy),
98
+                bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
99
+                bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
100
+                expTitleFont: .systemFont(ofSize: 14, weight: .bold),
101
+                expMetaFont: .systemFont(ofSize: 12, weight: .medium),
102
+                eduTitleFont: .systemFont(ofSize: 13.5, weight: .bold),
103
+                eduMetaFont: .systemFont(ofSize: 12, weight: .regular),
104
+                bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
105
+                bulletMarkerFont: .systemFont(ofSize: 13, weight: .bold),
106
+                bulletMarkerColor: theme,
107
+                ink: NSColor(srgbRed: 24 / 255, green: 34 / 255, blue: 52 / 255, alpha: 1),
108
+                muted: NSColor(srgbRed: 96 / 255, green: 110 / 255, blue: 132 / 255, alpha: 1),
109
+                rule: NSColor(srgbRed: 200 / 255, green: 214 / 255, blue: 236 / 255, alpha: 1),
110
+                cardBackground: NSColor(srgbRed: 0.99, green: 0.995, blue: 1, alpha: 1),
111
+                columnVerticalSpacing: 17,
112
+                bodyBlockSpacing: 16,
113
+                roleUsesThemeColor: true,
114
+                sectionInk: theme
115
+            )
116
+
117
+        case .executive:
118
+            let serifName = NSFont(name: "Georgia-Bold", size: 23) ?? .systemFont(ofSize: 23, weight: .semibold)
119
+            let serifRole = NSFont(name: "Georgia", size: 14) ?? .systemFont(ofSize: 14, weight: .regular)
120
+            let serifBody = NSFont(name: "Georgia", size: 12.5) ?? .systemFont(ofSize: 12.5, weight: .regular)
121
+            let serifCompact = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12, weight: .regular)
122
+            let georgia12 = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12)
123
+            let georgia115 = NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5)
124
+            let expMeta = NSFont(name: "Georgia-Italic", size: 12)
125
+                ?? NSFontManager.shared.convert(georgia12, toHaveTrait: .italicFontMask)
126
+            let eduMeta = NSFont(name: "Georgia-Italic", size: 11.5)
127
+                ?? NSFontManager.shared.convert(georgia115, toHaveTrait: .italicFontMask)
128
+            return DocumentStyle(
129
+                nameFont: serifName,
130
+                roleFont: serifRole,
131
+                contactFont: NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5),
132
+                sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
133
+                bodyFont: serifBody,
134
+                bodyCompactFont: serifCompact,
135
+                expTitleFont: NSFont(name: "Georgia-Bold", size: 14) ?? .systemFont(ofSize: 14, weight: .semibold),
136
+                expMetaFont: expMeta,
137
+                eduTitleFont: NSFont(name: "Georgia-Bold", size: 13.5) ?? .systemFont(ofSize: 13.5, weight: .semibold),
138
+                eduMetaFont: eduMeta,
139
+                bulletBodyFont: serifCompact,
140
+                bulletMarkerFont: .systemFont(ofSize: 11, weight: .bold),
141
+                bulletMarkerColor: NSColor(srgbRed: 55 / 255, green: 55 / 255, blue: 62 / 255, alpha: 1),
142
+                ink: NSColor(srgbRed: 22 / 255, green: 22 / 255, blue: 28 / 255, alpha: 1),
143
+                muted: NSColor(srgbRed: 82 / 255, green: 82 / 255, blue: 90 / 255, alpha: 1),
144
+                rule: NSColor(srgbRed: 72 / 255, green: 72 / 255, blue: 78 / 255, alpha: 0.35),
145
+                cardBackground: NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1),
146
+                columnVerticalSpacing: 18,
147
+                bodyBlockSpacing: 17,
148
+                roleUsesThemeColor: false,
149
+                sectionInk: NSColor(srgbRed: 32 / 255, green: 32 / 255, blue: 38 / 255, alpha: 1)
150
+            )
151
+
152
+        case .creative:
153
+            return DocumentStyle(
154
+                nameFont: .systemFont(ofSize: 23, weight: .heavy),
155
+                roleFont: .systemFont(ofSize: 14, weight: .semibold),
156
+                contactFont: .systemFont(ofSize: 11.5, weight: .medium),
157
+                sectionFont: .systemFont(ofSize: 11.5, weight: .heavy),
158
+                bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
159
+                bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
160
+                expTitleFont: .systemFont(ofSize: 14, weight: .heavy),
161
+                expMetaFont: .systemFont(ofSize: 12, weight: .semibold),
162
+                eduTitleFont: .systemFont(ofSize: 13.5, weight: .heavy),
163
+                eduMetaFont: .systemFont(ofSize: 12, weight: .medium),
164
+                bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
165
+                bulletMarkerFont: .systemFont(ofSize: 13, weight: .heavy),
166
+                bulletMarkerColor: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1),
167
+                ink: NSColor(srgbRed: 32 / 255, green: 26 / 255, blue: 52 / 255, alpha: 1),
168
+                muted: NSColor(srgbRed: 108 / 255, green: 96 / 255, blue: 130 / 255, alpha: 1),
169
+                rule: theme.withAlphaComponent(0.22),
170
+                cardBackground: NSColor(srgbRed: 0.995, green: 0.993, blue: 1, alpha: 1),
171
+                columnVerticalSpacing: 18,
172
+                bodyBlockSpacing: 17,
173
+                roleUsesThemeColor: true,
174
+                sectionInk: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1)
175
+            )
176
+        }
177
+    }
178
+}
179
+
180
+/// Full-width résumé layout that injects `SavedProfile` into the visual language of `CVTemplate`.
181
+final class CVProfileDocumentView: NSView {
182
+
183
+    private let profile: SavedProfile
184
+    private let template: CVTemplate
185
+    private let style: DocumentStyle
186
+
187
+    init(profile: SavedProfile, template: CVTemplate) {
188
+        self.profile = profile
189
+        self.template = template
190
+        self.style = DocumentStyle.make(for: template)
191
+        super.init(frame: .zero)
192
+        translatesAutoresizingMaskIntoConstraints = false
193
+        wantsLayer = true
194
+        layer?.backgroundColor = NSColor.clear.cgColor
195
+        userInterfaceLayoutDirection = .leftToRight
196
+
197
+        let card = NSView()
198
+        card.translatesAutoresizingMaskIntoConstraints = false
199
+        card.wantsLayer = true
200
+        card.layer?.backgroundColor = style.cardBackground.cgColor
201
+        card.layer?.cornerRadius = template.family == .executive ? 6 : 10
202
+        card.layer?.borderWidth = 1
203
+        card.layer?.borderColor = style.rule.cgColor
204
+        card.layer?.masksToBounds = true
205
+
206
+        let root = buildRoot()
207
+        root.translatesAutoresizingMaskIntoConstraints = false
208
+        card.addSubview(root)
209
+
210
+        addSubview(card)
211
+        NSLayoutConstraint.activate([
212
+            card.leadingAnchor.constraint(equalTo: leadingAnchor),
213
+            card.trailingAnchor.constraint(equalTo: trailingAnchor),
214
+            card.topAnchor.constraint(equalTo: topAnchor),
215
+            card.bottomAnchor.constraint(equalTo: bottomAnchor),
216
+            card.widthAnchor.constraint(equalToConstant: 640),
217
+
218
+            root.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 36),
219
+            root.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -36),
220
+            root.topAnchor.constraint(equalTo: card.topAnchor, constant: 32),
221
+            root.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -36)
222
+        ])
223
+    }
224
+
225
+    @available(*, unavailable)
226
+    required init?(coder: NSCoder) {
227
+        fatalError("init(coder:) has not been implemented")
228
+    }
229
+
230
+    // MARK: - Composition
231
+
232
+    private func buildRoot() -> NSView {
233
+        switch template.layout {
234
+        case .singleColumn:
235
+            return singleColumnLayout()
236
+        case .twoColumn(let side, let tinted):
237
+            return twoColumnLayout(sidebar: side, tinted: tinted)
238
+        }
239
+    }
240
+
241
+    private func singleColumnLayout() -> NSView {
242
+        let v = NSStackView()
243
+        v.orientation = .vertical
244
+        v.alignment = .leading
245
+        v.spacing = style.columnVerticalSpacing + 3
246
+        v.addArrangedSubview(headerBlock())
247
+        v.addArrangedSubview(hairline())
248
+        v.addArrangedSubview(bodyColumn(compact: false))
249
+        return v
250
+    }
251
+
252
+    private func twoColumnLayout(sidebar: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
253
+        let v = NSStackView()
254
+        v.orientation = .vertical
255
+        v.alignment = .leading
256
+        v.spacing = style.columnVerticalSpacing + 2
257
+        v.addArrangedSubview(headerBlock())
258
+        v.addArrangedSubview(hairline())
259
+
260
+        let row = NSStackView()
261
+        row.orientation = .horizontal
262
+        row.alignment = .top
263
+        row.spacing = template.family == .minimal ? 18 : 22
264
+
265
+        let sidebarCol = sidebarColumn(tinted: tinted)
266
+        let mainCol = bodyColumn(compact: true)
267
+
268
+        if sidebar == .leading {
269
+            row.addArrangedSubview(sidebarCol)
270
+            row.addArrangedSubview(mainCol)
271
+        } else {
272
+            row.addArrangedSubview(mainCol)
273
+            row.addArrangedSubview(sidebarCol)
274
+        }
275
+        sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: template.family == .executive ? 0.34 : 0.32).isActive = true
276
+
277
+        v.addArrangedSubview(row)
278
+        return v
279
+    }
280
+
281
+    private func headerBlock() -> NSView {
282
+        let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
283
+        let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
284
+        let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
285
+        let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ")
286
+
287
+        let roleColor = style.roleUsesThemeColor ? template.themeColor : style.muted
288
+        let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
289
+        let role = label(roleText, font: style.roleFont, color: roleColor, maxLines: 2)
290
+        let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.92), maxLines: 3)
291
+
292
+        let textCol = NSStackView(views: [name, role, contact])
293
+        textCol.orientation = .vertical
294
+        textCol.spacing = template.family == .professional ? 3 : 4
295
+        textCol.alignment = .leading
296
+
297
+        switch template.headline {
298
+        case .centered:
299
+            textCol.alignment = .centerX
300
+            name.alignment = .center
301
+            role.alignment = .center
302
+            contact.alignment = .center
303
+            let accent = headlineAccent()
304
+            let stack = NSStackView(views: [textCol, accent])
305
+            stack.orientation = .vertical
306
+            stack.spacing = 8
307
+            stack.alignment = .centerX
308
+            return stack
309
+        case .avatarStacked:
310
+            textCol.alignment = .centerX
311
+            name.alignment = .center
312
+            role.alignment = .center
313
+            contact.alignment = .center
314
+            let accent = headlineAccent()
315
+            let avatar = initialsBadge(for: nameText)
316
+            let stack = NSStackView(views: [avatar, textCol, accent])
317
+            stack.orientation = .vertical
318
+            stack.spacing = 8
319
+            stack.alignment = .centerX
320
+            return stack
321
+        case .leftAligned, .leftWithInitials:
322
+            let row = NSStackView()
323
+            row.orientation = .horizontal
324
+            row.spacing = 14
325
+            row.alignment = .centerY
326
+            row.addArrangedSubview(textCol)
327
+            if template.headline == .leftWithInitials {
328
+                row.addArrangedSubview(NSView())
329
+                row.addArrangedSubview(initialsBadge(for: nameText))
330
+            }
331
+            let col = NSStackView(views: [row, headlineAccent()])
332
+            col.orientation = .vertical
333
+            col.spacing = 8
334
+            col.alignment = .leading
335
+            return col
336
+        }
337
+    }
338
+
339
+    private func initialsBadge(for fullName: String) -> NSView {
340
+        let initials = Self.initials(from: fullName)
341
+        let t = NSTextField(labelWithString: initials)
342
+        t.font = .systemFont(ofSize: 13, weight: .bold)
343
+        t.textColor = template.themeColor
344
+        t.alignment = .center
345
+        t.translatesAutoresizingMaskIntoConstraints = false
346
+        let wrap = NSView()
347
+        wrap.translatesAutoresizingMaskIntoConstraints = false
348
+        wrap.wantsLayer = true
349
+        wrap.layer?.cornerRadius = 22
350
+        wrap.layer?.borderWidth = 1.5
351
+        wrap.layer?.borderColor = template.themeColor.withAlphaComponent(0.35).cgColor
352
+        wrap.addSubview(t)
353
+        NSLayoutConstraint.activate([
354
+            wrap.widthAnchor.constraint(equalToConstant: 44),
355
+            wrap.heightAnchor.constraint(equalToConstant: 44),
356
+            t.centerXAnchor.constraint(equalTo: wrap.centerXAnchor),
357
+            t.centerYAnchor.constraint(equalTo: wrap.centerYAnchor)
358
+        ])
359
+        return wrap
360
+    }
361
+
362
+    private static func initials(from fullName: String) -> String {
363
+        let parts = fullName.split(separator: " ").filter { !$0.isEmpty }
364
+        if parts.count >= 2 {
365
+            let a = parts[0].prefix(1)
366
+            let b = parts[1].prefix(1)
367
+            return "\(a)\(b)".uppercased()
368
+        }
369
+        if let first = parts.first { return String(first.prefix(2)).uppercased() }
370
+        return "CV"
371
+    }
372
+
373
+    private func headlineAccent() -> NSView {
374
+        let bar = NSView()
375
+        bar.translatesAutoresizingMaskIntoConstraints = false
376
+        bar.wantsLayer = true
377
+        switch template.accent {
378
+        case .none:
379
+            bar.heightAnchor.constraint(equalToConstant: 1).isActive = true
380
+            return bar
381
+        case .redUnderline:
382
+            bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
383
+            bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
384
+            bar.widthAnchor.constraint(equalToConstant: template.family == .minimal ? 140 : 168).isActive = true
385
+            return bar
386
+        case .redBar:
387
+            bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
388
+            bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
389
+            bar.widthAnchor.constraint(equalToConstant: 56).isActive = true
390
+            return bar
391
+        case .blueBar:
392
+            bar.layer?.backgroundColor = template.themeColor.cgColor
393
+            bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
394
+            bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
395
+            return bar
396
+        }
397
+    }
398
+
399
+    private func hairline() -> NSView {
400
+        let v = NSView()
401
+        v.translatesAutoresizingMaskIntoConstraints = false
402
+        v.wantsLayer = true
403
+        v.layer?.backgroundColor = style.rule.cgColor
404
+        let h: CGFloat = template.family == .executive ? 1.5 : 1
405
+        v.heightAnchor.constraint(equalToConstant: h).isActive = true
406
+        return v
407
+    }
408
+
409
+    private func sidebarColumn(tinted: Bool) -> NSView {
410
+        let box = NSStackView()
411
+        box.orientation = .vertical
412
+        box.spacing = 12
413
+        box.alignment = .leading
414
+        if tinted {
415
+            box.wantsLayer = true
416
+            box.layer?.backgroundColor = template.themeColor.withAlphaComponent(template.family == .creative ? 0.12 : 0.08).cgColor
417
+            box.layer?.cornerRadius = 8
418
+        }
419
+        box.edgeInsets = NSEdgeInsets(top: tinted ? 14 : 0, left: tinted ? 14 : 0, bottom: tinted ? 14 : 0, right: tinted ? 14 : 0)
420
+
421
+        box.addArrangedSubview(sectionHeading("Contact"))
422
+        for line in contactLines() {
423
+            box.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
424
+        }
425
+
426
+        if let skillsBlock = ancillaryBlock(title: "Languages & more", body: combinedAncillaryText()) {
427
+            box.addArrangedSubview(skillsBlock)
428
+        }
429
+
430
+        return box
431
+    }
432
+
433
+    private func bodyColumn(compact: Bool) -> NSView {
434
+        let v = NSStackView()
435
+        v.orientation = .vertical
436
+        v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
437
+        v.alignment = .leading
438
+
439
+        if let summary = nonEmpty(profile.careerSummary) {
440
+            v.addArrangedSubview(sectionHeading("Summary"))
441
+            v.addArrangedSubview(paragraph(summary, compact: compact))
442
+        }
443
+
444
+        let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
445
+        if !jobs.isEmpty {
446
+            v.addArrangedSubview(sectionHeading("Experience"))
447
+            for job in jobs {
448
+                v.addArrangedSubview(experienceBlock(job: job, compact: compact))
449
+            }
450
+        }
451
+
452
+        let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
453
+        if !schools.isEmpty {
454
+            v.addArrangedSubview(sectionHeading("Education"))
455
+            for edu in schools {
456
+                v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
457
+            }
458
+        }
459
+
460
+        if let cert = nonEmpty(profile.certificates) {
461
+            v.addArrangedSubview(sectionHeading("Certificates"))
462
+            v.addArrangedSubview(paragraph(cert, compact: compact))
463
+        }
464
+        if let interests = nonEmpty(profile.interests) {
465
+            v.addArrangedSubview(sectionHeading("Interests"))
466
+            v.addArrangedSubview(paragraph(interests, compact: compact))
467
+        }
468
+        if let ref = nonEmpty(profile.referral) {
469
+            v.addArrangedSubview(sectionHeading("Referrals"))
470
+            v.addArrangedSubview(paragraph(ref, compact: compact))
471
+        }
472
+
473
+        return v
474
+    }
475
+
476
+    private func ancillaryBlock(title: String, body: String?) -> NSStackView? {
477
+        guard let body, !body.isEmpty else { return nil }
478
+        let s = NSStackView()
479
+        s.orientation = .vertical
480
+        s.spacing = 6
481
+        s.alignment = .leading
482
+        s.addArrangedSubview(sectionHeading(title))
483
+        s.addArrangedSubview(paragraph(body, compact: true))
484
+        return s
485
+    }
486
+
487
+    private func contactLines() -> [String] {
488
+        var lines: [String] = []
489
+        let p = profile.personal
490
+        if !p.email.isEmpty { lines.append(p.email) }
491
+        if !p.phone.isEmpty { lines.append(p.phone) }
492
+        if !p.address.isEmpty { lines.append(p.address) }
493
+        return lines.isEmpty ? ["—"] : lines
494
+    }
495
+
496
+    private func combinedAncillaryText() -> String? {
497
+        let chunks = [profile.languages, profile.interests].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
498
+        return chunks.isEmpty ? nil : chunks.joined(separator: "\n\n")
499
+    }
500
+
501
+    private func experienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
502
+        let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
503
+        let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
504
+        let v = NSStackView()
505
+        v.orientation = .vertical
506
+        v.spacing = template.family == .professional ? 4 : 6
507
+        v.alignment = .leading
508
+        if !titleLine.isEmpty {
509
+            v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
510
+        }
511
+        if !meta.isEmpty {
512
+            v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
513
+        }
514
+        for bullet in Self.bulletChunks(from: job.description) {
515
+            v.addArrangedSubview(bulletRow(bullet, compact: compact))
516
+        }
517
+        return v
518
+    }
519
+
520
+    private func educationBlock(edu: EducationPayload, compact: Bool) -> NSView {
521
+        let v = NSStackView()
522
+        v.orientation = .vertical
523
+        v.spacing = 4
524
+        v.alignment = .leading
525
+        let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
526
+        if !head.isEmpty {
527
+            v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
528
+        }
529
+        if !edu.year.isEmpty {
530
+            v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
531
+        }
532
+        return v
533
+    }
534
+
535
+    private func bulletRow(_ text: String, compact: Bool) -> NSView {
536
+        let marker: String = template.family == .minimal ? "·" : "•"
537
+        let dot = NSTextField(labelWithString: marker)
538
+        dot.font = style.bulletMarkerFont
539
+        dot.textColor = style.bulletMarkerColor
540
+        dot.translatesAutoresizingMaskIntoConstraints = false
541
+        let bodyFont = compact ? style.bodyCompactFont : style.bulletBodyFont
542
+        let body = label(text, font: bodyFont, color: style.ink, maxLines: 0)
543
+        let row = NSStackView(views: [dot, body])
544
+        row.orientation = .horizontal
545
+        row.spacing = template.family == .creative ? 10 : 8
546
+        row.alignment = .top
547
+        dot.setContentHuggingPriority(.required, for: .horizontal)
548
+        return row
549
+    }
550
+
551
+    private func paragraph(_ text: String, compact: Bool) -> NSTextField {
552
+        let font = compact ? style.bodyCompactFont : style.bodyFont
553
+        return label(text, font: font, color: style.ink, maxLines: 0)
554
+    }
555
+
556
+    private func sectionHeading(_ raw: String) -> NSTextField {
557
+        let upper = raw.uppercased()
558
+        let s: String
559
+        switch template.sectionLabelStyle {
560
+        case .uppercase: s = upper
561
+        case .slashed: s = "// \(upper)"
562
+        case .bracketed: s = "[ \(upper) ]"
563
+        }
564
+        let t = NSTextField(labelWithString: s)
565
+        t.font = style.sectionFont
566
+        t.textColor = style.sectionInk
567
+        t.alignment = .left
568
+        return t
569
+    }
570
+
571
+    private func label(_ string: String, font: NSFont, color: NSColor, maxLines: Int) -> NSTextField {
572
+        let isWrapping = maxLines == 0
573
+        let t: NSTextField
574
+        if isWrapping {
575
+            t = NSTextField(wrappingLabelWithString: string)
576
+            t.maximumNumberOfLines = 0
577
+        } else {
578
+            t = NSTextField(labelWithString: string)
579
+            t.maximumNumberOfLines = maxLines
580
+        }
581
+        t.font = font
582
+        t.textColor = color
583
+        t.alignment = .left
584
+        return t
585
+    }
586
+
587
+    private func displayable(_ value: String, placeholder: String) -> String {
588
+        let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
589
+        return t.isEmpty ? placeholder : t
590
+    }
591
+
592
+    private func nonEmpty(_ value: String) -> String? {
593
+        let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
594
+        return t.isEmpty ? nil : t
595
+    }
596
+
597
+    private static func bulletChunks(from text: String) -> [String] {
598
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
599
+        if trimmed.isEmpty { return [] }
600
+        let byNewline = trimmed.components(separatedBy: .newlines)
601
+            .map { $0.trimmingCharacters(in: .whitespaces) }
602
+            .filter { !$0.isEmpty }
603
+        if byNewline.count > 1 { return byNewline }
604
+        let byBullet = trimmed.split(separator: "•")
605
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
606
+            .filter { !$0.isEmpty }
607
+        if byBullet.count > 1 { return byBullet.map { String($0) } }
608
+        return [trimmed]
609
+    }
610
+}
611
+
612
+// MARK: - Payload helpers
613
+
614
+private extension WorkExperiencePayload {
615
+    var isEffectivelyEmpty: Bool {
616
+        [jobTitle, company, duration, description].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
617
+    }
618
+}
619
+
620
+private extension EducationPayload {
621
+    var isEffectivelyEmpty: Bool {
622
+        [degree, institution, year].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
623
+    }
624
+}

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

@@ -134,6 +134,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
134 134
     }()
135 135
     /// When true, `myProfilePageView` is visible instead of the profiles list.
136 136
     private var isProfileEditorPresented = false
137
+    /// When true, the merged CV preview is visible instead of the profiles list or editor.
138
+    private var isCVDocumentPreviewPresented = false
139
+    /// Template id chosen in CV Maker until the user leaves Profile or starts a new CV Maker hand-off.
140
+    private var pendingCVTemplateID: String?
141
+    private let cvFilledPreviewPageView = CVFilledPreviewPageView()
137 142
 
138 143
     private var currentSidebarItems: [SidebarItem] = []
139 144
     private var selectedSidebarIndex: Int = 0
@@ -1338,6 +1343,20 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1338 1343
             cvMakerPageView.topAnchor.constraint(equalTo: cvMakerPageContainer.topAnchor),
1339 1344
             cvMakerPageView.bottomAnchor.constraint(equalTo: cvMakerPageContainer.bottomAnchor)
1340 1345
         ])
1346
+
1347
+        cvMakerPageView.onContinueToProfileSelection = { [weak self] templateID in
1348
+            guard let self else { return }
1349
+            self.pendingCVTemplateID = templateID
1350
+            let name = self.cvMakerPageView.resolvedTemplate(withID: templateID)?.name ?? "Selected template"
1351
+            self.profilesListPageView.setPendingCVTemplateDisplayName(name)
1352
+            self.selectProfileSidebarForCVMakerFlow()
1353
+        }
1354
+    }
1355
+
1356
+    /// Switches the main panel to **Profile** so the user can pick a saved CV profile after choosing a template in CV Maker.
1357
+    private func selectProfileSidebarForCVMakerFlow() {
1358
+        guard let index = currentSidebarItems.firstIndex(where: { $0.title == "Profile" }) else { return }
1359
+        selectSidebarItem(at: index)
1341 1360
     }
1342 1361
 
1343 1362
     private func configureProfilePage() {
@@ -1348,8 +1367,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1348 1367
 
1349 1368
         profilesListPageView.translatesAutoresizingMaskIntoConstraints = false
1350 1369
         myProfilePageView.translatesAutoresizingMaskIntoConstraints = false
1370
+        cvFilledPreviewPageView.translatesAutoresizingMaskIntoConstraints = false
1351 1371
         profilePageContainer.addSubview(profilesListPageView)
1352 1372
         profilePageContainer.addSubview(myProfilePageView)
1373
+        profilePageContainer.addSubview(cvFilledPreviewPageView)
1353 1374
 
1354 1375
         NSLayoutConstraint.activate([
1355 1376
             profilesListPageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
@@ -1360,7 +1381,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1360 1381
             myProfilePageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
1361 1382
             myProfilePageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
1362 1383
             myProfilePageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
1363
-            myProfilePageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor)
1384
+            myProfilePageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor),
1385
+
1386
+            cvFilledPreviewPageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
1387
+            cvFilledPreviewPageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
1388
+            cvFilledPreviewPageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
1389
+            cvFilledPreviewPageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor)
1364 1390
         ])
1365 1391
 
1366 1392
         profilesListPageView.onAddProfile = { [weak self] in
@@ -1372,17 +1398,48 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1372 1398
         profilesListPageView.onDeleteProfile = { [weak self] id in
1373 1399
             self?.confirmDeleteProfile(id: id)
1374 1400
         }
1401
+        profilesListPageView.onBuildCVWithProfile = { [weak self] profileID in
1402
+            guard let self,
1403
+                  let tid = self.pendingCVTemplateID,
1404
+                  let template = self.cvMakerPageView.resolvedTemplate(withID: tid),
1405
+                  let profile = SavedProfilesStore.profile(id: profileID) else { return }
1406
+            self.presentCVDocumentPreview(profile: profile, template: template)
1407
+        }
1408
+        cvFilledPreviewPageView.onDismiss = { [weak self] in
1409
+            self?.dismissCVDocumentPreview()
1410
+        }
1375 1411
         myProfilePageView.onDismiss = { [weak self] in
1376 1412
             self?.dismissProfileEditor()
1377 1413
         }
1378 1414
 
1379 1415
         isProfileEditorPresented = false
1416
+        isCVDocumentPreviewPresented = false
1380 1417
         profilesListPageView.isHidden = false
1381 1418
         myProfilePageView.isHidden = true
1419
+        cvFilledPreviewPageView.isHidden = true
1382 1420
         profilesListPageView.reloadFromStore()
1383 1421
     }
1384 1422
 
1423
+    private func presentCVDocumentPreview(profile: SavedProfile, template: CVTemplate) {
1424
+        isCVDocumentPreviewPresented = true
1425
+        cvFilledPreviewPageView.configure(profile: profile, template: template)
1426
+        cvFilledPreviewPageView.isHidden = false
1427
+        profilesListPageView.isHidden = true
1428
+        myProfilePageView.isHidden = true
1429
+    }
1430
+
1431
+    private func dismissCVDocumentPreview() {
1432
+        isCVDocumentPreviewPresented = false
1433
+        cvFilledPreviewPageView.isHidden = true
1434
+        profilesListPageView.reloadFromStore()
1435
+        profilesListPageView.isHidden = false
1436
+        myProfilePageView.isHidden = true
1437
+    }
1438
+
1385 1439
     private func presentProfileEditor(existingID: UUID?) {
1440
+        if isCVDocumentPreviewPresented {
1441
+            dismissCVDocumentPreview()
1442
+        }
1386 1443
         isProfileEditorPresented = true
1387 1444
         if let id = existingID, let profile = SavedProfilesStore.profile(id: id) {
1388 1445
             myProfilePageView.loadSavedProfile(profile)
@@ -1691,13 +1748,24 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1691 1748
         profilePageContainer.isHidden = !profile
1692 1749
         if !profile {
1693 1750
             isProfileEditorPresented = false
1751
+            isCVDocumentPreviewPresented = false
1752
+            pendingCVTemplateID = nil
1753
+            profilesListPageView.setPendingCVTemplateDisplayName(nil)
1754
+            cvFilledPreviewPageView.isHidden = true
1694 1755
             profilesListPageView.isHidden = false
1695 1756
             myProfilePageView.isHidden = true
1696 1757
         }
1697 1758
         if profile, !isProfileEditorPresented {
1698
-            profilesListPageView.reloadFromStore()
1699
-            profilesListPageView.isHidden = false
1700
-            myProfilePageView.isHidden = true
1759
+            if isCVDocumentPreviewPresented {
1760
+                profilesListPageView.isHidden = true
1761
+                myProfilePageView.isHidden = true
1762
+                cvFilledPreviewPageView.isHidden = false
1763
+            } else {
1764
+                profilesListPageView.reloadFromStore()
1765
+                profilesListPageView.isHidden = false
1766
+                myProfilePageView.isHidden = true
1767
+                cvFilledPreviewPageView.isHidden = true
1768
+            }
1701 1769
         }
1702 1770
         if !home, selectedSidebarIndex < currentSidebarItems.count {
1703 1771
             if savedJobs {

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

@@ -26,12 +26,17 @@ final class ProfilesListPageView: NSView {
26 26
     var onAddProfile: (() -> Void)?
27 27
     var onEditProfile: ((UUID) -> Void)?
28 28
     var onDeleteProfile: ((UUID) -> Void)?
29
+    /// Fired when the user taps **Build CV** on a row while a CV Maker template is pending.
30
+    var onBuildCVWithProfile: ((UUID) -> Void)?
29 31
 
30 32
     private let scrollView = NSScrollView()
31 33
     private let documentView = ProfilesListDocumentView()
32 34
     private let contentStack = NSStackView()
33 35
     private let emptyStateLabel = NSTextField(wrappingLabelWithString: "")
34 36
     private let addButton = ProfilesPrimaryButton(title: "Add new profile", target: nil, action: nil)
37
+    private let pendingFlowLabel = NSTextField(wrappingLabelWithString: "")
38
+    /// Non-`nil` after **Use Template & Select Profile** until the dashboard clears the flow (e.g. leaving Profile).
39
+    private var pendingCVTemplateDisplayName: String?
35 40
 
36 41
     override var userInterfaceLayoutDirection: NSUserInterfaceLayoutDirection {
37 42
         get { .leftToRight }
@@ -57,16 +62,30 @@ final class ProfilesListPageView: NSView {
57 62
         let profiles = SavedProfilesStore.loadAll()
58 63
         emptyStateLabel.isHidden = !profiles.isEmpty
59 64
 
65
+        let showBuildCV = pendingCVTemplateDisplayName != nil
60 66
         for profile in profiles {
61
-            let row = ProfileListRowView(profile: profile)
67
+            let row = ProfileListRowView(profile: profile, showBuildCV: showBuildCV)
62 68
             row.translatesAutoresizingMaskIntoConstraints = false
63 69
             row.onEdit = { [weak self] id in self?.onEditProfile?(id) }
64 70
             row.onDelete = { [weak self] id in self?.onDeleteProfile?(id) }
71
+            row.onBuildCV = { [weak self] id in self?.onBuildCVWithProfile?(id) }
65 72
             contentStack.addArrangedSubview(row)
66 73
             row.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
67 74
         }
68 75
     }
69 76
 
77
+    /// Shows the CV Maker hand-off banner and per-profile **Build CV** actions, or clears them when `nil`.
78
+    func setPendingCVTemplateDisplayName(_ name: String?) {
79
+        pendingCVTemplateDisplayName = name
80
+        if let name, !name.isEmpty {
81
+            pendingFlowLabel.stringValue = "You chose the “\(name)” template. Tap Build CV on a profile to preview your résumé with that layout."
82
+            pendingFlowLabel.isHidden = false
83
+        } else {
84
+            pendingFlowLabel.isHidden = true
85
+        }
86
+        reloadFromStore()
87
+    }
88
+
70 89
     private func setup() {
71 90
         wantsLayer = true
72 91
         layer?.backgroundColor = ProfilesListPalette.pageBackground.cgColor
@@ -99,6 +118,17 @@ final class ProfilesListPageView: NSView {
99 118
         titleSubtitleStack.spacing = 10
100 119
         titleSubtitleStack.translatesAutoresizingMaskIntoConstraints = false
101 120
 
121
+        pendingFlowLabel.font = .systemFont(ofSize: 13, weight: .medium)
122
+        pendingFlowLabel.textColor = ProfilesListPalette.brandBlue
123
+        pendingFlowLabel.maximumNumberOfLines = 0
124
+        pendingFlowLabel.isHidden = true
125
+
126
+        let pageHeaderStack = NSStackView(views: [titleSubtitleStack, pendingFlowLabel])
127
+        pageHeaderStack.orientation = .vertical
128
+        pageHeaderStack.alignment = .leading
129
+        pageHeaderStack.spacing = 12
130
+        pageHeaderStack.translatesAutoresizingMaskIntoConstraints = false
131
+
102 132
         let footerStack = NSStackView(views: [emptyStateLabel, addButton])
103 133
         footerStack.orientation = .vertical
104 134
         footerStack.alignment = .leading
@@ -112,7 +142,7 @@ final class ProfilesListPageView: NSView {
112 142
         contentStack.translatesAutoresizingMaskIntoConstraints = false
113 143
 
114 144
         documentView.translatesAutoresizingMaskIntoConstraints = false
115
-        documentView.addSubview(titleSubtitleStack)
145
+        documentView.addSubview(pageHeaderStack)
116 146
         documentView.addSubview(contentStack)
117 147
         documentView.addSubview(footerStack)
118 148
 
@@ -137,19 +167,19 @@ final class ProfilesListPageView: NSView {
137 167
             documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
138 168
             documentView.bottomAnchor.constraint(equalTo: footerStack.bottomAnchor, constant: 32),
139 169
 
140
-            titleSubtitleStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
141
-            titleSubtitleStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
142
-            titleSubtitleStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 24),
170
+            pageHeaderStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
171
+            pageHeaderStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
172
+            pageHeaderStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 24),
143 173
 
144 174
             contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
145 175
             contentStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
146
-            contentStack.topAnchor.constraint(equalTo: titleSubtitleStack.bottomAnchor, constant: 24),
176
+            contentStack.topAnchor.constraint(equalTo: pageHeaderStack.bottomAnchor, constant: 24),
147 177
 
148 178
             footerStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
149 179
             footerStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
150 180
             footerStack.topAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 24),
151 181
 
152
-            titleSubtitleStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
182
+            pageHeaderStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
153 183
             contentStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
154 184
             footerStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64)
155 185
         ])
@@ -167,12 +197,14 @@ final class ProfilesListPageView: NSView {
167 197
 private final class ProfileListRowView: NSView {
168 198
     var onEdit: ((UUID) -> Void)?
169 199
     var onDelete: ((UUID) -> Void)?
200
+    var onBuildCV: ((UUID) -> Void)?
170 201
 
171 202
     private let profileID: UUID
203
+    private let buildCVButton = NSButton(title: "Build CV", target: nil, action: nil)
172 204
     private let editButton = NSButton(title: "Edit", target: nil, action: nil)
173 205
     private let deleteButton = NSButton(title: "Delete", target: nil, action: nil)
174 206
 
175
-    init(profile: SavedProfile) {
207
+    init(profile: SavedProfile, showBuildCV: Bool) {
176 208
         self.profileID = profile.id
177 209
         super.init(frame: .zero)
178 210
         translatesAutoresizingMaskIntoConstraints = false
@@ -201,6 +233,15 @@ private final class ProfileListRowView: NSView {
201 233
         textStack.spacing = 4
202 234
         textStack.translatesAutoresizingMaskIntoConstraints = false
203 235
 
236
+        buildCVButton.translatesAutoresizingMaskIntoConstraints = false
237
+        buildCVButton.bezelStyle = .rounded
238
+        buildCVButton.isBordered = true
239
+        buildCVButton.font = .systemFont(ofSize: 12, weight: .semibold)
240
+        buildCVButton.contentTintColor = ProfilesListPalette.brandBlue
241
+        buildCVButton.target = self
242
+        buildCVButton.action = #selector(didTapBuildCV)
243
+        buildCVButton.isHidden = !showBuildCV
244
+
204 245
         editButton.translatesAutoresizingMaskIntoConstraints = false
205 246
         editButton.bezelStyle = .rounded
206 247
         editButton.isBordered = true
@@ -219,7 +260,8 @@ private final class ProfileListRowView: NSView {
219 260
         let spacer = NSView()
220 261
         spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
221 262
 
222
-        let actions = NSStackView(views: [editButton, deleteButton])
263
+        let actionViews: [NSView] = showBuildCV ? [buildCVButton, editButton, deleteButton] : [editButton, deleteButton]
264
+        let actions = NSStackView(views: actionViews)
223 265
         actions.orientation = .horizontal
224 266
         actions.spacing = 8
225 267
         actions.alignment = .centerY
@@ -248,6 +290,10 @@ private final class ProfileListRowView: NSView {
248 290
         onEdit?(profileID)
249 291
     }
250 292
 
293
+    @objc private func didTapBuildCV() {
294
+        onBuildCV?(profileID)
295
+    }
296
+
251 297
     @objc private func didTapDelete() {
252 298
         onDelete?(profileID)
253 299
     }