Explorar o código

Add multi-profile storage and profiles list UI

Persist full saved profiles (personal info, career, work, education, extras)
in UserDefaults via SavedProfilesStore, with migration from the legacy
single personal-information key.

Introduce ProfilesListPageView as the Profile sidebar hub: list profiles,
add new, edit, and delete with confirmation. Wire DashboardView to swap
between the list and MyProfilePageView; editor includes back navigation
and saves complete profiles on Save.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 hai 2 meses
pai
achega
39bb2ad0c0

+ 114 - 0
App for Indeed/Models/SavedProfilesStore.swift

@@ -0,0 +1,114 @@
+//
+//  SavedProfilesStore.swift
+//  App for Indeed
+//
+
+import Foundation
+
+struct PersonalInformation: Codable, Equatable {
+    var fullName: String
+    var email: String
+    var phone: String
+    var jobTitle: String
+    var address: String
+
+    static let empty = PersonalInformation(fullName: "", email: "", phone: "", jobTitle: "", address: "")
+}
+
+struct WorkExperiencePayload: Codable, Equatable {
+    var jobTitle: String
+    var company: String
+    var duration: String
+    var description: String
+
+    static let empty = WorkExperiencePayload(jobTitle: "", company: "", duration: "", description: "")
+}
+
+struct EducationPayload: Codable, Equatable {
+    var degree: String
+    var institution: String
+    var year: String
+
+    static let empty = EducationPayload(degree: "", institution: "", year: "")
+}
+
+struct SavedProfile: Codable, Equatable, Identifiable {
+    var id: UUID
+    var profileDisplayName: String
+    var personal: PersonalInformation
+    var careerSummary: String
+    var workExperiences: [WorkExperiencePayload]
+    var educations: [EducationPayload]
+    var certificates: String
+    var interests: String
+    var languages: String
+    var referral: String
+}
+
+enum SavedProfilesStore {
+    private static let profilesKey = "com.appforindeed.savedProfiles.v1"
+    private static let legacyPersonalKey = "com.appforindeed.personalInformation.v1"
+
+    static func loadAll() -> [SavedProfile] {
+        migrateLegacyPersonalInformationIfNeeded()
+        guard let data = UserDefaults.standard.data(forKey: profilesKey) else { return [] }
+        do {
+            return try JSONDecoder().decode([SavedProfile].self, from: data)
+        } catch {
+            return []
+        }
+    }
+
+    static func saveAll(_ profiles: [SavedProfile]) {
+        do {
+            let data = try JSONEncoder().encode(profiles)
+            UserDefaults.standard.set(data, forKey: profilesKey)
+        } catch {
+            // Best-effort persistence.
+        }
+    }
+
+    static func upsert(_ profile: SavedProfile) {
+        var all = loadAll()
+        if let i = all.firstIndex(where: { $0.id == profile.id }) {
+            all[i] = profile
+        } else {
+            all.append(profile)
+        }
+        saveAll(all)
+    }
+
+    static func delete(id: UUID) {
+        var all = loadAll()
+        all.removeAll { $0.id == id }
+        saveAll(all)
+    }
+
+    static func profile(id: UUID) -> SavedProfile? {
+        loadAll().first { $0.id == id }
+    }
+
+    /// Imports the older single `PersonalInformation` blob into the first saved profile when the new store has never been written.
+    private static func migrateLegacyPersonalInformationIfNeeded() {
+        guard UserDefaults.standard.object(forKey: profilesKey) == nil else { return }
+        guard let data = UserDefaults.standard.data(forKey: legacyPersonalKey) else { return }
+        guard let personal = try? JSONDecoder().decode(PersonalInformation.self, from: data) else {
+            UserDefaults.standard.removeObject(forKey: legacyPersonalKey)
+            return
+        }
+        let imported = SavedProfile(
+            id: UUID(),
+            profileDisplayName: "My profile",
+            personal: personal,
+            careerSummary: "",
+            workExperiences: [.empty],
+            educations: [.empty],
+            certificates: "",
+            interests: "",
+            languages: "",
+            referral: ""
+        )
+        saveAll([imported])
+        UserDefaults.standard.removeObject(forKey: legacyPersonalKey)
+    }
+}

+ 90 - 0
App for Indeed/Views/DashboardView.swift

@@ -126,9 +126,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         CVMakerPageView()
         CVMakerPageView()
     }()
     }()
     private let profilePageContainer = NSView()
     private let profilePageContainer = NSView()
+    private lazy var profilesListPageView: ProfilesListPageView = {
+        ProfilesListPageView()
+    }()
     private lazy var myProfilePageView: MyProfilePageView = {
     private lazy var myProfilePageView: MyProfilePageView = {
         MyProfilePageView()
         MyProfilePageView()
     }()
     }()
+    /// When true, `myProfilePageView` is visible instead of the profiles list.
+    private var isProfileEditorPresented = false
 
 
     private var currentSidebarItems: [SidebarItem] = []
     private var currentSidebarItems: [SidebarItem] = []
     private var selectedSidebarIndex: Int = 0
     private var selectedSidebarIndex: Int = 0
@@ -1341,14 +1346,89 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         profilePageContainer.isHidden = true
         profilePageContainer.isHidden = true
         profilePageContainer.userInterfaceLayoutDirection = .leftToRight
         profilePageContainer.userInterfaceLayoutDirection = .leftToRight
 
 
+        profilesListPageView.translatesAutoresizingMaskIntoConstraints = false
         myProfilePageView.translatesAutoresizingMaskIntoConstraints = false
         myProfilePageView.translatesAutoresizingMaskIntoConstraints = false
+        profilePageContainer.addSubview(profilesListPageView)
         profilePageContainer.addSubview(myProfilePageView)
         profilePageContainer.addSubview(myProfilePageView)
+
         NSLayoutConstraint.activate([
         NSLayoutConstraint.activate([
+            profilesListPageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
+            profilesListPageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
+            profilesListPageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
+            profilesListPageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor),
+
             myProfilePageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
             myProfilePageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
             myProfilePageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
             myProfilePageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
             myProfilePageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
             myProfilePageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
             myProfilePageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor)
             myProfilePageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor)
         ])
         ])
+
+        profilesListPageView.onAddProfile = { [weak self] in
+            self?.presentProfileEditor(existingID: nil)
+        }
+        profilesListPageView.onEditProfile = { [weak self] id in
+            self?.presentProfileEditor(existingID: id)
+        }
+        profilesListPageView.onDeleteProfile = { [weak self] id in
+            self?.confirmDeleteProfile(id: id)
+        }
+        myProfilePageView.onDismiss = { [weak self] in
+            self?.dismissProfileEditor()
+        }
+
+        isProfileEditorPresented = false
+        profilesListPageView.isHidden = false
+        myProfilePageView.isHidden = true
+        profilesListPageView.reloadFromStore()
+    }
+
+    private func presentProfileEditor(existingID: UUID?) {
+        isProfileEditorPresented = true
+        if let id = existingID, let profile = SavedProfilesStore.profile(id: id) {
+            myProfilePageView.loadSavedProfile(profile)
+        } else {
+            myProfilePageView.prepareNewProfile()
+        }
+        profilesListPageView.isHidden = true
+        myProfilePageView.isHidden = false
+    }
+
+    private func dismissProfileEditor() {
+        isProfileEditorPresented = false
+        profilesListPageView.reloadFromStore()
+        profilesListPageView.isHidden = false
+        myProfilePageView.isHidden = true
+    }
+
+    private func confirmDeleteProfile(id: UUID) {
+        let displayName = SavedProfilesStore.profile(id: id)?.profileDisplayName ?? ""
+        let alert = NSAlert()
+        alert.messageText = "Delete this profile?"
+        alert.informativeText = displayName.isEmpty
+            ? "This profile will be removed from this Mac."
+            : "“\(displayName)” will be removed from this Mac."
+        alert.alertStyle = .warning
+        alert.addButton(withTitle: "Cancel")
+        alert.addButton(withTitle: "Delete")
+        guard let window = window else {
+            let response = alert.runModal()
+            if response == .alertSecondButtonReturn {
+                SavedProfilesStore.delete(id: id)
+                profilesListPageView.reloadFromStore()
+            }
+            return
+        }
+        alert.beginSheetModal(for: window) { [weak self] response in
+            guard let self else { return }
+            if response == .alertSecondButtonReturn {
+                SavedProfilesStore.delete(id: id)
+                if self.isProfileEditorPresented {
+                    self.dismissProfileEditor()
+                } else {
+                    self.profilesListPageView.reloadFromStore()
+                }
+            }
+        }
     }
     }
 
 
     private func configureSettingsPage() {
     private func configureSettingsPage() {
@@ -1609,6 +1689,16 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         settingsPageContainer.isHidden = !settings
         settingsPageContainer.isHidden = !settings
         cvMakerPageContainer.isHidden = !cvMaker
         cvMakerPageContainer.isHidden = !cvMaker
         profilePageContainer.isHidden = !profile
         profilePageContainer.isHidden = !profile
+        if !profile {
+            isProfileEditorPresented = false
+            profilesListPageView.isHidden = false
+            myProfilePageView.isHidden = true
+        }
+        if profile, !isProfileEditorPresented {
+            profilesListPageView.reloadFromStore()
+            profilesListPageView.isHidden = false
+            myProfilePageView.isHidden = true
+        }
         if !home, selectedSidebarIndex < currentSidebarItems.count {
         if !home, selectedSidebarIndex < currentSidebarItems.count {
             if savedJobs {
             if savedJobs {
                 reloadSavedJobsListings()
                 reloadSavedJobsListings()

+ 217 - 2
App for Indeed/Views/MyProfilePageView.swift

@@ -152,6 +152,14 @@ final class MyProfilePageView: NSView {
     private var nameEmailRow: ProfileDualFieldRow!
     private var nameEmailRow: ProfileDualFieldRow!
     private var phoneJobRow: ProfileDualFieldRow!
     private var phoneJobRow: ProfileDualFieldRow!
 
 
+    private let topChrome = NSView()
+    private let backButton = NSButton(title: "← All profiles", target: nil, action: nil)
+    private let contextLabel = NSTextField(labelWithString: "")
+    private var editingProfileID: UUID?
+
+    /// Called from the back control and after a successful save (returns to the profiles list).
+    var onDismiss: (() -> Void)?
+
     private let workExperienceRowsStack = NSStackView()
     private let workExperienceRowsStack = NSStackView()
     private var workExperienceEntries: [WorkExperienceEntryView] = []
     private var workExperienceEntries: [WorkExperienceEntryView] = []
     private let educationRowsStack = NSStackView()
     private let educationRowsStack = NSStackView()
@@ -278,15 +286,52 @@ final class MyProfilePageView: NSView {
         formStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
         formStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
         formStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         formStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
 
 
+        topChrome.translatesAutoresizingMaskIntoConstraints = false
+        topChrome.userInterfaceLayoutDirection = .leftToRight
+
+        backButton.translatesAutoresizingMaskIntoConstraints = false
+        backButton.bezelStyle = .rounded
+        backButton.isBordered = false
+        backButton.font = .systemFont(ofSize: 13, weight: .medium)
+        backButton.contentTintColor = ProfilePagePalette.brandBlue
+        backButton.target = self
+        backButton.action = #selector(didTapBack)
+
+        contextLabel.translatesAutoresizingMaskIntoConstraints = false
+        contextLabel.font = .systemFont(ofSize: 15, weight: .semibold)
+        contextLabel.textColor = ProfilePagePalette.primaryText
+        contextLabel.stringValue = "New profile"
+        contextLabel.backgroundColor = .clear
+        contextLabel.isBordered = false
+        contextLabel.isEditable = false
+        contextLabel.isSelectable = false
+        ProfileLayoutEnforcement.applyLeftAlignedTextField(contextLabel)
+
+        topChrome.addSubview(backButton)
+        topChrome.addSubview(contextLabel)
+        NSLayoutConstraint.activate([
+            backButton.leadingAnchor.constraint(equalTo: topChrome.leadingAnchor, constant: 4),
+            backButton.centerYAnchor.constraint(equalTo: topChrome.centerYAnchor),
+            contextLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 10),
+            contextLabel.centerYAnchor.constraint(equalTo: topChrome.centerYAnchor),
+            contextLabel.trailingAnchor.constraint(lessThanOrEqualTo: topChrome.trailingAnchor, constant: -8)
+        ])
+
+        addSubview(topChrome)
         addSubview(scrollView)
         addSubview(scrollView)
         scrollView.documentView = documentView
         scrollView.documentView = documentView
         documentView.addSubview(cardView)
         documentView.addSubview(cardView)
         cardView.addSubview(formStack)
         cardView.addSubview(formStack)
 
 
         NSLayoutConstraint.activate([
         NSLayoutConstraint.activate([
+            topChrome.leftAnchor.constraint(equalTo: leftAnchor),
+            topChrome.rightAnchor.constraint(equalTo: rightAnchor),
+            topChrome.topAnchor.constraint(equalTo: topAnchor, constant: 4),
+            topChrome.heightAnchor.constraint(equalToConstant: 40),
+
             scrollView.leftAnchor.constraint(equalTo: leftAnchor),
             scrollView.leftAnchor.constraint(equalTo: leftAnchor),
             scrollView.rightAnchor.constraint(equalTo: rightAnchor),
             scrollView.rightAnchor.constraint(equalTo: rightAnchor),
-            scrollView.topAnchor.constraint(equalTo: topAnchor),
+            scrollView.topAnchor.constraint(equalTo: topChrome.bottomAnchor, constant: 2),
             scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
             scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
 
 
             // Pin the document to the clip view’s geometric width so LTR/RTL semantics cannot slide the form.
             // Pin the document to the clip view’s geometric width so LTR/RTL semantics cannot slide the form.
@@ -369,6 +414,124 @@ final class MyProfilePageView: NSView {
         ProfileLayoutEnforcement.applyForcedLTRSubtree(from: self)
         ProfileLayoutEnforcement.applyForcedLTRSubtree(from: self)
     }
     }
 
 
+    func prepareNewProfile() {
+        editingProfileID = nil
+        contextLabel.stringValue = "New profile"
+        applyForm(
+            from: SavedProfile(
+                id: UUID(),
+                profileDisplayName: "",
+                personal: .empty,
+                careerSummary: "",
+                workExperiences: [.empty],
+                educations: [.empty],
+                certificates: "",
+                interests: "",
+                languages: "",
+                referral: ""
+            )
+        )
+    }
+
+    func loadSavedProfile(_ profile: SavedProfile) {
+        editingProfileID = profile.id
+        contextLabel.stringValue = "Edit profile"
+        applyForm(from: profile)
+    }
+
+    private func applyForm(from profile: SavedProfile) {
+        profileNameField.stringValue = profile.profileDisplayName
+        applyPersonalInformation(profile.personal)
+        careerField.stringValue = profile.careerSummary
+        certificatesField.stringValue = profile.certificates
+        interestsField.stringValue = profile.interests
+        languagesField.stringValue = profile.languages
+        referralField.stringValue = profile.referral
+
+        let workCount = max(1, profile.workExperiences.count)
+        syncWorkExperienceRowCount(to: workCount)
+        if profile.workExperiences.isEmpty {
+            workExperienceEntries[0].applyPayload(.empty)
+        } else {
+            for (i, payload) in profile.workExperiences.enumerated() where i < workExperienceEntries.count {
+                workExperienceEntries[i].applyPayload(payload)
+            }
+        }
+
+        let eduCount = max(1, profile.educations.count)
+        syncEducationRowCount(to: eduCount)
+        if profile.educations.isEmpty {
+            educationEntries[0].applyPayload(.empty)
+        } else {
+            for (i, payload) in profile.educations.enumerated() where i < educationEntries.count {
+                educationEntries[i].applyPayload(payload)
+            }
+        }
+
+        lastCompactLayout = nil
+        needsLayout = true
+    }
+
+    private func syncWorkExperienceRowCount(to target: Int) {
+        let n = max(1, target)
+        while workExperienceEntries.count < n {
+            appendWorkExperienceEntry()
+        }
+        while workExperienceEntries.count > n {
+            guard let last = workExperienceEntries.last, workExperienceEntries.count > 1 else { break }
+            removeWorkExperienceEntry(last)
+        }
+    }
+
+    private func syncEducationRowCount(to target: Int) {
+        let n = max(1, target)
+        while educationEntries.count < n {
+            appendEducationEntry()
+        }
+        while educationEntries.count > n {
+            guard let last = educationEntries.last, educationEntries.count > 1 else { break }
+            removeEducationEntry(last)
+        }
+    }
+
+    private func captureSavedProfileForSave() -> SavedProfile {
+        let id = editingProfileID ?? UUID()
+        return SavedProfile(
+            id: id,
+            profileDisplayName: profileNameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            personal: collectPersonalInformationFromFields(),
+            careerSummary: careerField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            workExperiences: workExperienceEntries.map { $0.capturePayload() },
+            educations: educationEntries.map { $0.capturePayload() },
+            certificates: certificatesField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            interests: interestsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            languages: languagesField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            referral: referralField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+        )
+    }
+
+    @objc private func didTapBack() {
+        onDismiss?()
+    }
+
+    private func applyPersonalInformation(_ info: PersonalInformation) {
+        fullNameField.stringValue = info.fullName
+        emailField.stringValue = info.email
+        phoneField.stringValue = info.phone
+        jobTitleField.stringValue = info.jobTitle
+        addressField.stringValue = info.address
+    }
+
+    private func collectPersonalInformationFromFields() -> PersonalInformation {
+        PersonalInformation(
+            fullName: fullNameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            email: emailField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            phone: phoneField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            jobTitle: jobTitleField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            address: addressField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+        )
+    }
+
     private func applyResponsiveRowsIfNeeded() {
     private func applyResponsiveRowsIfNeeded() {
         let w = cardView.bounds.width
         let w = cardView.bounds.width
         guard w > 1 else { return }
         guard w > 1 else { return }
@@ -881,7 +1044,29 @@ final class MyProfilePageView: NSView {
     }
     }
 
 
     @objc private func didTapSave() {
     @objc private func didTapSave() {
-        // UI shell only; wire persistence when profiles are stored.
+        window?.makeFirstResponder(nil)
+        let profile = captureSavedProfileForSave()
+        var missing: [String] = []
+        if profile.profileDisplayName.isEmpty { missing.append("Profile name") }
+        if profile.personal.fullName.isEmpty { missing.append("Full Name") }
+        if profile.personal.email.isEmpty { missing.append("Email") }
+        if profile.personal.jobTitle.isEmpty { missing.append("Job Title") }
+        guard missing.isEmpty else {
+            let alert = NSAlert()
+            alert.messageText = "Complete required fields"
+            alert.informativeText = "Please fill in: " + missing.joined(separator: ", ") + "."
+            alert.alertStyle = .informational
+            alert.addButton(withTitle: "OK")
+            if let window = window {
+                alert.beginSheetModal(for: window) { _ in }
+            } else {
+                alert.runModal()
+            }
+            return
+        }
+        SavedProfilesStore.upsert(profile)
+        editingProfileID = profile.id
+        onDismiss?()
     }
     }
 }
 }
 
 
@@ -924,6 +1109,22 @@ private final class WorkExperienceEntryView: NSView {
         jobCompanyRow.setCompact(compact)
         jobCompanyRow.setCompact(compact)
     }
     }
 
 
+    func capturePayload() -> WorkExperiencePayload {
+        WorkExperiencePayload(
+            jobTitle: jobTitleField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            company: companyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            duration: durationField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            description: descriptionField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+        )
+    }
+
+    func applyPayload(_ payload: WorkExperiencePayload) {
+        jobTitleField.stringValue = payload.jobTitle
+        companyField.stringValue = payload.company
+        durationField.stringValue = payload.duration
+        descriptionField.stringValue = payload.description
+    }
+
     private func configure() {
     private func configure() {
         userInterfaceLayoutDirection = .leftToRight
         userInterfaceLayoutDirection = .leftToRight
         ProfileLayoutEnforcement.applyForcedLTR(to: self)
         ProfileLayoutEnforcement.applyForcedLTR(to: self)
@@ -1192,6 +1393,20 @@ private final class EducationEntryView: NSView {
         degreeInstitutionRow.setCompact(compact)
         degreeInstitutionRow.setCompact(compact)
     }
     }
 
 
+    func capturePayload() -> EducationPayload {
+        EducationPayload(
+            degree: degreeField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            institution: institutionField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+            year: yearField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+        )
+    }
+
+    func applyPayload(_ payload: EducationPayload) {
+        degreeField.stringValue = payload.degree
+        institutionField.stringValue = payload.institution
+        yearField.stringValue = payload.year
+    }
+
     private func configure() {
     private func configure() {
         userInterfaceLayoutDirection = .leftToRight
         userInterfaceLayoutDirection = .leftToRight
         ProfileLayoutEnforcement.applyForcedLTR(to: self)
         ProfileLayoutEnforcement.applyForcedLTR(to: self)

+ 310 - 0
App for Indeed/Views/ProfilesListPageView.swift

@@ -0,0 +1,310 @@
+//
+//  ProfilesListPageView.swift
+//  App for Indeed
+//
+
+import Cocoa
+
+private enum ProfilesListPalette {
+    static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
+    static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
+    static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
+    static let cardBackground = NSColor(srgbRed: 252 / 255, green: 252 / 255, blue: 252 / 255, alpha: 1)
+    static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1)
+    static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
+    static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
+    static let destructive = NSColor(srgbRed: 220 / 255, green: 38 / 255, blue: 38 / 255, alpha: 1)
+}
+
+/// Hub for saved job profiles: list, add, edit (opens editor elsewhere), delete.
+final class ProfilesListPageView: NSView {
+    var onAddProfile: (() -> Void)?
+    var onEditProfile: ((UUID) -> Void)?
+    var onDeleteProfile: ((UUID) -> Void)?
+
+    private let scrollView = NSScrollView()
+    private let documentView = NSView()
+    private let contentStack = NSStackView()
+    private let emptyStateLabel = NSTextField(wrappingLabelWithString: "")
+    private let addButton = ProfilesPrimaryButton(title: "Add new profile  →", target: nil, action: nil)
+
+    override var userInterfaceLayoutDirection: NSUserInterfaceLayoutDirection {
+        get { .leftToRight }
+        set { super.userInterfaceLayoutDirection = .leftToRight }
+    }
+
+    override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        setup()
+    }
+
+    required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        setup()
+    }
+
+    func reloadFromStore() {
+        for row in contentStack.arrangedSubviews {
+            contentStack.removeArrangedSubview(row)
+            row.removeFromSuperview()
+        }
+
+        let profiles = SavedProfilesStore.loadAll()
+        emptyStateLabel.isHidden = !profiles.isEmpty
+
+        for profile in profiles {
+            let row = ProfileListRowView(profile: profile)
+            row.translatesAutoresizingMaskIntoConstraints = false
+            row.onEdit = { [weak self] id in self?.onEditProfile?(id) }
+            row.onDelete = { [weak self] id in self?.onDeleteProfile?(id) }
+            contentStack.addArrangedSubview(row)
+            row.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
+        }
+    }
+
+    private func setup() {
+        wantsLayer = true
+        layer?.backgroundColor = ProfilesListPalette.pageBackground.cgColor
+        userInterfaceLayoutDirection = .leftToRight
+
+        let title = NSTextField(labelWithString: "Profiles")
+        title.font = .systemFont(ofSize: 22, weight: .semibold)
+        title.textColor = ProfilesListPalette.primaryText
+
+        let subtitle = NSTextField(wrappingLabelWithString: "Create and manage CV profiles. Each profile stores your details on this Mac.")
+        subtitle.font = .systemFont(ofSize: 13, weight: .regular)
+        subtitle.textColor = ProfilesListPalette.secondaryText
+        subtitle.maximumNumberOfLines = 0
+
+        emptyStateLabel.stringValue = "No profiles yet. Tap “Add new profile” to create your first one."
+        emptyStateLabel.font = .systemFont(ofSize: 13, weight: .regular)
+        emptyStateLabel.textColor = ProfilesListPalette.secondaryText
+        emptyStateLabel.isHidden = true
+
+        addButton.target = self
+        addButton.action = #selector(didTapAdd)
+        addButton.translatesAutoresizingMaskIntoConstraints = false
+
+        let headerStack = NSStackView(views: [title, subtitle, emptyStateLabel, addButton])
+        headerStack.orientation = .vertical
+        headerStack.alignment = .leading
+        headerStack.spacing = 10
+        headerStack.translatesAutoresizingMaskIntoConstraints = false
+        headerStack.setCustomSpacing(16, after: subtitle)
+
+        contentStack.orientation = .vertical
+        contentStack.alignment = .leading
+        contentStack.spacing = 12
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
+
+        documentView.translatesAutoresizingMaskIntoConstraints = false
+        documentView.addSubview(headerStack)
+        documentView.addSubview(contentStack)
+
+        scrollView.translatesAutoresizingMaskIntoConstraints = false
+        scrollView.drawsBackground = false
+        scrollView.hasVerticalScroller = true
+        scrollView.hasHorizontalScroller = false
+        scrollView.autohidesScrollers = true
+        scrollView.borderType = .noBorder
+        scrollView.documentView = documentView
+
+        addSubview(scrollView)
+
+        NSLayoutConstraint.activate([
+            scrollView.leftAnchor.constraint(equalTo: leftAnchor),
+            scrollView.rightAnchor.constraint(equalTo: rightAnchor),
+            scrollView.topAnchor.constraint(equalTo: topAnchor),
+            scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+            documentView.leftAnchor.constraint(equalTo: scrollView.contentView.leftAnchor),
+            documentView.rightAnchor.constraint(equalTo: scrollView.contentView.rightAnchor),
+            documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
+            documentView.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 32),
+
+            headerStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
+            headerStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
+            headerStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 24),
+
+            contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
+            contentStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
+            contentStack.topAnchor.constraint(equalTo: headerStack.bottomAnchor, constant: 24),
+
+            headerStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
+            contentStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64)
+        ])
+
+        reloadFromStore()
+    }
+
+    @objc private func didTapAdd() {
+        onAddProfile?()
+    }
+}
+
+// MARK: - Row
+
+private final class ProfileListRowView: NSView {
+    var onEdit: ((UUID) -> Void)?
+    var onDelete: ((UUID) -> Void)?
+
+    private let profileID: UUID
+    private let editButton = NSButton(title: "Edit", target: nil, action: nil)
+    private let deleteButton = NSButton(title: "Delete", target: nil, action: nil)
+
+    init(profile: SavedProfile) {
+        self.profileID = profile.id
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        wantsLayer = true
+        layer?.cornerRadius = 14
+        layer?.borderWidth = 1
+        layer?.borderColor = ProfilesListPalette.border.cgColor
+        layer?.backgroundColor = ProfilesListPalette.cardBackground.cgColor
+        if #available(macOS 11.0, *) {
+            layer?.cornerCurve = .continuous
+        }
+
+        let name = NSTextField(labelWithString: profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName)
+        name.font = .systemFont(ofSize: 15, weight: .semibold)
+        name.textColor = ProfilesListPalette.primaryText
+
+        let detailParts = [profile.personal.fullName, profile.personal.email].filter { !$0.isEmpty }
+        let detail = NSTextField(wrappingLabelWithString: detailParts.isEmpty ? "No contact details yet" : detailParts.joined(separator: " · "))
+        detail.font = .systemFont(ofSize: 12, weight: .regular)
+        detail.textColor = ProfilesListPalette.secondaryText
+        detail.maximumNumberOfLines = 2
+
+        let textStack = NSStackView(views: [name, detail])
+        textStack.orientation = .vertical
+        textStack.alignment = .leading
+        textStack.spacing = 4
+        textStack.translatesAutoresizingMaskIntoConstraints = false
+
+        editButton.translatesAutoresizingMaskIntoConstraints = false
+        editButton.bezelStyle = .rounded
+        editButton.isBordered = true
+        editButton.font = .systemFont(ofSize: 12, weight: .medium)
+        editButton.target = self
+        editButton.action = #selector(didTapEdit)
+
+        deleteButton.translatesAutoresizingMaskIntoConstraints = false
+        deleteButton.bezelStyle = .rounded
+        deleteButton.isBordered = false
+        deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
+        deleteButton.contentTintColor = ProfilesListPalette.destructive
+        deleteButton.target = self
+        deleteButton.action = #selector(didTapDelete)
+
+        let spacer = NSView()
+        spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
+
+        let actions = NSStackView(views: [editButton, deleteButton])
+        actions.orientation = .horizontal
+        actions.spacing = 8
+        actions.alignment = .centerY
+        actions.translatesAutoresizingMaskIntoConstraints = false
+
+        let row = NSStackView(views: [textStack, spacer, actions])
+        row.orientation = .horizontal
+        row.alignment = .top
+        row.spacing = 16
+        row.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(row)
+        NSLayoutConstraint.activate([
+            row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
+            row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
+            row.topAnchor.constraint(equalTo: topAnchor, constant: 16),
+            row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16)
+        ])
+    }
+
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    @objc private func didTapEdit() {
+        onEdit?(profileID)
+    }
+
+    @objc private func didTapDelete() {
+        onDelete?(profileID)
+    }
+}
+
+// MARK: - Primary CTA (matches profile page button)
+
+private final class ProfilesPrimaryButton: NSButton {
+    private var trackingArea: NSTrackingArea?
+    private var didPushCursor = false
+
+    override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        commonInit()
+    }
+
+    required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        commonInit()
+    }
+
+    convenience init(title: String, target: AnyObject?, action: Selector?) {
+        self.init(frame: .zero)
+        self.title = title
+        self.target = target
+        self.action = action
+    }
+
+    private func commonInit() {
+        bezelStyle = .rounded
+        isBordered = false
+        font = .systemFont(ofSize: 16, weight: .semibold)
+        contentTintColor = .white
+        wantsLayer = true
+        layer?.cornerRadius = 14
+        if #available(macOS 11.0, *) {
+            layer?.cornerCurve = .continuous
+        }
+        layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
+    }
+
+    override func updateTrackingAreas() {
+        super.updateTrackingAreas()
+        if let trackingArea { removeTrackingArea(trackingArea) }
+        let area = NSTrackingArea(
+            rect: bounds,
+            options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
+            owner: self,
+            userInfo: nil
+        )
+        addTrackingArea(area)
+        trackingArea = area
+    }
+
+    override func mouseEntered(with event: NSEvent) {
+        super.mouseEntered(with: event)
+        layer?.backgroundColor = ProfilesListPalette.brandBlueHover.cgColor
+        if !didPushCursor {
+            NSCursor.pointingHand.push()
+            didPushCursor = true
+        }
+    }
+
+    override func mouseExited(with event: NSEvent) {
+        super.mouseExited(with: event)
+        layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
+        if didPushCursor {
+            NSCursor.pop()
+            didPushCursor = false
+        }
+    }
+
+    override func viewWillMove(toWindow newWindow: NSWindow?) {
+        super.viewWillMove(toWindow: newWindow)
+        if newWindow == nil, didPushCursor {
+            NSCursor.pop()
+            didPushCursor = false
+        }
+    }
+}