Files
wehub-resource-sync 869b84f27c
Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:29:30 +08:00

332 lines
12 KiB
Swift

import SwiftUI
/// User-overridable appearance setting that lives independently of the system
/// preference, mirroring the prototype's titlebar light/dark toggle.
enum AppearanceMode: String, CaseIterable, Identifiable {
case system, light, dark
var id: String { rawValue }
var label: String {
switch self {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
}
var icon: String {
switch self {
case .system: return "circle.lefthalf.filled"
case .light: return "sun.max.fill"
case .dark: return "moon.fill"
}
}
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
@MainActor
final class ThemeManager: ObservableObject {
static let shared = ThemeManager()
@AppStorage("PureMac.Appearance") private var rawValue: String = AppearanceMode.system.rawValue
var appearance: AppearanceMode {
get { AppearanceMode(rawValue: rawValue) ?? .system }
set { rawValue = newValue.rawValue; objectWillChange.send() }
}
}
/// Centralized accent palette. One blue, one green for success, one orange
/// for warning, one red for destructive. Other tints exist for categorical
/// differentiation but the surface chrome only uses these four.
enum Tint {
static let blue = Color(red: 0.04, green: 0.52, blue: 1.00)
static let green = Color(red: 0.18, green: 0.78, blue: 0.47)
static let orange = Color(red: 1.00, green: 0.58, blue: 0.04)
static let purple = Color(red: 0.55, green: 0.32, blue: 0.87)
static let pink = Color(red: 1.00, green: 0.30, blue: 0.50)
static let cyan = Color(red: 0.30, green: 0.78, blue: 0.95)
static let red = Color(red: 1.00, green: 0.27, blue: 0.23)
static let yellow = Color(red: 1.00, green: 0.78, blue: 0.04)
}
/// Shared animation vocabulary so every surface moves with the same feel.
/// Hover/selection feedback uses `snappy`, entrances and state swaps use
/// `gentle`, press acknowledgment uses `press`.
enum MotionTokens {
static let snappy = Animation.spring(response: 0.3, dampingFraction: 0.7)
static let gentle = Animation.spring(response: 0.5, dampingFraction: 0.85)
static let press = Animation.easeOut(duration: 0.12)
}
/// Gradient pairs built from the flat `Tint` palette. Reserved for primary
/// CTAs and focal chrome — secondary surfaces stay flat.
enum TintGradient {
static let accent = LinearGradient(
colors: [Tint.blue, Tint.purple],
startPoint: .topLeading, endPoint: .bottomTrailing
)
static let destructive = LinearGradient(
colors: [Tint.red, Tint.red.opacity(0.8)],
startPoint: .top, endPoint: .bottom
)
static func of(_ color: Color) -> LinearGradient {
LinearGradient(colors: [color, color.opacity(0.65)], startPoint: .top, endPoint: .bottom)
}
}
/// Tinted square icon container used in the sidebar and on dashboard cards.
/// Single muted fill, thin border. When `glow` is set (selected sidebar row,
/// emphasized card) the tile picks up a gradient fill and a soft tinted halo.
struct IconTile: View {
let systemName: String
var tint: Color = Tint.blue
var size: CGFloat = 26
var corner: CGFloat = 7
var glow: Bool = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: corner, style: .continuous)
.fill(
LinearGradient(
colors: [tint.opacity(glow ? 0.30 : 0.14), tint.opacity(0.14)],
startPoint: .topLeading, endPoint: .bottomTrailing
)
)
.shadow(color: tint.opacity(glow ? 0.45 : 0), radius: glow ? 5 : 0)
Image(systemName: systemName)
.font(.system(size: size * 0.52, weight: .semibold))
.foregroundStyle(tint)
.shadow(color: tint.opacity(glow ? 0.5 : 0), radius: glow ? 3 : 0)
}
.frame(width: size, height: size)
.animation(reduceMotion ? nil : MotionTokens.snappy, value: glow)
}
}
/// Card surface. Flat fill, hairline border, single soft shadow. No accent
/// stripe by default — content hierarchy carries the meaning, not chrome.
/// Pass `material` for a vibrancy/glass panel (used on focal hero states
/// where a tinted backdrop sits behind the card).
struct CardSurface<Content: View>: View {
var padding: CGFloat = 16
/// Retained for callsite compatibility; the accent line is intentionally
/// not rendered in the restrained design.
var accent: Color? = nil
var elevation: CardElevation = .standard
var material: Material? = nil
@ViewBuilder var content: Content
var body: some View {
content
.padding(padding)
.background(
ZStack {
if let material {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(material)
} else {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
}
}
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(
material != nil ? Color.white.opacity(0.14) : Color.primary.opacity(0.07),
lineWidth: material != nil ? 1 : 0.5
)
)
.shadow(color: .black.opacity(elevation.ambient), radius: elevation.ambientRadius, y: elevation.ambientY)
}
}
enum CardElevation {
case flat, standard, raised
var ambient: Double {
switch self {
case .flat: return 0.0
case .standard: return 0.04
case .raised: return 0.07
}
}
var ambientRadius: CGFloat {
switch self {
case .flat: return 0
case .standard: return 4
case .raised: return 10
}
}
var ambientY: CGFloat {
switch self {
case .flat: return 0
case .standard: return 1
case .raised: return 3
}
}
}
/// Small status pill. Solid tint background at low opacity, no gradient.
struct StatusChip: View {
let label: String
var systemImage: String? = nil
var tint: Color = Tint.blue
var body: some View {
HStack(spacing: 4) {
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 9, weight: .bold))
}
Text(label)
.font(.system(size: 11, weight: .semibold))
.monospacedDigit()
}
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Capsule().fill(tint.opacity(0.14)))
.foregroundStyle(tint)
}
}
/// Hover/press feedback for tappable cards. Plain mode is a subtle scale;
/// `lift` mode adds the CleanMyMac-style float (rise + soft shadow). Under
/// Reduce Motion the scale/offset are dropped and only the shadow remains.
struct PressableScale: ViewModifier {
@State private var hovering = false
@State private var pressing = false
var hoverScale: CGFloat = 1.006
var lift: Bool = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View {
content
.scaleEffect(reduceMotion ? 1.0 : (pressing ? 0.97 : (hovering ? (lift ? 1.02 : hoverScale) : 1.0)))
.offset(y: lift && hovering && !reduceMotion ? -2 : 0)
.shadow(color: .black.opacity(lift && hovering ? 0.12 : 0),
radius: lift && hovering ? 14 : 0,
y: lift && hovering ? 6 : 0)
.animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering)
.animation(reduceMotion ? nil : MotionTokens.press, value: pressing)
.onHover { hovering = $0 }
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in pressing = true }
.onEnded { _ in pressing = false }
)
}
}
extension View {
func pressable(hoverScale: CGFloat = 1.006, lift: Bool = false) -> some View {
modifier(PressableScale(hoverScale: hoverScale, lift: lift))
}
}
/// Gradient capsule CTA with a soft tinted glow — the primary-action style.
/// Hover lifts the glow and scale; press squeezes. `breathes` adds a gentle
/// idle glow pulse (radius only, no scale) for the single hero CTA on an
/// otherwise calm screen. All motion is suppressed under Reduce Motion.
struct GlowProminentButtonStyle: ButtonStyle {
var tint: Color = Tint.blue
var gradient: LinearGradient = TintGradient.accent
var breathes: Bool = false
func makeBody(configuration: Configuration) -> some View {
GlowBody(configuration: configuration, tint: tint, gradient: gradient, breathes: breathes)
}
private struct GlowBody: View {
let configuration: ButtonStyleConfiguration
let tint: Color
let gradient: LinearGradient
let breathes: Bool
@State private var hovering = false
@State private var breathe = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
configuration.label
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 18)
.padding(.vertical, 9)
.background(Capsule().fill(gradient))
.overlay(Capsule().strokeBorder(.white.opacity(0.18), lineWidth: 0.5))
.shadow(color: tint.opacity(hovering ? 0.45 : (breathe ? 0.40 : 0.22)),
radius: hovering ? 14 : (breathe ? 12 : 7), y: 3)
.scaleEffect(reduceMotion ? 1 : (configuration.isPressed ? 0.97 : (hovering ? 1.03 : 1)))
.animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering)
.animation(reduceMotion ? nil : MotionTokens.press, value: configuration.isPressed)
.onHover { hovering = $0 }
.onAppear {
guard breathes, !reduceMotion else { return }
withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true)) {
breathe = true
}
}
}
}
}
/// Checkbox replacement with a springy check pop. Visually matches the size
/// of the native control so adopting it doesn't shift row layout. Keeps the
/// native checkbox's accessibility semantics (role, checked value, Space-key
/// toggling, VoiceOver) via accessibilityRepresentation so swapping it in
/// doesn't regress keyboard/screen-reader users.
struct AnimatedCheckboxStyle: ToggleStyle {
var tint: Color = Tint.blue
func makeBody(configuration: Configuration) -> some View {
CheckBody(configuration: configuration, tint: tint)
.accessibilityRepresentation {
Toggle(isOn: configuration.$isOn) { configuration.label }
.toggleStyle(.checkbox)
}
}
private struct CheckBody: View {
let configuration: ToggleStyleConfiguration
let tint: Color
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
HStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 4.5, style: .continuous)
.fill(configuration.isOn ? tint : Color.primary.opacity(0.05))
RoundedRectangle(cornerRadius: 4.5, style: .continuous)
.strokeBorder(configuration.isOn ? tint : Color.primary.opacity(0.25), lineWidth: 1)
Image(systemName: "checkmark")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.scaleEffect(configuration.isOn ? 1 : 0.3)
.opacity(configuration.isOn ? 1 : 0)
}
.frame(width: 15, height: 15)
.animation(reduceMotion ? nil : .spring(response: 0.25, dampingFraction: 0.6), value: configuration.isOn)
configuration.label
}
.contentShape(Rectangle())
.onTapGesture { configuration.isOn.toggle() }
}
}
}