// // 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) } }