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) } }