Bläddra i källkod

Stabilize CV Maker filter chips and template card layout.

Equal-width filter rows, fixed chip slots, and uniform card chrome prevent layout shifts when switching category groups or selection state.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 veckor sedan
förälder
incheckning
14152a9801
1 ändrade filer med 86 tillägg och 39 borttagningar
  1. 86 39
      App for Indeed/Views/CVMakerPageView.swift

+ 86 - 39
App for Indeed/Views/CVMakerPageView.swift

@@ -629,6 +629,20 @@ final class CVMakerPageView: NSView {
629 629
 
630 630
     private var appliedGridColumnCount: Int = 0
631 631
 
632
+    /// Family filter row always renders this many slots so chip widths stay stable
633
+    /// when switching between Design-Based (3 labels) and Profession-Based (4).
634
+    private let familyChipSlotCount = 4
635
+
636
+    private enum FilterChromeLayout {
637
+        static let padding: CGFloat = 12
638
+        static let rowGap: CGFloat = 12
639
+        static let groupRowHeight: CGFloat = 38
640
+        static let familyRowHeight: CGFloat = 30
641
+        static var height: CGFloat {
642
+            padding * 2 + groupRowHeight + rowGap + familyRowHeight
643
+        }
644
+    }
645
+
632 646
     override init(frame frameRect: NSRect) {
633 647
         super.init(frame: frameRect)
634 648
         configureLayout()
@@ -681,7 +695,12 @@ final class CVMakerPageView: NSView {
681 695
             filterStack.leadingAnchor.constraint(equalTo: filterChrome.leadingAnchor, constant: 14),
682 696
             filterStack.trailingAnchor.constraint(equalTo: filterChrome.trailingAnchor, constant: -14),
683 697
             filterStack.topAnchor.constraint(equalTo: filterChrome.topAnchor, constant: 12),
684
-            filterStack.bottomAnchor.constraint(equalTo: filterChrome.bottomAnchor, constant: -12)
698
+            filterStack.bottomAnchor.constraint(equalTo: filterChrome.bottomAnchor, constant: -12),
699
+            // On this SDK `alignment` is `NSLayoutConstraint.Attribute` (no `.fill`).
700
+            // Pin row widths so group tabs stay full-width / equal split instead of
701
+            // shrinking to intrinsic width when selection changes.
702
+            groupTabsRow.widthAnchor.constraint(equalTo: filterStack.widthAnchor),
703
+            familyChipsRow.widthAnchor.constraint(equalTo: filterStack.widthAnchor)
685 704
         ])
686 705
 
687 706
         titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
@@ -704,13 +723,16 @@ final class CVMakerPageView: NSView {
704 723
         groupTabsRow.alignment = .centerY
705 724
         groupTabsRow.distribution = .fillEqually
706 725
         groupTabsRow.translatesAutoresizingMaskIntoConstraints = false
726
+        groupTabsRow.heightAnchor.constraint(equalToConstant: 38).isActive = true
707 727
         configureGroupTabs()
708 728
 
709 729
         familyChipsRow.orientation = .horizontal
710 730
         familyChipsRow.spacing = 8
711 731
         familyChipsRow.alignment = .centerY
712
-        familyChipsRow.distribution = .fill
732
+        // Match the top row: equal-width segments, evenly spaced across the row.
733
+        familyChipsRow.distribution = .fillEqually
713 734
         familyChipsRow.translatesAutoresizingMaskIntoConstraints = false
735
+        familyChipsRow.heightAnchor.constraint(equalToConstant: 30).isActive = true
714 736
 
715 737
         gridStack.orientation = .vertical
716 738
         gridStack.spacing = 26
@@ -759,8 +781,9 @@ final class CVMakerPageView: NSView {
759 781
             headerStack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
760 782
 
761 783
             filterChrome.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
762
-            filterChrome.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset),
784
+            filterChrome.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
763 785
             filterChrome.topAnchor.constraint(equalTo: headerStack.bottomAnchor, constant: 16),
786
+            filterChrome.heightAnchor.constraint(equalToConstant: FilterChromeLayout.height),
764 787
 
765 788
             scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
766 789
             scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
@@ -808,6 +831,7 @@ final class CVMakerPageView: NSView {
808 831
 
809 832
         let allCount = templates(forGroup: selectedGroup, family: nil).count
810 833
         let allChip = CVChipButton(title: "All", badgeText: "\(allCount)", leadingSymbol: nil, style: .pillSmall)
834
+        allChip.translatesAutoresizingMaskIntoConstraints = false
811 835
         allChip.onSelect = { [weak self] in self?.didSelectFamily(nil) }
812 836
         familyChipsRow.addArrangedSubview(allChip)
813 837
         familyChipButtons[nil] = allChip
@@ -816,16 +840,21 @@ final class CVMakerPageView: NSView {
816 840
             let count = templates(forGroup: selectedGroup, family: family).count
817 841
             guard count > 0 else { continue }
818 842
             let chip = CVChipButton(title: family.title, badgeText: "\(count)", leadingSymbol: nil, style: .pillSmall)
843
+            chip.translatesAutoresizingMaskIntoConstraints = false
819 844
             chip.onSelect = { [weak self] in self?.didSelectFamily(family) }
820 845
             familyChipsRow.addArrangedSubview(chip)
821 846
             familyChipButtons[family] = chip
822 847
         }
823 848
 
824
-        let familyRowTailSpacer = NSView()
825
-        familyRowTailSpacer.translatesAutoresizingMaskIntoConstraints = false
826
-        familyRowTailSpacer.setContentHuggingPriority(.init(1), for: .horizontal)
827
-        familyRowTailSpacer.setContentCompressionResistancePriority(.init(1), for: .horizontal)
828
-        familyChipsRow.addArrangedSubview(familyRowTailSpacer)
849
+        // Pad to a fixed slot count so `fillEqually` chip widths never change when
850
+        // the visible family count differs between category groups.
851
+        while familyChipsRow.arrangedSubviews.count < familyChipSlotCount {
852
+            let slot = NSView()
853
+            slot.translatesAutoresizingMaskIntoConstraints = false
854
+            slot.setContentHuggingPriority(.defaultLow, for: .horizontal)
855
+            slot.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
856
+            familyChipsRow.addArrangedSubview(slot)
857
+        }
829 858
 
830 859
         if let f = selectedFamily, templates(forGroup: selectedGroup, family: f).isEmpty {
831 860
             selectedFamily = nil
@@ -890,10 +919,12 @@ final class CVMakerPageView: NSView {
890 919
                     let filler = NSView()
891 920
                     filler.translatesAutoresizingMaskIntoConstraints = false
892 921
                     row.addArrangedSubview(filler)
922
+                    filler.heightAnchor.constraint(equalToConstant: CVTemplateCard.layoutHeight).isActive = true
893 923
                 }
894 924
             }
895 925
             gridStack.addArrangedSubview(row)
896 926
             row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
927
+            row.heightAnchor.constraint(equalToConstant: CVTemplateCard.layoutHeight).isActive = true
897 928
             index += columns
898 929
         }
899 930
 
@@ -1090,11 +1121,12 @@ private final class CVChipButton: NSView {
1090 1121
     private var trackingArea: NSTrackingArea?
1091 1122
 
1092 1123
     private enum Palette {
1093
-        static let restFill = NSColor(srgbRed: 247 / 255, green: 249 / 255, blue: 252 / 255, alpha: 1)
1124
+        static let restFill = NSColor.white
1094 1125
         static let restBorder = NSColor(srgbRed: 222 / 255, green: 226 / 255, blue: 233 / 255, alpha: 1)
1095
-        static let hoverFill = NSColor(srgbRed: 238 / 255, green: 241 / 255, blue: 247 / 255, alpha: 1)
1126
+        static let hoverFill = NSColor(srgbRed: 248 / 255, green: 250 / 255, blue: 252 / 255, alpha: 1)
1096 1127
         static let activeFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
1097 1128
         static let activeFillHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
1129
+        static let activeBorder = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
1098 1130
         static let restText = NSColor(srgbRed: 71 / 255, green: 85 / 255, blue: 105 / 255, alpha: 1)
1099 1131
         static let activeText = NSColor.white
1100 1132
         static let restBadge = NSColor(srgbRed: 230 / 255, green: 234 / 255, blue: 240 / 255, alpha: 1)
@@ -1103,6 +1135,10 @@ private final class CVChipButton: NSView {
1103 1135
         static let activeBadgeText = NSColor.white
1104 1136
     }
1105 1137
 
1138
+    private static let symbolSide: CGFloat = 18
1139
+    private static let badgeWidthLarge: CGFloat = 28
1140
+    private static let badgeWidthSmall: CGFloat = 26
1141
+
1106 1142
     init(title: String, badgeText: String, leadingSymbol: String?, style: Style) {
1107 1143
         self.style = style
1108 1144
         super.init(frame: .zero)
@@ -1116,13 +1152,16 @@ private final class CVChipButton: NSView {
1116 1152
 
1117 1153
         titleLabel.stringValue = title
1118 1154
         titleLabel.font = .systemFont(ofSize: style == .pillLarge ? 13 : 12, weight: .semibold)
1155
+        titleLabel.maximumNumberOfLines = 1
1156
+        titleLabel.lineBreakMode = .byTruncatingTail
1157
+        titleLabel.cell?.lineBreakMode = .byTruncatingTail
1119 1158
         titleLabel.isBordered = false
1120 1159
         titleLabel.drawsBackground = false
1121 1160
         titleLabel.isEditable = false
1122 1161
         titleLabel.isSelectable = false
1123 1162
 
1124 1163
         badgeLabel.stringValue = badgeText
1125
-        badgeLabel.font = .systemFont(ofSize: 10.5, weight: .semibold)
1164
+        badgeLabel.font = .monospacedDigitSystemFont(ofSize: 10.5, weight: .semibold)
1126 1165
         badgeLabel.alignment = .center
1127 1166
         badgeLabel.isBordered = false
1128 1167
         badgeLabel.drawsBackground = false
@@ -1139,7 +1178,9 @@ private final class CVChipButton: NSView {
1139 1178
             badgeLabel.trailingAnchor.constraint(equalTo: badgePill.trailingAnchor, constant: -7),
1140 1179
             badgeLabel.centerYAnchor.constraint(equalTo: badgePill.centerYAnchor),
1141 1180
             badgePill.heightAnchor.constraint(equalToConstant: 18),
1142
-            badgePill.widthAnchor.constraint(greaterThanOrEqualToConstant: 22)
1181
+            badgePill.widthAnchor.constraint(
1182
+                equalToConstant: style == .pillLarge ? Self.badgeWidthLarge : Self.badgeWidthSmall
1183
+            )
1143 1184
         ])
1144 1185
 
1145 1186
         stack.orientation = .horizontal
@@ -1151,30 +1192,38 @@ private final class CVChipButton: NSView {
1151 1192
             symbolView.translatesAutoresizingMaskIntoConstraints = false
1152 1193
             symbolView.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)
1153 1194
             symbolView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
1195
+            symbolView.setContentHuggingPriority(.required, for: .horizontal)
1196
+            symbolView.setContentCompressionResistancePriority(.required, for: .horizontal)
1154 1197
             stack.addArrangedSubview(symbolView)
1198
+            NSLayoutConstraint.activate([
1199
+                symbolView.widthAnchor.constraint(equalToConstant: Self.symbolSide),
1200
+                symbolView.heightAnchor.constraint(equalToConstant: Self.symbolSide)
1201
+            ])
1155 1202
         }
1156 1203
         stack.addArrangedSubview(titleLabel)
1157 1204
         stack.addArrangedSubview(badgePill)
1158 1205
 
1159 1206
         addSubview(stack)
1160 1207
         let horizontalInset: CGFloat = style == .pillLarge ? 16 : 12
1208
+        // Leading alignment for every chip so selected vs unselected pills share the
1209
+        // same geometry (centered stacks shift when badge digits change).
1161 1210
         if style == .pillLarge {
1162
-            // Group toggle shares a row: equal widths from the parent stack; keep
1163
-            // icon + label + badge centered so selection state does not reshuffle width.
1164 1211
             NSLayoutConstraint.activate([
1165
-                stack.centerXAnchor.constraint(equalTo: centerXAnchor),
1166
-                stack.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: horizontalInset),
1212
+                stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
1167 1213
                 stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset),
1168 1214
                 stack.centerYAnchor.constraint(equalTo: centerYAnchor)
1169 1215
             ])
1170 1216
             setContentHuggingPriority(.defaultLow, for: .horizontal)
1171 1217
         } else {
1218
+            // Parent row uses `fillEqually`; center label + badge inside each segment.
1172 1219
             NSLayoutConstraint.activate([
1173
-                stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
1174
-                stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
1220
+                stack.centerXAnchor.constraint(equalTo: centerXAnchor),
1221
+                stack.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: horizontalInset),
1222
+                stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -horizontalInset),
1175 1223
                 stack.centerYAnchor.constraint(equalTo: centerYAnchor)
1176 1224
             ])
1177
-            setContentHuggingPriority(.required, for: .horizontal)
1225
+            setContentHuggingPriority(.defaultLow, for: .horizontal)
1226
+            setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
1178 1227
         }
1179 1228
         applyState()
1180 1229
     }
@@ -1244,7 +1293,7 @@ private final class CVChipButton: NSView {
1244 1293
         let badgeText: NSColor
1245 1294
         if isSelected {
1246 1295
             fill = isHovering ? Palette.activeFillHover : Palette.activeFill
1247
-            border = fill
1296
+            border = Palette.activeBorder
1248 1297
             textColor = Palette.activeText
1249 1298
             badgeFill = Palette.activeBadge
1250 1299
             badgeText = Palette.activeBadgeText
@@ -1257,6 +1306,7 @@ private final class CVChipButton: NSView {
1257 1306
         }
1258 1307
         layer?.backgroundColor = fill.cgColor
1259 1308
         layer?.borderColor = border.cgColor
1309
+        layer?.borderWidth = 1
1260 1310
         titleLabel.textColor = textColor
1261 1311
         symbolView.contentTintColor = textColor
1262 1312
         badgePill.layer?.backgroundColor = badgeFill.cgColor
@@ -1269,6 +1319,8 @@ private final class CVChipButton: NSView {
1269 1319
 /// Premium gallery card: live résumé thumbnail, soft shadow, and an animated
1270 1320
 /// brand border when selected.
1271 1321
 private final class CVTemplateCard: NSView {
1322
+    static let layoutHeight: CGFloat = 292
1323
+
1272 1324
     var onSelect: (() -> Void)?
1273 1325
     var isSelected: Bool = false { didSet { applyChrome() } }
1274 1326
     /// Distinguishes this card from others that may share the same catalog `template.id`.
@@ -1296,7 +1348,7 @@ private final class CVTemplateCard: NSView {
1296 1348
         layer?.cornerRadius = 24
1297 1349
         layer?.backgroundColor = NSColor.white.cgColor
1298 1350
         translatesAutoresizingMaskIntoConstraints = false
1299
-        heightAnchor.constraint(greaterThanOrEqualToConstant: 292).isActive = true
1351
+        heightAnchor.constraint(equalToConstant: Self.layoutHeight).isActive = true
1300 1352
 
1301 1353
         previewSurface.translatesAutoresizingMaskIntoConstraints = false
1302 1354
         previewSurface.wantsLayer = true
@@ -1436,28 +1488,23 @@ private final class CVTemplateCard: NSView {
1436 1488
     }
1437 1489
 
1438 1490
     private func applyChrome() {
1491
+        // Same border + shadow metrics in every state — only color changes on select
1492
+        // so thumbnails keep identical insets (stroke is drawn inside the layer).
1493
+        let uniformBorder: CGFloat = 2
1494
+        let borderColor: NSColor
1439 1495
         if isSelected {
1440
-            layer?.borderColor = palette.borderSelected.cgColor
1441
-            layer?.borderWidth = 2.5
1442
-            layer?.shadowColor = palette.borderSelected.cgColor
1443
-            layer?.shadowOpacity = 0.42
1444
-            layer?.shadowRadius = 22
1445
-            layer?.shadowOffset = CGSize(width: 0, height: 10)
1496
+            borderColor = palette.borderSelected
1446 1497
         } else if isHovering {
1447
-            layer?.borderColor = palette.borderHover.cgColor
1448
-            layer?.borderWidth = 1.5
1449
-            layer?.shadowColor = NSColor.black.cgColor
1450
-            layer?.shadowOpacity = 0.14
1451
-            layer?.shadowRadius = 18
1452
-            layer?.shadowOffset = CGSize(width: 0, height: 10)
1498
+            borderColor = palette.borderHover
1453 1499
         } else {
1454
-            layer?.borderColor = palette.border.cgColor
1455
-            layer?.borderWidth = 1
1456
-            layer?.shadowColor = NSColor.black.cgColor
1457
-            layer?.shadowOpacity = 0.08
1458
-            layer?.shadowRadius = 12
1459
-            layer?.shadowOffset = CGSize(width: 0, height: 6)
1500
+            borderColor = palette.border
1460 1501
         }
1502
+        layer?.borderColor = borderColor.cgColor
1503
+        layer?.borderWidth = uniformBorder
1504
+        layer?.shadowColor = NSColor.black.cgColor
1505
+        layer?.shadowOpacity = isSelected ? 0.14 : (isHovering ? 0.11 : 0.08)
1506
+        layer?.shadowRadius = 14
1507
+        layer?.shadowOffset = CGSize(width: 0, height: 8)
1461 1508
     }
1462 1509
 }
1463 1510