feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s
Rebuild & Deploy / rebuild (push) Failing after 23s
This commit is contained in:
commit
d735ee72fa
102 files changed
+13077
-258
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 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 {
|
||||
enum Phase: Equatable {
|
||||
case idle, connecting, waiting, thinking, tearingDown, failed, readyNoInput
|
||||
case listening(level: Double)
|
||||
case speaking(level: Double)
|
||||
}
|
||||
|
||||
let phase: Phase
|
||||
var compact = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
|
||||
private var side: CGFloat {
|
||||
if compact { return typeSize.isAccessibilitySize ? 80 : 112 }
|
||||
return typeSize.isAccessibilitySize ? 136 : 184
|
||||
}
|
||||
|
||||
private var animated: Bool {
|
||||
guard !reduceMotion, scenePhase == .active else { return false }
|
||||
switch phase {
|
||||
case .connecting, .waiting, .thinking, .listening, .speaking: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
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.personaSurface, 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 .failed: return Nearlight.alert
|
||||
case .tearingDown: return Nearlight.quiet
|
||||
default: return Nearlight.rosewood
|
||||
}
|
||||
}
|
||||
|
||||
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