AppTheme.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. import Cocoa
  2. import ObjectiveC
  3. enum AppTheme {
  4. static let windowWidth: CGFloat = 800
  5. static let windowHeight: CGFloat = 720
  6. static let windowMinWidth: CGFloat = 690
  7. static let windowMinHeight: CGFloat = windowHeight
  8. static let sidebarWidth: CGFloat = 240
  9. static let contentPadding: CGFloat = 20
  10. static let quickStartSpacing: CGFloat = 12
  11. static let featureGridSpacing: CGFloat = 10
  12. static let featureGridColumns = 3
  13. static let quickStartIconMax: CGFloat = 80
  14. static let quickStartIconMin: CGFloat = 56
  15. static let featureIconMax: CGFloat = 48
  16. static let featureIconMin: CGFloat = 36
  17. static func quickStartIconSize(forCardWidth width: CGFloat) -> CGFloat {
  18. min(quickStartIconMax, max(quickStartIconMin, width * 0.58))
  19. }
  20. static func featureIconSize(forCardWidth width: CGFloat) -> CGFloat {
  21. min(featureIconMax, max(featureIconMin, width * 0.24))
  22. }
  23. static let cornerRadius: CGFloat = 14
  24. static let cardCornerRadius: CGFloat = 20
  25. static let featureCardCornerRadius: CGFloat = 16
  26. static let contentPanelCornerRadius: CGFloat = 24
  27. static let contentPanelInset: CGFloat = 16
  28. static let headerTopInset: CGFloat = 14
  29. static let headerHeight: CGFloat = 32
  30. static let contentBelowHeaderGap: CGFloat = 2
  31. private static var isDark: Bool { AppSettings.darkModeEnabled }
  32. static var background: NSColor {
  33. isDark
  34. ? NSColor(calibratedWhite: 0.11, alpha: 1)
  35. : NSColor(calibratedWhite: 0.965, alpha: 1)
  36. }
  37. static var sidebarBackground: NSColor {
  38. isDark
  39. ? NSColor(calibratedWhite: 0.15, alpha: 1)
  40. : NSColor.white
  41. }
  42. static var cardBackground: NSColor {
  43. isDark
  44. ? NSColor(calibratedWhite: 0.18, alpha: 1)
  45. : NSColor.white
  46. }
  47. static var groupCardBackground: NSColor { cardBackground }
  48. static var elevatedBackground: NSColor {
  49. isDark
  50. ? NSColor(calibratedWhite: 0.22, alpha: 1)
  51. : NSColor.white
  52. }
  53. static var textPrimary: NSColor {
  54. isDark
  55. ? NSColor(calibratedWhite: 0.92, alpha: 1)
  56. : NSColor(calibratedWhite: 0.12, alpha: 1)
  57. }
  58. static var textSecondary: NSColor {
  59. isDark
  60. ? NSColor(calibratedWhite: 0.55, alpha: 1)
  61. : NSColor(calibratedWhite: 0.48, alpha: 1)
  62. }
  63. static var border: NSColor {
  64. isDark
  65. ? NSColor(calibratedWhite: 0.28, alpha: 1)
  66. : NSColor(calibratedWhite: 0.92, alpha: 1)
  67. }
  68. static var waveDotColor: NSColor {
  69. isDark
  70. ? NSColor(calibratedWhite: 0.35, alpha: 0.45)
  71. : NSColor(calibratedWhite: 0.82, alpha: 0.55)
  72. }
  73. static var homeActiveBackground: NSColor {
  74. isDark
  75. ? NSColor(red: 0.18, green: 0.28, blue: 0.48, alpha: 1)
  76. : NSColor(red: 0.91, green: 0.94, blue: 1.0, alpha: 1)
  77. }
  78. static var homeActiveForeground: NSColor {
  79. isDark
  80. ? NSColor(red: 0.45, green: 0.65, blue: 1.0, alpha: 1)
  81. : NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
  82. }
  83. static var sidebarHoverBackground: NSColor {
  84. isDark
  85. ? NSColor(calibratedWhite: 0.20, alpha: 1)
  86. : NSColor(calibratedWhite: 0.96, alpha: 1)
  87. }
  88. static var premiumHoverBackground: NSColor {
  89. isDark
  90. ? NSColor(red: 0.24, green: 0.20, blue: 0.36, alpha: 1)
  91. : NSColor(red: 0.97, green: 0.95, blue: 1.0, alpha: 1)
  92. }
  93. static let hoverAnimationDuration: TimeInterval = 0.15
  94. static var cardShadowOpacity: Float {
  95. AppSettings.darkModeEnabled ? 0.25 : 0.07
  96. }
  97. static var cardHoverShadowOpacity: Float {
  98. AppSettings.darkModeEnabled ? 0.35 : 0.14
  99. }
  100. static let cardShadowRadius: CGFloat = 14
  101. static let cardHoverShadowRadius: CGFloat = 18
  102. static let cardHoverScale: CGFloat = 1.02
  103. static var premiumBackground: NSColor {
  104. isDark
  105. ? NSColor(red: 0.24, green: 0.20, blue: 0.36, alpha: 1)
  106. : NSColor(red: 0.95, green: 0.93, blue: 1.0, alpha: 1)
  107. }
  108. static var premiumCardBackground: NSColor {
  109. isDark ? cardBackground : NSColor(red: 0.95, green: 0.93, blue: 1.0, alpha: 1)
  110. }
  111. static var premiumForeground: NSColor {
  112. isDark
  113. ? NSColor(red: 0.72, green: 0.58, blue: 1.0, alpha: 1)
  114. : NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1)
  115. }
  116. static var premiumCardBorder: NSColor {
  117. isDark ? paywallBorder : NSColor(red: 0.88, green: 0.82, blue: 0.98, alpha: 1)
  118. }
  119. static var premiumCardTitle: NSColor { textPrimary }
  120. static var premiumCardSubtitle: NSColor {
  121. isDark ? textSecondary : NSColor(red: 0.44, green: 0.38, blue: 0.58, alpha: 1)
  122. }
  123. static let premiumCrown = NSColor(red: 0.95, green: 0.75, blue: 0.20, alpha: 1)
  124. static var premiumActionBackground: NSColor {
  125. isDark ? elevatedBackground : cardBackground
  126. }
  127. static var premiumActionHoverBackground: NSColor {
  128. isDark ? paywallTrustIconBackground : NSColor(red: 0.98, green: 0.96, blue: 1.0, alpha: 1)
  129. }
  130. static var premiumActionBorder: NSColor { paywallBorder }
  131. static var premiumActionBorderHover: NSColor { paywallBorder }
  132. static var premiumActionForeground: NSColor {
  133. isDark ? textPrimary : premiumForeground
  134. }
  135. static var premiumActionForegroundHover: NSColor {
  136. isDark ? premiumForeground : NSColor(red: 0.48, green: 0.28, blue: 0.90, alpha: 1)
  137. }
  138. static var premiumButtonBackground: NSColor { purple }
  139. static var premiumButtonHoverBackground: NSColor {
  140. isDark
  141. ? NSColor(red: 0.62, green: 0.45, blue: 1.0, alpha: 1)
  142. : NSColor(red: 0.48, green: 0.28, blue: 0.90, alpha: 1)
  143. }
  144. static let premiumButtonForeground = NSColor.white
  145. static let blue = NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1)
  146. static let quickStartBlueLight = NSColor(red: 0.88, green: 0.93, blue: 1.0, alpha: 1)
  147. static let quickStartCardSubtitle = NSColor(calibratedWhite: 0.48, alpha: 1)
  148. static var blueLight: NSColor {
  149. isDark
  150. ? NSColor(red: 0.20, green: 0.28, blue: 0.42, alpha: 1)
  151. : quickStartBlueLight
  152. }
  153. static let green = NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1)
  154. static let greenLight = NSColor(red: 0.88, green: 0.97, blue: 0.91, alpha: 1)
  155. static let orange = NSColor(red: 0.96, green: 0.52, blue: 0.18, alpha: 1)
  156. static let orangeLight = NSColor(red: 1.0, green: 0.94, blue: 0.88, alpha: 1)
  157. static let purple = NSColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1)
  158. static let teal = NSColor(red: 0.18, green: 0.72, blue: 0.82, alpha: 1)
  159. static let navy = NSColor(red: 0.12, green: 0.18, blue: 0.32, alpha: 1)
  160. static let paywallPink = NSColor(red: 0.98, green: 0.85, blue: 0.90, alpha: 1)
  161. static let paywallPinkText = NSColor(red: 0.75, green: 0.30, blue: 0.45, alpha: 1)
  162. static let paywallGold = NSColor(red: 0.96, green: 0.90, blue: 0.78, alpha: 1)
  163. static let paywallGoldText = NSColor(red: 0.65, green: 0.48, blue: 0.22, alpha: 1)
  164. static var paywallBorder: NSColor {
  165. isDark
  166. ? NSColor(calibratedWhite: 0.36, alpha: 1)
  167. : NSColor(calibratedWhite: 0.80, alpha: 1)
  168. }
  169. static var paywallBackground: NSColor { cardBackground }
  170. static var paywallAccent: NSColor {
  171. isDark ? textPrimary : navy
  172. }
  173. static var paywallLeftGradientColors: [NSColor] {
  174. isDark
  175. ? [
  176. NSColor(calibratedWhite: 0.17, alpha: 1),
  177. NSColor(calibratedWhite: 0.13, alpha: 1),
  178. ]
  179. : [
  180. NSColor(red: 0.88, green: 0.94, blue: 1.0, alpha: 1),
  181. NSColor(red: 0.95, green: 0.97, blue: 1.0, alpha: 1),
  182. ]
  183. }
  184. static var paywallCTABackground: NSColor {
  185. isDark
  186. ? NSColor(calibratedWhite: 0.96, alpha: 1)
  187. : navy
  188. }
  189. static var paywallCTAForeground: NSColor {
  190. isDark
  191. ? NSColor(calibratedWhite: 0.10, alpha: 1)
  192. : NSColor.white
  193. }
  194. static var paywallTrustIconBackground: NSColor {
  195. isDark
  196. ? NSColor(calibratedWhite: 0.24, alpha: 1)
  197. : quickStartBlueLight
  198. }
  199. static var paywallIconAccent: NSColor {
  200. isDark
  201. ? NSColor(calibratedWhite: 0.78, alpha: 1)
  202. : navy
  203. }
  204. static var paywallTrustBackground: NSColor { elevatedBackground }
  205. static var paywallOverlayBackdrop: NSColor {
  206. isDark
  207. ? NSColor(calibratedWhite: 0.05, alpha: 0.55)
  208. : NSColor(calibratedWhite: 0.15, alpha: 0.22)
  209. }
  210. static func semiboldFont(size: CGFloat) -> NSFont {
  211. .systemFont(ofSize: size, weight: .semibold)
  212. }
  213. static func mediumFont(size: CGFloat) -> NSFont {
  214. .systemFont(ofSize: size, weight: .medium)
  215. }
  216. static func regularFont(size: CGFloat) -> NSFont {
  217. .systemFont(ofSize: size, weight: .regular)
  218. }
  219. }
  220. protocol AppearanceRefreshable: AnyObject {
  221. func refreshAppearance()
  222. }
  223. // MARK: - Hover Tracking
  224. final class HoverTracker: NSResponder {
  225. private weak var view: NSView?
  226. private let onHoverChanged: (Bool) -> Void
  227. init(view: NSView, onHoverChanged: @escaping (Bool) -> Void) {
  228. self.view = view
  229. self.onHoverChanged = onHoverChanged
  230. super.init()
  231. let area = NSTrackingArea(
  232. rect: .zero,
  233. options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
  234. owner: self,
  235. userInfo: nil
  236. )
  237. view.addTrackingArea(area)
  238. }
  239. @available(*, unavailable)
  240. required init?(coder: NSCoder) { nil }
  241. override func mouseEntered(with event: NSEvent) {
  242. onHoverChanged(true)
  243. }
  244. override func mouseExited(with event: NSEvent) {
  245. onHoverChanged(false)
  246. }
  247. }
  248. extension NSView {
  249. func animateHover(_ changes: () -> Void) {
  250. NSAnimationContext.runAnimationGroup { context in
  251. context.duration = AppTheme.hoverAnimationDuration
  252. context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  253. context.allowsImplicitAnimation = true
  254. changes()
  255. }
  256. }
  257. func applyHoverLift(_ hovering: Bool, on targetLayer: CALayer? = nil) {
  258. let layer = targetLayer ?? layer
  259. animateHover {
  260. guard let layer else { return }
  261. layer.transform = hovering
  262. ? CATransform3DMakeScale(AppTheme.cardHoverScale, AppTheme.cardHoverScale, 1)
  263. : CATransform3DIdentity
  264. layer.shadowOpacity = hovering ? AppTheme.cardHoverShadowOpacity : AppTheme.cardShadowOpacity
  265. layer.shadowRadius = hovering ? AppTheme.cardHoverShadowRadius : AppTheme.cardShadowRadius
  266. }
  267. }
  268. func applyCardShadow() {
  269. wantsLayer = true
  270. layer?.shadowColor = NSColor.black.cgColor
  271. layer?.shadowOpacity = AppTheme.cardShadowOpacity
  272. layer?.shadowOffset = NSSize(width: 0, height: -3)
  273. layer?.shadowRadius = AppTheme.cardShadowRadius
  274. layer?.masksToBounds = false
  275. }
  276. func roundCorners(_ radius: CGFloat) {
  277. wantsLayer = true
  278. layer?.cornerRadius = radius
  279. layer?.masksToBounds = true
  280. }
  281. func refreshAppearanceRecursively() {
  282. if let refreshable = self as? AppearanceRefreshable {
  283. refreshable.refreshAppearance()
  284. }
  285. if let label = self as? NSTextField {
  286. label.refreshThemeLabelColor()
  287. }
  288. if let imageView = self as? NSImageView, imageView.usesThemeTint {
  289. imageView.contentTintColor = AppTheme.textPrimary
  290. }
  291. subviews.forEach { $0.refreshAppearanceRecursively() }
  292. }
  293. }
  294. private enum ThemeAssociatedKeys {
  295. static var labelStyle = 0
  296. static var usesThemeTint = 0
  297. }
  298. enum ThemeLabelStyle: String {
  299. case primary
  300. case secondary
  301. }
  302. extension NSTextField {
  303. static func themeLabel(_ string: String, style: ThemeLabelStyle, font: NSFont) -> NSTextField {
  304. let label = NSTextField(labelWithString: string)
  305. label.font = font
  306. label.themeLabelStyle = style
  307. label.textColor = style == .primary ? AppTheme.textPrimary : AppTheme.textSecondary
  308. return label
  309. }
  310. var themeLabelStyle: ThemeLabelStyle? {
  311. get {
  312. guard let rawValue = objc_getAssociatedObject(self, &ThemeAssociatedKeys.labelStyle) as? String else {
  313. return nil
  314. }
  315. return ThemeLabelStyle(rawValue: rawValue)
  316. }
  317. set {
  318. objc_setAssociatedObject(
  319. self,
  320. &ThemeAssociatedKeys.labelStyle,
  321. newValue?.rawValue,
  322. .OBJC_ASSOCIATION_RETAIN_NONATOMIC
  323. )
  324. }
  325. }
  326. func refreshThemeLabelColor() {
  327. guard let style = themeLabelStyle else { return }
  328. textColor = style == .primary ? AppTheme.textPrimary : AppTheme.textSecondary
  329. }
  330. }
  331. extension NSImageView {
  332. var usesThemeTint: Bool {
  333. get { (objc_getAssociatedObject(self, &ThemeAssociatedKeys.usesThemeTint) as? Bool) ?? false }
  334. set { objc_setAssociatedObject(self, &ThemeAssociatedKeys.usesThemeTint, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
  335. }
  336. }