Files
hope-voice-api/Sources/Features/Conversation/LightwellView.swift
T
snowspeeder afe37ad5f4 Fix reading-safety and accessibility majors from review
- 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
2026-09-12 08:27:48 -04:00

152 lines
5.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}