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 /// 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 } @Environment(\.colorSchemeContrast) private var contrast var body: some View { Group { if pulseWanted { // Isolated TimelineView: ticks only while pending states are // showing; pauses when offscreen/inactive automatically. TimelineView(.periodic(from: .now, by: 0.05)) { context in lightwell(pulse: pulseValue(at: context.date)) } } else { lightwell(pulse: 0) } } } private func lightwell(pulse: Double) -> some View { let inner = innerSize return ZStack { Capsule() .fill(Nearlight.surface) Capsule() .fill(innerFill) .frame(width: inner.width, height: inner.height) .opacity(overallOpacity(pulse: pulse)) } .frame(width: frameSize.width, height: frameSize.height) .overlay( Capsule().strokeBorder( contrast == .increased ? Nearlight.rosewood : Nearlight.quiet, lineWidth: 1 ) ) .accessibilityHidden(true) .animation(reduceMotion ? nil : Nearlight.settle, value: innerSize) } /// Increase Contrast promotes the gradient to solid Rosewood. private var innerFill: AnyShapeStyle { if contrast == .increased { return AnyShapeStyle(Nearlight.rosewood) } return AnyShapeStyle( 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 ) ) } /// Whether a pulse TimelineView should run for this phase. private var pulseWanted: Bool { switch phase { case .connecting, .waiting, .thinking: return !reduceMotion default: return false } } /// Triangle wave 0→1→0 over the 2.4s cycle from the timeline date. private func pulseValue(at date: Date) -> Double { let t = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.4) / 2.4 return t < 0.5 ? t * 2 : (1 - t) * 2 } /// Inner capsule geometry per spec table. `a` is the smoothed level. /// Reduce Motion: static 16×40 (idle/listening) and 16×48 (pending/ /// thinking/speaking) with no level-driven resizing. private var innerSize: CGSize { let base = baseInner if reduceMotion { switch phase { case .waiting, .thinking, .speaking: return CGSize(width: base.width, height: base.height + (compact ? 3 : 8)) default: return base } } 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 func overallOpacity(pulse: Double) -> 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 } } }