Bläddra i källkod

Align filled CV output with gallery templates in light and dark mode.

Shared résumé colors and per-family layouts so the preview and PDF match the template card the user selected.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 veckor sedan
förälder
incheckning
4ef6f513d9

+ 80 - 0
App for Indeed/Services/CVResumeAppearance.swift

@@ -0,0 +1,80 @@
1
+//
2
+//  CVResumeAppearance.swift
3
+//  App for Indeed
4
+//
5
+//  Shared résumé colours for gallery thumbnails and filled CV preview/export.
6
+//  Tracks app light / dark mode so the selected template looks the same everywhere.
7
+//
8
+
9
+import AppKit
10
+
11
+@MainActor
12
+enum CVResumeAppearance {
13
+
14
+    struct Colors {
15
+        let paper: NSColor
16
+        let ink: NSColor
17
+        let muted: NSColor
18
+        let rule: NSColor
19
+        let cardBackground: NSColor
20
+        let sidebarTint: NSColor
21
+        let accentRed: NSColor
22
+        let accentBlue: NSColor
23
+    }
24
+
25
+    static var isDark: Bool { AppAppearanceManager.shared.isDark }
26
+
27
+    static func colors(isDark dark: Bool? = nil) -> Colors {
28
+        let dark = dark ?? isDark
29
+        if dark {
30
+            return Colors(
31
+                paper: NSColor(srgbRed: 28 / 255, green: 30 / 255, blue: 36 / 255, alpha: 1),
32
+                ink: NSColor(srgbRed: 0.94, green: 0.95, blue: 0.97, alpha: 1),
33
+                muted: NSColor(srgbRed: 0.62, green: 0.66, blue: 0.72, alpha: 1),
34
+                rule: NSColor(srgbRed: 0.38, green: 0.40, blue: 0.46, alpha: 1),
35
+                cardBackground: NSColor(srgbRed: 32 / 255, green: 34 / 255, blue: 40 / 255, alpha: 1),
36
+                sidebarTint: NSColor(srgbRed: 40 / 255, green: 42 / 255, blue: 48 / 255, alpha: 1),
37
+                accentRed: NSColor(srgbRed: 235 / 255, green: 88 / 255, blue: 72 / 255, alpha: 1),
38
+                accentBlue: AppDashboardTheme.brandBlue
39
+            )
40
+        }
41
+        return Colors(
42
+            paper: NSColor.white,
43
+            ink: NSColor(srgbRed: 38 / 255, green: 50 / 255, blue: 71 / 255, alpha: 1),
44
+            muted: NSColor(srgbRed: 110 / 255, green: 118 / 255, blue: 132 / 255, alpha: 1),
45
+            rule: NSColor(srgbRed: 228 / 255, green: 232 / 255, blue: 240 / 255, alpha: 1),
46
+            cardBackground: NSColor.white,
47
+            sidebarTint: AppDashboardTheme.cvMakerPreviewSidebarTint,
48
+            accentRed: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1),
49
+            accentBlue: AppDashboardTheme.brandBlue
50
+        )
51
+    }
52
+
53
+    /// Slight paper tint by layout variant (gallery + filled CV use the same rule).
54
+    static func paperBackground(variant: Int, base: NSColor) -> NSColor {
55
+        guard !isDark else { return base }
56
+        switch variant % 5 {
57
+        case 0: return base
58
+        case 1: return NSColor(srgbRed: 0.995, green: 0.992, blue: 0.985, alpha: 1)
59
+        case 2: return NSColor(srgbRed: 0.96, green: 0.99, blue: 1, alpha: 1)
60
+        case 3: return NSColor(srgbRed: 0.99, green: 0.99, blue: 0.99, alpha: 1)
61
+        default: return NSColor(srgbRed: 0.99, green: 0.98, blue: 0.995, alpha: 1)
62
+        }
63
+    }
64
+
65
+    static func accentColor(for template: CVTemplate) -> NSColor {
66
+        let palette = colors()
67
+        switch template.accent {
68
+        case .redUnderline, .redBar:
69
+            return palette.accentRed
70
+        case .blueBar:
71
+            return template.themeColor
72
+        case .none:
73
+            return template.themeColor.blended(withFraction: 0.5, of: palette.ink) ?? template.themeColor
74
+        }
75
+    }
76
+
77
+    static func sectionHeadingColor(for template: CVTemplate) -> NSColor {
78
+        accentColor(for: template)
79
+    }
80
+}

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

@@ -225,6 +225,9 @@ final class CVFilledPreviewPageView: NSView {
225 225
         titleLabel.textColor = AppDashboardTheme.primaryText
226 226
         subtitleLabel.textColor = AppDashboardTheme.secondaryText
227 227
         exportButton.applyCurrentAppearance()
228
+        if let profile = lastProfile, let template = lastTemplate {
229
+            configure(profile: profile, template: template)
230
+        }
228 231
     }
229 232
 
230 233
     func configure(profile: SavedProfile, template: CVTemplate) {

+ 6 - 12
App for Indeed/Views/CVMakerPageView.swift

@@ -563,18 +563,12 @@ final class CVMakerPageView: NSView {
563 563
         static var cardBorderSelected: NSColor { AppDashboardTheme.brandBlue }
564 564
         static var cardFooter: NSColor { AppDashboardTheme.cvMakerCardFooter }
565 565
         static var previewSurface: NSColor { AppDashboardTheme.cvMakerPreviewSurface }
566
-        static var previewPaper: NSColor { NSColor.white }
567
-        static var previewSidebarTint: NSColor { AppDashboardTheme.cvMakerPreviewSidebarTint }
568
-        static var previewInk: NSColor {
569
-            NSColor(srgbRed: 38 / 255, green: 50 / 255, blue: 71 / 255, alpha: 1)
570
-        }
571
-        static var previewMuted: NSColor {
572
-            NSColor(srgbRed: 165 / 255, green: 175 / 255, blue: 192 / 255, alpha: 1)
573
-        }
574
-        static var previewAccentRed: NSColor {
575
-            NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1)
576
-        }
577
-        static var previewAccentBlue: NSColor { AppDashboardTheme.brandBlue }
566
+        static var previewPaper: NSColor { CVResumeAppearance.colors().paper }
567
+        static var previewSidebarTint: NSColor { CVResumeAppearance.colors().sidebarTint }
568
+        static var previewInk: NSColor { CVResumeAppearance.colors().ink }
569
+        static var previewMuted: NSColor { CVResumeAppearance.colors().muted }
570
+        static var previewAccentRed: NSColor { CVResumeAppearance.colors().accentRed }
571
+        static var previewAccentBlue: NSColor { CVResumeAppearance.colors().accentBlue }
578 572
         static var ctaBackground: NSColor { AppDashboardTheme.brandBlue }
579 573
         static var ctaHover: NSColor { AppDashboardTheme.brandBlueHover }
580 574
         static var ctaText: NSColor { AppDashboardTheme.proCTAText }

+ 395 - 47
App for Indeed/Views/CVProfileDocumentView.swift

@@ -38,6 +38,12 @@ private struct DocumentStyle {
38 38
 
39 39
     static func make(for template: CVTemplate) -> DocumentStyle {
40 40
         let theme = template.themeColor
41
+        let colors = CVResumeAppearance.colors()
42
+        let sectionInk = CVResumeAppearance.sectionHeadingColor(for: template)
43
+        let cardBG = CVResumeAppearance.paperBackground(
44
+            variant: template.galleryLayoutVariant,
45
+            base: colors.cardBackground
46
+        )
41 47
         switch template.family {
42 48
         case .minimal:
43 49
             return DocumentStyle(
@@ -54,14 +60,14 @@ private struct DocumentStyle {
54 60
                 bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
55 61
                 bulletMarkerFont: .systemFont(ofSize: 11, weight: .light),
56 62
                 bulletMarkerColor: theme.withAlphaComponent(0.55),
57
-                ink: NSColor(srgbRed: 42 / 255, green: 48 / 255, blue: 56 / 255, alpha: 1),
58
-                muted: NSColor(srgbRed: 110 / 255, green: 118 / 255, blue: 128 / 255, alpha: 1),
59
-                rule: NSColor(srgbRed: 228 / 255, green: 230 / 255, blue: 234 / 255, alpha: 1),
60
-                cardBackground: NSColor(srgbRed: 0.998, green: 0.998, blue: 0.998, alpha: 1),
63
+                ink: colors.ink,
64
+                muted: colors.muted,
65
+                rule: colors.rule,
66
+                cardBackground: cardBG,
61 67
                 columnVerticalSpacing: 15,
62 68
                 bodyBlockSpacing: 15,
63 69
                 roleUsesThemeColor: false,
64
-                sectionInk: theme.withAlphaComponent(0.92)
70
+                sectionInk: sectionInk
65 71
             )
66 72
 
67 73
         case .professional:
@@ -78,15 +84,15 @@ private struct DocumentStyle {
78 84
                 eduMetaFont: .systemFont(ofSize: 11.5, weight: .medium),
79 85
                 bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
80 86
                 bulletMarkerFont: .systemFont(ofSize: 12, weight: .bold),
81
-                bulletMarkerColor: NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55),
82
-                ink: NSColor(srgbRed: 28 / 255, green: 36 / 255, blue: 48 / 255, alpha: 1),
83
-                muted: NSColor(srgbRed: 88 / 255, green: 98 / 255, blue: 118 / 255, alpha: 1),
84
-                rule: NSColor(srgbRed: 210 / 255, green: 218 / 255, blue: 232 / 255, alpha: 1),
85
-                cardBackground: NSColor.white,
87
+                bulletMarkerColor: theme,
88
+                ink: colors.ink,
89
+                muted: colors.muted,
90
+                rule: colors.rule,
91
+                cardBackground: cardBG,
86 92
                 columnVerticalSpacing: 13,
87 93
                 bodyBlockSpacing: 13,
88 94
                 roleUsesThemeColor: false,
89
-                sectionInk: theme
95
+                sectionInk: sectionInk
90 96
             )
91 97
 
92 98
         case .modern:
@@ -104,14 +110,14 @@ private struct DocumentStyle {
104 110
                 bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
105 111
                 bulletMarkerFont: .systemFont(ofSize: 13, weight: .bold),
106 112
                 bulletMarkerColor: theme,
107
-                ink: NSColor(srgbRed: 24 / 255, green: 34 / 255, blue: 52 / 255, alpha: 1),
108
-                muted: NSColor(srgbRed: 96 / 255, green: 110 / 255, blue: 132 / 255, alpha: 1),
109
-                rule: NSColor(srgbRed: 200 / 255, green: 214 / 255, blue: 236 / 255, alpha: 1),
110
-                cardBackground: NSColor(srgbRed: 0.99, green: 0.995, blue: 1, alpha: 1),
113
+                ink: colors.ink,
114
+                muted: colors.muted,
115
+                rule: colors.rule,
116
+                cardBackground: cardBG,
111 117
                 columnVerticalSpacing: 17,
112 118
                 bodyBlockSpacing: 16,
113 119
                 roleUsesThemeColor: true,
114
-                sectionInk: theme
120
+                sectionInk: sectionInk
115 121
             )
116 122
 
117 123
         case .executive:
@@ -125,6 +131,9 @@ private struct DocumentStyle {
125 131
                 ?? NSFontManager.shared.convert(georgia12, toHaveTrait: .italicFontMask)
126 132
             let eduMeta = NSFont(name: "Georgia-Italic", size: 11.5)
127 133
                 ?? NSFontManager.shared.convert(georgia115, toHaveTrait: .italicFontMask)
134
+            let execCard = CVResumeAppearance.isDark
135
+                ? cardBG
136
+                : NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1)
128 137
             return DocumentStyle(
129 138
                 nameFont: serifName,
130 139
                 roleFont: serifRole,
@@ -138,15 +147,15 @@ private struct DocumentStyle {
138 147
                 eduMetaFont: eduMeta,
139 148
                 bulletBodyFont: serifCompact,
140 149
                 bulletMarkerFont: .systemFont(ofSize: 11, weight: .bold),
141
-                bulletMarkerColor: NSColor(srgbRed: 55 / 255, green: 55 / 255, blue: 62 / 255, alpha: 1),
142
-                ink: NSColor(srgbRed: 22 / 255, green: 22 / 255, blue: 28 / 255, alpha: 1),
143
-                muted: NSColor(srgbRed: 82 / 255, green: 82 / 255, blue: 90 / 255, alpha: 1),
144
-                rule: NSColor(srgbRed: 72 / 255, green: 72 / 255, blue: 78 / 255, alpha: 0.35),
145
-                cardBackground: NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1),
150
+                bulletMarkerColor: colors.ink.withAlphaComponent(0.75),
151
+                ink: colors.ink,
152
+                muted: colors.muted,
153
+                rule: colors.rule,
154
+                cardBackground: execCard,
146 155
                 columnVerticalSpacing: 18,
147 156
                 bodyBlockSpacing: 17,
148 157
                 roleUsesThemeColor: false,
149
-                sectionInk: NSColor(srgbRed: 32 / 255, green: 32 / 255, blue: 38 / 255, alpha: 1)
158
+                sectionInk: sectionInk
150 159
             )
151 160
 
152 161
         case .creative:
@@ -163,15 +172,15 @@ private struct DocumentStyle {
163 172
                 eduMetaFont: .systemFont(ofSize: 12, weight: .medium),
164 173
                 bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
165 174
                 bulletMarkerFont: .systemFont(ofSize: 13, weight: .heavy),
166
-                bulletMarkerColor: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1),
167
-                ink: NSColor(srgbRed: 32 / 255, green: 26 / 255, blue: 52 / 255, alpha: 1),
168
-                muted: NSColor(srgbRed: 108 / 255, green: 96 / 255, blue: 130 / 255, alpha: 1),
175
+                bulletMarkerColor: CVResumeAppearance.accentColor(for: template),
176
+                ink: colors.ink,
177
+                muted: colors.muted,
169 178
                 rule: theme.withAlphaComponent(0.22),
170
-                cardBackground: NSColor(srgbRed: 0.995, green: 0.993, blue: 1, alpha: 1),
179
+                cardBackground: cardBG,
171 180
                 columnVerticalSpacing: 18,
172 181
                 bodyBlockSpacing: 17,
173 182
                 roleUsesThemeColor: true,
174
-                sectionInk: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1)
183
+                sectionInk: sectionInk
175 184
             )
176 185
         }
177 186
     }
@@ -187,9 +196,12 @@ final class CVProfileDocumentView: NSView {
187 196
 
188 197
     private let profile: SavedProfile
189 198
     private let template: CVTemplate
190
-    private let style: DocumentStyle
199
+    private var style: DocumentStyle
191 200
     /// Matches `CVTemplatePreviewView` so the same template id + layout recipe renders the same silhouette as the gallery card.
192 201
     private let variant: Int
202
+    private var appearanceObserver: NSObjectProtocol?
203
+    private weak var cardView: NSView?
204
+
193 205
     init(profile: SavedProfile, template: CVTemplate) {
194 206
         self.profile = profile
195 207
         self.template = template
@@ -200,9 +212,35 @@ final class CVProfileDocumentView: NSView {
200 212
         wantsLayer = true
201 213
         layer?.backgroundColor = NSColor.clear.cgColor
202 214
         userInterfaceLayoutDirection = .leftToRight
203
-        // Let the preview stack stretch us to the scroll view width; don’t shrink to label intrinsic widths.
204 215
         setContentHuggingPriority(.defaultLow, for: .horizontal)
216
+        installCardContent()
217
+        appearanceObserver = NotificationCenter.default.addObserver(
218
+            forName: AppAppearanceManager.didChangeNotification,
219
+            object: nil,
220
+            queue: .main
221
+        ) { [weak self] _ in
222
+            self?.refreshForAppearanceChange()
223
+        }
224
+    }
225
+
226
+    deinit {
227
+        if let appearanceObserver {
228
+            NotificationCenter.default.removeObserver(appearanceObserver)
229
+        }
230
+    }
231
+
232
+    override func viewDidChangeEffectiveAppearance() {
233
+        super.viewDidChangeEffectiveAppearance()
234
+        refreshForAppearanceChange()
235
+    }
205 236
 
237
+    private func refreshForAppearanceChange() {
238
+        style = DocumentStyle.make(for: template)
239
+        installCardContent()
240
+    }
241
+
242
+    private func installCardContent() {
243
+        subviews.forEach { $0.removeFromSuperview() }
206 244
         let card = NSView()
207 245
         card.translatesAutoresizingMaskIntoConstraints = false
208 246
         card.wantsLayer = true
@@ -211,6 +249,7 @@ final class CVProfileDocumentView: NSView {
211 249
         card.layer?.borderWidth = 1
212 250
         card.layer?.borderColor = style.rule.cgColor
213 251
         card.layer?.masksToBounds = true
252
+        cardView = card
214 253
 
215 254
         let root = buildRoot()
216 255
         root.translatesAutoresizingMaskIntoConstraints = false
@@ -278,8 +317,12 @@ final class CVProfileDocumentView: NSView {
278 317
             return buildModernFamilyDocument()
279 318
         case .creative:
280 319
             return buildCreativeFamilyDocument()
281
-        case .professional, .minimal, .executive:
282
-            return buildTraditionalFamilyDocument()
320
+        case .executive:
321
+            return buildExecutiveDocument()
322
+        case .minimal:
323
+            return buildMinimalDocument()
324
+        case .professional:
325
+            return buildProfessionalDocument()
283 326
         }
284 327
     }
285 328
 
@@ -550,7 +593,7 @@ final class CVProfileDocumentView: NSView {
550 593
         let iv = NSImageView(image: img)
551 594
         iv.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
552 595
         iv.contentTintColor = theme
553
-        let t = label(title.uppercased(), font: style.sectionFont, color: style.sectionInk, maxLines: 1)
596
+        let t = label(title.uppercased(), font: style.sectionFont, color: style.ink, maxLines: 1)
554 597
         let r = NSStackView(views: [iv, t])
555 598
         r.orientation = .horizontal
556 599
         r.spacing = 8
@@ -762,9 +805,300 @@ final class CVProfileDocumentView: NSView {
762 805
         return stack
763 806
     }
764 807
 
765
-    // MARK: - Traditional families (professional / minimal / executive)
808
+    // MARK: - Executive (matches gallery serif layout)
809
+
810
+    private func buildExecutiveDocument() -> NSView {
811
+        let centeredHead = (variant % 2) == 0
812
+        let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
813
+        let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
814
+        let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter {
815
+            !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
816
+        }
817
+        let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ")
818
+
819
+        let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
820
+        let role = label(roleText, font: style.roleFont, color: style.muted, maxLines: 2)
821
+        let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.9), maxLines: 3)
822
+        if centeredHead {
823
+            name.alignment = .center
824
+            role.alignment = .center
825
+            contact.alignment = .center
826
+        }
827
+
828
+        let rule = executiveHeaderRule(wide: variant % 3 == 0)
829
+        let head = NSStackView(views: [name, role, contact, rule])
830
+        head.orientation = .vertical
831
+        head.spacing = 8
832
+        head.alignment = centeredHead ? .centerX : .leading
833
+
834
+        let body: NSView
835
+        switch template.layout {
836
+        case .singleColumn:
837
+            body = executiveMainColumn(compact: false, tightLeading: variant % 5 == 2)
838
+        case .twoColumn(let side, let tinted):
839
+            let main = executiveMainColumn(compact: true, tightLeading: variant % 5 == 2)
840
+            let sideCol = executiveSidebarColumn(tinted: tinted, showMetrics: variant % 4 == 1)
841
+            let row = NSStackView()
842
+            row.orientation = .horizontal
843
+            row.spacing = 20
844
+            row.alignment = .top
845
+            if side == .leading {
846
+                row.addArrangedSubview(sideCol)
847
+                row.addArrangedSubview(main)
848
+            } else {
849
+                row.addArrangedSubview(main)
850
+                row.addArrangedSubview(sideCol)
851
+            }
852
+            let mult: CGFloat = (variant % 5 == 3) ? 0.38 : 0.33
853
+            sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
854
+            body = row
855
+        }
856
+
857
+        let wrap = NSStackView(views: [head, body])
858
+        wrap.orientation = .vertical
859
+        wrap.spacing = style.columnVerticalSpacing
860
+        wrap.alignment = .leading
861
+        return wrap
862
+    }
863
+
864
+    private func executiveHeaderRule(wide: Bool) -> NSView {
865
+        let theme = template.themeColor
866
+        let v = NSView()
867
+        v.translatesAutoresizingMaskIntoConstraints = false
868
+        v.wantsLayer = true
869
+        v.layer?.backgroundColor = theme.withAlphaComponent(0.45).cgColor
870
+        v.heightAnchor.constraint(equalToConstant: wide ? 2 : 1.5).isActive = true
871
+        v.widthAnchor.constraint(equalToConstant: wide ? 160 : 110).isActive = true
872
+        return v
873
+    }
874
+
875
+    private func executiveMainColumn(compact: Bool, tightLeading: Bool) -> NSView {
876
+        let stack = NSStackView()
877
+        stack.orientation = .vertical
878
+        stack.spacing = (compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2) - (tightLeading ? 2 : 0)
879
+        stack.alignment = .leading
880
+
881
+        let summaryTitle = (variant % 6 == 3) ? "Summary" : "Professional Summary"
882
+        if let summary = nonEmpty(profile.careerSummary) {
883
+            stack.addArrangedSubview(sectionHeading(summaryTitle))
884
+            stack.addArrangedSubview(paragraph(summary, compact: compact))
885
+        }
886
+
887
+        let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
888
+        if !jobs.isEmpty {
889
+            stack.addArrangedSubview(sectionHeading("Selected Experience"))
890
+            for job in jobs {
891
+                stack.addArrangedSubview(executiveExperienceBlock(job: job, compact: compact))
892
+            }
893
+        }
894
+
895
+        let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
896
+        if !schools.isEmpty {
897
+            stack.addArrangedSubview(sectionHeading("Education"))
898
+            for edu in schools {
899
+                stack.addArrangedSubview(educationBlock(edu: edu, compact: compact))
900
+            }
901
+        }
902
+        appendCertificatesInterestsReferrals(to: stack, compact: compact)
903
+        return stack
904
+    }
905
+
906
+    private func executiveExperienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
907
+        let v = NSStackView()
908
+        v.orientation = .vertical
909
+        v.spacing = 4
910
+        v.alignment = .leading
911
+        let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: ", ")
912
+        if !titleLine.isEmpty {
913
+            v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
914
+        }
915
+        let duration = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
916
+        if !duration.isEmpty {
917
+            v.addArrangedSubview(label(duration, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
918
+        }
919
+        for bullet in Self.bulletChunks(from: job.description) {
920
+            v.addArrangedSubview(label(bullet, font: style.bodyFont, color: style.muted, maxLines: 0))
921
+        }
922
+        return v
923
+    }
924
+
925
+    private func executiveSidebarColumn(tinted: Bool, showMetrics: Bool) -> NSView {
926
+        let stack = NSStackView()
927
+        stack.orientation = .vertical
928
+        stack.spacing = 10
929
+        stack.alignment = .leading
930
+        if tinted {
931
+            stack.wantsLayer = true
932
+            let fill = (variant % 3 == 0)
933
+                ? CVResumeAppearance.colors().sidebarTint
934
+                : template.themeColor.withAlphaComponent(0.07)
935
+            stack.layer?.backgroundColor = fill.cgColor
936
+            stack.layer?.cornerRadius = variant % 4 == 2 ? 8 : 6
937
+            stack.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
938
+        }
939
+
940
+        let skills = skillTokensFromProfile(max: 12)
941
+        if !skills.isEmpty {
942
+            stack.addArrangedSubview(sectionHeading("Core Competencies"))
943
+            for s in skills {
944
+                stack.addArrangedSubview(label("· \(s)", font: style.bodyFont, color: style.ink, maxLines: 0))
945
+            }
946
+        }
947
+
948
+        if let cert = nonEmpty(profile.certificates) {
949
+            stack.addArrangedSubview(sectionHeading("Tools"))
950
+            stack.addArrangedSubview(paragraph(cert, compact: true))
951
+        }
952
+
953
+        if showMetrics, let hi = highlightsBodyText() {
954
+            stack.addArrangedSubview(sectionHeading("Impact"))
955
+            stack.addArrangedSubview(paragraph(hi, compact: true))
956
+        }
957
+
958
+        if stack.arrangedSubviews.isEmpty {
959
+            stack.addArrangedSubview(sectionHeading("Contact"))
960
+            for line in contactLines() {
961
+                stack.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
962
+            }
963
+        }
964
+        return stack
965
+    }
966
+
967
+    // MARK: - Minimal (matches gallery light typography)
968
+
969
+    private func buildMinimalDocument() -> NSView {
970
+        let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
971
+        let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
972
+        let contactParts = [profile.personal.email, profile.personal.phone].filter {
973
+            !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
974
+        }
975
+        let contactText = contactParts.isEmpty ? "Add contact in your profile" : contactParts.joined(separator: "   ")
976
+
977
+        let nameWeight: NSFont.Weight = (variant % 3 == 0) ? .ultraLight : .light
978
+        let nameSize: CGFloat = template.headline == .centered ? 22 + CGFloat(variant % 2) : 20 + CGFloat(variant % 3)
979
+        let name = label(nameText, font: .systemFont(ofSize: nameSize, weight: nameWeight), color: style.ink, maxLines: 2)
980
+        let role = label(roleText.uppercased(), font: .systemFont(ofSize: 12.5, weight: .medium), color: style.muted, maxLines: 2)
981
+        let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.75), maxLines: 2)
982
+
983
+        let head = NSStackView()
984
+        head.orientation = .vertical
985
+        head.spacing = template.headline == .avatarStacked ? 10 : 6 + CGFloat(variant % 4)
986
+        head.alignment = template.headline == .centered ? .centerX : .leading
987
+        if template.headline == .avatarStacked {
988
+            head.addArrangedSubview(initialsBadge(for: nameText))
989
+        }
990
+        if template.headline == .centered {
991
+            name.alignment = .center
992
+            role.alignment = .center
993
+            contact.alignment = .center
994
+        }
995
+        head.addArrangedSubview(name)
996
+        head.addArrangedSubview(role)
997
+        head.addArrangedSubview(contact)
998
+        head.addArrangedSubview(hairline())
999
+        if variant % 5 == 1 {
1000
+            head.addArrangedSubview(hairline())
1001
+        }
1002
+
1003
+        let swapEdu = (variant % 4) == 2
1004
+        let body: NSView
1005
+        switch template.layout {
1006
+        case .singleColumn:
1007
+            body = minimalMainColumn(spacing: style.bodyBlockSpacing, educationBeforeExperience: swapEdu)
1008
+        case .twoColumn(let side, _):
1009
+            let main = minimalMainColumn(spacing: style.bodyBlockSpacing - 2, educationBeforeExperience: swapEdu)
1010
+            let aside = minimalSkillsAside(numbered: variant % 3 == 1)
1011
+            let row = NSStackView()
1012
+            row.orientation = .horizontal
1013
+            row.spacing = 18 + CGFloat(variant % 3)
1014
+            row.alignment = .top
1015
+            if side == .leading {
1016
+                row.addArrangedSubview(aside)
1017
+                row.addArrangedSubview(main)
1018
+            } else {
1019
+                row.addArrangedSubview(main)
1020
+                row.addArrangedSubview(aside)
1021
+            }
1022
+            let mult: CGFloat = (variant % 5 == 0) ? 0.34 : 0.30
1023
+            aside.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
1024
+            body = row
1025
+        }
1026
+
1027
+        let wrap = NSStackView(views: [head, body])
1028
+        wrap.orientation = .vertical
1029
+        wrap.spacing = style.columnVerticalSpacing
1030
+        wrap.alignment = .leading
1031
+        return wrap
1032
+    }
1033
+
1034
+    private func minimalMainColumn(spacing: CGFloat, educationBeforeExperience: Bool) -> NSView {
1035
+        let stack = NSStackView()
1036
+        stack.orientation = .vertical
1037
+        stack.spacing = spacing
1038
+        stack.alignment = .leading
1039
+
1040
+        let appendSummary: () -> Void = { [self] in
1041
+            if let summary = nonEmpty(profile.careerSummary) {
1042
+                stack.addArrangedSubview(sectionHeading("Profile"))
1043
+                stack.addArrangedSubview(paragraph(summary, compact: false))
1044
+            }
1045
+        }
1046
+        let appendExperience: () -> Void = { [self] in
1047
+            let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
1048
+            if !jobs.isEmpty {
1049
+                stack.addArrangedSubview(sectionHeading("Experience"))
1050
+                for job in jobs {
1051
+                    stack.addArrangedSubview(experienceBlock(job: job, compact: false))
1052
+                }
1053
+            }
1054
+        }
1055
+        let appendEducation: () -> Void = { [self] in
1056
+            let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
1057
+            if !schools.isEmpty {
1058
+                stack.addArrangedSubview(sectionHeading("Education"))
1059
+                for edu in schools {
1060
+                    stack.addArrangedSubview(educationBlock(edu: edu, compact: false))
1061
+                }
1062
+            }
1063
+        }
1064
+
1065
+        if educationBeforeExperience {
1066
+            appendEducation()
1067
+            appendSummary()
1068
+            appendExperience()
1069
+        } else {
1070
+            appendSummary()
1071
+            appendExperience()
1072
+            appendEducation()
1073
+        }
1074
+        appendCertificatesInterestsReferrals(to: stack, compact: false)
1075
+        return stack
1076
+    }
1077
+
1078
+    private func minimalSkillsAside(numbered: Bool) -> NSView {
1079
+        let stack = NSStackView()
1080
+        stack.orientation = .vertical
1081
+        stack.spacing = 8
1082
+        stack.alignment = .leading
1083
+        let skills = skillTokensFromProfile(max: 12)
1084
+        if skills.isEmpty {
1085
+            stack.addArrangedSubview(sectionHeading("Contact"))
1086
+            for line in contactLines() {
1087
+                stack.addArrangedSubview(label(line, font: style.contactFont, color: style.muted, maxLines: 0))
1088
+            }
1089
+            return stack
1090
+        }
1091
+        stack.addArrangedSubview(sectionHeading("Skills"))
1092
+        for (i, s) in skills.enumerated() {
1093
+            let prefix = numbered ? "\(i + 1). " : "·  "
1094
+            stack.addArrangedSubview(label("\(prefix)\(s)", font: style.bodyCompactFont, color: style.muted, maxLines: 0))
1095
+        }
1096
+        return stack
1097
+    }
1098
+
1099
+    // MARK: - Professional
766 1100
 
767
-    private func buildTraditionalFamilyDocument() -> NSView {
1101
+    private func buildProfessionalDocument() -> NSView {
768 1102
         switch template.layout {
769 1103
         case .singleColumn:
770 1104
             return singleColumnLayout()
@@ -840,7 +1174,7 @@ final class CVProfileDocumentView: NSView {
840 1174
         let row = NSStackView()
841 1175
         row.orientation = .horizontal
842 1176
         row.alignment = .top
843
-        row.spacing = template.family == .minimal ? 18 : 22
1177
+        row.spacing = 22
844 1178
 
845 1179
         let sidebarCol = sidebarColumn(tinted: tinted)
846 1180
         let mainCol = bodyColumn(compact: true, experienceFirst: professionalExperienceFirst)
@@ -852,12 +1186,7 @@ final class CVProfileDocumentView: NSView {
852 1186
             row.addArrangedSubview(mainCol)
853 1187
             row.addArrangedSubview(sidebarCol)
854 1188
         }
855
-        let sidebarMult: CGFloat
856
-        if template.family == .professional {
857
-            sidebarMult = (variant % 5 == 2) ? 0.38 : 0.32
858
-        } else {
859
-            sidebarMult = template.family == .executive ? 0.34 : 0.32
860
-        }
1189
+        let sidebarMult: CGFloat = (variant % 5 == 2) ? 0.38 : 0.32
861 1190
         sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: sidebarMult).isActive = true
862 1191
 
863 1192
         v.addArrangedSubview(row)
@@ -965,12 +1294,12 @@ final class CVProfileDocumentView: NSView {
965 1294
             bar.heightAnchor.constraint(equalToConstant: 1).isActive = true
966 1295
             return bar
967 1296
         case .redUnderline:
968
-            bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
1297
+            bar.layer?.backgroundColor = CVResumeAppearance.accentColor(for: template).cgColor
969 1298
             bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
970 1299
             bar.widthAnchor.constraint(equalToConstant: template.family == .minimal ? 140 : 168).isActive = true
971 1300
             return bar
972 1301
         case .redBar:
973
-            bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
1302
+            bar.layer?.backgroundColor = CVResumeAppearance.accentColor(for: template).cgColor
974 1303
             bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
975 1304
             bar.widthAnchor.constraint(equalToConstant: 56).isActive = true
976 1305
             return bar
@@ -1004,8 +1333,11 @@ final class CVProfileDocumentView: NSView {
1004 1333
         box.alignment = .leading
1005 1334
         if tinted {
1006 1335
             box.wantsLayer = true
1007
-            box.layer?.backgroundColor = template.themeColor.withAlphaComponent(template.family == .creative ? 0.12 : 0.08).cgColor
1008
-            box.layer?.cornerRadius = 8
1336
+            let tint = (variant % 4 == 1)
1337
+                ? template.themeColor.withAlphaComponent(0.08)
1338
+                : CVResumeAppearance.colors().sidebarTint
1339
+            box.layer?.backgroundColor = tint.cgColor
1340
+            box.layer?.cornerRadius = variant % 3 == 0 ? 8 : 6
1009 1341
         }
1010 1342
         box.edgeInsets = NSEdgeInsets(top: tinted ? 14 : 0, left: tinted ? 14 : 0, bottom: tinted ? 14 : 0, right: tinted ? 14 : 0)
1011 1343
 
@@ -1014,8 +1346,24 @@ final class CVProfileDocumentView: NSView {
1014 1346
             box.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
1015 1347
         }
1016 1348
 
1017
-        if let skillsBlock = ancillaryBlock(title: "Languages & more", body: combinedAncillaryText()) {
1018
-            box.addArrangedSubview(skillsBlock)
1349
+        let skills = skillTokensFromProfile(max: 8)
1350
+        if !skills.isEmpty {
1351
+            box.addArrangedSubview(sectionHeading("Skills"))
1352
+            if variant % 5 == 2 {
1353
+                box.addArrangedSubview(skillTagRow(theme: template.themeColor, maxTags: 5))
1354
+            } else {
1355
+                for token in skills {
1356
+                    box.addArrangedSubview(label("·  \(token)", font: style.contactFont, color: style.ink, maxLines: 0))
1357
+                }
1358
+            }
1359
+        }
1360
+
1361
+        if variant % 7 == 3, let tools = nonEmpty(profile.certificates) {
1362
+            box.addArrangedSubview(sectionHeading("Tools"))
1363
+            box.addArrangedSubview(paragraph(tools, compact: true))
1364
+        } else if let ancillary = combinedAncillaryText(), !ancillary.isEmpty {
1365
+            box.addArrangedSubview(sectionHeading("Languages & more"))
1366
+            box.addArrangedSubview(paragraph(ancillary, compact: true))
1019 1367
         }
1020 1368
 
1021 1369
         return box

+ 51 - 74
App for Indeed/Views/CVTemplateMiniPreview.swift

@@ -62,21 +62,8 @@ final class CVTemplatePreviewView: NSView {
62 62
     private let palette: CVTemplateCardPalette
63 63
     private let paper = NSView()
64 64
 
65
-    /// Stable 0…11 from template identity so each row looks different (not just by `family`).
66
-    private var idVariant: Int {
67
-        var h: UInt64 = 1469598103934665603
68
-        let layoutDesc: String
69
-        switch template.layout {
70
-        case .singleColumn: layoutDesc = "1col"
71
-        case .twoColumn(let s, let t): layoutDesc = "2col_\(s)_\(t)"
72
-        }
73
-        let blob = "\(template.id)|\(template.family.rawValue)|\(template.headline)|\(template.accent)|\(layoutDesc)|\(template.sectionLabelStyle)"
74
-        for b in blob.utf8 {
75
-            h ^= UInt64(b)
76
-            h &*= 1_099_511_628_211
77
-        }
78
-        return Int(h % 12)
79
-    }
65
+    /// Same variant index as `CVProfileDocumentView` (shared `CVTemplate.galleryLayoutVariant`).
66
+    private var layoutVariant: Int { template.galleryLayoutVariant }
80 67
 
81 68
     init(template: CVTemplate, palette: CVTemplateCardPalette) {
82 69
         self.template = template
@@ -133,29 +120,23 @@ final class CVTemplatePreviewView: NSView {
133 120
     }
134 121
 
135 122
     private func paperBackgroundColor() -> NSColor {
136
-        switch idVariant % 5 {
137
-        case 0: return palette.previewPaper
138
-        case 1: return NSColor(srgbRed: 0.995, green: 0.992, blue: 0.985, alpha: 1)
139
-        case 2: return NSColor(srgbRed: 0.96, green: 0.99, blue: 1, alpha: 1)
140
-        case 3: return NSColor(srgbRed: 0.99, green: 0.99, blue: 0.99, alpha: 1)
141
-        default: return NSColor(srgbRed: 0.99, green: 0.98, blue: 0.995, alpha: 1)
142
-        }
123
+        CVResumeAppearance.paperBackground(variant: layoutVariant, base: palette.previewPaper)
143 124
     }
144 125
 
145 126
     // MARK: - Family: Professional (ATS-friendly)
146 127
 
147 128
     private func buildProfessionalResume() -> NSView {
148
-        let swapExpFirst = (idVariant % 3) == 1
149
-        let sidebarMult: CGFloat = (idVariant % 5 == 2) ? 0.38 : 0.34
129
+        let swapExpFirst = (layoutVariant % 3) == 1
130
+        let sidebarMult: CGFloat = (layoutVariant % 5 == 2) ? 0.38 : 0.34
150 131
 
151 132
         switch template.layout {
152 133
         case .singleColumn:
153 134
             let v = NSStackView()
154 135
             v.orientation = .vertical
155
-            v.spacing = 4 + CGFloat(idVariant % 3)
136
+            v.spacing = 4 + CGFloat(layoutVariant % 3)
156 137
             v.alignment = .leading
157 138
             v.addArrangedSubview(proHeaderBlock())
158
-            if (idVariant % 6) == 4 {
139
+            if (layoutVariant % 6) == 4 {
159 140
                 v.addArrangedSubview(proInlineSkillsRow())
160 141
             }
161 142
             v.addArrangedSubview(hairline())
@@ -169,9 +150,9 @@ final class CVTemplatePreviewView: NSView {
169 150
             let rule = hairline()
170 151
             let row = NSStackView()
171 152
             row.orientation = .horizontal
172
-            row.spacing = 5 + CGFloat(idVariant % 3)
153
+            row.spacing = 5 + CGFloat(layoutVariant % 3)
173 154
             row.alignment = .top
174
-            let sidebar = proSidebarColumn(tinted: tinted, variant: idVariant)
155
+            let sidebar = proSidebarColumn(tinted: tinted, variant: layoutVariant)
175 156
             let main = proMainColumn(compact: true, experienceFirst: swapExpFirst)
176 157
             if side == .leading {
177 158
                 row.addArrangedSubview(sidebar)
@@ -315,7 +296,7 @@ final class CVTemplatePreviewView: NSView {
315 296
     private func proMainColumn(compact: Bool, experienceFirst: Bool) -> NSView {
316 297
         let stack = NSStackView()
317 298
         stack.orientation = .vertical
318
-        stack.spacing = compact ? 4 : 5 + CGFloat(idVariant % 2)
299
+        stack.spacing = compact ? 4 : 5 + CGFloat(layoutVariant % 2)
319 300
         stack.alignment = .leading
320 301
         let sp: CGFloat = compact ? 6.2 : 6.5
321 302
 
@@ -348,7 +329,7 @@ final class CVTemplatePreviewView: NSView {
348 329
     // MARK: - Family: Modern (three distinct silhouettes per id)
349 330
 
350 331
     private func buildModernResume() -> NSView {
351
-        switch idVariant % 3 {
332
+        switch layoutVariant % 3 {
352 333
         case 0: return buildModernClassicBandLayout()
353 334
         case 1: return buildModernRailDocLayout()
354 335
         default: return buildModernSplitHeaderLayout()
@@ -364,7 +345,7 @@ final class CVTemplatePreviewView: NSView {
364 345
             let sideCol = modernSidebar(theme: theme, tinted: tinted)
365 346
             let row = NSStackView()
366 347
             row.orientation = .horizontal
367
-            row.spacing = 5 + CGFloat(idVariant % 3)
348
+            row.spacing = 5 + CGFloat(layoutVariant % 3)
368 349
             row.alignment = .top
369 350
             if side == .leading {
370 351
                 row.addArrangedSubview(sideCol)
@@ -373,7 +354,7 @@ final class CVTemplatePreviewView: NSView {
373 354
                 row.addArrangedSubview(main)
374 355
                 row.addArrangedSubview(sideCol)
375 356
             }
376
-            let mult: CGFloat = (idVariant % 4 == 2) ? 0.36 : 0.32
357
+            let mult: CGFloat = (layoutVariant % 4 == 2) ? 0.36 : 0.32
377 358
             sideCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
378 359
             return row
379 360
         }
@@ -385,7 +366,7 @@ final class CVTemplatePreviewView: NSView {
385 366
         header.translatesAutoresizingMaskIntoConstraints = false
386 367
         header.wantsLayer = true
387 368
         header.layer?.backgroundColor = theme.cgColor
388
-        header.layer?.cornerRadius = idVariant % 2 == 0 ? 5 : 3
369
+        header.layer?.cornerRadius = layoutVariant % 2 == 0 ? 5 : 3
389 370
 
390 371
         let white = NSColor.white
391 372
         let name = makeLabel(CVPreviewDemoContent.fullName, font: .systemFont(ofSize: 9, weight: .bold), color: white, alignment: .left, maxLines: 1)
@@ -423,13 +404,13 @@ final class CVTemplatePreviewView: NSView {
423 404
             topRow.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -7),
424 405
             topRow.topAnchor.constraint(equalTo: header.topAnchor, constant: 6),
425 406
             topRow.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -6),
426
-            header.heightAnchor.constraint(greaterThanOrEqualToConstant: 32 + CGFloat(idVariant % 3) * 2)
407
+            header.heightAnchor.constraint(greaterThanOrEqualToConstant: 32 + CGFloat(layoutVariant % 3) * 2)
427 408
         ])
428 409
 
429 410
         let body = modernPrimaryBody(theme: theme)
430 411
         let wrap = NSStackView(views: [header, body])
431 412
         wrap.orientation = .vertical
432
-        wrap.spacing = 6 + CGFloat(idVariant % 2)
413
+        wrap.spacing = 6 + CGFloat(layoutVariant % 2)
433 414
         wrap.alignment = .leading
434 415
         return wrap
435 416
     }
@@ -443,7 +424,7 @@ final class CVTemplatePreviewView: NSView {
443 424
         rail.wantsLayer = true
444 425
         rail.layer?.backgroundColor = theme.cgColor
445 426
         rail.layer?.cornerRadius = 2
446
-        rail.widthAnchor.constraint(equalToConstant: 3 + CGFloat(idVariant % 2)).isActive = true
427
+        rail.widthAnchor.constraint(equalToConstant: 3 + CGFloat(layoutVariant % 2)).isActive = true
447 428
 
448 429
         let inner = NSStackView()
449 430
         inner.orientation = .vertical
@@ -578,18 +559,18 @@ final class CVTemplatePreviewView: NSView {
578 559
     private func buildMinimalResume() -> NSView {
579 560
         let ink = palette.previewInk
580 561
         let muted = palette.previewMuted
581
-        let nameWeight: NSFont.Weight = (idVariant % 3 == 0) ? .ultraLight : .light
582
-        let nameSize: CGFloat = template.headline == .centered ? 11 + CGFloat(idVariant % 2) : 9.5 + CGFloat(idVariant % 3)
562
+        let nameWeight: NSFont.Weight = (layoutVariant % 3 == 0) ? .ultraLight : .light
563
+        let nameSize: CGFloat = template.headline == .centered ? 11 + CGFloat(layoutVariant % 2) : 9.5 + CGFloat(layoutVariant % 3)
583 564
         let name = makeLabel(CVPreviewDemoContent.fullName, font: .systemFont(ofSize: nameSize, weight: nameWeight), color: ink, alignment: .left, maxLines: 1)
584 565
         let role = makeLabel(CVPreviewDemoContent.title.uppercased(), font: .systemFont(ofSize: 6.5, weight: .medium), color: muted, alignment: .left, maxLines: 1)
585 566
         let contact = makeLabel("\(CVPreviewDemoContent.email)   \(CVPreviewDemoContent.phone)", font: .systemFont(ofSize: 5.8, weight: .regular), color: muted.withAlphaComponent(0.75), alignment: .left, maxLines: 1)
586 567
 
587 568
         let head = NSStackView()
588 569
         head.orientation = .vertical
589
-        head.spacing = template.headline == .avatarStacked ? 8 : 4 + CGFloat(idVariant % 4)
570
+        head.spacing = template.headline == .avatarStacked ? 8 : 4 + CGFloat(layoutVariant % 4)
590 571
         head.alignment = template.headline == .centered ? .centerX : .leading
591 572
         if template.headline == .avatarStacked {
592
-            head.addArrangedSubview(initialsAvatar(diameter: 24 + CGFloat(idVariant % 2) * 2, ink: ink))
573
+            head.addArrangedSubview(initialsAvatar(diameter: 24 + CGFloat(layoutVariant % 2) * 2, ink: ink))
593 574
         }
594 575
         if template.headline == .centered {
595 576
             name.alignment = .center
@@ -600,21 +581,21 @@ final class CVTemplatePreviewView: NSView {
600 581
         head.addArrangedSubview(role)
601 582
         head.addArrangedSubview(contact)
602 583
         head.addArrangedSubview(hairlineSoft())
603
-        if idVariant % 5 == 1 {
584
+        if layoutVariant % 5 == 1 {
604 585
             head.addArrangedSubview(hairlineSoft())
605 586
         }
606 587
 
607
-        let swapEdu = (idVariant % 4) == 2
588
+        let swapEdu = (layoutVariant % 4) == 2
608 589
         let body: NSView
609 590
         switch template.layout {
610 591
         case .singleColumn:
611
-            body = minimalBody(spacing: 6 + CGFloat(idVariant % 3), educationBeforeExperience: swapEdu)
592
+            body = minimalBody(spacing: 6 + CGFloat(layoutVariant % 3), educationBeforeExperience: swapEdu)
612 593
         case .twoColumn(let side, _):
613 594
             let a = minimalBody(spacing: 5, educationBeforeExperience: swapEdu)
614
-            let b = minimalAside(numbered: idVariant % 3 == 1)
595
+            let b = minimalAside(numbered: layoutVariant % 3 == 1)
615 596
             let row = NSStackView()
616 597
             row.orientation = .horizontal
617
-            row.spacing = 8 + CGFloat(idVariant % 3)
598
+            row.spacing = 8 + CGFloat(layoutVariant % 3)
618 599
             row.alignment = .top
619 600
             if side == .leading {
620 601
                 row.addArrangedSubview(b)
@@ -623,14 +604,14 @@ final class CVTemplatePreviewView: NSView {
623 604
                 row.addArrangedSubview(a)
624 605
                 row.addArrangedSubview(b)
625 606
             }
626
-            let mult: CGFloat = (idVariant % 5 == 0) ? 0.34 : 0.3
607
+            let mult: CGFloat = (layoutVariant % 5 == 0) ? 0.34 : 0.3
627 608
             b.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
628 609
             body = row
629 610
         }
630 611
 
631 612
         let wrap = NSStackView(views: [head, body])
632 613
         wrap.orientation = .vertical
633
-        wrap.spacing = 7 + CGFloat(idVariant % 2)
614
+        wrap.spacing = 7 + CGFloat(layoutVariant % 2)
634 615
         wrap.alignment = .leading
635 616
         return wrap
636 617
     }
@@ -690,13 +671,13 @@ final class CVTemplatePreviewView: NSView {
690 671
         let ink = palette.previewInk
691 672
         let muted = palette.previewMuted
692 673
         let theme = template.themeColor
693
-        let centeredHead = (idVariant % 2) == 0
674
+        let centeredHead = (layoutVariant % 2) == 0
694 675
 
695 676
         let name = makeLabel(CVPreviewDemoContent.fullName, font: serifBold, color: ink, alignment: centeredHead ? .center : .left, maxLines: 1)
696 677
         let role = makeLabel(CVPreviewDemoContent.title, font: serif, color: muted, alignment: centeredHead ? .center : .left, maxLines: 1)
697 678
         let contact = makeLabel("\(CVPreviewDemoContent.email) · \(CVPreviewDemoContent.phone) · \(CVPreviewDemoContent.location)", font: NSFont(name: "Georgia", size: 5.8) ?? serif, color: muted.withAlphaComponent(0.9), alignment: centeredHead ? .center : .left, maxLines: 2)
698 679
 
699
-        let rule = executiveRule(theme: theme, wide: idVariant % 3 == 0)
680
+        let rule = executiveRule(theme: theme, wide: layoutVariant % 3 == 0)
700 681
         let head = NSStackView(views: [name, role, contact, rule])
701 682
         head.orientation = .vertical
702 683
         head.spacing = 4
@@ -705,13 +686,13 @@ final class CVTemplatePreviewView: NSView {
705 686
         let body: NSView
706 687
         switch template.layout {
707 688
         case .singleColumn:
708
-            body = executiveBody(serif: serif, ink: ink, muted: muted, theme: theme, compact: false, tightLeading: idVariant % 5 == 2)
689
+            body = executiveBody(serif: serif, ink: ink, muted: muted, theme: theme, compact: false, tightLeading: layoutVariant % 5 == 2)
709 690
         case .twoColumn(let side, let tinted):
710
-            let main = executiveBody(serif: serif, ink: ink, muted: muted, theme: theme, compact: true, tightLeading: idVariant % 5 == 2)
711
-            let sideC = executiveSidebar(serif: serif, tinted: tinted, showMetrics: idVariant % 4 == 1)
691
+            let main = executiveBody(serif: serif, ink: ink, muted: muted, theme: theme, compact: true, tightLeading: layoutVariant % 5 == 2)
692
+            let sideC = executiveSidebar(serif: serif, tinted: tinted, showMetrics: layoutVariant % 4 == 1)
712 693
             let row = NSStackView()
713 694
             row.orientation = .horizontal
714
-            row.spacing = 6 + CGFloat(idVariant % 3)
695
+            row.spacing = 6 + CGFloat(layoutVariant % 3)
715 696
             row.alignment = .top
716 697
             if side == .leading {
717 698
                 row.addArrangedSubview(sideC)
@@ -720,14 +701,14 @@ final class CVTemplatePreviewView: NSView {
720 701
                 row.addArrangedSubview(main)
721 702
                 row.addArrangedSubview(sideC)
722 703
             }
723
-            let mult: CGFloat = (idVariant % 5 == 3) ? 0.38 : 0.33
704
+            let mult: CGFloat = (layoutVariant % 5 == 3) ? 0.38 : 0.33
724 705
             sideC.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: mult).isActive = true
725 706
             body = row
726 707
         }
727 708
 
728 709
         let wrap = NSStackView(views: [head, body])
729 710
         wrap.orientation = .vertical
730
-        wrap.spacing = 6 + CGFloat(idVariant % 2)
711
+        wrap.spacing = 6 + CGFloat(layoutVariant % 2)
731 712
         wrap.alignment = .leading
732 713
         return wrap
733 714
     }
@@ -747,7 +728,7 @@ final class CVTemplatePreviewView: NSView {
747 728
         stack.orientation = .vertical
748 729
         stack.spacing = (compact ? 4 : 5) - (tightLeading ? 1 : 0)
749 730
         stack.alignment = .leading
750
-        let sumTitle = (idVariant % 6 == 3) ? "SUMMARY" : "PROFESSIONAL SUMMARY"
731
+        let sumTitle = (layoutVariant % 6 == 3) ? "SUMMARY" : "PROFESSIONAL SUMMARY"
751 732
         stack.addArrangedSubview(sectionHeading(sumTitle))
752 733
         stack.addArrangedSubview(makeLabel(CVPreviewDemoContent.summary, font: serif, color: ink, alignment: .left, maxLines: 3))
753 734
         stack.addArrangedSubview(sectionHeading("SELECTED EXPERIENCE"))
@@ -768,11 +749,11 @@ final class CVTemplatePreviewView: NSView {
768 749
         stack.alignment = .leading
769 750
         if tinted {
770 751
             stack.wantsLayer = true
771
-            let fill = (idVariant % 3 == 0)
752
+            let fill = (layoutVariant % 3 == 0)
772 753
                 ? NSColor(srgbRed: 0.97, green: 0.97, blue: 0.98, alpha: 1)
773 754
                 : template.themeColor.withAlphaComponent(0.07)
774 755
             stack.layer?.backgroundColor = fill.cgColor
775
-            stack.layer?.cornerRadius = idVariant % 4 == 2 ? 5 : 3
756
+            stack.layer?.cornerRadius = layoutVariant % 4 == 2 ? 5 : 3
776 757
             stack.edgeInsets = NSEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
777 758
         }
778 759
         stack.addArrangedSubview(sectionHeading("CORE COMPETENCIES"))
@@ -794,18 +775,18 @@ final class CVTemplatePreviewView: NSView {
794 775
         let theme = template.themeColor
795 776
         let deep = creativeDeepBackground(theme: theme)
796 777
         let onSidebar = NSColor.white.withAlphaComponent(0.95)
797
-        let skillPrefix = (idVariant % 3 == 0) ? "•  " : "▸  "
778
+        let skillPrefix = (layoutVariant % 3 == 0) ? "•  " : "▸  "
798 779
 
799 780
         let sidebar = NSStackView()
800 781
         sidebar.orientation = .vertical
801
-        sidebar.spacing = 4 + CGFloat(idVariant % 3)
782
+        sidebar.spacing = 4 + CGFloat(layoutVariant % 3)
802 783
         sidebar.alignment = .leading
803 784
         sidebar.wantsLayer = true
804 785
         sidebar.layer?.backgroundColor = deep.cgColor
805
-        sidebar.layer?.cornerRadius = idVariant % 2 == 0 ? 6 : 4
786
+        sidebar.layer?.cornerRadius = layoutVariant % 2 == 0 ? 6 : 4
806 787
         sidebar.edgeInsets = NSEdgeInsets(top: 6, left: 6, bottom: 6, right: 6)
807 788
 
808
-        let sbTitle = makeLabel(CVPreviewDemoContent.fullName, font: .systemFont(ofSize: 7.5 + CGFloat(idVariant % 2), weight: .bold), color: onSidebar, alignment: .left, maxLines: 2)
789
+        let sbTitle = makeLabel(CVPreviewDemoContent.fullName, font: .systemFont(ofSize: 7.5 + CGFloat(layoutVariant % 2), weight: .bold), color: onSidebar, alignment: .left, maxLines: 2)
809 790
         sidebar.addArrangedSubview(sbTitle)
810 791
         sidebar.addArrangedSubview(makeLabel(CVPreviewDemoContent.title, font: .systemFont(ofSize: 6.5, weight: .medium), color: onSidebar.withAlphaComponent(0.85), alignment: .left, maxLines: 2))
811 792
         sidebar.addArrangedSubview(makeLabel(CVPreviewDemoContent.email, font: .systemFont(ofSize: 5.8), color: onSidebar.withAlphaComponent(0.8), alignment: .left, maxLines: 1))
@@ -817,20 +798,20 @@ final class CVTemplatePreviewView: NSView {
817 798
 
818 799
         let main = NSStackView()
819 800
         main.orientation = .vertical
820
-        main.spacing = 4 + CGFloat(idVariant % 3)
801
+        main.spacing = 4 + CGFloat(layoutVariant % 3)
821 802
         main.alignment = .leading
822 803
         main.addArrangedSubview(creativeMainHeader(theme: theme))
823 804
         main.addArrangedSubview(sectionHeading("PROFILE"))
824 805
         main.addArrangedSubview(makeLabel(CVPreviewDemoContent.summary, font: .systemFont(ofSize: 6.2), color: palette.previewInk, alignment: .left, maxLines: 3))
825 806
         main.addArrangedSubview(sectionHeading("IMPACT"))
826 807
         main.addArrangedSubview(makeLabel("\(CVPreviewDemoContent.company) — \(CVPreviewDemoContent.experienceRole)", font: .systemFont(ofSize: 6.6, weight: .heavy), color: palette.previewInk, alignment: .left, maxLines: 2))
827
-        let bMark = (idVariant % 2 == 0) ? "—  " : "▸  "
808
+        let bMark = (layoutVariant % 2 == 0) ? "—  " : "▸  "
828 809
         main.addArrangedSubview(makeLabel("\(bMark)\(CVPreviewDemoContent.bullet1)", font: .systemFont(ofSize: 6), color: palette.previewMuted, alignment: .left, maxLines: 2))
829 810
         main.addArrangedSubview(makeLabel("\(bMark)\(CVPreviewDemoContent.bullet2)", font: .systemFont(ofSize: 6), color: palette.previewMuted, alignment: .left, maxLines: 2))
830 811
         main.addArrangedSubview(sectionHeading("EDUCATION"))
831 812
         main.addArrangedSubview(makeLabel("\(CVPreviewDemoContent.university) · \(CVPreviewDemoContent.degree) · \(CVPreviewDemoContent.educationYears)", font: .systemFont(ofSize: 6.1), color: palette.previewInk, alignment: .left, maxLines: 2))
832 813
 
833
-        let sidebarMult = 0.32 + CGFloat(idVariant % 3) * 0.02
814
+        let sidebarMult = 0.32 + CGFloat(layoutVariant % 3) * 0.02
834 815
 
835 816
         switch template.layout {
836 817
         case .singleColumn:
@@ -838,14 +819,14 @@ final class CVTemplatePreviewView: NSView {
838 819
             banner.translatesAutoresizingMaskIntoConstraints = false
839 820
             banner.wantsLayer = true
840 821
             banner.layer?.backgroundColor = theme.cgColor
841
-            banner.layer?.cornerRadius = idVariant % 4 == 1 ? 6 : 3
822
+            banner.layer?.cornerRadius = layoutVariant % 4 == 1 ? 6 : 3
842 823
             let inner = makeLabel("  \(CVPreviewDemoContent.fullName)  ·  \(CVPreviewDemoContent.title)", font: .systemFont(ofSize: 6.5, weight: .bold), color: .white, alignment: .left, maxLines: 1)
843 824
             inner.translatesAutoresizingMaskIntoConstraints = false
844 825
             banner.addSubview(inner)
845 826
             NSLayoutConstraint.activate([
846 827
                 inner.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 5),
847 828
                 inner.trailingAnchor.constraint(lessThanOrEqualTo: banner.trailingAnchor, constant: -5),
848
-                inner.topAnchor.constraint(equalTo: banner.topAnchor, constant: 4 + CGFloat(idVariant % 2)),
829
+                inner.topAnchor.constraint(equalTo: banner.topAnchor, constant: 4 + CGFloat(layoutVariant % 2)),
849 830
                 inner.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -4)
850 831
             ])
851 832
             let col = NSStackView(views: [banner, main])
@@ -856,7 +837,7 @@ final class CVTemplatePreviewView: NSView {
856 837
         case .twoColumn(let side, _):
857 838
             let row = NSStackView()
858 839
             row.orientation = .horizontal
859
-            row.spacing = 5 + CGFloat(idVariant % 3)
840
+            row.spacing = 5 + CGFloat(layoutVariant % 3)
860 841
             row.alignment = .top
861 842
             if side == .leading {
862 843
                 row.addArrangedSubview(sidebar)
@@ -873,7 +854,7 @@ final class CVTemplatePreviewView: NSView {
873 854
     private func creativeDeepBackground(theme: NSColor) -> NSColor {
874 855
         let navy = NSColor(srgbRed: 0.08, green: 0.1, blue: 0.18, alpha: 1)
875 856
         let plum = NSColor(srgbRed: 0.14, green: 0.07, blue: 0.24, alpha: 1)
876
-        switch idVariant % 4 {
857
+        switch layoutVariant % 4 {
877 858
         case 0: return theme.blended(withFraction: 0.52, of: navy) ?? theme
878 859
         case 1: return theme.blended(withFraction: 0.7, of: NSColor.black) ?? theme
879 860
         case 2: return palette.previewInk.blended(withFraction: 0.38, of: theme) ?? theme
@@ -944,11 +925,7 @@ final class CVTemplatePreviewView: NSView {
944 925
     }
945 926
 
946 927
     private func accentDecorationColor() -> NSColor {
947
-        switch template.accent {
948
-        case .redUnderline, .redBar: return palette.previewAccentRed
949
-        case .blueBar: return palette.previewAccentBlue
950
-        case .none: return template.themeColor.blended(withFraction: 0.5, of: palette.previewInk) ?? palette.previewInk
951
-        }
928
+        CVResumeAppearance.accentColor(for: template)
952 929
     }
953 930
 
954 931
     private func headlineAccent(theme: NSColor, width: CGFloat) -> NSView {