feat: center square Hope presence and guide secure certificate setup

This commit is contained in:
snowspeeder committed 2026-10-01 18:02:14 -04:00
1 parent 6f2659421c
commit 15c6389ffb
12 files changed
+252 -129

No files matched your search

+97 -123
View File
@@ -1,151 +1,125 @@
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.
/// Hope's square LED presence, matching the web's 21 × 21 dot matrix.
/// One Canvas replaces hundreds of subviews. The adjacent status text is the
/// single accessible description; individual LEDs are never focusable.
struct LightwellView: View {
/// Lightwell presentation state, mapped from session/conversation models.
enum Phase: Equatable {
case idle
case connecting
case waiting
case idle, connecting, waiting, thinking, tearingDown, failed, readyNoInput
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
var compact = 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
@Environment(\.scenePhase) private var scenePhase
@Environment(\.dynamicTypeSize) private var typeSize
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 var side: CGFloat {
if compact { return typeSize.isAccessibilitySize ? 88 : 136 }
return typeSize.isAccessibilitySize ? 160 : 220
}
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 {
private var animated: Bool {
guard !reduceMotion, scenePhase == .active else { return false }
switch phase {
case .connecting, .waiting, .thinking: return !reduceMotion
case .connecting, .waiting, .thinking, .listening, .speaking: return true
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
var body: some View {
Group {
if animated {
TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { timeline in
matrix(time: timeline.date.timeIntervalSinceReferenceDate)
}
} else {
matrix(time: 0)
}
}
.frame(width: side, height: side)
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
.overlay {
RoundedRectangle(cornerRadius: Nearlight.radiusDock)
.strokeBorder(contrast == .increased ? Nearlight.quiet : Nearlight.separator, lineWidth: 1)
}
.accessibilityHidden(true)
}
private var tint: Color {
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
case .failed: return Nearlight.alert
case .idle, .tearingDown, .readyNoInput: return Nearlight.quiet
default: return Nearlight.rosewood
}
}
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
private func matrix(time: Double) -> some View {
Canvas { context, size in
let spacing = (min(size.width, size.height) - 28) / 20
for row in 0..<21 {
for column in 0..<21 {
let dot = Self.dot(row: row, column: column, phase: phase,
time: time, reduceMotion: reduceMotion)
let diameter = max(1.6, spacing * 0.28) * dot.scale
let center = CGPoint(x: 14 + CGFloat(column) * spacing,
y: 14 + CGFloat(row) * spacing)
let rect = CGRect(x: center.x - diameter / 2, y: center.y - diameter / 2,
width: diameter, height: diameter)
// Increased contrast brightens the whole lattice; state is
// encoded in its pattern and adjacent text, never color alone.
let opacity = contrast == .increased ? min(1, dot.opacity * 1.25 + 0.16) : dot.opacity
context.fill(Path(ellipseIn: rect), with: .color(tint.opacity(opacity)))
}
}
}
}
struct Dot: Equatable {
let opacity: Double
let scale: Double
}
/// Deterministic sampling is shared with offline tests. Reduce Motion freezes
/// waves and signal-driven geometry, while keeping each state's pattern clear.
static func dot(row: Int, column: Int, phase: Phase, time: Double, reduceMotion: Bool) -> Dot {
let x = Double(column - 10) / 10.5
let y = Double(row - 10) / 10.5
let radius = sqrt(x * x + y * y)
let t = reduceMotion ? 0 : time
let baseline = max(0.08, 0.20 - radius * 0.075)
let offset = sin(Double(row) * 12.9898 + Double(column) * 78.233)
var brightness = baseline
var scale = 1.0
switch phase {
case .idle, .tearingDown:
break
case .readyNoInput:
brightness += 0.28 * exp(-radius * radius * 3)
case .failed:
// A stable diagonal cross rather than an animated alarm.
if abs(row - column) <= 1 || abs(row + column - 20) <= 1 { brightness = 0.65 }
case .connecting, .waiting:
let ring = exp(-pow((radius - 0.53) * 7, 2))
let sweep = reduceMotion ? 0.75 : 0.55 + 0.45 * cos(atan2(y, x) - t * 2)
brightness += ring * sweep * 0.65
case .thinking:
let bandCenter = reduceMotion ? 0 : sin(t * 1.8) * 0.5
let band = exp(-pow((y - bandCenter) * 4, 2)) * exp(-x * x * 2)
brightness += band * 0.65
case .listening(let raw), .speaking(let raw):
let level = reduceMotion ? 0.35 : (raw.isFinite ? min(max(raw, 0), 1) : 0)
let speaking: Bool
if case .speaking = phase { speaking = true } else { speaking = false }
let signal = min(1, level * 1.3)
let bloom = exp(-radius * radius * (3.0 - signal * 1.5))
let ripple = reduceMotion ? 1 : 0.85 + 0.15 * sin(t * 4 + offset)
brightness += bloom * (0.25 + signal * 0.7) * ripple * (speaking ? 1 : 0.65)
scale += max(0, 1 - radius) * (speaking ? 1.4 : 0.85) * signal
}
return Dot(opacity: min(max(brightness, 0.02), 1), scale: scale)
}
}