浏览代码

Match filled CVs to gallery layouts and add PDF export.

Deterministic variants align CVProfileDocumentView with thumbnails; professional single-column previews always use the left rail. Preview adds in-place editing and NSSavePanel export with user-selected read/write for sandboxed saves.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 周之前
父节点
当前提交
bd8706dc92

+ 2 - 1
App for Indeed/App for Indeed.entitlements

@@ -4,7 +4,8 @@
4 4
 <dict>
5 5
 	<key>com.apple.security.app-sandbox</key>
6 6
 	<true/>
7
-	<key>com.apple.security.files.user-selected.read-only</key>
7
+	<!-- Read/write: required so NSSavePanel destinations can receive exported PDFs under App Sandbox. -->
8
+	<key>com.apple.security.files.user-selected.read-write</key>
8 9
 	<true/>
9 10
 	<key>com.apple.security.network.client</key>
10 11
 	<true/>

+ 103 - 4
App for Indeed/Views/CVFilledPreviewPageView.swift

@@ -6,6 +6,7 @@
6 6
 //
7 7
 
8 8
 import Cocoa
9
+import UniformTypeIdentifiers
9 10
 
10 11
 private final class CVPreviewFlippedDocumentView: NSView {
11 12
     override var isFlipped: Bool { true }
@@ -18,10 +19,16 @@ final class CVFilledPreviewPageView: NSView {
18 19
 
19 20
     private let backButton = NSButton(title: "← Profiles", target: nil, action: nil)
20 21
     private let titleLabel = NSTextField(labelWithString: "CV preview")
22
+    private let exportButton = NSButton(title: "Export PDF…", target: nil, action: nil)
23
+    private let editCheckbox = NSButton(checkboxWithTitle: "Edit text in place", target: nil, action: nil)
21 24
     private let scrollView = NSScrollView()
22 25
     private let documentView = CVPreviewFlippedDocumentView()
23 26
     private let contentStack = NSStackView()
24 27
 
28
+    private weak var profileDocumentView: CVProfileDocumentView?
29
+    private var lastProfile: SavedProfile?
30
+    private var lastTemplate: CVTemplate?
31
+
25 32
     private static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
26 33
     private static let secondaryText = NSColor(srgbRed: 100 / 255, green: 116 / 255, blue: 139 / 255, alpha: 1)
27 34
     private static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
@@ -43,16 +50,36 @@ final class CVFilledPreviewPageView: NSView {
43 50
         titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
44 51
         titleLabel.textColor = NSColor(srgbRed: 31 / 255, green: 41 / 255, blue: 55 / 255, alpha: 1)
45 52
 
46
-        let subtitle = NSTextField(wrappingLabelWithString: "Your profile fields are laid out using the template you chose in CV Maker.")
53
+        exportButton.translatesAutoresizingMaskIntoConstraints = false
54
+        exportButton.bezelStyle = .rounded
55
+        exportButton.isBordered = false
56
+        exportButton.contentTintColor = Self.brandBlue
57
+        exportButton.font = .systemFont(ofSize: 13, weight: .semibold)
58
+        exportButton.target = self
59
+        exportButton.action = #selector(didTapExportPDF)
60
+
61
+        editCheckbox.translatesAutoresizingMaskIntoConstraints = false
62
+        editCheckbox.font = .systemFont(ofSize: 12, weight: .regular)
63
+        editCheckbox.target = self
64
+        editCheckbox.action = #selector(didToggleEditMode)
65
+
66
+        let subtitle = NSTextField(wrappingLabelWithString: "Layout matches the CV Maker thumbnail for this template. Turn on editing to adjust wording, then export a vector PDF.")
47 67
         subtitle.font = .systemFont(ofSize: 12, weight: .regular)
48 68
         subtitle.textColor = Self.secondaryText
49 69
         subtitle.maximumNumberOfLines = 0
50 70
 
51
-        let headerCol = NSStackView(views: [backButton, titleLabel, subtitle])
71
+        let actions = NSStackView(views: [exportButton, editCheckbox])
72
+        actions.orientation = .horizontal
73
+        actions.spacing = 16
74
+        actions.alignment = .centerY
75
+        actions.translatesAutoresizingMaskIntoConstraints = false
76
+
77
+        let headerCol = NSStackView(views: [backButton, titleLabel, subtitle, actions])
52 78
         headerCol.orientation = .vertical
53 79
         headerCol.alignment = .leading
54 80
         headerCol.spacing = 6
55 81
         headerCol.setCustomSpacing(14, after: backButton)
82
+        headerCol.setCustomSpacing(10, after: subtitle)
56 83
         headerCol.translatesAutoresizingMaskIntoConstraints = false
57 84
 
58 85
         contentStack.orientation = .vertical
@@ -101,12 +128,16 @@ final class CVFilledPreviewPageView: NSView {
101 128
         fatalError("init(coder:) has not been implemented")
102 129
     }
103 130
 
104
-    func configure(profile: SavedProfile, template: CVTemplate) {
131
+    func configure(profile: SavedProfile, template: CVTemplate, isEditable: Bool = false) {
132
+        lastProfile = profile
133
+        lastTemplate = template
134
+        editCheckbox.state = isEditable ? .on : .off
105 135
         for v in contentStack.arrangedSubviews {
106 136
             contentStack.removeArrangedSubview(v)
107 137
             v.removeFromSuperview()
108 138
         }
109
-        let doc = CVProfileDocumentView(profile: profile, template: template)
139
+        let doc = CVProfileDocumentView(profile: profile, template: template, isEditable: isEditable)
140
+        profileDocumentView = doc
110 141
         contentStack.addArrangedSubview(doc)
111 142
         let profileTitle = profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName
112 143
         titleLabel.stringValue = "\(template.name) · \(profileTitle)"
@@ -115,4 +146,72 @@ final class CVFilledPreviewPageView: NSView {
115 146
     @objc private func didTapBack() {
116 147
         onDismiss?()
117 148
     }
149
+
150
+    @objc private func didToggleEditMode(_ sender: NSButton) {
151
+        guard let profile = lastProfile, let template = lastTemplate else { return }
152
+        configure(profile: profile, template: template, isEditable: sender.state == .on)
153
+    }
154
+
155
+    @objc private func didTapExportPDF() {
156
+        // NSSavePanel and sandboxed file writes must run on the main thread (and after a button
157
+        // callback can be mis-attributed under Swift’s default actor isolation).
158
+        DispatchQueue.main.async { [weak self] in
159
+            self?.runExportPDFOnMainThread()
160
+        }
161
+    }
162
+
163
+    private func runExportPDFOnMainThread() {
164
+        guard let doc = profileDocumentView else { return }
165
+        doc.layoutSubtreeIfNeeded()
166
+        let bounds = doc.bounds
167
+        guard !bounds.isEmpty else { return }
168
+        let data = doc.dataWithPDF(inside: bounds)
169
+        guard !data.isEmpty else {
170
+            presentExportError("The résumé could not be rendered to PDF (empty output). Try scrolling the preview so it lays out, then export again.")
171
+            return
172
+        }
173
+
174
+        let panel = NSSavePanel()
175
+        panel.canCreateDirectories = true
176
+        panel.allowedContentTypes = [.pdf]
177
+        let base = lastTemplate?.name ?? "CV"
178
+        let safe = base.replacingOccurrences(of: "/", with: "-")
179
+        panel.nameFieldStringValue = "\(safe).pdf"
180
+
181
+        guard let hostWindow = window else {
182
+            if panel.runModal() == .OK, let url = panel.url {
183
+                writePDFData(data, to: url)
184
+            }
185
+            return
186
+        }
187
+        panel.beginSheetModal(for: hostWindow) { [weak self] response in
188
+            guard let self, response == .OK, let url = panel.url else { return }
189
+            self.writePDFData(data, to: url)
190
+        }
191
+    }
192
+
193
+    private func writePDFData(_ data: Data, to url: URL) {
194
+        let accessing = url.startAccessingSecurityScopedResource()
195
+        defer {
196
+            if accessing { url.stopAccessingSecurityScopedResource() }
197
+        }
198
+        do {
199
+            try data.write(to: url, options: .atomic)
200
+        } catch {
201
+            presentExportError(error.localizedDescription)
202
+        }
203
+    }
204
+
205
+    private func presentExportError(_ message: String) {
206
+        let alert = NSAlert()
207
+        alert.messageText = "Couldn’t save PDF"
208
+        alert.informativeText = message
209
+        alert.alertStyle = .warning
210
+        alert.addButton(withTitle: "OK")
211
+        if let window {
212
+            alert.beginSheetModal(for: window, completionHandler: nil)
213
+        } else {
214
+            alert.runModal()
215
+        }
216
+    }
118 217
 }

+ 21 - 3
App for Indeed/Views/CVMakerPageView.swift

@@ -189,6 +189,24 @@ struct CVTemplate: Hashable {
189 189
     }
190 190
 }
191 191
 
192
+extension CVTemplate {
193
+    /// Same 0…11 silhouette index as `CVTemplatePreviewView` so the filled résumé matches the gallery thumbnail for that template.
194
+    var galleryLayoutVariant: Int {
195
+        var h: UInt64 = 1469598103934665603
196
+        let layoutDesc: String
197
+        switch layout {
198
+        case .singleColumn: layoutDesc = "1col"
199
+        case .twoColumn(let s, let t): layoutDesc = "2col_\(s)_\(t)"
200
+        }
201
+        let blob = "\(id)|\(family.rawValue)|\(headline)|\(accent)|\(layoutDesc)|\(sectionLabelStyle)"
202
+        for b in blob.utf8 {
203
+            h ^= UInt64(b)
204
+            h &*= 1_099_511_628_211
205
+        }
206
+        return Int(h % 12)
207
+    }
208
+}
209
+
192 210
 // MARK: - Catalog
193 211
 
194 212
 enum CVTemplateCatalog {
@@ -288,7 +306,7 @@ enum CVTemplateCatalog {
288 306
         ),
289 307
         CVTemplate(
290 308
             id: "metro",
291
-            name: "Metro",
309
+            name: "Clear Path",
292 310
             family: .professional,
293 311
             headline: .centered,
294 312
             accent: .blueBar,
@@ -584,9 +602,9 @@ final class CVMakerPageView: NSView {
584 602
     private let gridStack = NSStackView()
585 603
     private let ctaButton = CVHoverableButton(title: "Use Template & Select Profile  →", target: nil, action: nil)
586 604
 
587
-    private var selectedGroup: CVCategoryGroup = .designBased
605
+    private var selectedGroup: CVCategoryGroup = .professionBased
588 606
     private var selectedFamily: CVDesignFamily? = nil // nil == "All"
589
-    private var selectedTemplateID: String? = "paper-white"
607
+    private var selectedTemplateID: String? = "metro"
590 608
     /// Exactly one gallery card — avoids multiple highlighted cards when catalog entries share the same `template.id`.
591 609
     private var selectedTemplateCardToken: UUID?
592 610
     /// Shown immediately; replaced when `CVTemplateFetchService` returns AI-generated entries.

+ 682 - 34
App for Indeed/Views/CVProfileDocumentView.swift

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

+ 3 - 6
App for Indeed/Views/CVTemplateMiniPreview.swift

@@ -145,7 +145,6 @@ final class CVTemplatePreviewView: NSView {
145 145
 
146 146
     private func buildProfessionalResume() -> NSView {
147 147
         let swapExpFirst = (idVariant % 3) == 1
148
-        let showRail = (idVariant % 4) == 0
149 148
         let sidebarMult: CGFloat = (idVariant % 5 == 2) ? 0.38 : 0.34
150 149
 
151 150
         switch template.layout {
@@ -160,11 +159,9 @@ final class CVTemplatePreviewView: NSView {
160 159
             }
161 160
             v.addArrangedSubview(hairline())
162 161
             let main = proMainColumn(compact: false, experienceFirst: swapExpFirst)
163
-            if showRail {
164
-                v.addArrangedSubview(horizontalWithLeadingRail(theme: template.themeColor, content: main))
165
-            } else {
166
-                v.addArrangedSubview(main)
167
-            }
162
+            // Single-column professional résumés use the same left rail in the gallery
163
+            // and in `CVProfileDocumentView` so the filled CV matches the thumbnail.
164
+            v.addArrangedSubview(horizontalWithLeadingRail(theme: template.themeColor, content: main))
168 165
             return v
169 166
         case .twoColumn(let side, let tinted):
170 167
             let bar = proHeaderBlock()