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

+1 -1
View File
@@ -77,7 +77,7 @@ final class AuthStore: ObservableObject {
user = response.user
} catch {
guard current == generation else { return }
self.error = error.localizedDescription
self.error = ConnectionFailure.message(error, server: config.baseURL)
}
guard current == generation else { return }
isBusy = false
@@ -219,6 +219,7 @@ private struct ConversationContent: View {
ScrollView {
VStack(alignment: .leading, spacing: Nearlight.s24) {
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode))
.frame(maxWidth: .infinity)
.padding(.top, Nearlight.s32)
VStack(alignment: .leading, spacing: Nearlight.s12) {
Text("A moment with Hope.")
@@ -248,9 +249,9 @@ private struct ConversationContent: View {
// MARK: Status strip (active conversation)
private var statusStrip: some View {
HStack(spacing: Nearlight.s12) {
VStack(spacing: Nearlight.s12) {
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode), compact: true)
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .center, spacing: 2) {
Text(statusTitle)
.font(NearlightFont.control(typeSize))
.foregroundStyle(Nearlight.ink)
@@ -261,11 +262,12 @@ private struct ConversationContent: View {
.foregroundStyle(Nearlight.quiet)
}
}
Spacer(minLength: 0)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Nearlight.s20)
.padding(.vertical, Nearlight.s12)
.frame(minHeight: 60, alignment: .leading)
.background(Nearlight.ground)
}
+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)
}
}
@@ -38,8 +38,13 @@ struct LoginView: View {
}
}.disabled(auth.isBusy)
if let serverError { Text(serverError).foregroundStyle(.red) }
Text("Connections use HTTPS and secure WebSockets. A private server certificate may require an installed, trusted CA on this iPhone.")
Text("Connections use HTTPS and secure WebSockets. A private server certificate must be trusted by this iPhone for conversations.")
.font(.footnote).foregroundStyle(.secondary)
if auth.config.baseURL.host == TLSPinning.host, auth.config.baseURL.port == 9178 {
Link("Install Hope server certificate in Safari", destination: auth.config.baseURL.appendingPathComponent("hope-server.mobileconfig"))
Text("Install Hope LAN Server Trust in Settings → General → VPN & Device Management. Then enable 192.168.86.2 in Settings → General → About → Certificate Trust Settings. Only trust this certificate for your own server.")
.font(.footnote).foregroundStyle(.secondary)
}
}
}
.navigationTitle("Hope")
+11
View File
@@ -32,6 +32,17 @@ struct AuthLoginResponse: Decodable {
}
}
enum ConnectionFailure {
static func message(_ error: Error, server: URL) -> String {
let ns = error as NSError
let certificateCodes: Set<Int> = [-1200, -1201, -1202, -1203, -1204, -1205, -1206]
if ns.domain == NSURLErrorDomain && certificateCodes.contains(ns.code) {
return "The certificate for \(server.host ?? "this server") is not trusted. For your private Hope server, install the verified certificate profile in Safari, then enable it in Settings → General → About → Certificate Trust Settings. Do not continue if the server address is unfamiliar."
}
return error.localizedDescription
}
}
enum AuthError: LocalizedError {
case status(Int)
case invalidResponse