881 lines
36 KiB
Swift
881 lines
36 KiB
Swift
//
|
||
// DesignSystem.swift
|
||
// leanring-buddy
|
||
//
|
||
// Centralized design system using a blue accent palette on dark surfaces,
|
||
// with a unified button style system. All colors, button styles, and
|
||
// interaction states are defined here as the single source of truth.
|
||
//
|
||
|
||
import SwiftUI
|
||
import AppKit
|
||
|
||
// MARK: - Design System Namespace
|
||
|
||
/// The top-level namespace for all design system tokens.
|
||
/// Usage: `DS.Colors.background`, `DS.Colors.accent`, etc.
|
||
enum DS {
|
||
|
||
// MARK: - Color Tokens
|
||
|
||
enum Colors {
|
||
|
||
// ── Backgrounds ──────────────────────────────────────────────
|
||
// Layered surfaces from deepest to most elevated.
|
||
// Higher surfaces are lighter, creating a sense of depth.
|
||
|
||
/// The deepest background — used for the main app window fill.
|
||
static let background = Color(hex: "#101211")
|
||
|
||
/// First elevation layer — used for cards, sidebar, top bar backgrounds.
|
||
static let surface1 = Color(hex: "#171918")
|
||
|
||
/// Second elevation layer — used for input fields, elevated cards, chat bubbles.
|
||
static let surface2 = Color(hex: "#202221")
|
||
|
||
/// Third elevation layer — used for hover backgrounds on interactive elements.
|
||
static let surface3 = Color(hex: "#272A29")
|
||
|
||
/// Fourth elevation layer — used for active/pressed states on interactive elements.
|
||
static let surface4 = Color(hex: "#2E3130")
|
||
|
||
// ── Borders ──────────────────────────────────────────────────
|
||
|
||
/// Subtle border — used for card outlines, dividers, input field borders.
|
||
static let borderSubtle = Color(hex: "#373B39")
|
||
|
||
/// Strong border — used for focused inputs, hovered card outlines.
|
||
static let borderStrong = Color(hex: "#444947")
|
||
|
||
// ── Text ─────────────────────────────────────────────────────
|
||
|
||
/// Primary text — main body text, titles, headings.
|
||
static let textPrimary = Color(hex: "#ECEEED")
|
||
|
||
/// Secondary text — descriptions, hints, muted labels.
|
||
static let textSecondary = Color(hex: "#ADB5B2")
|
||
|
||
/// Tertiary text — very muted, used for section labels, timestamps, disabled text.
|
||
static let textTertiary = Color(hex: "#6B736F")
|
||
|
||
/// Text used on top of the accent fill (#2563eb blue), like the primary button label.
|
||
/// White on #2563eb achieves ~5.1:1 contrast — WCAG AA compliant.
|
||
/// White on #1d4ed8 hover achieves ~6.5:1 — also WCAG AA compliant.
|
||
static let textOnAccent: Color = .white
|
||
|
||
// ── Tailwind Blue Scale ─────────────────────────────────────
|
||
// Full Tailwind CSS v4 blue palette for consistent blue usage.
|
||
//
|
||
// Usage guide:
|
||
// 50–100 → Very subtle tinted backgrounds (selected rows, hover fills on dark surfaces)
|
||
// 200–300 → Light text/icons on dark backgrounds, disabled states
|
||
// 400 → Bright accent text, links, icons, chat user bubbles
|
||
// 500 → Mid-tone fills, badges, secondary buttons
|
||
// 600 → Primary action fills (buttons, toggles) — main accent
|
||
// 700 → Hover/pressed state for primary actions
|
||
// 800–900 → Deep backgrounds, dark overlays, header bars
|
||
// 950 → Deepest blue — near-black tinted backgrounds
|
||
|
||
static let blue50 = Color(hex: "#eff6ff")
|
||
static let blue100 = Color(hex: "#dbeafe")
|
||
static let blue200 = Color(hex: "#bfdbfe")
|
||
static let blue300 = Color(hex: "#93c5fd")
|
||
static let blue400 = Color(hex: "#60a5fa")
|
||
static let blue500 = Color(hex: "#3b82f6")
|
||
static let blue600 = Color(hex: "#2563eb")
|
||
static let blue700 = Color(hex: "#1d4ed8")
|
||
static let blue800 = Color(hex: "#1e40af")
|
||
static let blue900 = Color(hex: "#1e3a8a")
|
||
static let blue950 = Color(hex: "#172554")
|
||
|
||
// ── Accent (derived from blue scale) ───────────────────────
|
||
// The primary fill is Blue 600; hover darkens to Blue 700.
|
||
|
||
/// Accent fill — used for solid button backgrounds.
|
||
/// #2563eb → ~5.1:1 contrast with white text (WCAG AA).
|
||
static let accent = blue600
|
||
|
||
/// Accent hover — slightly darker blue for hover state.
|
||
/// #1d4ed8 → ~6.5:1 contrast with white text (WCAG AA+).
|
||
static let accentHover = blue700
|
||
|
||
/// Accent text — bright blue used for accent-colored text and icons
|
||
/// on dark backgrounds (links, active nav items, highlighted labels).
|
||
static let accentText = blue400
|
||
|
||
/// Very subtle accent tint — used for selected item backgrounds (e.g. current step
|
||
/// in the sidebar). Low opacity so it doesn't overpower.
|
||
static let accentSubtle = blue500.opacity(0.10)
|
||
|
||
// ── Semantic Colors ──────────────────────────────────────────
|
||
|
||
/// Destructive/error actions — delete buttons, error messages, close button hover.
|
||
static let destructive = Color(hex: "#E5484D") // Radix Red 9
|
||
|
||
/// Destructive hover state.
|
||
static let destructiveHover = Color(hex: "#F2555A") // Radix Red 10
|
||
|
||
/// Destructive used for text on dark backgrounds (brighter for readability).
|
||
static let destructiveText = Color(hex: "#FF6369") // Radix Red 11
|
||
|
||
/// Success — checkmarks, granted status, completion indicators.
|
||
/// Independent green so success states are visually distinct from the blue accent.
|
||
static let success = Color(hex: "#34D399") // Tailwind Emerald 400
|
||
|
||
/// Warning — caution messages, manual verification failure explanations.
|
||
static let warning = Color(hex: "#FFB224") // Radix Amber 9
|
||
|
||
/// Warning text — brighter variant for text on dark backgrounds.
|
||
static let warningText = Color(hex: "#F1A10D") // Radix Amber 11
|
||
|
||
/// Info/feature highlight — used for prompt card headers, code highlights.
|
||
/// Lighter than accentText so informational elements are visually distinct
|
||
/// from interactive accent-colored elements.
|
||
static let info = Color(hex: "#70B8FF") // Radix Blue 9
|
||
|
||
/// Inline code text color — slightly brighter blue for monospace code snippets.
|
||
static let codeText = Color(hex: "#9DC2FF") // Radix Blue 11 variant
|
||
|
||
// ── Overlay Cursor ───────────────────────────────────────────
|
||
|
||
/// The blue cursor/bubble color used in OverlayWindow.
|
||
/// Kept distinct from the accent since it serves a different purpose
|
||
/// (screen overlay vs in-app UI).
|
||
static let overlayCursorBlue = Color(hex: "#3380FF")
|
||
|
||
// ── Floating Button Gradient ─────────────────────────────────
|
||
|
||
/// The floating session button gradient colors (unchanged from original —
|
||
/// this gradient is intentionally distinct from the rest of the palette
|
||
/// to make the floating button stand out as a "jewel" on the desktop).
|
||
static let floatingGradientPurple = Color(hex: "#8F46EB")
|
||
static let floatingGradientPink = Color(hex: "#E84D9E")
|
||
static let floatingGradientOrange = Color(hex: "#FF8C33")
|
||
|
||
// ── Help Chat ──────────────────────────────────────────────
|
||
|
||
/// User message bubble background in the help chat.
|
||
/// Blue 800 — deep blue that's clearly distinct from the dark surface
|
||
/// while keeping white text highly readable (~9:1 contrast).
|
||
static let helpChatUserBubble = blue800
|
||
|
||
/// Slightly lighter variant for hover/pressed states on user bubbles.
|
||
static let helpChatUserBubbleHover = blue700
|
||
|
||
/// Footer/backdrop behind the floating help chat.
|
||
/// Slightly lighter than the main window background so the chat zone reads
|
||
/// as a distinct docked surface even before the pill input is visible.
|
||
static let helpChatBackdrop = Color(hex: "#212121")
|
||
|
||
// ── Disabled State ───────────────────────────────────────────
|
||
// Following Material Design 3's disabled pattern:
|
||
// Container: onSurface at 12% opacity
|
||
// Content: onSurface at 38% opacity
|
||
|
||
/// Disabled button/container background.
|
||
static var disabledBackground: Color {
|
||
textPrimary.opacity(0.12)
|
||
}
|
||
|
||
/// Disabled text/icon color.
|
||
static var disabledText: Color {
|
||
textPrimary.opacity(0.38)
|
||
}
|
||
}
|
||
|
||
// MARK: - Spacing (for reference, not enforced)
|
||
|
||
enum Spacing {
|
||
static let xs: CGFloat = 4
|
||
static let sm: CGFloat = 8
|
||
static let md: CGFloat = 12
|
||
static let lg: CGFloat = 16
|
||
static let xl: CGFloat = 20
|
||
static let xxl: CGFloat = 24
|
||
static let xxxl: CGFloat = 32
|
||
}
|
||
|
||
// MARK: - Corner Radii
|
||
|
||
enum CornerRadius {
|
||
/// Small elements like tags, badges.
|
||
static let small: CGFloat = 6
|
||
/// Buttons, input fields, small cards.
|
||
static let medium: CGFloat = 8
|
||
/// Cards, dialogs, chat bubbles.
|
||
static let large: CGFloat = 10
|
||
/// Large panels, permission cards.
|
||
static let extraLarge: CGFloat = 12
|
||
/// Pill-shaped buttons (the continue button).
|
||
static let pill: CGFloat = .infinity
|
||
}
|
||
|
||
// MARK: - Animation Durations
|
||
|
||
enum Animation {
|
||
/// Quick state changes — hover in/out, press feedback.
|
||
static let fast: Double = 0.15
|
||
/// Standard transitions — content reveal, button state changes.
|
||
static let normal: Double = 0.25
|
||
/// Slower, more dramatic — fade-ins, celebration screen elements.
|
||
static let slow: Double = 0.4
|
||
}
|
||
|
||
// MARK: - State Layer Opacities
|
||
// Based on Material Design 3's state layer system.
|
||
// A "state layer" overlays the button's content color at these opacities.
|
||
|
||
enum StateLayer {
|
||
/// Hover: subtle highlight to indicate interactivity.
|
||
static let hover: Double = 0.08
|
||
/// Focus: keyboard navigation indicator (slightly stronger than hover).
|
||
static let focus: Double = 0.12
|
||
/// Pressed: active press feedback (same strength as focus).
|
||
static let pressed: Double = 0.12
|
||
/// Dragged: strongest overlay (rarely used).
|
||
static let dragged: Double = 0.16
|
||
}
|
||
}
|
||
|
||
// MARK: - Button Styles
|
||
|
||
/// Primary button — the main call-to-action per screen.
|
||
/// Accent-colored background with white text. One per view maximum.
|
||
/// Used for: "start"/"resume", "let's go", "continue", "verify completion".
|
||
struct DSPrimaryButtonStyle: ButtonStyle {
|
||
var isFullWidth: Bool = true
|
||
|
||
@State private var isHovered = false
|
||
|
||
// Separate state for the scale expansion so it animates on a slower,
|
||
// more gradual timeline (0.6s) than the background color snap (0.15s).
|
||
@State private var isHoverScaleExpanded = false
|
||
|
||
// Whether the hover glow shadow is active. Builds up gradually (0.6s)
|
||
// on hover entry, fades out faster (0.3s) on exit.
|
||
@State private var isHoverGlowActive = false
|
||
|
||
// Continuously toggles while hovered to drive a gentle breathing pulse
|
||
// in the glow shadow. Creates a living, organic feel — like the button
|
||
// is softly glowing, not just statically lit.
|
||
@State private var isGlowBreathingIn = false
|
||
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(DS.Colors.textOnAccent)
|
||
.frame(maxWidth: isFullWidth ? .infinity : nil)
|
||
.padding(.vertical, 14)
|
||
.padding(.horizontal, isFullWidth ? 0 : 20)
|
||
.background(
|
||
Capsule()
|
||
.fill(buttonBackgroundColor(isPressed: configuration.isPressed))
|
||
)
|
||
// Hover glow — builds up gradually, then gently breathes while hovered.
|
||
// The breathing oscillates opacity and radius on a slow 2.5s loop,
|
||
// creating a candle-flame-like "alive" quality rather than a static highlight.
|
||
.shadow(
|
||
color: DS.Colors.accent.opacity(
|
||
isHoverGlowActive ? (isGlowBreathingIn ? 0.32 : 0.18) : 0
|
||
),
|
||
radius: isHoverGlowActive ? (isGlowBreathingIn ? 16 : 10) : 0
|
||
)
|
||
// Hover: gradually expand to 1.03. Press: snap down to 0.97.
|
||
.scaleEffect(configuration.isPressed ? 0.97 : (isHoverScaleExpanded ? 1.03 : 1.0))
|
||
.animation(.easeOut(duration: 0.1), value: configuration.isPressed)
|
||
.onHover { hovering in
|
||
// Background color — fast snap so the button feels responsive
|
||
withAnimation(.easeOut(duration: 0.15)) {
|
||
isHovered = hovering
|
||
}
|
||
|
||
// Scale — slow, gradual expansion (like the button is swelling)
|
||
withAnimation(.easeInOut(duration: hovering ? 0.6 : 0.3)) {
|
||
isHoverScaleExpanded = hovering
|
||
}
|
||
|
||
// Glow — builds up gradually on entry, fades faster on exit
|
||
withAnimation(.easeInOut(duration: hovering ? 0.6 : 0.3)) {
|
||
isHoverGlowActive = hovering
|
||
}
|
||
|
||
// Breathing glow loop — gentle pulse while hovered.
|
||
// The 2.5s cycle keeps it feeling organic, not mechanical.
|
||
if hovering {
|
||
withAnimation(
|
||
.easeInOut(duration: 2.5)
|
||
.repeatForever(autoreverses: true)
|
||
) {
|
||
isGlowBreathingIn = true
|
||
}
|
||
} else {
|
||
// Override the repeating animation with a finite one to stop cleanly
|
||
withAnimation(.easeOut(duration: 0.3)) {
|
||
isGlowBreathingIn = false
|
||
}
|
||
}
|
||
|
||
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
||
}
|
||
}
|
||
|
||
private func buttonBackgroundColor(isPressed: Bool) -> Color {
|
||
if isPressed {
|
||
// Pressed: brighten slightly beyond hover
|
||
return DS.Colors.accentHover.blendedWithWhite(fraction: DS.StateLayer.pressed)
|
||
} else if isHovered {
|
||
return DS.Colors.accentHover
|
||
} else {
|
||
return DS.Colors.accent
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Secondary button — supporting actions, less visual weight than primary.
|
||
/// Surface-colored background with primary text. Used for: action buttons
|
||
/// (download, open link), embedded element buttons.
|
||
struct DSSecondaryButtonStyle: ButtonStyle {
|
||
var isFullWidth: Bool = true
|
||
|
||
@State private var isHovered = false
|
||
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(DS.Colors.textPrimary)
|
||
.frame(maxWidth: isFullWidth ? .infinity : nil)
|
||
.padding(.vertical, 12)
|
||
.padding(.horizontal, isFullWidth ? 0 : 16)
|
||
.background(
|
||
Capsule()
|
||
.fill(buttonBackgroundColor(isPressed: configuration.isPressed))
|
||
)
|
||
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: configuration.isPressed)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: isHovered)
|
||
.onHover { hovering in
|
||
isHovered = hovering
|
||
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
||
}
|
||
}
|
||
|
||
private func buttonBackgroundColor(isPressed: Bool) -> Color {
|
||
if isPressed {
|
||
return DS.Colors.surface4
|
||
} else if isHovered {
|
||
return DS.Colors.surface3
|
||
} else {
|
||
return DS.Colors.surface2
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Tertiary/ghost button — low-emphasis actions with subtle hover background.
|
||
/// Transparent at rest, shows surface fill on hover. Used for: navigation
|
||
/// links, sidebar items, medium-low emphasis actions.
|
||
struct DSTertiaryButtonStyle: ButtonStyle {
|
||
@State private var isHovered = false
|
||
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(
|
||
configuration.isPressed
|
||
? DS.Colors.accentHover
|
||
: isHovered
|
||
? DS.Colors.accentText
|
||
: DS.Colors.textSecondary
|
||
)
|
||
.padding(.vertical, 8)
|
||
.padding(.horizontal, 12)
|
||
.background(
|
||
Capsule()
|
||
.fill(buttonBackgroundColor(isPressed: configuration.isPressed))
|
||
)
|
||
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: configuration.isPressed)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: isHovered)
|
||
.onHover { hovering in
|
||
isHovered = hovering
|
||
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
||
}
|
||
}
|
||
|
||
private func buttonBackgroundColor(isPressed: Bool) -> Color {
|
||
if isPressed {
|
||
return DS.Colors.surface3
|
||
} else if isHovered {
|
||
return DS.Colors.surface2
|
||
} else {
|
||
return Color.clear
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Text button — the lowest-emphasis button style. No background on any
|
||
/// state, not even hover. Only the text color changes. Used for: "restart",
|
||
/// "skip", "cancel", and other truly minimal inline actions where a
|
||
/// background would add too much visual weight.
|
||
struct DSTextButtonStyle: ButtonStyle {
|
||
var fontSize: CGFloat = 14
|
||
|
||
@State private var isHovered = false
|
||
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.font(.system(size: fontSize, weight: .medium))
|
||
.foregroundColor(
|
||
configuration.isPressed
|
||
? DS.Colors.textPrimary
|
||
: isHovered
|
||
? DS.Colors.textPrimary
|
||
: DS.Colors.textTertiary
|
||
)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: configuration.isPressed)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: isHovered)
|
||
.onHover { hovering in
|
||
isHovered = hovering
|
||
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Outlined button — medium emphasis, used where a border helps define
|
||
/// the button's bounds. Used for: display selector, copy prompt.
|
||
struct DSOutlinedButtonStyle: ButtonStyle {
|
||
var isFullWidth: Bool = true
|
||
|
||
@State private var isHovered = false
|
||
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(DS.Colors.textPrimary)
|
||
.frame(maxWidth: isFullWidth ? .infinity : nil)
|
||
.padding(.vertical, 12)
|
||
.padding(.horizontal, isFullWidth ? 0 : 16)
|
||
.background(
|
||
Capsule()
|
||
.fill(buttonBackgroundColor(isPressed: configuration.isPressed))
|
||
)
|
||
.overlay(
|
||
Capsule()
|
||
.stroke(
|
||
borderColor(isPressed: configuration.isPressed),
|
||
lineWidth: 1
|
||
)
|
||
)
|
||
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: configuration.isPressed)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: isHovered)
|
||
.onHover { hovering in
|
||
isHovered = hovering
|
||
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
||
}
|
||
}
|
||
|
||
private func buttonBackgroundColor(isPressed: Bool) -> Color {
|
||
if isPressed {
|
||
return DS.Colors.surface3
|
||
} else if isHovered {
|
||
return DS.Colors.surface2
|
||
} else {
|
||
return DS.Colors.surface1
|
||
}
|
||
}
|
||
|
||
private func borderColor(isPressed: Bool) -> Color {
|
||
if isPressed || isHovered {
|
||
return DS.Colors.borderStrong
|
||
} else {
|
||
return DS.Colors.borderSubtle
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Destructive button — for dangerous/irreversible actions (close session, delete).
|
||
/// Red-tinted background that intensifies on hover and press.
|
||
struct DSDestructiveButtonStyle: ButtonStyle {
|
||
@State private var isHovered = false
|
||
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(
|
||
isHovered || configuration.isPressed
|
||
? .white
|
||
: DS.Colors.destructiveText
|
||
)
|
||
.padding(.vertical, 10)
|
||
.padding(.horizontal, 16)
|
||
.background(
|
||
Capsule()
|
||
.fill(buttonBackgroundColor(isPressed: configuration.isPressed))
|
||
)
|
||
.overlay(
|
||
Capsule()
|
||
.stroke(
|
||
borderColor(isPressed: configuration.isPressed),
|
||
lineWidth: 1
|
||
)
|
||
)
|
||
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: configuration.isPressed)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: isHovered)
|
||
.onHover { hovering in
|
||
isHovered = hovering
|
||
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
||
}
|
||
}
|
||
|
||
private func buttonBackgroundColor(isPressed: Bool) -> Color {
|
||
if isPressed {
|
||
return DS.Colors.destructive.opacity(0.40)
|
||
} else if isHovered {
|
||
return DS.Colors.destructive.opacity(0.30)
|
||
} else {
|
||
return DS.Colors.destructive.opacity(0.10)
|
||
}
|
||
}
|
||
|
||
private func borderColor(isPressed: Bool) -> Color {
|
||
if isPressed || isHovered {
|
||
return DS.Colors.destructive.opacity(0.40)
|
||
} else {
|
||
return DS.Colors.destructive.opacity(0.15)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Icon-only button — compact circular button for utility actions.
|
||
/// Used for: close button (x), send message, small toolbar actions.
|
||
struct DSIconButtonStyle: ButtonStyle {
|
||
var size: CGFloat = 28
|
||
var isDestructiveOnHover: Bool = false
|
||
var tooltipText: String? = nil
|
||
|
||
/// Controls horizontal alignment of the tooltip relative to the button.
|
||
/// Use `.leading` for buttons near the left edge of the window (tooltip extends right),
|
||
/// `.trailing` for buttons near the right edge (tooltip extends left),
|
||
/// and `.center` for buttons in the middle.
|
||
var tooltipAlignment: Alignment = .center
|
||
|
||
@State private var isHovered = false
|
||
@State private var isTooltipVisible = false
|
||
@State private var tooltipShowWorkItem: DispatchWorkItem? = nil
|
||
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.font(.system(size: size * 0.43, weight: .semibold))
|
||
.foregroundColor(iconColor(isPressed: configuration.isPressed))
|
||
.frame(width: size, height: size)
|
||
.background(
|
||
Circle()
|
||
.fill(circleBackgroundColor(isPressed: configuration.isPressed))
|
||
)
|
||
.overlay(
|
||
Circle()
|
||
.stroke(circleBorderColor(isPressed: configuration.isPressed), lineWidth: 1)
|
||
)
|
||
.scaleEffect(configuration.isPressed ? 0.93 : 1.0)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: configuration.isPressed)
|
||
.animation(.easeOut(duration: DS.Animation.fast), value: isHovered)
|
||
.contentShape(Circle())
|
||
// Cursor change via AppKit cursor rects — more reliable than NSCursor.push/pop
|
||
// because cursor rects are managed at the window level and don't conflict
|
||
// with SwiftUI's internal cursor handling.
|
||
.overlay(PointerCursorView())
|
||
.onHover { hovering in
|
||
isHovered = hovering
|
||
// Show the tooltip after a delay (like native tooltips), hide immediately
|
||
tooltipShowWorkItem?.cancel()
|
||
if hovering {
|
||
let workItem = DispatchWorkItem {
|
||
withAnimation(.easeOut(duration: 0.15)) {
|
||
isTooltipVisible = true
|
||
}
|
||
}
|
||
tooltipShowWorkItem = workItem
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: workItem)
|
||
} else {
|
||
withAnimation(.easeOut(duration: 0.1)) {
|
||
isTooltipVisible = false
|
||
}
|
||
}
|
||
}
|
||
// Custom styled tooltip — positioned above the button with enough gap
|
||
// to not overlap the button. Horizontally aligned based on tooltipAlignment
|
||
// so tooltips near window edges don't clip outside the visible area.
|
||
// Uses .allowsHitTesting(false) so the tooltip doesn't interfere
|
||
// with the button's hover state.
|
||
.overlay(
|
||
Group {
|
||
if isTooltipVisible, let text = tooltipText, !text.isEmpty {
|
||
Text(text)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(DS.Colors.textSecondary)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.fill(DS.Colors.surface3.opacity(0.85))
|
||
)
|
||
.overlay(
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.stroke(Color.white.opacity(0.20), lineWidth: 0.8)
|
||
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.trim(from: 0, to: 0.5)
|
||
.stroke(
|
||
LinearGradient(
|
||
colors: [
|
||
Color.white.opacity(0.10),
|
||
Color.white.opacity(0.02)
|
||
],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
),
|
||
lineWidth: 0.8
|
||
)
|
||
}
|
||
)
|
||
.shadow(color: Color.black.opacity(0.42), radius: 14, x: 0, y: 8)
|
||
.shadow(color: Color.black.opacity(0.26), radius: 4, x: 0, y: 2)
|
||
.fixedSize()
|
||
.offset(y: -(size / 2 + 20))
|
||
.allowsHitTesting(false)
|
||
.transition(.opacity)
|
||
}
|
||
},
|
||
alignment: tooltipAlignment
|
||
)
|
||
}
|
||
|
||
private func iconColor(isPressed: Bool) -> Color {
|
||
if isDestructiveOnHover && (isHovered || isPressed) {
|
||
return .white
|
||
}
|
||
if isPressed {
|
||
return DS.Colors.textPrimary
|
||
} else if isHovered {
|
||
return DS.Colors.textPrimary
|
||
} else {
|
||
return DS.Colors.textSecondary
|
||
}
|
||
}
|
||
|
||
private func circleBackgroundColor(isPressed: Bool) -> Color {
|
||
if isDestructiveOnHover {
|
||
if isPressed {
|
||
return DS.Colors.destructive.opacity(0.40)
|
||
} else if isHovered {
|
||
return DS.Colors.destructive.opacity(0.30)
|
||
} else {
|
||
return DS.Colors.surface2
|
||
}
|
||
}
|
||
if isPressed {
|
||
return DS.Colors.surface4
|
||
} else if isHovered {
|
||
return DS.Colors.surface3
|
||
} else {
|
||
return DS.Colors.surface2
|
||
}
|
||
}
|
||
|
||
private func circleBorderColor(isPressed: Bool) -> Color {
|
||
if isDestructiveOnHover && (isHovered || isPressed) {
|
||
return DS.Colors.destructive.opacity(0.30)
|
||
}
|
||
if isPressed || isHovered {
|
||
return DS.Colors.borderStrong
|
||
} else {
|
||
return DS.Colors.borderSubtle.opacity(0.5)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Convenience View Extensions
|
||
|
||
extension View {
|
||
/// Applies the primary button style (accent-colored CTA).
|
||
func dsPrimaryButtonStyle(isFullWidth: Bool = true) -> some View {
|
||
self.buttonStyle(DSPrimaryButtonStyle(isFullWidth: isFullWidth))
|
||
}
|
||
|
||
/// Applies the secondary button style (surface-colored supporting action).
|
||
func dsSecondaryButtonStyle(isFullWidth: Bool = true) -> some View {
|
||
self.buttonStyle(DSSecondaryButtonStyle(isFullWidth: isFullWidth))
|
||
}
|
||
|
||
/// Applies the tertiary/ghost button style (subtle hover background).
|
||
func dsTertiaryButtonStyle() -> some View {
|
||
self.buttonStyle(DSTertiaryButtonStyle())
|
||
}
|
||
|
||
/// Applies the text-only button style (no background ever, just color change).
|
||
func dsTextButtonStyle(fontSize: CGFloat = 14) -> some View {
|
||
self.buttonStyle(DSTextButtonStyle(fontSize: fontSize))
|
||
}
|
||
|
||
/// Applies the outlined button style (bordered, medium emphasis).
|
||
func dsOutlinedButtonStyle(isFullWidth: Bool = true) -> some View {
|
||
self.buttonStyle(DSOutlinedButtonStyle(isFullWidth: isFullWidth))
|
||
}
|
||
|
||
/// Applies the destructive button style (red-tinted danger action).
|
||
func dsDestructiveButtonStyle() -> some View {
|
||
self.buttonStyle(DSDestructiveButtonStyle())
|
||
}
|
||
|
||
/// Applies the icon-only button style (compact circle).
|
||
/// `tooltipAlignment` controls where the tooltip sits horizontally relative to the button:
|
||
/// `.leading` for left-edge buttons, `.trailing` for right-edge buttons, `.center` for middle.
|
||
func dsIconButtonStyle(size: CGFloat = 28, isDestructiveOnHover: Bool = false, tooltip: String? = nil, tooltipAlignment: Alignment = .center) -> some View {
|
||
self.buttonStyle(DSIconButtonStyle(size: size, isDestructiveOnHover: isDestructiveOnHover, tooltipText: tooltip, tooltipAlignment: tooltipAlignment))
|
||
}
|
||
|
||
/// Attaches the shared pointing-hand cursor treatment used across interactive controls.
|
||
/// Disabled controls can opt out so they keep the default arrow cursor.
|
||
func pointerCursor(isEnabled: Bool = true) -> some View {
|
||
self.overlay {
|
||
if isEnabled {
|
||
PointerCursorView()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Buddy Composer Visual Style
|
||
|
||
enum BuddyComposerVisualStyle {
|
||
static let waveformLeadingColor = Color(hex: "#F3FBFF")
|
||
static let waveformTrailingColor = Color(hex: "#8FD2FF")
|
||
static let waveformGlowColor = Color(hex: "#AEE3FF")
|
||
}
|
||
|
||
// MARK: - Pointer Cursor (AppKit Bridge)
|
||
|
||
/// Uses AppKit's cursor rect system to reliably show a pointing hand cursor.
|
||
/// More reliable than NSCursor.push()/pop() inside SwiftUI's .onHover because
|
||
/// cursor rects are managed at the window level and don't conflict with
|
||
/// SwiftUI's internal cursor handling.
|
||
private class PointerCursorNSView: NSView {
|
||
override func resetCursorRects() {
|
||
super.resetCursorRects()
|
||
addCursorRect(bounds, cursor: .pointingHand)
|
||
}
|
||
|
||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private struct PointerCursorView: NSViewRepresentable {
|
||
func makeNSView(context: Context) -> NSView {
|
||
return PointerCursorNSView()
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
// Invalidate cursor rects when the view updates (e.g., resizes)
|
||
// so AppKit recalculates the cursor area.
|
||
nsView.window?.invalidateCursorRects(for: nsView)
|
||
}
|
||
}
|
||
|
||
// MARK: - I-Beam Cursor (AppKit Bridge)
|
||
|
||
/// Uses AppKit's cursor rect system to reliably show an I-beam (text selection) cursor.
|
||
/// Same approach as PointerCursorView — cursor rects are managed at the window level
|
||
/// and don't conflict with SwiftUI's internal cursor handling.
|
||
/// Unlike NSCursor.push()/pop() in .onHover, this avoids cursor stack imbalance
|
||
/// when the mouse moves quickly between views.
|
||
private class IBeamCursorNSView: NSView {
|
||
override func resetCursorRects() {
|
||
super.resetCursorRects()
|
||
addCursorRect(bounds, cursor: .iBeam)
|
||
}
|
||
|
||
/// Pass through all mouse events so the TextField underneath still receives
|
||
/// focus, clicks, and text selection. Cursor rects are registered with the
|
||
/// window (via resetCursorRects) and work independently of hit testing.
|
||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
struct IBeamCursorView: NSViewRepresentable {
|
||
func makeNSView(context: Context) -> NSView {
|
||
return IBeamCursorNSView()
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
// Invalidate cursor rects when the view updates (e.g., resizes)
|
||
// so AppKit recalculates the cursor area.
|
||
nsView.window?.invalidateCursorRects(for: nsView)
|
||
}
|
||
}
|
||
|
||
// MARK: - Native Tooltip
|
||
|
||
/// Uses AppKit's `NSView.toolTip` to show a tooltip on hover.
|
||
/// SwiftUI's `.help()` conflicts with `.onHover` tracking areas, so
|
||
/// this bridges directly to AppKit's tooltip system which works independently.
|
||
private struct NativeTooltipView: NSViewRepresentable {
|
||
let tooltip: String
|
||
|
||
func makeNSView(context: Context) -> NSView {
|
||
let view = NSView()
|
||
view.toolTip = tooltip
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
nsView.toolTip = tooltip
|
||
}
|
||
}
|
||
|
||
extension View {
|
||
/// Attaches a native macOS tooltip that works even alongside `.onHover`.
|
||
func nativeTooltip(_ text: String?) -> some View {
|
||
if let text = text, !text.isEmpty {
|
||
return AnyView(self.overlay(NativeTooltipView(tooltip: text)))
|
||
} else {
|
||
return AnyView(self)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Color Utilities
|
||
|
||
extension Color {
|
||
/// Create a Color from a hex string like "#FF5733" or "FF5733".
|
||
init(hex: String) {
|
||
let hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.replacingOccurrences(of: "#", with: "")
|
||
|
||
var rgbValue: UInt64 = 0
|
||
Scanner(string: hexSanitized).scanHexInt64(&rgbValue)
|
||
|
||
let red = Double((rgbValue & 0xFF0000) >> 16) / 255.0
|
||
let green = Double((rgbValue & 0x00FF00) >> 8) / 255.0
|
||
let blue = Double(rgbValue & 0x0000FF) / 255.0
|
||
|
||
self.init(red: red, green: green, blue: blue)
|
||
}
|
||
|
||
/// Returns a lighter version of this color by blending toward white.
|
||
/// `fraction` is 0.0 (no change) to 1.0 (pure white).
|
||
func blendedWithWhite(fraction: Double) -> Color {
|
||
// Convert to NSColor to access RGB components for blending
|
||
guard let nsColor = NSColor(self).usingColorSpace(.sRGB) else { return self }
|
||
|
||
let red = nsColor.redComponent + (1.0 - nsColor.redComponent) * fraction
|
||
let green = nsColor.greenComponent + (1.0 - nsColor.greenComponent) * fraction
|
||
let blue = nsColor.blueComponent + (1.0 - nsColor.blueComponent) * fraction
|
||
|
||
return Color(red: red, green: green, blue: blue)
|
||
}
|
||
}
|