Jelajahi Sumber

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 2 bulan lalu
induk
melakukan
bd8706dc92

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

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

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

@@ -6,6 +6,7 @@
 //
 
 import Cocoa
+import UniformTypeIdentifiers
 
 private final class CVPreviewFlippedDocumentView: NSView {
     override var isFlipped: Bool { true }
@@ -18,10 +19,16 @@ final class CVFilledPreviewPageView: NSView {
 
     private let backButton = NSButton(title: "← Profiles", target: nil, action: nil)
     private let titleLabel = NSTextField(labelWithString: "CV preview")
+    private let exportButton = NSButton(title: "Export PDF…", target: nil, action: nil)
+    private let editCheckbox = NSButton(checkboxWithTitle: "Edit text in place", target: nil, action: nil)
     private let scrollView = NSScrollView()
     private let documentView = CVPreviewFlippedDocumentView()
     private let contentStack = NSStackView()
 
+    private weak var profileDocumentView: CVProfileDocumentView?
+    private var lastProfile: SavedProfile?
+    private var lastTemplate: CVTemplate?
+
     private static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
     private static let secondaryText = NSColor(srgbRed: 100 / 255, green: 116 / 255, blue: 139 / 255, alpha: 1)
     private static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
@@ -43,16 +50,36 @@ final class CVFilledPreviewPageView: NSView {
         titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
         titleLabel.textColor = NSColor(srgbRed: 31 / 255, green: 41 / 255, blue: 55 / 255, alpha: 1)
 
-        let subtitle = NSTextField(wrappingLabelWithString: "Your profile fields are laid out using the template you chose in CV Maker.")
+        exportButton.translatesAutoresizingMaskIntoConstraints = false
+        exportButton.bezelStyle = .rounded
+        exportButton.isBordered = false
+        exportButton.contentTintColor = Self.brandBlue
+        exportButton.font = .systemFont(ofSize: 13, weight: .semibold)
+        exportButton.target = self
+        exportButton.action = #selector(didTapExportPDF)
+
+        editCheckbox.translatesAutoresizingMaskIntoConstraints = false
+        editCheckbox.font = .systemFont(ofSize: 12, weight: .regular)
+        editCheckbox.target = self
+        editCheckbox.action = #selector(didToggleEditMode)
+
+        let subtitle = NSTextField(wrappingLabelWithString: "Layout matches the CV Maker thumbnail for this template. Turn on editing to adjust wording, then export a vector PDF.")
         subtitle.font = .systemFont(ofSize: 12, weight: .regular)
         subtitle.textColor = Self.secondaryText
         subtitle.maximumNumberOfLines = 0
 
-        let headerCol = NSStackView(views: [backButton, titleLabel, subtitle])
+        let actions = NSStackView(views: [exportButton, editCheckbox])
+        actions.orientation = .horizontal
+        actions.spacing = 16
+        actions.alignment = .centerY
+        actions.translatesAutoresizingMaskIntoConstraints = false
+
+        let headerCol = NSStackView(views: [backButton, titleLabel, subtitle, actions])
         headerCol.orientation = .vertical
         headerCol.alignment = .leading
         headerCol.spacing = 6
         headerCol.setCustomSpacing(14, after: backButton)
+        headerCol.setCustomSpacing(10, after: subtitle)
         headerCol.translatesAutoresizingMaskIntoConstraints = false
 
         contentStack.orientation = .vertical
@@ -101,12 +128,16 @@ final class CVFilledPreviewPageView: NSView {
         fatalError("init(coder:) has not been implemented")
     }
 
-    func configure(profile: SavedProfile, template: CVTemplate) {
+    func configure(profile: SavedProfile, template: CVTemplate, isEditable: Bool = false) {
+        lastProfile = profile
+        lastTemplate = template
+        editCheckbox.state = isEditable ? .on : .off
         for v in contentStack.arrangedSubviews {
             contentStack.removeArrangedSubview(v)
             v.removeFromSuperview()
         }
-        let doc = CVProfileDocumentView(profile: profile, template: template)
+        let doc = CVProfileDocumentView(profile: profile, template: template, isEditable: isEditable)
+        profileDocumentView = doc
         contentStack.addArrangedSubview(doc)
         let profileTitle = profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName
         titleLabel.stringValue = "\(template.name) · \(profileTitle)"
@@ -115,4 +146,72 @@ final class CVFilledPreviewPageView: NSView {
     @objc private func didTapBack() {
         onDismiss?()
     }
+
+    @objc private func didToggleEditMode(_ sender: NSButton) {
+        guard let profile = lastProfile, let template = lastTemplate else { return }
+        configure(profile: profile, template: template, isEditable: sender.state == .on)
+    }
+
+    @objc private func didTapExportPDF() {
+        // NSSavePanel and sandboxed file writes must run on the main thread (and after a button
+        // callback can be mis-attributed under Swift’s default actor isolation).
+        DispatchQueue.main.async { [weak self] in
+            self?.runExportPDFOnMainThread()
+        }
+    }
+
+    private func runExportPDFOnMainThread() {
+        guard let doc = profileDocumentView else { return }
+        doc.layoutSubtreeIfNeeded()
+        let bounds = doc.bounds
+        guard !bounds.isEmpty else { return }
+        let data = doc.dataWithPDF(inside: bounds)
+        guard !data.isEmpty else {
+            presentExportError("The résumé could not be rendered to PDF (empty output). Try scrolling the preview so it lays out, then export again.")
+            return
+        }
+
+        let panel = NSSavePanel()
+        panel.canCreateDirectories = true
+        panel.allowedContentTypes = [.pdf]
+        let base = lastTemplate?.name ?? "CV"
+        let safe = base.replacingOccurrences(of: "/", with: "-")
+        panel.nameFieldStringValue = "\(safe).pdf"
+
+        guard let hostWindow = window else {
+            if panel.runModal() == .OK, let url = panel.url {
+                writePDFData(data, to: url)
+            }
+            return
+        }
+        panel.beginSheetModal(for: hostWindow) { [weak self] response in
+            guard let self, response == .OK, let url = panel.url else { return }
+            self.writePDFData(data, to: url)
+        }
+    }
+
+    private func writePDFData(_ data: Data, to url: URL) {
+        let accessing = url.startAccessingSecurityScopedResource()
+        defer {
+            if accessing { url.stopAccessingSecurityScopedResource() }
+        }
+        do {
+            try data.write(to: url, options: .atomic)
+        } catch {
+            presentExportError(error.localizedDescription)
+        }
+    }
+
+    private func presentExportError(_ message: String) {
+        let alert = NSAlert()
+        alert.messageText = "Couldn’t save PDF"
+        alert.informativeText = message
+        alert.alertStyle = .warning
+        alert.addButton(withTitle: "OK")
+        if let window {
+            alert.beginSheetModal(for: window, completionHandler: nil)
+        } else {
+            alert.runModal()
+        }
+    }
 }

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

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

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

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