Parcourir la source

Add My Profile page and wire it into the dashboard

Introduce MyProfilePageView with a scrollable card layout for profile
fields and photo upload. Embed it from DashboardView when the Profile
sidebar item is selected, matching other non-home sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 il y a 2 mois
Parent
commit
c1a81de065

+ 37 - 3
App for Indeed/Views/DashboardView.swift

@@ -125,6 +125,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private lazy var cvMakerPageView: CVMakerPageView = {
         CVMakerPageView()
     }()
+    private let profilePageContainer = NSView()
+    private lazy var myProfilePageView: MyProfilePageView = {
+        MyProfilePageView()
+    }()
 
     private var currentSidebarItems: [SidebarItem] = []
     private var selectedSidebarIndex: Int = 0
@@ -1186,10 +1190,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         savedJobsPageContainer.translatesAutoresizingMaskIntoConstraints = false
         settingsPageContainer.translatesAutoresizingMaskIntoConstraints = false
         cvMakerPageContainer.translatesAutoresizingMaskIntoConstraints = false
+        profilePageContainer.translatesAutoresizingMaskIntoConstraints = false
         nonHomeHost.addSubview(nonHomeGenericContainer)
         nonHomeHost.addSubview(savedJobsPageContainer)
         nonHomeHost.addSubview(settingsPageContainer)
         nonHomeHost.addSubview(cvMakerPageContainer)
+        nonHomeHost.addSubview(profilePageContainer)
 
         NSLayoutConstraint.activate([
             nonHomeGenericContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
@@ -1210,7 +1216,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             cvMakerPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
             cvMakerPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
             cvMakerPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
-            cvMakerPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor)
+            cvMakerPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor),
+
+            profilePageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
+            profilePageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
+            profilePageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
+            profilePageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor)
         ])
 
         nonHomeTitleLabel.font = .systemFont(ofSize: 22, weight: .bold)
@@ -1305,6 +1316,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
         configureSettingsPage()
         configureCVMakerPage()
+        configureProfilePage()
     }
 
     private func configureCVMakerPage() {
@@ -1322,6 +1334,21 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         ])
     }
 
+    private func configureProfilePage() {
+        profilePageContainer.wantsLayer = true
+        profilePageContainer.layer?.backgroundColor = Theme.mainHostBackground.cgColor
+        profilePageContainer.isHidden = true
+
+        myProfilePageView.translatesAutoresizingMaskIntoConstraints = false
+        profilePageContainer.addSubview(myProfilePageView)
+        NSLayoutConstraint.activate([
+            myProfilePageView.leadingAnchor.constraint(equalTo: profilePageContainer.leadingAnchor),
+            myProfilePageView.trailingAnchor.constraint(equalTo: profilePageContainer.trailingAnchor),
+            myProfilePageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
+            myProfilePageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor)
+        ])
+    }
+
     private func configureSettingsPage() {
         settingsPageContainer.wantsLayer = true
         settingsPageContainer.layer?.backgroundColor = Theme.settingsPageBackground.cgColor
@@ -1555,6 +1582,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         return currentSidebarItems[index].title == "CV Maker"
     }
 
+    private func isProfileSidebarIndex(_ index: Int) -> Bool {
+        guard index >= 0, index < currentSidebarItems.count else { return false }
+        return currentSidebarItems[index].title == "Profile"
+    }
+
     private func updateMainContentVisibility() {
         if isIndeedJobBrowserPresented {
             mainOverlay.isHidden = true
@@ -1567,16 +1599,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         let savedJobs = isSavedJobsSidebarIndex(selectedSidebarIndex)
         let settings = isSettingsSidebarIndex(selectedSidebarIndex)
         let cvMaker = isCVMakerSidebarIndex(selectedSidebarIndex)
+        let profile = isProfileSidebarIndex(selectedSidebarIndex)
         mainOverlay.isHidden = !home
         nonHomeHost.isHidden = home
-        nonHomeGenericContainer.isHidden = savedJobs || settings || cvMaker
+        nonHomeGenericContainer.isHidden = savedJobs || settings || cvMaker || profile
         savedJobsPageContainer.isHidden = !savedJobs
         settingsPageContainer.isHidden = !settings
         cvMakerPageContainer.isHidden = !cvMaker
+        profilePageContainer.isHidden = !profile
         if !home, selectedSidebarIndex < currentSidebarItems.count {
             if savedJobs {
                 reloadSavedJobsListings()
-            } else if settings || cvMaker {
+            } else if settings || cvMaker || profile {
                 window?.makeFirstResponder(nil)
             } else {
                 nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title

+ 499 - 0
App for Indeed/Views/MyProfilePageView.swift

@@ -0,0 +1,499 @@
+//
+//  MyProfilePageView.swift
+//  App for Indeed
+//
+//  Light-theme profile editor: card layout, adaptive two-column rows, and
+//  vertical scrolling when the window is short.
+//
+
+import Cocoa
+import UniformTypeIdentifiers
+
+private enum ProfilePagePalette {
+    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 fieldFill = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 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 avatarWell = NSColor(srgbRed: 232 / 255, green: 232 / 255, blue: 232 / 255, alpha: 1)
+}
+
+final class MyProfilePageView: NSView {
+    private static let compactFormWidth: CGFloat = 520
+
+    private let scrollView = NSScrollView()
+    private let documentView = NSView()
+    private let cardView = NSView()
+    private let formStack = NSStackView()
+
+    private let profileNameField = NSTextField()
+    private let fullNameField = NSTextField()
+    private let emailField = NSTextField()
+    private let phoneField = NSTextField()
+    private let jobTitleField = NSTextField()
+    private let addressField = NSTextField()
+    private let careerField = NSTextField()
+    private let avatarImageView = NSImageView()
+    private let uploadPhotoButton = NSButton(title: "Upload Photo", target: nil, action: nil)
+    private let saveButton = ProfilePrimaryButton(title: "Save Profile  →", target: nil, action: nil)
+
+    private let nameEmailRow = NSStackView()
+    private let phoneJobRow = NSStackView()
+
+    private var lastCompactLayout: Bool?
+
+    override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        setup()
+    }
+
+    required init?(coder: NSCoder) {
+        super.init(coder: coder)
+        setup()
+    }
+
+    override func layout() {
+        super.layout()
+        applyResponsiveRowsIfNeeded()
+    }
+
+    private func setup() {
+        wantsLayer = true
+        layer?.backgroundColor = ProfilePagePalette.pageBackground.cgColor
+
+        scrollView.translatesAutoresizingMaskIntoConstraints = false
+        scrollView.hasVerticalScroller = true
+        scrollView.hasHorizontalScroller = false
+        scrollView.autohidesScrollers = true
+        scrollView.drawsBackground = false
+        scrollView.borderType = .noBorder
+        scrollView.scrollerStyle = .overlay
+        scrollView.automaticallyAdjustsContentInsets = false
+
+        documentView.translatesAutoresizingMaskIntoConstraints = false
+        documentView.userInterfaceLayoutDirection = .leftToRight
+
+        cardView.translatesAutoresizingMaskIntoConstraints = false
+        cardView.wantsLayer = true
+        cardView.layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
+        cardView.layer?.cornerRadius = 16
+        cardView.layer?.borderWidth = 1
+        cardView.layer?.borderColor = ProfilePagePalette.border.cgColor
+        cardView.userInterfaceLayoutDirection = .leftToRight
+        if #available(macOS 11.0, *) {
+            cardView.layer?.cornerCurve = .continuous
+        }
+
+        formStack.translatesAutoresizingMaskIntoConstraints = false
+        formStack.orientation = .vertical
+        formStack.alignment = .width
+        formStack.spacing = 20
+        formStack.edgeInsets = NSEdgeInsets(top: 28, left: 28, bottom: 28, right: 28)
+        formStack.userInterfaceLayoutDirection = .leftToRight
+
+        addSubview(scrollView)
+        scrollView.documentView = documentView
+        documentView.addSubview(cardView)
+        cardView.addSubview(formStack)
+
+        NSLayoutConstraint.activate([
+            scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
+            scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
+            scrollView.topAnchor.constraint(equalTo: topAnchor),
+            scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+            documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
+            documentView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor),
+            documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
+            documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
+
+            cardView.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 24),
+            cardView.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -24),
+            cardView.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 16),
+            cardView.bottomAnchor.constraint(equalTo: documentView.bottomAnchor, constant: -24),
+
+            formStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor),
+            formStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor),
+            formStack.topAnchor.constraint(equalTo: cardView.topAnchor),
+            formStack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor)
+        ])
+
+        formStack.addArrangedSubview(labeledGroup(title: "Profile Name *", field: profileNameField, placeholder: "Marketing Director Profile"))
+        formStack.addArrangedSubview(sectionHeading("Personal Information"))
+
+        let nameGroup = labeledGroup(title: "Full Name *", field: fullNameField, placeholder: "John Doe")
+        let emailGroup = labeledGroup(title: "Email *", field: emailField, placeholder: "john@example.com")
+        configureTwoColumnRow(nameEmailRow, left: nameGroup, right: emailGroup)
+        pinEqualColumnWidths(in: nameEmailRow)
+        formStack.addArrangedSubview(nameEmailRow)
+
+        let phoneGroup = labeledGroup(title: "Phone", field: phoneField, placeholder: "+1 (555) 123-4567")
+        let jobGroup = labeledGroup(title: "Job Title *", field: jobTitleField, placeholder: "Software Engineer")
+        configureTwoColumnRow(phoneJobRow, left: phoneGroup, right: jobGroup)
+        pinEqualColumnWidths(in: phoneJobRow)
+        formStack.addArrangedSubview(phoneJobRow)
+
+        formStack.addArrangedSubview(labeledGroup(title: "Address", field: addressField, placeholder: "123 Main St, City, State, ZIP"))
+        formStack.addArrangedSubview(careerSummaryBlock())
+        formStack.addArrangedSubview(profileImageBlock())
+        formStack.addArrangedSubview(saveButtonHost())
+
+        uploadPhotoButton.target = self
+        uploadPhotoButton.action = #selector(didTapUploadPhoto)
+        saveButton.target = self
+        saveButton.action = #selector(didTapSave)
+    }
+
+    private func applyResponsiveRowsIfNeeded() {
+        let w = cardView.bounds.width
+        guard w > 1 else { return }
+        let formWidth = max(0, w - formStack.edgeInsets.left - formStack.edgeInsets.right)
+        let compact = formWidth < Self.compactFormWidth
+        guard compact != lastCompactLayout else { return }
+        lastCompactLayout = compact
+        let orientation: NSUserInterfaceLayoutOrientation = compact ? .vertical : .horizontal
+        let rowSpacing: CGFloat = compact ? 16 : 12
+        nameEmailRow.orientation = orientation
+        nameEmailRow.spacing = rowSpacing
+        phoneJobRow.orientation = orientation
+        phoneJobRow.spacing = rowSpacing
+        nameEmailRow.distribution = compact ? .fill : .fillEqually
+        phoneJobRow.distribution = compact ? .fill : .fillEqually
+    }
+
+    /// Keeps two columns the same width when the row is horizontal; avoids NSTextField intrinsic width fighting the stack.
+    private func pinEqualColumnWidths(in row: NSStackView) {
+        guard row.arrangedSubviews.count == 2 else { return }
+        let left = row.arrangedSubviews[0]
+        let right = row.arrangedSubviews[1]
+        left.widthAnchor.constraint(equalTo: right.widthAnchor).isActive = true
+    }
+
+    private func configureTwoColumnRow(_ row: NSStackView, left: NSView, right: NSView) {
+        row.translatesAutoresizingMaskIntoConstraints = false
+        row.orientation = .horizontal
+        row.spacing = 12
+        row.distribution = .fillEqually
+        row.alignment = .top
+        row.userInterfaceLayoutDirection = .leftToRight
+        row.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        row.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        row.addArrangedSubview(left)
+        row.addArrangedSubview(right)
+    }
+
+    private func sectionHeading(_ text: String) -> NSTextField {
+        let label = NSTextField(labelWithString: text)
+        label.font = .systemFont(ofSize: 15, weight: .semibold)
+        label.textColor = ProfilePagePalette.primaryText
+        label.alignment = .left
+        label.translatesAutoresizingMaskIntoConstraints = false
+        label.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        return label
+    }
+
+    private func labeledGroup(title: String, field: NSTextField, placeholder: String) -> NSView {
+        let label = NSTextField(labelWithString: title)
+        label.font = .systemFont(ofSize: 12, weight: .medium)
+        label.textColor = ProfilePagePalette.secondaryText
+        label.alignment = .left
+        label.translatesAutoresizingMaskIntoConstraints = false
+
+        styleSingleLineField(field, placeholder: placeholder)
+        let wrap = roundedFieldChrome(containing: field, minHeight: 40)
+
+        let stack = NSStackView(views: [label, wrap])
+        stack.orientation = .vertical
+        stack.spacing = 8
+        stack.alignment = .width
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        stack.userInterfaceLayoutDirection = .leftToRight
+        stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        wrap.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        return stack
+    }
+
+    private func styleSingleLineField(_ field: NSTextField, placeholder: String) {
+        field.translatesAutoresizingMaskIntoConstraints = false
+        field.isBordered = false
+        field.drawsBackground = false
+        field.focusRingType = .none
+        field.font = .systemFont(ofSize: 14, weight: .regular)
+        field.textColor = ProfilePagePalette.primaryText
+        field.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        field.placeholderAttributedString = NSAttributedString(
+            string: placeholder,
+            attributes: [
+                .foregroundColor: ProfilePagePalette.secondaryText,
+                .font: NSFont.systemFont(ofSize: 14, weight: .regular)
+            ]
+        )
+        field.cell?.usesSingleLineMode = true
+        field.cell?.wraps = false
+        field.cell?.isScrollable = true
+    }
+
+    private func roundedFieldChrome(containing field: NSTextField, minHeight: CGFloat) -> NSView {
+        let wrap = NSView()
+        wrap.translatesAutoresizingMaskIntoConstraints = false
+        wrap.wantsLayer = true
+        wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
+        wrap.layer?.cornerRadius = 10
+        wrap.layer?.borderWidth = 1
+        wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
+        if #available(macOS 11.0, *) {
+            wrap.layer?.cornerCurve = .continuous
+        }
+        wrap.addSubview(field)
+        NSLayoutConstraint.activate([
+            field.leadingAnchor.constraint(equalTo: wrap.leadingAnchor, constant: 12),
+            field.trailingAnchor.constraint(equalTo: wrap.trailingAnchor, constant: -12),
+            field.centerYAnchor.constraint(equalTo: wrap.centerYAnchor),
+            wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
+        ])
+        return wrap
+    }
+
+    private func careerSummaryBlock() -> NSView {
+        let label = NSTextField(labelWithString: "Career Summary")
+        label.font = .systemFont(ofSize: 12, weight: .medium)
+        label.textColor = ProfilePagePalette.secondaryText
+        label.alignment = .left
+        label.translatesAutoresizingMaskIntoConstraints = false
+
+        careerField.translatesAutoresizingMaskIntoConstraints = false
+        careerField.isEditable = true
+        careerField.isSelectable = true
+        careerField.isBordered = false
+        careerField.drawsBackground = false
+        careerField.focusRingType = .none
+        careerField.font = .systemFont(ofSize: 14, weight: .regular)
+        careerField.textColor = ProfilePagePalette.primaryText
+        careerField.maximumNumberOfLines = 0
+        careerField.cell?.wraps = true
+        careerField.cell?.isScrollable = false
+        careerField.cell?.usesSingleLineMode = false
+        careerField.stringValue = ""
+        careerField.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        careerField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        careerField.placeholderAttributedString = NSAttributedString(
+            string: "Brief overview of your professional background and key achievements...",
+            attributes: [
+                .foregroundColor: ProfilePagePalette.secondaryText,
+                .font: NSFont.systemFont(ofSize: 14, weight: .regular)
+            ]
+        )
+
+        let wrap = NSView()
+        wrap.translatesAutoresizingMaskIntoConstraints = false
+        wrap.wantsLayer = true
+        wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
+        wrap.layer?.cornerRadius = 10
+        wrap.layer?.borderWidth = 1
+        wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
+        if #available(macOS 11.0, *) {
+            wrap.layer?.cornerCurve = .continuous
+        }
+        wrap.addSubview(careerField)
+        NSLayoutConstraint.activate([
+            careerField.leadingAnchor.constraint(equalTo: wrap.leadingAnchor, constant: 12),
+            careerField.trailingAnchor.constraint(equalTo: wrap.trailingAnchor, constant: -12),
+            careerField.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
+            careerField.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
+            wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: 120)
+        ])
+
+        let stack = NSStackView(views: [label, wrap])
+        stack.orientation = .vertical
+        stack.spacing = 8
+        stack.alignment = .width
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        stack.userInterfaceLayoutDirection = .leftToRight
+        stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        return stack
+    }
+
+    private func profileImageBlock() -> NSView {
+        let title = NSTextField(labelWithString: "Profile Image (Optional)")
+        title.font = .systemFont(ofSize: 12, weight: .medium)
+        title.textColor = ProfilePagePalette.secondaryText
+        title.translatesAutoresizingMaskIntoConstraints = false
+
+        let avatarHost = NSView()
+        avatarHost.translatesAutoresizingMaskIntoConstraints = false
+        avatarHost.wantsLayer = true
+        avatarHost.layer?.backgroundColor = ProfilePagePalette.avatarWell.cgColor
+        avatarHost.layer?.cornerRadius = 36
+        avatarHost.layer?.masksToBounds = true
+
+        avatarImageView.translatesAutoresizingMaskIntoConstraints = false
+        avatarImageView.imageScaling = .scaleProportionallyUpOrDown
+        avatarImageView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .light)
+        avatarImageView.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: nil)
+        avatarImageView.contentTintColor = ProfilePagePalette.secondaryText
+        avatarHost.addSubview(avatarImageView)
+        NSLayoutConstraint.activate([
+            avatarHost.widthAnchor.constraint(equalToConstant: 72),
+            avatarHost.heightAnchor.constraint(equalToConstant: 72),
+            avatarImageView.centerXAnchor.constraint(equalTo: avatarHost.centerXAnchor),
+            avatarImageView.centerYAnchor.constraint(equalTo: avatarHost.centerYAnchor),
+            avatarImageView.widthAnchor.constraint(equalToConstant: 44),
+            avatarImageView.heightAnchor.constraint(equalToConstant: 44)
+        ])
+
+        uploadPhotoButton.translatesAutoresizingMaskIntoConstraints = false
+        uploadPhotoButton.bezelStyle = .rounded
+        uploadPhotoButton.controlSize = .large
+        uploadPhotoButton.font = .systemFont(ofSize: 13, weight: .medium)
+        if let image = NSImage(systemSymbolName: "arrow.up.circle", accessibilityDescription: nil) {
+            uploadPhotoButton.image = image
+            uploadPhotoButton.imagePosition = .imageLeading
+            uploadPhotoButton.contentTintColor = ProfilePagePalette.brandBlue
+        }
+
+        let hint = NSTextField(wrappingLabelWithString: "Recommended: Square image, max 2MB")
+        hint.font = .systemFont(ofSize: 11, weight: .regular)
+        hint.textColor = ProfilePagePalette.secondaryText
+        hint.maximumNumberOfLines = 0
+
+        let rightColumn = NSStackView(views: [uploadPhotoButton, hint])
+        rightColumn.orientation = .vertical
+        rightColumn.alignment = .leading
+        rightColumn.spacing = 6
+        rightColumn.translatesAutoresizingMaskIntoConstraints = false
+
+        let row = NSStackView(views: [avatarHost, rightColumn])
+        row.orientation = .horizontal
+        row.alignment = .centerY
+        row.spacing = 16
+        row.translatesAutoresizingMaskIntoConstraints = false
+
+        let stack = NSStackView(views: [title, row])
+        stack.orientation = .vertical
+        stack.spacing = 12
+        stack.alignment = .width
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        stack.userInterfaceLayoutDirection = .leftToRight
+        row.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        row.alignment = .leading
+        return stack
+    }
+
+    private func saveButtonHost() -> NSView {
+        saveButton.translatesAutoresizingMaskIntoConstraints = false
+        let host = NSView()
+        host.translatesAutoresizingMaskIntoConstraints = false
+        host.userInterfaceLayoutDirection = .leftToRight
+        host.addSubview(saveButton)
+        NSLayoutConstraint.activate([
+            saveButton.leadingAnchor.constraint(equalTo: host.leadingAnchor),
+            saveButton.trailingAnchor.constraint(equalTo: host.trailingAnchor),
+            saveButton.topAnchor.constraint(equalTo: host.topAnchor),
+            saveButton.bottomAnchor.constraint(equalTo: host.bottomAnchor),
+            saveButton.heightAnchor.constraint(equalToConstant: 48)
+        ])
+        return host
+    }
+
+    @objc private func didTapUploadPhoto() {
+        let panel = NSOpenPanel()
+        panel.allowedContentTypes = [UTType.image]
+        panel.allowsMultipleSelection = false
+        panel.canChooseDirectories = false
+        guard let window else { return }
+        panel.beginSheetModal(for: window) { [weak self] response in
+            guard response == .OK, let url = panel.url else { return }
+            if let image = NSImage(contentsOf: url) {
+                self?.avatarImageView.image = image
+                self?.avatarImageView.contentTintColor = nil
+                self?.avatarImageView.imageScaling = .scaleAxesIndependently
+            }
+        }
+    }
+
+    @objc private func didTapSave() {
+        // UI shell only; wire persistence when profiles are stored.
+    }
+}
+
+// MARK: - Primary CTA
+
+private final class ProfilePrimaryButton: 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: 15, weight: .semibold)
+        contentTintColor = .white
+        wantsLayer = true
+        layer?.cornerRadius = 12
+        if #available(macOS 11.0, *) {
+            layer?.cornerCurve = .continuous
+        }
+        layer?.backgroundColor = ProfilePagePalette.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 = ProfilePagePalette.brandBlueHover.cgColor
+        if !didPushCursor {
+            NSCursor.pointingHand.push()
+            didPushCursor = true
+        }
+    }
+
+    override func mouseExited(with event: NSEvent) {
+        super.mouseExited(with: event)
+        layer?.backgroundColor = ProfilePagePalette.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
+        }
+    }
+}