Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 173 additions & 31 deletions panel/CompactView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ struct CompactView: View {

@State private var rippleScale: CGFloat = 0.3
@State private var rippleOpacity: Double = 0
@State private var isHovering: Bool = false

private static let glowColor = Color(red: 0.4, green: 0.85, blue: 1.0)
private static let recentEventWindow: TimeInterval = 5 * 60
Expand All @@ -39,6 +40,14 @@ struct CompactView: View {
.contentShape(Capsule())
.onTapGesture(count: 2) { onExitCompact() }
.onChange(of: store.events.first?.id) { _ in triggerRipple() }
// Hover state drives per-mascot reactions (robot antenna flick,
// cat wink + ear twitch, eye pupil dilate-and-dart, ghost
// pop-and-yawn). Gated on pill mode inside each mascot.
.onHover { isHovering = $0 }
}

private var mascotHovered: Bool {
isHovering && !nav.compactExpanded
}

// Soft outward wave on event arrival. Capsule scales from 0.3 → 1.15
Expand Down Expand Up @@ -79,7 +88,8 @@ struct CompactView: View {
hasSeven: nav.quota?.sevenDay != nil,
polling: nav.quotaSyncing,
anyBusy: anyBusy,
paused: nav.compactDragging
paused: nav.compactDragging,
showRemaining: nav.quotaShowRemaining
)
.frame(width: 42, height: 42)
}
Expand All @@ -97,7 +107,7 @@ struct CompactView: View {
@ViewBuilder
private var headline: some View {
HStack(spacing: 6) {
BotMascot(state: botState, kind: nav.mascot, paused: nav.compactDragging)
BotMascot(state: botState, kind: nav.mascot, paused: nav.compactDragging, hovered: mascotHovered)
.frame(width: 26, height: 24)
headlineText
}
Expand Down Expand Up @@ -351,6 +361,7 @@ private struct QuotaGauge: View {
let polling: Bool
let anyBusy: Bool
let paused: Bool
let showRemaining: Bool

private static let cyan = Color(red: 0.30, green: 0.92, blue: 1.0)
private static let outerLineWidth: CGFloat = 4.0
Expand Down Expand Up @@ -443,7 +454,11 @@ private struct QuotaGauge: View {
}

private var centerReadout: some View {
Text(hasFive ? "\(Int(fivePct.rounded()))" : "—")
// Ring still fills with "used" so the urgency-colored gradient
// (green→red as it grows) keeps its meaning. Only the number in
// the middle flips: 30% used ↔ 70% (remaining).
let pct = showRemaining ? max(0, 100 - fivePct) : fivePct
return Text(hasFive ? "\(Int(pct.rounded()))" : "—")
.font(.system(size: 14, weight: .semibold).monospacedDigit())
.foregroundStyle(.primary)
}
Expand Down Expand Up @@ -474,13 +489,14 @@ private struct BotMascot: View {
let state: BotState
let kind: MascotKind
let paused: Bool
let hovered: Bool

var body: some View {
switch kind {
case .robot: RobotMascot(state: state, paused: paused)
case .cat: CatMascot(state: state, paused: paused)
case .eye: EyeMascot(state: state, paused: paused)
case .ghost: GhostMascot(state: state, paused: paused)
case .robot: RobotMascot(state: state, paused: paused, hovered: hovered)
case .cat: CatMascot(state: state, paused: paused, hovered: hovered)
case .eye: EyeMascot(state: state, paused: paused, hovered: hovered)
case .ghost: GhostMascot(state: state, paused: paused, hovered: hovered)
}
}
}
Expand All @@ -489,6 +505,7 @@ private struct RobotMascot: View {

let state: BotState
let paused: Bool
let hovered: Bool

private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0)
private static let outline = Color.secondary.opacity(0.7)
Expand Down Expand Up @@ -521,9 +538,11 @@ private struct RobotMascot: View {
VStack(spacing: 0) {
Circle().fill(antennaColor.opacity(0.9))
.frame(width: 3.5, height: 3.5)
.scaleEffect(hovered ? 1.5 : 1.0)
Rectangle().fill(Self.outline).frame(width: 1, height: 3)
}
.offset(y: -8.5)
.offset(y: hovered ? -10.5 : -8.5)
.animation(.spring(response: 0.25, dampingFraction: 0.5), value: hovered)
}

private var staticEyes: some View {
Expand Down Expand Up @@ -560,11 +579,13 @@ private struct RobotMascot: View {
Circle()
.fill(antennaColor.opacity(0.6 + 0.4 * blink))
.frame(width: 3.5, height: 3.5)
.scaleEffect(hovered ? 1.5 : 1.0)
Rectangle()
.fill(Self.outline)
.frame(width: 1, height: 3)
}
.offset(y: -8.5)
.offset(y: hovered ? -10.5 : -8.5)
.animation(.spring(response: 0.25, dampingFraction: 0.5), value: hovered)
}

private var antennaColor: Color {
Expand Down Expand Up @@ -647,7 +668,21 @@ private struct RobotMascot: View {
.frame(width: 4.5, height: 1.2)
.offset(y: 6)
default:
EmptyView()
// Hover-only smile arc — the robot grins back when you reach
// for it, regardless of its current state.
if hovered {
Path { p in
p.move(to: CGPoint(x: 0, y: 0))
p.addQuadCurve(to: CGPoint(x: 6, y: 0),
control: CGPoint(x: 3, y: 2.2))
}
.stroke(Self.cyan, style: StrokeStyle(lineWidth: 1.2, lineCap: .round))
.frame(width: 6, height: 2.2)
.offset(y: 6)
.transition(.scale.combined(with: .opacity))
} else {
EmptyView()
}
}
}
}
Expand All @@ -658,23 +693,41 @@ private struct CatMascot: View {

let state: BotState
let paused: Bool
let hovered: Bool

private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0)
private static let outline = Color.secondary.opacity(0.7)

var body: some View {
if paused {
ZStack { head; staticEyes; mouth; whiskers }
ZStack { head; staticEyes; mouth; whiskers; blep }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
TimelineView(.animation(minimumInterval: 0.05)) { tl in
let t = tl.date.timeIntervalSinceReferenceDate
ZStack { head; eyes(at: t); mouth; whiskers }
ZStack { head; eyes(at: t); mouth; whiskers; blep }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}

// Ears perked = head + ear-hints lift slightly while hovered.
private var earLift: CGFloat { hovered ? -1.5 : 0 }

// Tiny tongue blep — a pink rounded triangle peeking out below the
// mouth on hover. Pure decoration; appears only while hovered.
@ViewBuilder
private var blep: some View {
if hovered {
Capsule()
.fill(Color(red: 1.0, green: 0.55, blue: 0.7))
.frame(width: 2.2, height: 2.8)
.offset(y: 7.5)
.transition(.scale.combined(with: .opacity))
.animation(.spring(response: 0.25, dampingFraction: 0.55), value: hovered)
}
}

private var head: some View {
ZStack {
// Face + ears combined as a single path so the ears feel
Expand All @@ -683,16 +736,19 @@ private struct CatMascot: View {
.fill(headFill)
.overlay(CatHeadShape().stroke(Self.outline, lineWidth: 1.2))
.frame(width: 20, height: 18)
.offset(y: 1)
.offset(y: 1 + earLift)
.animation(.spring(response: 0.28, dampingFraction: 0.6), value: hovered)
// Inner ear hints (small triangles inside each ear)
Triangle()
.fill(Self.outline.opacity(0.4))
.frame(width: 2, height: 2.2)
.offset(x: -5.5, y: -5.5)
.offset(x: -5.5, y: -5.5 + earLift)
.animation(.spring(response: 0.28, dampingFraction: 0.6), value: hovered)
Triangle()
.fill(Self.outline.opacity(0.4))
.frame(width: 2, height: 2.2)
.offset(x: 5.5, y: -5.5)
.offset(x: 5.5, y: -5.5 + earLift)
.animation(.spring(response: 0.28, dampingFraction: 0.6), value: hovered)
// Nose (tiny triangle)
Triangle()
.rotation(.degrees(180))
Expand All @@ -715,14 +771,24 @@ private struct CatMascot: View {
let cycle = t.truncatingRemainder(dividingBy: 3.5)
let blinking = state != .alert && cycle < 0.15
let scaleY: CGFloat = blinking ? 0.15 : 1.0
return HStack(spacing: 5) { eyeShape; eyeShape }
.scaleEffect(x: 1, y: scaleY)
.animation(.easeInOut(duration: 0.08), value: blinking)
.offset(y: -0.5)
// While hovered, scale the left eye flat → looks like a wink.
let leftScale: CGFloat = hovered ? 0.15 : scaleY
return HStack(spacing: 5) {
eyeShape.scaleEffect(x: 1, y: leftScale)
eyeShape.scaleEffect(x: 1, y: scaleY)
}
.animation(.easeInOut(duration: 0.18), value: hovered)
.animation(.easeInOut(duration: 0.08), value: blinking)
.offset(y: -0.5)
}

private var staticEyes: some View {
HStack(spacing: 5) { eyeShape; eyeShape }.offset(y: -0.5)
HStack(spacing: 5) {
eyeShape.scaleEffect(x: 1, y: hovered ? 0.15 : 1)
eyeShape
}
.animation(.easeInOut(duration: 0.18), value: hovered)
.offset(y: -0.5)
}

@ViewBuilder
Expand Down Expand Up @@ -843,23 +909,42 @@ private struct EyeMascot: View {

let state: BotState
let paused: Bool
let hovered: Bool

private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0)
private static let outline = Color.secondary.opacity(0.7)

var body: some View {
if paused {
ZStack { lens; staticPupil }
ZStack { lens; staticPupil; eyebrow }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
TimelineView(.animation(minimumInterval: 0.05)) { tl in
let t = tl.date.timeIntervalSinceReferenceDate
ZStack { lens; pupil(at: t) }
ZStack { lens; pupil(at: t); eyebrow }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}

// Hover-only eyebrow arc above the lens — gives the sentinel a
// questioning, "oh hey you" expression when the cursor approaches.
@ViewBuilder
private var eyebrow: some View {
if hovered {
Path { p in
p.move(to: CGPoint(x: 0, y: 2))
p.addQuadCurve(to: CGPoint(x: 9, y: 2),
control: CGPoint(x: 4.5, y: -1.5))
}
.stroke(Self.outline, style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
.frame(width: 9, height: 3)
.offset(y: -10)
.transition(.scale.combined(with: .opacity))
.animation(.spring(response: 0.25, dampingFraction: 0.6), value: hovered)
}
}

private var lens: some View {
ZStack {
Circle()
Expand All @@ -884,15 +969,27 @@ private struct EyeMascot: View {
}

private func pupil(at t: TimeInterval) -> some View {
let scan = state == .watching ? sin(t * 1.5) * 3 : 0
// Hovered: faster, wider dart — the eye is "tracking" the cursor.
// Otherwise: the slow watching scan, or static.
let scan: Double
if hovered { scan = sin(t * 6.0) * 4.5 }
else if state == .watching { scan = sin(t * 1.5) * 3 }
else { scan = 0 }
return Circle()
.fill(pupilColor)
.frame(width: pupilSize, height: pupilSize)
.frame(width: pupilSize + (hovered ? 2.0 : 0),
height: pupilSize + (hovered ? 2.0 : 0))
.offset(x: scan, y: 1)
.animation(.easeInOut(duration: 0.18), value: hovered)
}

private var staticPupil: some View {
Circle().fill(pupilColor).frame(width: pupilSize, height: pupilSize).offset(y: 1)
Circle()
.fill(pupilColor)
.frame(width: pupilSize + (hovered ? 2.0 : 0),
height: pupilSize + (hovered ? 2.0 : 0))
.offset(y: 1)
.animation(.easeInOut(duration: 0.18), value: hovered)
}

private var pupilSize: CGFloat {
Expand All @@ -919,25 +1016,61 @@ private struct GhostMascot: View {

let state: BotState
let paused: Bool
let hovered: Bool

private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0)
private static let outline = Color.secondary.opacity(0.7)

var body: some View {
if paused {
ZStack { body_; staticEyes; mouth }
.frame(maxWidth: .infinity, maxHeight: .infinity)
ZStack {
sparkles
ZStack { body_; staticEyes; mouth }
.offset(y: hovered ? -4 : 0)
.scaleEffect(hovered ? 1.08 : 1.0)
.animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
TimelineView(.animation(minimumInterval: 0.05)) { tl in
let t = tl.date.timeIntervalSinceReferenceDate
let bob = sin(t * 1.3) * 1.0
ZStack { body_; eyes(at: t); mouth }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: bob)
ZStack {
sparkles
ZStack { body_; eyes(at: t); mouth }
.offset(y: bob + (hovered ? -4 : 0))
.scaleEffect(hovered ? 1.08 : 1.0)
.animation(.spring(response: 0.32, dampingFraction: 0.55), value: hovered)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}

// Three little sparkles around the ghost on hover. They fade in via
// transition, then a subtle pulse via TimelineView would be overkill
// — keeping them static for clarity and frame budget.
@ViewBuilder
private var sparkles: some View {
if hovered {
ZStack {
sparkle.offset(x: -10, y: -8)
sparkle.offset(x: 10, y: -3).scaleEffect(0.7)
sparkle.offset(x: 8, y: 9).scaleEffect(0.85)
}
.transition(.scale.combined(with: .opacity))
.animation(.spring(response: 0.3, dampingFraction: 0.55), value: hovered)
}
}

private var sparkle: some View {
// Four-point star drawn as two crossed thin capsules.
ZStack {
Capsule().fill(Self.cyan).frame(width: 1.2, height: 4)
Capsule().fill(Self.cyan).frame(width: 4, height: 1.2)
}
}

private var body_: some View {
GhostShape()
.stroke(Self.outline, lineWidth: 1.2)
Expand Down Expand Up @@ -1006,7 +1139,16 @@ private struct GhostMascot: View {
.frame(width: 2.4, height: 3.5)
.offset(y: 4)
default:
EmptyView()
if hovered {
// Yawning "boo" while hovered — open oval mouth.
Capsule()
.fill(Self.outline.opacity(0.85))
.frame(width: 3, height: 4)
.offset(y: 4)
.transition(.scale.combined(with: .opacity))
} else {
EmptyView()
}
}
}
}
Expand Down
Loading
Loading