import SwiftUI import UIKit /// The Nearlight signature element: an upright, softly lit aperture. /// /// Two capsules in a ZStack — no assets, shaders, blur, or particles. The /// inner capsule carries a vertical Rosewood gradient; its geometry encodes /// conversation state and audio level. Noninteractive and accessibility /// hidden: the companion status text carries meaning. struct LightwellView: View { /// Lightwell presentation state, mapped from session/conversation models. enum Phase: Equatable { case idle case connecting case waiting case listening(level: Double) case thinking case speaking(level: Double) case tearingDown case failed /// Input never drives the light (muted or Type mode). case readyNoInput } let phase: Phase /// Compact (24×36) in the conversation status strip; full (64×96) on welcome. var compact: Bool = false @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Pending-state pulse driver, 0 → 1 → 0 over the 2.4s cycle. @State private var pulse: Double = 0 /// Frame dimensions never animate; inner dimensions multiply by 0.375 /// in the compact frame. private var frameSize: CGSize { compact ? CGSize(width: 24, height: 36) : CGSize(width: 64, height: 96) } private var baseInner: CGSize { let base = CGSize(width: 16, height: 40) return compact ? CGSize(width: base.width * 0.375, height: base.height * 0.375) : base } var body: some View { let inner = innerSize ZStack { Capsule() .fill(Nearlight.surface) Capsule() .fill( LinearGradient( stops: [ .init(color: Nearlight.rosewood.opacity(0.35), location: 0), .init(color: Nearlight.rosewood.opacity(1), location: 0.5), .init(color: Nearlight.rosewood.opacity(0.55), location: 1), ], startPoint: .top, endPoint: .bottom ) ) .frame(width: inner.width, height: inner.height) .opacity(overallOpacity) .animation(reduceMotion ? nil : Nearlight.quick, value: overallOpacity) .animation(reduceMotion ? nil : Nearlight.settle, value: innerSize) } .frame(width: frameSize.width, height: frameSize.height) .overlay( Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1) ) .accessibilityHidden(true) .task(id: pulseActive) { guard pulseActive else { pulse = 0 return } while !Task.isCancelled { withAnimation(reduceMotion ? nil : Nearlight.cycle) { pulse = pulse == 0 ? 1 : 0 } try? await Task.sleep(for: .seconds(2.4)) } } } /// Whether the pulse loop should run for this phase. private var pulseActive: Bool { switch phase { case .connecting, .waiting, .thinking: return !reduceMotion default: return false } } /// Inner capsule geometry per spec table. `a` is the smoothed level. private var innerSize: CGSize { let base = baseInner switch phase { case .listening(let a): let level = min(max(a, 0), 1) return CGSize(width: base.width + (compact ? 3 : 8) * level, height: base.height + (compact ? 6 : 16) * level) case .speaking(let a): let level = min(max(a, 0), 1) return CGSize(width: base.width + (compact ? 4.5 : 12) * level, height: base.height + (compact ? 9 : 24) * level) case .waiting, .thinking: // Pulse drives opacity; height steps up one band. return CGSize(width: base.width, height: base.height + (compact ? 3 : 8)) case .connecting, .idle, .failed, .tearingDown, .readyNoInput: return base } } private var overallOpacity: Double { switch phase { case .connecting, .waiting: return 0.45 + 0.45 * pulse case .thinking: return 0.65 + 0.35 * pulse case .idle, .failed, .tearingDown: return 0.45 case .listening, .speaking, .readyNoInput: return 1 } } }