- Transcript: drag suspends auto-follow; long-press selection suspends following; Latest is a trailing outlined capsule that re-enables follow - Lightwell: pulse via isolated TimelineView (2.4s triangle wave), pauses when offscreen; Reduce Motion uses static geometry (no level resizing) - Increase Contrast: solid Rosewood lightwell fill, 2pt Rosewood selected outlines on mode buttons - Haptics: success only on entering ready, error only on entering failure
152 lines
5.4 KiB
Swift
152 lines
5.4 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|