From fa21c81787ffeb6d71acfa00491dc38956044c3d Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sat, 12 Sep 2026 08:14:06 -0400 Subject: [PATCH] wip: Nearlight redesign implementation --- Sources/App/Nearlight.swift | 119 +++++ .../Conversation/ConversationView.swift | 483 +++++++++++++----- .../Features/Conversation/LightwellView.swift | 122 +++++ ...OrbView.swift => TranscriptListView.swift} | 100 ++-- Sources/Features/Settings/SettingsView.swift | 26 +- Sources/Features/Tasks/TaskPanelView.swift | 283 ++++++---- UITests/VoiceUITests.swift | 20 +- Voice.xcodeproj/project.pbxproj | 22 +- project.yml | 2 +- 9 files changed, 904 insertions(+), 273 deletions(-) create mode 100644 Sources/App/Nearlight.swift create mode 100644 Sources/Features/Conversation/LightwellView.swift rename Sources/Features/Conversation/{OrbView.swift => TranscriptListView.swift} (55%) diff --git a/Sources/App/Nearlight.swift b/Sources/App/Nearlight.swift new file mode 100644 index 0000000..a740553 --- /dev/null +++ b/Sources/App/Nearlight.swift @@ -0,0 +1,119 @@ +import SwiftUI +import UIKit + +/// Nearlight design tokens (design spec: local://ui-design-spec.md). +/// +/// Views reference semantic roles; the primitive hex values live only here. +/// All custom surfaces are opaque; native system materials are untouched. +enum Nearlight { + // MARK: Palette (light / dark variants) + + /// Screen background. + static let ground = Color(light: UIColor(red: 0.957, green: 0.949, blue: 0.965, alpha: 1), + dark: UIColor(red: 0.110, green: 0.098, blue: 0.125, alpha: 1)) + /// Controls, grouped rows, dock. + static let surface = Color(light: .white, + dark: UIColor(red: 0.157, green: 0.137, blue: 0.180, alpha: 1)) + /// Primary text. + static let ink = Color(light: UIColor(red: 0.161, green: 0.137, blue: 0.184, alpha: 1), + dark: UIColor(red: 0.965, green: 0.937, blue: 0.969, alpha: 1)) + /// Supporting text, inactive icons. + static let quiet = Color(light: UIColor(red: 0.396, green: 0.365, blue: 0.424, alpha: 1), + dark: UIColor(red: 0.749, green: 0.698, blue: 0.776, alpha: 1)) + /// Action tint, selection, Lightwell. + static let rosewood = Color(light: UIColor(red: 0.529, green: 0.278, blue: 0.380, alpha: 1), + dark: UIColor(red: 0.890, green: 0.635, blue: 0.737, alpha: 1)) + /// Errors and destructive actions. + static let alert = Color(light: UIColor(red: 0.675, green: 0.208, blue: 0.282, alpha: 1), + dark: UIColor(red: 1.0, green: 0.604, blue: 0.675, alpha: 1)) + + // MARK: Derived roles + + /// Decorative separator: Ink at 14% over its parent. + static var separator: Color { ink.opacity(0.14) } + /// Selected wash: Rosewood at 10% over Surface. + static var selectedWash: Color { rosewood.opacity(0.10) } + /// Error wash: Alert at 8% over Surface. + static var errorWash: Color { alert.opacity(0.08) } + /// Primary-action label: Surface in light, Ground in dark. + static var onRosewood: Color { + UITraitCollection.current.userInterfaceStyle == .dark ? ground : surface + } + + // MARK: Spacing scale + + static let s4: CGFloat = 4 + static let s8: CGFloat = 8 + static let s12: CGFloat = 12 + static let s16: CGFloat = 16 + static let s20: CGFloat = 20 + static let s24: CGFloat = 24 + static let s32: CGFloat = 32 + static let s48: CGFloat = 48 + + // MARK: Radius scale + + static let radiusSegment: CGFloat = 8 + static let radiusControl: CGFloat = 12 + static let radiusDock: CGFloat = 16 + + // MARK: Motion tokens + + /// quick = 0.12s easeOut. + static let quick: Animation = .easeOut(duration: 0.12) + /// standard = 0.22s timingCurve(0.22, 1, 0.36, 1). + static let standard: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.22) + /// settle = 0.32s timingCurve(0.22, 1, 0.36, 1). + static let settle: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.32) + /// cycle = 2.4s easeInOut (Lightwell pulses). + static let cycle: Animation = .easeInOut(duration: 2.4) +} + +extension Color { + /// Builds an adaptive color from explicit light/dark UIColors without + /// requiring an asset catalog. + init(light: UIColor, dark: UIColor) { + self.init(uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark ? dark : light + }) + } +} + +/// Shared typography roles from the spec. SF Pro default design except the +/// rounded welcome heading and wordmark. +enum NearlightFont { + /// Welcome heading: 32 medium, rounded, largeTitle metrics. + static func welcome(_ sizeCategory: DynamicTypeSize) -> Font { + scaled(.largeTitle, base: 32, weight: .medium, design: .rounded, sizeCategory: sizeCategory) + } + /// "Hope" wordmark: 20 semibold, rounded, title3 metrics. + static func wordmark(_ sizeCategory: DynamicTypeSize) -> Font { + scaled(.title3, base: 20, weight: .semibold, design: .rounded, sizeCategory: sizeCategory) + } + /// Section/detail heading: 22 semibold, title2 metrics. + static func heading(_ sizeCategory: DynamicTypeSize) -> Font { + scaled(.title2, base: 22, weight: .semibold, sizeCategory: sizeCategory) + } + /// Control/row label: 17 semibold, body metrics. + static func control(_ sizeCategory: DynamicTypeSize) -> Font { + scaled(.body, base: 17, weight: .semibold, sizeCategory: sizeCategory) + } + /// Status/support: 15 regular, subheadline metrics. + static func support(_ sizeCategory: DynamicTypeSize) -> Font { + scaled(.subheadline, base: 15, weight: .regular, sizeCategory: sizeCategory) + } + /// Speaker/timestamp: 13 medium, footnote metrics. + static func metadata(_ sizeCategory: DynamicTypeSize) -> Font { + scaled(.footnote, base: 13, weight: .medium, sizeCategory: sizeCategory) + } + + /// Scales a base size with the given text style via UIFontMetrics and + /// returns a SwiftUI font with the requested weight/design. + private static func scaled(_ textStyle: UIFont.TextStyle, base: CGFloat, weight: Font.Weight, + design: Font.Design = .default, sizeCategory: DynamicTypeSize) -> Font { + // UIFontMetrics scales the base size against the CURRENT Dynamic Type + // setting at render time (no explicit traits: inherits environment). + let scaledSize = UIFontMetrics(forTextStyle: textStyle).scaledValue(for: base) + return .system(size: scaledSize, weight: weight, design: design) + } +} diff --git a/Sources/Features/Conversation/ConversationView.swift b/Sources/Features/Conversation/ConversationView.swift index 845e76c..dd3d095 100644 --- a/Sources/Features/Conversation/ConversationView.swift +++ b/Sources/Features/Conversation/ConversationView.swift @@ -12,13 +12,14 @@ struct ConversationView: View { init(settings: SettingsStore) { self.settings = settings - _activeServerURL = State(initialValue: settings.trimmedServerURL) - _session = State(initialValue: Self.makeSession(settings.trimmedServerURL, settings: settings)) + let url = settings.trimmedServerURL + _activeServerURL = State(initialValue: url) + _session = State(initialValue: Self.makeSession(url, settings: settings)) } private static func makeSession(_ url: String, settings: SettingsStore) -> VoiceSession { guard case .success(let config) = ServerConfig.parse(url) else { - preconditionFailure("SettingsStore must supply a validated server URL") + preconditionFailure("Stored server URL failed validation; init sanitizes to a valid value.") } let session = VoiceSession(config: config, tokenClient: TokenClient(config: config), settings: settings) #if DEBUG @@ -37,14 +38,14 @@ struct ConversationView: View { ConversationContent(settings: settings, session: session) .toolbar { settingsLink } } - .tabItem { Label("Talk", systemImage: "waveform") } + .tabItem { Label("Talk", systemImage: "bubble.left.and.bubble.right") } .tag(0) ActivityTab(taskStore: session.taskStore, settings: settings, session: session) .tag(1) } .id(sessionID) - .tint(HopeStyle.tint) + .tint(Nearlight.rosewood) .onChange(of: settings.textMode) { _, enabled in Task { await session.applyTextMode(enabled) } } @@ -107,6 +108,28 @@ private struct ActivityTab: View { } } +/// Maps session + conversation models onto Lightwell presentation phases. +@MainActor +private func lightwellPhase(session: VoiceSession, conversation: ConversationStateModel, + textMode: Bool) -> LightwellView.Phase { + if session.state.isFailed { return .failed } + switch session.state { + case .idle: return .idle + case .connecting: return .connecting + case .waitingAgent: return .waiting + case .tearingDown: return .tearingDown + case .failed: return .failed + case .ready: + if textMode { return .readyNoInput } + if session.isMuted { return .readyNoInput } + switch conversation.phase { + case .speaking: return .speaking(level: conversation.agentLevel) + case .thinking: return .thinking + default: return .listening(level: conversation.micLevel) + } + } +} + private struct ConversationContent: View { @ObservedObject var settings: SettingsStore @ObservedObject var session: VoiceSession @@ -116,6 +139,8 @@ private struct ConversationContent: View { @State private var sendError: String? @State private var isSending = false @FocusState private var composerFocused: Bool + @Environment(\.dynamicTypeSize) private var typeSize + @Environment(\.accessibilityReduceMotion) private var reduceMotion init(settings: SettingsStore, session: VoiceSession) { self.settings = settings @@ -128,140 +153,341 @@ private struct ConversationContent: View { VStack(spacing: 0) { if transcripts.events.isEmpty && !session.isReady { welcome + if failureNotice != nil { failureBanner } } else { - connectionStatus.padding(.horizontal, 20).padding(.vertical, 12) + statusStrip + if session.isReconnecting { reconnectBanner } + if failureNotice != nil { failureBanner } TranscriptListView(events: transcripts.events, onClear: transcripts.clearAll) } } - .background(Color(.systemBackground)) + .background(Nearlight.ground) .navigationTitle("Hope") - .safeAreaInset(edge: .bottom, spacing: 0) { conversationControls } + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom, spacing: 0) { dock } .onChange(of: session.state) { _, state in if state == .ready && settings.textMode { composerFocused = true } } + .sensoryFeedback(.success, trigger: session.state == .ready ? true : false) { _, _ in true } + .sensoryFeedback(.error, trigger: session.state.isFailed) { _, _ in true } } + // MARK: Welcome (pre-connection, no history) + private var welcome: some View { ScrollView { - VStack(alignment: .leading, spacing: 28) { - OrbView(state: orbState).padding(.top, 24).accessibilityHidden(true) - VStack(alignment: .leading, spacing: 12) { - Text("A little help.\nA good conversation.") - .font(.largeTitle.weight(.semibold)) + VStack(alignment: .leading, spacing: Nearlight.s24) { + LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode)) + .padding(.top, Nearlight.s32) + VStack(alignment: .leading, spacing: Nearlight.s12) { + Text("A moment with Hope.") + .font(NearlightFont.welcome(typeSize)) .fixedSize(horizontal: false, vertical: true) - Text("Talk things through, find something out, or get something done.") - .font(.body).foregroundStyle(.secondary) + Text("Speak or type to your own assistant.") + .font(NearlightFont.support(typeSize)) + .foregroundStyle(Nearlight.quiet) } - VStack(alignment: .leading, spacing: 16) { - Label("Think out loud", systemImage: "bubble.left.and.bubble.right") - Label("Find an answer", systemImage: "sparkle.magnifyingglass") - } - .font(.subheadline).foregroundStyle(.secondary) + statusLine } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24).padding(.bottom, 24) + .padding(.horizontal, Nearlight.s20) + .padding(.bottom, Nearlight.s24) } } - private var connectionStatus: some View { - HStack(spacing: 12) { - Image(systemName: statusSymbol).font(.title3) - .foregroundStyle(session.state.isFailed ? Color.red : HopeStyle.tint) - .frame(width: 32, height: 36).accessibilityHidden(true) - VStack(alignment: .leading, spacing: 3) { - Text(statusTitle).font(.headline) - Text(statusDetail).font(.subheadline).foregroundStyle(.secondary) + /// Status text 24 below the supporting copy on welcome. + private var statusLine: some View { + Text(statusTitle) + .font(NearlightFont.support(typeSize)) + .foregroundStyle(Nearlight.quiet) + .padding(.top, Nearlight.s24) + .accessibilityIdentifier("talk.status") + } + + // MARK: Status strip (active conversation) + + private var statusStrip: some View { + HStack(spacing: Nearlight.s12) { + LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode), compact: true) + VStack(alignment: .leading, spacing: 2) { + Text(statusTitle) + .font(NearlightFont.control(typeSize)) + .foregroundStyle(Nearlight.ink) + .accessibilityIdentifier("talk.status") + if showStatusDetail { + Text(statusDetail) + .font(NearlightFont.support(typeSize)) + .foregroundStyle(Nearlight.quiet) + } } Spacer(minLength: 0) - if session.isReady && !settings.textMode && !session.isMuted { - ProgressView(value: min(conversation.micLevel * 2.5, 1)) - .frame(width: 46).accessibilityLabel("Microphone level") - } } - .accessibilityElement(children: .combine) + .padding(.horizontal, Nearlight.s20) + .padding(.vertical, Nearlight.s12) + .frame(minHeight: 60, alignment: .leading) + .background(Nearlight.ground) } - private var conversationControls: some View { - VStack(spacing: 12) { - if let failure = failureMessage { - VStack(alignment: .leading, spacing: 6) { - Label("Couldn’t connect to Hope", systemImage: "wifi.exclamationmark") - .font(.subheadline.weight(.semibold)) - Text(failure).font(.footnote).foregroundStyle(.secondary) - NavigationLink("Check connection settings") { - SettingsView(settings: settings, session: session) + /// Subtitle omitted for ordinary listening/thinking/speaking per spec. + private var showStatusDetail: Bool { + guard session.isReady else { return true } + if settings.textMode || session.isMuted { return true } + return false + } + + private var reconnectBanner: some View { + VStack(alignment: .leading, spacing: 2) { + Label("Reconnecting…", systemImage: "wifi.exclamationmark") + .font(NearlightFont.control(typeSize)) + Text("Your conversation is still here.") + .font(NearlightFont.support(typeSize)) + .foregroundStyle(Nearlight.quiet) + } + .padding(Nearlight.s12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Nearlight.surface) + .clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl)) + .padding(.horizontal, Nearlight.s20) + .accessibilityIdentifier("talk.connection.notice") + } + + /// Classified failure notice with actionable recovery. + private var failureBanner: some View { + VStack(alignment: .leading, spacing: Nearlight.s8) { + Label(failureTitle, systemImage: "exclamationmark.circle") + .font(NearlightFont.control(typeSize)) + .foregroundStyle(Nearlight.alert) + Text(failureBody) + .font(NearlightFont.support(typeSize)) + .foregroundStyle(Nearlight.ink) + if micDenied { + Button("Open iPhone Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) } - .font(.subheadline).frame(minHeight: 44) } - .frame(maxWidth: .infinity, alignment: .leading) - } - if session.isReconnecting { - Label("Reconnecting… Your conversation is still here.", systemImage: "wifi.exclamationmark") - .font(.footnote) - } - if let error = session.micError, !settings.textMode { - VStack(alignment: .leading, spacing: 4) { - Text(error).font(.footnote) - Button("Switch to typing") { settings.textMode = true }.frame(minHeight: 44) - } - .frame(maxWidth: .infinity, alignment: .leading) + .font(NearlightFont.support(typeSize)) + .frame(minHeight: 44) + .accessibilityIdentifier("talk.connection.openSystemSettings") } + } + .padding(Nearlight.s16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Nearlight.errorWash) + .clipShape(RoundedRectangle(cornerRadius: Nearlight.radiusControl)) + .padding(.horizontal, Nearlight.s20) + .accessibilityIdentifier("talk.connection.notice") + } + + private var micDenied: Bool { + (session.micError?.contains("Microphone") ?? false) && session.state.isFailed == false && session.state == .ready + } + + private var failureTitle: String { + if session.micError != nil { return "Microphone unavailable" } + return session.everConnected ? "Connection interrupted" : "Couldn’t connect" + } + + private var failureBody: String { + if let micError = session.micError { + return micError + } + let raw = failureMessage ?? "" + if raw.lowercased().contains("agent") { + return "Check that Hope is running on your server." + } + if raw.lowercased().contains("timed out") || raw.lowercased().contains("connect") { + return "Check your Wi-Fi or VPN and make sure your server is running." + } + return raw.isEmpty ? "Check your Wi-Fi or VPN and make sure your server is running." : raw + } + + // MARK: Dock + + private var dock: some View { + VStack(spacing: Nearlight.s12) { if let sendError { - Text(sendError).font(.footnote).foregroundStyle(.red) + Label(sendError, systemImage: "exclamationmark.circle") + .font(NearlightFont.support(typeSize)) + .foregroundStyle(Nearlight.alert) .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("talk.composer.error") } + modeSelector + if session.isReady { + if settings.textMode { typeControls } else { voiceControls } + } else { + startControls + } + } + .padding(.horizontal, Nearlight.s20) + .padding(.top, Nearlight.s12) + .padding(.bottom, Nearlight.s12) + .background( + // Dock: Surface, 16-point top corners, decorative top separator. + VStack(spacing: 0) { + Rectangle().fill(Nearlight.separator).frame(height: 1 / UIScreen.main.scale) + Nearlight.surface + } + .ignoresSafeArea(.container, edges: .bottom) + .clipShape(UnevenRoundedRectangle(topLeadingRadius: Nearlight.radiusDock, topTrailingRadius: Nearlight.radiusDock)) + ) + } + + @ViewBuilder private var modeSelector: some View { + if typeSize.isAccessibilitySize { + // Two stacked 52-point selection buttons at accessibility sizes. + VStack(spacing: Nearlight.s8) { + modeButton("Voice", systemImage: "mic", selected: !settings.textMode, id: "talk.mode.voice") { + settings.textMode = false + } + modeButton("Type", systemImage: "keyboard", selected: settings.textMode, id: "talk.mode.type") { + settings.textMode = true + } + } + .disabled(isBusy) + } else { Picker("Conversation mode", selection: $settings.textMode) { Label("Voice", systemImage: "mic").tag(false) Label("Type", systemImage: "keyboard").tag(true) } - .pickerStyle(.segmented).disabled(isBusy) - if session.isReady { - if settings.textMode { textComposer } - else { - HStack(spacing: 12) { - Button { - Task { await session.setMuted(!session.isMuted) } - } label: { - Label(session.isMuted ? "Unmute" : "Mute", systemImage: session.isMuted ? "mic.slash.fill" : "mic.fill") - .frame(maxWidth: .infinity, minHeight: 44) - } - .buttonStyle(.bordered) - endButton - } - } - } else { - Button { - sendError = nil - Task { await session.connect() } - } label: { - HStack(spacing: 10) { - if isBusy { ProgressView().tint(.white) } - else { Image(systemName: settings.textMode ? "bubble.left.fill" : "waveform") } - Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : settings.textMode ? "Start chatting" : "Let’s talk") - .font(.headline) - } - .frame(maxWidth: .infinity, minHeight: 48) - } - .buttonStyle(.borderedProminent).disabled(isBusy) - Text(settings.textMode ? "Your microphone stays off." : "Your microphone turns on when you connect.") - .font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center) - } + .pickerStyle(.segmented) + .disabled(isBusy) } - .padding(.horizontal, 20).padding(.top, 12).padding(.bottom, 16) - .background(.bar) } - private var textComposer: some View { - VStack(spacing: 6) { - HStack(alignment: .bottom, spacing: 8) { + private func modeButton(_ title: String, systemImage: String, selected: Bool, + id: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: Nearlight.s8) { + Image(systemName: systemImage) + Text(title) + if selected { Spacer(); Image(systemName: "checkmark") } + } + .font(NearlightFont.control(typeSize)) + .foregroundStyle(selected ? Nearlight.rosewood : Nearlight.ink) + .frame(maxWidth: .infinity, minHeight: 52) + .background(selected ? Nearlight.selectedWash : Nearlight.surface, + in: RoundedRectangle(cornerRadius: Nearlight.radiusControl)) + .overlay( + RoundedRectangle(cornerRadius: Nearlight.radiusControl) + .strokeBorder(selected ? Nearlight.rosewood.opacity(0.4) : Nearlight.quiet, lineWidth: 1) + ) + } + .accessibilityIdentifier(id) + .accessibilityAddTraits(selected ? .isSelected : []) + } + + private var startControls: some View { + VStack(spacing: Nearlight.s8) { + Text(settings.textMode ? "Your microphone stays off." : "Your microphone starts after you connect.") + .font(NearlightFont.support(typeSize)) + .foregroundStyle(Nearlight.quiet) + Button { + sendError = nil + Task { await session.connect() } + } label: { + HStack(spacing: Nearlight.s8) { + if isBusy { + ProgressView().tint(Nearlight.onRosewood) + } else { + Image(systemName: settings.textMode ? "keyboard" : "mic") + } + Text(isBusy ? statusTitle : session.state.isFailed ? "Try again" : "Start chatting") + .font(NearlightFont.control(typeSize)) + } + .frame(maxWidth: .infinity, minHeight: 52) + .background(isBusy ? Nearlight.rosewood.opacity(0.6) : Nearlight.rosewood, + in: RoundedRectangle(cornerRadius: Nearlight.radiusControl)) + .foregroundStyle(Nearlight.onRosewood) + } + .disabled(isBusy) + .accessibilityIdentifier("start.chatting") + } + } + + private var voiceControls: some View { + Group { + if typeSize.isAccessibilitySize { + VStack(spacing: Nearlight.s12) { + HStack(spacing: Nearlight.s12) { + muteButton + micMeter + } + endButton + } + } else { + HStack(spacing: Nearlight.s12) { + muteButton + micMeter + endButton + } + } + } + } + + private var muteButton: some View { + Button { + Task { await session.setMuted(!session.isMuted) } + } label: { + Image(systemName: session.isMuted ? "mic.slash" : "mic") + .font(.system(size: 20, weight: .medium)) + .frame(width: 52, height: 52) + .background(session.isMuted ? Nearlight.selectedWash : Nearlight.surface, + in: RoundedRectangle(cornerRadius: Nearlight.radiusControl)) + .overlay( + RoundedRectangle(cornerRadius: Nearlight.radiusControl) + .strokeBorder(Nearlight.quiet, lineWidth: 1) + ) + .foregroundStyle(session.isMuted ? Nearlight.rosewood : Nearlight.ink) + } + .accessibilityLabel(session.isMuted ? "Unmute microphone" : "Mute microphone") + .accessibilityIdentifier("talk.microphone.toggle") + .sensoryFeedback(.selection, trigger: session.isMuted) + } + + /// Five-bar capsule meter, 64×24, leading aligned. + private var micMeter: some View { + HStack(spacing: 6) { + let level = session.isMuted || settings.textMode ? 0.0 : conversation.micLevel + let filled = Int(ceil(level * 5)) + ForEach(0..<5, id: \.self) { index in + Capsule() + .fill(index < filled ? Nearlight.rosewood : Color.clear) + .overlay(index < filled ? nil : Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1)) + .frame(width: 6, height: [6.0, 12.0, 24.0, 12.0, 6.0][index]) + } + } + .frame(width: 64, height: 24, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Microphone level") + .accessibilityValue(session.isMuted || settings.textMode ? "Off" : "\(Int(round(conversation.micLevel * 100)))%") + } + + private var typeControls: some View { + VStack(spacing: Nearlight.s12) { + HStack(alignment: .bottom, spacing: Nearlight.s8) { TextField("Message Hope", text: $composerText, axis: .vertical) - .lineLimit(1...5).padding(12) - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) - .focused($composerFocused).disabled(isSending) + .lineLimit(1...5) + .padding(Nearlight.s12) + .background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl)) + .overlay( + RoundedRectangle(cornerRadius: Nearlight.radiusControl) + .strokeBorder(Nearlight.quiet, lineWidth: 1) + ) + .focused($composerFocused) + .disabled(isSending) .accessibilityLabel("Message Hope") Button(action: sendComposer) { - if isSending { ProgressView().frame(width: 44, height: 44) } - else { Image(systemName: "arrow.up.circle.fill").font(.title).frame(width: 44, height: 44) } + if isSending { + ProgressView().frame(width: 52, height: 52) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 20, weight: .medium)) + .frame(width: 52, height: 52) + .background(Nearlight.rosewood, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl)) + .foregroundStyle(Nearlight.onRosewood) + } } .disabled(composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending || session.isReconnecting) .accessibilityLabel("Send message") @@ -275,12 +501,22 @@ private struct ConversationContent: View { composerFocused = false Task { await session.disconnect() } } label: { - Label("End conversation", systemImage: "phone.down.fill") - .font(.subheadline.weight(.medium)).frame(minHeight: 44) + Label("End conversation", systemImage: "stop.fill") + .font(NearlightFont.control(typeSize)) + .lineLimit(2...3) + .frame(maxWidth: .infinity, minHeight: 52) + .background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl)) + .overlay( + RoundedRectangle(cornerRadius: Nearlight.radiusControl) + .strokeBorder(Nearlight.alert, lineWidth: 1) + ) + .foregroundStyle(Nearlight.alert) } - .buttonStyle(.bordered) + .accessibilityIdentifier("end.conversation") } + // MARK: Status copy + private var isBusy: Bool { session.state == .connecting || session.state == .waitingAgent || session.state == .tearingDown } @@ -288,36 +524,36 @@ private struct ConversationContent: View { if case .failed(let message) = session.state { return message } return nil } + private var failureNotice: String? { failureMessage } + private var statusTitle: String { switch session.state { case .idle: return "Ready when you are" - case .connecting: return "Connecting…" - case .waitingAgent: return "Getting Hope ready…" + case .connecting: return "Connecting to your server…" + case .waitingAgent: return "Waiting for Hope…" case .tearingDown: return "Ending conversation…" - case .failed: return "Connection unavailable" + case .failed: return "Connection interrupted" case .ready: - if settings.textMode { return "You’re chatting with Hope" } - if session.isMuted { return "Microphone muted" } + if settings.textMode { return "Ready for your message" } + if session.isMuted { return "Microphone off" } switch conversation.phase { - case .thinking: return "Thinking it through…" + case .listening: return "Listening" + case .thinking: return "Thinking…" case .speaking: return "Hope is speaking" - default: return "I’m listening" + default: return "Listening" } } } + private var statusDetail: String { - if !session.isReady { return "Your recent conversation is below." } + if session.state == .waitingAgent { return "The server is connected. Hope has not joined yet." } + if session.state == .connecting { return "Reaching your server." } + if !session.isReady { return "" } if settings.textMode { return "Microphone off · Type whenever you’re ready" } if session.isMuted { return "You can still hear Hope." } - return conversation.phase == .speaking ? "You can interrupt at any time." : "Speak naturally. Take your time." - } - private var statusSymbol: String { - session.state.isFailed ? "wifi.exclamationmark" : !session.isReady ? "bubble.left.and.bubble.right" : settings.textMode ? "keyboard" : session.isMuted ? "mic.slash" : "waveform" - } - private var orbState: OrbView.OrbState { - if session.state.isFailed { return .failed } - return isBusy ? .connecting : .idle + return "" } + private func sendComposer() { let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, !isSending else { return } @@ -335,4 +571,15 @@ private struct ConversationContent: View { } } +extension VoiceSession { + /// Whether any connection reached ready during this session lifetime. + /// Used to title the failure notice per spec. + var everConnected: Bool { + // Sessions are recreated on replacement; reaching ready is recorded + // indirectly through transcript presence. Conservative: treat any + // prior transcript activity as evidence of a completed connection. + !transcriptHandler.events.isEmpty + } +} + #Preview { ConversationView(settings: SettingsStore()) } diff --git a/Sources/Features/Conversation/LightwellView.swift b/Sources/Features/Conversation/LightwellView.swift new file mode 100644 index 0000000..3db8142 --- /dev/null +++ b/Sources/Features/Conversation/LightwellView.swift @@ -0,0 +1,122 @@ +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. +struct LightwellView: View { + /// Lightwell presentation state, mapped from session/conversation models. + enum Phase: Equatable { + case idle + case connecting + case waiting + 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 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// Pending-state pulse driver, 0 → 1 → 0 over the 2.4s cycle. + @State private var pulse: Double = 0 + + /// 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 + } + + var body: some View { + let inner = innerSize + ZStack { + Capsule() + .fill(Nearlight.surface) + Capsule() + .fill( + 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 + ) + ) + .frame(width: inner.width, height: inner.height) + .opacity(overallOpacity) + .animation(reduceMotion ? nil : Nearlight.quick, value: overallOpacity) + .animation(reduceMotion ? nil : Nearlight.settle, value: innerSize) + } + .frame(width: frameSize.width, height: frameSize.height) + .overlay( + Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1) + ) + .accessibilityHidden(true) + .task(id: pulseActive) { + guard pulseActive else { + pulse = 0 + return + } + while !Task.isCancelled { + withAnimation(reduceMotion ? nil : Nearlight.cycle) { pulse = pulse == 0 ? 1 : 0 } + try? await Task.sleep(for: .seconds(2.4)) + } + } + } + + /// Whether the pulse loop should run for this phase. + private var pulseActive: Bool { + switch phase { + case .connecting, .waiting, .thinking: return !reduceMotion + default: return false + } + } + + /// Inner capsule geometry per spec table. `a` is the smoothed level. + private var innerSize: CGSize { + let base = baseInner + 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 + } + } + + private var overallOpacity: 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 + } + } +} diff --git a/Sources/Features/Conversation/OrbView.swift b/Sources/Features/Conversation/TranscriptListView.swift similarity index 55% rename from Sources/Features/Conversation/OrbView.swift rename to Sources/Features/Conversation/TranscriptListView.swift index d9dac9c..ce279c4 100644 --- a/Sources/Features/Conversation/OrbView.swift +++ b/Sources/Features/Conversation/TranscriptListView.swift @@ -1,27 +1,14 @@ import SwiftUI -struct OrbView: View { - enum OrbState { case idle, connecting, listening, speaking, thinking, failed } - let state: OrbState - - var body: some View { - ZStack { - Circle().fill(HopeStyle.tint.opacity(0.10)) - Image(systemName: state == .failed ? "wifi.exclamationmark" : "waveform") - .font(.largeTitle.weight(.medium)) - .foregroundStyle(HopeStyle.tint) - } - .frame(width: 76, height: 76) - .accessibilityLabel("Hope") - } -} - +/// Nearlight transcript: a legible shared record. Full-width rows, no +/// bubbles; metadata row per event; "Live" placeholder on partials. struct TranscriptListView: View { let events: [TranscriptEvent] var onClear: () -> Void @State private var confirmClear = false @State private var followingLatest = true @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var typeSize private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"# @@ -29,7 +16,9 @@ struct TranscriptListView: View { ScrollViewReader { proxy in VStack(spacing: 0) { HStack { - Text("Conversation").font(.subheadline.weight(.semibold)) + Text("Conversation") + .font(NearlightFont.support(typeSize).weight(.semibold)) + .foregroundStyle(Nearlight.quiet) Spacer() Menu { Button("Clear conversation", systemImage: "trash", role: .destructive) { confirmClear = true } @@ -39,20 +28,23 @@ struct TranscriptListView: View { .accessibilityLabel("Conversation options") .disabled(events.isEmpty) } - .padding(.horizontal, 20) + .padding(.horizontal, Nearlight.s20) ScrollView { - LazyVStack(alignment: .leading, spacing: 20) { + LazyVStack(alignment: .leading, spacing: Nearlight.s24) { if events.isEmpty { Text("Your conversation will appear here.") - .font(.body).foregroundStyle(.secondary) - .padding(.vertical, 32) + .font(.body) + .foregroundStyle(Nearlight.quiet) + .padding(.top, Nearlight.s24) } ForEach(events) { event in transcriptRow(event) } Color.clear.frame(height: 1).id("latest") .onAppear { followingLatest = true } .onDisappear { followingLatest = false } } - .padding(.horizontal, 20).padding(.bottom, 20) + .padding(.horizontal, Nearlight.s20) + .padding(.top, Nearlight.s16) + .padding(.bottom, Nearlight.s20) } .scrollDismissesKeyboard(.interactively) .defaultScrollAnchor(.bottom) @@ -64,13 +56,15 @@ struct TranscriptListView: View { } if !followingLatest && !events.isEmpty { Button { - withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) { + withAnimation(reduceMotion ? nil : Nearlight.standard) { proxy.scrollTo("latest", anchor: .bottom) } } label: { - Label("Latest messages", systemImage: "arrow.down").font(.subheadline) + Label("Latest messages", systemImage: "arrow.down") + .font(NearlightFont.support(typeSize)) .frame(minHeight: 44) } + .accessibilityIdentifier("talk.latest") } } } @@ -86,28 +80,52 @@ struct TranscriptListView: View { let isUser = event.role == .user let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression) .trimmingCharacters(in: .whitespaces) - return HStack(alignment: .top, spacing: 0) { - if isUser { Spacer(minLength: 32) } - VStack(alignment: .leading, spacing: 7) { - HStack(spacing: 8) { - Text(isUser ? "You" : "Hope").font(.subheadline.weight(.semibold)) - Text(event.timestamp, style: .time).font(.caption).foregroundStyle(.secondary) - if !event.isFinal { - Text("Live").font(.caption).foregroundStyle(.secondary) + return VStack(alignment: .leading, spacing: Nearlight.s8) { + // Metadata row: speaker, timestamp trailing. Stacks at + // accessibility sizes per spec. + Group { + if typeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: Nearlight.s4) { + speakerLabel(isUser) + timeLabel(event) + } + } else { + HStack(spacing: Nearlight.s8) { + speakerLabel(isUser) + timeLabel(event) } } - Text(text).font(.body).textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) } - .padding(isUser ? 14 : 0) - .frame(maxWidth: .infinity, alignment: .leading) - .background(isUser ? Color(.secondarySystemBackground) : .clear, in: RoundedRectangle(cornerRadius: 16)) - .contextMenu { - Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text } - } - if !isUser { Spacer(minLength: 16) } + Text(text) + .font(.body) + .foregroundStyle(Nearlight.ink) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contextMenu { + Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text } } .accessibilityElement(children: .combine) .id(event.id) } + + private func speakerLabel(_ isUser: Bool) -> some View { + Text(isUser ? "You" : "Hope") + .font(NearlightFont.metadata(typeSize).weight(.semibold)) + .foregroundStyle(Nearlight.ink) + } + + private func timeLabel(_ event: TranscriptEvent) -> some View { + Group { + if event.isFinal { + Text(event.timestamp, style: .time) + } else { + Text("Live") + } + } + .font(NearlightFont.metadata(typeSize)) + .foregroundStyle(Nearlight.quiet) + .monospacedDigit() + } } diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index 7e2ae9a..88ae0ec 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -12,6 +12,7 @@ struct SettingsView: View { @State private var urlText: String = "" @State private var urlError: String? @State private var showVoicePicker = false + @State private var urlJustSaved = false @State private var showModelPicker = false init(settings: SettingsStore, session: VoiceSession) { @@ -55,7 +56,7 @@ struct SettingsView: View { } else { LabeledContent("Model", value: settings.modelID ?? "Server default") Text("Connect from Talk to see the models available on your server.") - .font(.footnote).foregroundStyle(.secondary) + .font(.footnote).foregroundStyle(Nearlight.quiet) } } header: { Text("Intelligence") } Section { @@ -65,10 +66,24 @@ struct SettingsView: View { .textInputAutocapitalization(.never) .accessibilityLabel("Server URL") .onAppear { urlText = settings.serverURL } + .overlay(alignment: .trailing) { + if urlJustSaved { + Image(systemName: "checkmark.circle") + .foregroundStyle(Nearlight.quiet) + .padding(.trailing, 8) + .accessibilityHidden(true) + } + } if let urlError { - Text(urlError) + Label(urlError, systemImage: "exclamationmark.circle") .font(.footnote) - .foregroundStyle(.red) + .foregroundStyle(Nearlight.alert) + .accessibilityIdentifier("settings.server.error") + } else if urlJustSaved { + Text("Server address saved.") + .font(.footnote) + .foregroundStyle(Nearlight.quiet) + .accessibilityIdentifier("settings.server.saved") } Button("Save server address") { applyServerURL() } .disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL) @@ -77,7 +92,7 @@ struct SettingsView: View { ? "Waiting for failure cleanup to finish…" : "End the conversation before changing servers.") .font(.footnote) - .foregroundStyle(.secondary) + .foregroundStyle(Nearlight.quiet) } NavigationLink("Connection test") { ConnectionTestView(settings: settings) @@ -90,6 +105,7 @@ struct SettingsView: View { } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) + .tint(Nearlight.quiet) .sheet(isPresented: $showVoicePicker) { VoicePickerView(settings: settings, session: session) } @@ -103,6 +119,7 @@ struct SettingsView: View { /// left untouched so the active session keeps using its original URL. /// Guarded: only applies when the session is idle (no active call). private func applyServerURL() { + urlJustSaved = false guard canApplyServerURL else { urlError = "End the conversation before changing servers." return @@ -117,6 +134,7 @@ struct SettingsView: View { settings.serverURL = trimmed urlText = trimmed urlError = nil + urlJustSaved = true case .failure(let error): urlError = error.localizedDescription } diff --git a/Sources/Features/Tasks/TaskPanelView.swift b/Sources/Features/Tasks/TaskPanelView.swift index 3330ba7..e53c112 100644 --- a/Sources/Features/Tasks/TaskPanelView.swift +++ b/Sources/Features/Tasks/TaskPanelView.swift @@ -1,141 +1,240 @@ import SwiftUI +/// Nearlight Activity: a working log. Plain rows on Ground, no cards; +/// live updates land in place without reordering while reading. struct TaskPanelView: View { @ObservedObject var taskStore: TaskStore + @Environment(\.dynamicTypeSize) private var typeSize var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 20) { - if taskStore.sortedTasks.isEmpty { - emptyState - } else { - Text(taskStore.activeCount > 0 ? "Hope is working on \(taskStore.activeCount) task\(taskStore.activeCount == 1 ? "" : "s"). You can keep talking." : "Results from the things you’ve asked Hope to work on.") - .font(.subheadline).foregroundStyle(.secondary) - ForEach(taskStore.sortedTasks) { task in - TaskCardView(task: task) - } - } + Group { + if taskStore.sortedTasks.isEmpty { + emptyState + } else { + taskList } - .padding() } .navigationTitle("Activity") - .background(Color(.systemGroupedBackground)) + .background(Nearlight.ground) + } + + private var taskList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(taskStore.sortedTasks) { task in + NavigationLink { + TaskDetailView(task: task) + } label: { + TaskRowView(task: task) + } + .buttonStyle(.plain) + Divider() + .overlay(Nearlight.separator) + } + } + .padding(.horizontal, Nearlight.s20) + } } private var emptyState: some View { - ContentUnavailableView { - Label("A little help, in the background", systemImage: "checklist") - } description: { - Text("Ask Hope to research a topic or compare your options. Follow the progress and read the results here.") + VStack(alignment: .leading, spacing: Nearlight.s8) { + Image(systemName: "checklist") + .font(.system(size: 28)) + .foregroundStyle(Nearlight.quiet) + Text("No tasks yet") + .font(NearlightFont.heading(typeSize)) + Text("Tasks from your conversations will appear here.") + .font(.body) + .foregroundStyle(Nearlight.quiet) } - .padding(.top, 48) + .padding(.top, Nearlight.s32) + .padding(.horizontal, Nearlight.s20) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("activity.empty") } } -struct TaskCardView: View { +/// One activity row: status symbol, title, current step/status, chevron. +private struct TaskRowView: View { let task: TaskItem - @State private var showSteps = false + @Environment(\.dynamicTypeSize) private var typeSize var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - Label(statusTitle, systemImage: statusSymbol) - .font(.subheadline.weight(.medium)) - .foregroundStyle(dotColor) - Spacer() - Text(task.createdAt, style: .time).font(.caption).foregroundStyle(.secondary) - } - Text(task.description).font(.headline).fixedSize(horizontal: false, vertical: true) - if !task.steps.isEmpty { - DisclosureGroup("\(task.steps.count) progress updates", isExpanded: $showSteps) { - VStack(alignment: .leading, spacing: 12) { - ForEach(task.steps) { step in - VStack(alignment: .leading, spacing: 4) { - Text(stepLabel(step.role)) - .font(.caption.weight(.semibold)).foregroundStyle(.secondary) - Text(step.content ?? "") - .font(.subheadline).textSelection(.enabled) - } - } - }.padding(.top, 8) + HStack(spacing: Nearlight.s12) { + Image(systemName: statusSymbol) + .font(.system(size: 20)) + .foregroundStyle(statusColor) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: Nearlight.s4) { + Text(statusTitle) + .font(NearlightFont.support(typeSize)) + .foregroundStyle(statusColor) + .fixedSize(horizontal: false, vertical: true) + Text(task.description) + .font(NearlightFont.control(typeSize)) + .foregroundStyle(Nearlight.ink) + .fixedSize(horizontal: false, vertical: true) + if let latest = latestStep { + Text(latest) + .font(NearlightFont.support(typeSize)) + .foregroundStyle(Nearlight.quiet) + .fixedSize(horizontal: false, vertical: true) } - .font(.subheadline) - } - if let result = task.result { - block(result, color: .green, background: Color.green.opacity(0.12)) - } - if let error = task.error { - block(error, color: .red, background: Color.red.opacity(0.12)) + Text(task.createdAt, style: .time) + .font(NearlightFont.metadata(typeSize)) + .foregroundStyle(Nearlight.quiet) + .monospacedDigit() } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Nearlight.quiet) + .accessibilityHidden(true) } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16)) + .padding(.vertical, Nearlight.s16) + .contentShape(Rectangle()) } private var statusTitle: String { switch task.status { - case .pending: "Queued" - case .running: "In progress" - case .completed: "Complete" - case .failed: "Needs attention" + case .pending: return "Queued" + case .running: return "In progress" + case .completed: return "Complete" + case .failed: return "Needs attention" + } + } + + private var latestStep: String? { + if let error = task.error { return error } + if let result = task.result { return result } + return task.steps.last?.content + } + + private var statusSymbol: String { + switch task.status { + case .pending: return "circle.dotted" + case .running: return "arrow.triangle.2.circlepath" + case .completed: return "checkmark.circle" + case .failed: return "exclamationmark.circle" + } + } + + private var statusColor: Color { + switch task.status { + case .pending: return Nearlight.quiet + case .running: return Nearlight.rosewood + case .completed: return Nearlight.quiet + case .failed: return Nearlight.alert + } + } +} + +/// Native push detail for one task: title, status, steps, result/error. +struct TaskDetailView: View { + let task: TaskItem + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: Nearlight.s24) { + VStack(alignment: .leading, spacing: Nearlight.s12) { + Text(task.description) + .font(NearlightFont.heading(typeSize)) + .fixedSize(horizontal: false, vertical: true) + Label(statusTitle, systemImage: statusSymbol) + .font(NearlightFont.support(typeSize)) + .foregroundStyle(statusColor) + Text(task.createdAt, style: .time) + .font(NearlightFont.metadata(typeSize)) + .foregroundStyle(Nearlight.quiet) + .monospacedDigit() + } + VStack(alignment: .leading, spacing: 0) { + Text("Steps") + .font(NearlightFont.heading(typeSize)) + .padding(.bottom, Nearlight.s8) + if task.steps.isEmpty { + Text("No steps reported yet.") + .font(.body) + .foregroundStyle(Nearlight.quiet) + } else { + ForEach(task.steps) { step in + VStack(alignment: .leading, spacing: Nearlight.s4) { + if let role = step.role { + Text(roleLabel(role)) + .font(NearlightFont.support(typeSize).weight(.semibold)) + .foregroundStyle(Nearlight.quiet) + } + Text(step.content ?? "") + .font(.body) + .foregroundStyle(Nearlight.ink) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, Nearlight.s16) + } + } + } + if let result = task.result { + Text(result) + .font(.body) + .foregroundStyle(Nearlight.ink) + .textSelection(.enabled) + } + if let error = task.error { + Text(error) + .font(.body) + .foregroundStyle(Nearlight.alert) + .textSelection(.enabled) + } + } + .padding(.horizontal, Nearlight.s20) + .padding(.top, Nearlight.s24) + .padding(.bottom, Nearlight.s24) + } + .background(Nearlight.ground) + .navigationTitle("Task") + .navigationBarTitleDisplayMode(.inline) + } + + private var statusTitle: String { + switch task.status { + case .pending: return "Queued" + case .running: return "In progress" + case .completed: return "Complete" + case .failed: return "Needs attention" } } private var statusSymbol: String { switch task.status { - case .pending: "clock" - case .running: "arrow.triangle.2.circlepath" - case .completed: "checkmark.circle.fill" - case .failed: "exclamationmark.circle" + case .pending: return "circle.dotted" + case .running: return "arrow.triangle.2.circlepath" + case .completed: return "checkmark.circle" + case .failed: return "exclamationmark.circle" } } - private var dotColor: Color { + private var statusColor: Color { switch task.status { - case .pending: return .gray - case .running: return HopeStyle.tint - case .completed: return .green - case .failed: return .red + case .pending: return Nearlight.quiet + case .running: return Nearlight.rosewood + case .completed: return Nearlight.quiet + case .failed: return Nearlight.alert } } - private func block(_ text: String, color: Color, background: Color) -> some View { - Text(text) - .font(.body) - .foregroundStyle(.primary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func stepLabel(_ role: String?) -> String { + private func roleLabel(_ role: String) -> String { switch role { case "thinking": return "Thinking" case "tool_call": return "Tool" - case "tool_result": return "Result" - case "text": return "Note" - default: return role ?? "Step" - } - } - - private func stepColor(_ role: String?) -> Color { - switch role { - case "thinking": return .blue - case "tool_call": return .indigo - case "tool_result": return .secondary - default: return .primary + default: return role } } } #Preview { NavigationStack { - TaskPanelView(taskStore: { - let store = TaskStore() - #if DEBUG - store.injectSampleEvents() - #endif - return store - }()) + TaskPanelView(taskStore: TaskStore()) } } diff --git a/UITests/VoiceUITests.swift b/UITests/VoiceUITests.swift index 59aad2e..a5f3087 100644 --- a/UITests/VoiceUITests.swift +++ b/UITests/VoiceUITests.swift @@ -13,15 +13,15 @@ final class VoiceUITests: XCTestCase { func testWelcomeNavigationAndOfflineRecovery() { app.launch() - XCTAssertTrue(app.buttons["Let’s talk"].waitForExistence(timeout: 10)) - XCTAssertTrue(app.staticTexts["Your microphone turns on when you connect."].exists) + XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["Your microphone starts after you connect."].exists) app.buttons["Type"].tap() XCTAssertTrue(app.buttons["Start chatting"].exists) XCTAssertTrue(app.staticTexts["Your microphone stays off."].exists) app.buttons["Start chatting"].tap() XCTAssertTrue(app.buttons["Try again"].waitForExistence(timeout: 20)) - XCTAssertTrue(app.staticTexts["Couldn’t connect to Hope"].exists) - app.buttons["Check connection settings"].tap() + XCTAssertTrue(app.staticTexts["Couldn’t connect"].exists) + app.buttons["Settings"].tap() XCTAssertTrue(app.textFields["Server URL"].waitForExistence(timeout: 5)) capture("Offline recovery") } @@ -59,23 +59,23 @@ final class VoiceUITests: XCTestCase { app.buttons["Conversation options"].tap() app.buttons["Clear conversation"].tap() app.buttons["Clear conversation"].tap() - XCTAssertTrue(app.buttons["Let’s talk"].exists) + XCTAssertTrue(app.buttons["Start chatting"].exists) XCTAssertFalse(app.staticTexts["Help me plan a quiet weekend."].exists) app.tabBars.buttons["Activity"].tap() XCTAssertTrue(app.staticTexts["Needs attention"].waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts["Search backend timed out after 30s"].exists) - app.buttons["1 progress updates"].tap() - XCTAssertTrue(app.staticTexts["Fetching benchmark articles…"].exists) + app.staticTexts["Search backend timed out after 30s"].tap() + XCTAssertTrue(app.staticTexts["Fetching benchmark articles…"].waitForExistence(timeout: 5)) capture("Activity details") } func testLargeTypeNavigation() { app.launchArguments += ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] app.launch() - XCTAssertTrue(app.buttons["Let’s talk"].waitForExistence(timeout: 10)) - XCTAssertTrue(app.buttons["Let’s talk"].isHittable) + XCTAssertTrue(app.buttons["Start chatting"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["Start chatting"].isHittable) app.tabBars.buttons["Activity"].tap() - XCTAssertTrue(app.staticTexts["A little help, in the background"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["No tasks yet"].waitForExistence(timeout: 5)) app.buttons["Settings"].tap() XCTAssertTrue(app.navigationBars["Settings"].exists) capture("Accessibility text size") diff --git a/Voice.xcodeproj/project.pbxproj b/Voice.xcodeproj/project.pbxproj index 924d984..a8c1f14 100644 --- a/Voice.xcodeproj/project.pbxproj +++ b/Voice.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C71C48230C683D199D6F4B /* ControlChannel.swift */; }; 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; }; 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; }; - 3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */; }; 57E6476135CE30495E02D723 /* VoiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */; }; 64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C23305A6C55A83F8617D9D /* ConversationState.swift */; }; 70E4B860147494D9657E8C01 /* VoiceLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6A697985B36B3A1A3F2AD5 /* VoiceLiveTests.swift */; }; @@ -20,14 +19,17 @@ 89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; }; 8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; }; 943BCE971A01F220FAE9E250 /* TaskPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85BACA267ADD462306C9920 /* TaskPanelView.swift */; }; + 9461C0886FA294FDD9742E51 /* TranscriptListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */; }; 98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; }; A5868238CF4958D6D0C5908E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */; }; + A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */; }; A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; }; BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */; }; C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */; }; CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */; }; D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9681E7410B684D2D38752A70 /* TokenClient.swift */; }; D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */; }; + D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6FA2A93116B237CEA712E6 /* Nearlight.swift */; }; D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; }; EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5DF207EDB787F63CB3A64D /* Voices.swift */; }; FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */; }; @@ -64,13 +66,15 @@ 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.swift; sourceTree = ""; }; 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = ""; }; 8309AC6885ED7EB0047AE211 /* VoiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceUITests.swift; sourceTree = ""; }; + 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightwellView.swift; sourceTree = ""; }; 9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = ""; }; A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.swift; sourceTree = ""; }; - A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrbView.swift; sourceTree = ""; }; AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = ""; }; + BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptListView.swift; sourceTree = ""; }; BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationStateTests.swift; sourceTree = ""; }; C0B60FBBB9C47BB9ADACA7CA /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = ""; }; C22975C69240AACD3418091F /* VoiceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + CA6FA2A93116B237CEA712E6 /* Nearlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nearlight.swift; sourceTree = ""; }; D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = ""; }; D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = ""; }; E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = ""; }; @@ -147,7 +151,8 @@ isa = PBXGroup; children = ( 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */, - A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */, + 87DD77AA383E1ECCEEEFB399 /* LightwellView.swift */, + BD31F76E1880AC76ED31DD4D /* TranscriptListView.swift */, ); path = Conversation; sourceTree = ""; @@ -175,6 +180,7 @@ CE49A38E40AAF659A9281B85 /* App */ = { isa = PBXGroup; children = ( + CA6FA2A93116B237CEA712E6 /* Nearlight.swift */, 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */, 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */, 2E5DF207EDB787F63CB3A64D /* Voices.swift */, @@ -282,7 +288,7 @@ TestTargetID = 57E3A292BDE525A22EB7ADE4; }; 57E3A292BDE525A22EB7ADE4 = { - DevelopmentTeam = H8D4WY66F6; + DevelopmentTeam = YJFYRB92NW; ProvisioningStyle = Automatic; }; }; @@ -321,7 +327,8 @@ 28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */, 64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */, 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */, - 3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */, + A66BC894BEEC870D879F991F /* LightwellView.swift in Sources */, + D958BE8EE0FF9568F8FABB11 /* Nearlight.swift in Sources */, 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */, A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */, 98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */, @@ -331,6 +338,7 @@ D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */, D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */, 89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */, + 9461C0886FA294FDD9742E51 /* TranscriptListView.swift in Sources */, 1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */, CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */, EE0C5762EC97B123A1880FFB /* Voices.swift in Sources */, @@ -498,7 +506,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = H8D4WY66F6; + DEVELOPMENT_TEAM = YJFYRB92NW; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -521,7 +529,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = H8D4WY66F6; + DEVELOPMENT_TEAM = YJFYRB92NW; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/project.yml b/project.yml index e0c80e3..ee35f00 100644 --- a/project.yml +++ b/project.yml @@ -41,7 +41,7 @@ targets: TARGETED_DEVICE_FAMILY: "1" CURRENT_PROJECT_VERSION: 1 MARKETING_VERSION: "0.1.0" - DEVELOPMENT_TEAM: H8D4WY66F6 + DEVELOPMENT_TEAM: YJFYRB92NW CODE_SIGN_STYLE: Automatic VoiceTests: type: bundle.unit-test