Archived
feat: center square Hope presence and guide secure certificate setup
This commit is contained in:
1 parent
6f2659421c
commit
15c6389ffb
12 files changed
+252
-129
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user