diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png index a419723..d56e123 100644 Binary files a/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png and b/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/Sources/App/Nearlight.swift b/Sources/App/Nearlight.swift index a740553..80dae7f 100644 --- a/Sources/App/Nearlight.swift +++ b/Sources/App/Nearlight.swift @@ -8,21 +8,24 @@ import UIKit 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)) + /// Opal screen background: luminous pink-white #FFFDFD / deep plum #201820. + static let ground = Color(light: UIColor(red: 255 / 255, green: 253 / 255, blue: 253 / 255, alpha: 1), + dark: UIColor(red: 32 / 255, green: 24 / 255, blue: 32 / 255, alpha: 1)) + /// Controls, grouped rows, dock: #FFF7FA / #302330. + static let surface = Color(light: UIColor(red: 255 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1), + dark: UIColor(red: 48 / 255, green: 35 / 255, blue: 48 / 255, alpha: 1)) + /// The square persona's pale pink stage: #FFF2F7 / #3B2836. + static let personaSurface = Color(light: UIColor(red: 255 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1), + dark: UIColor(red: 59 / 255, green: 40 / 255, blue: 54 / 255, alpha: 1)) + /// Primary text: readable dark plum #302B40 / #FFF2F7. + static let ink = Color(light: UIColor(red: 48 / 255, green: 43 / 255, blue: 64 / 255, alpha: 1), + dark: UIColor(red: 255 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1)) + /// Supporting text, inactive icons: plum #706074 / #CBB6C7. + static let quiet = Color(light: UIColor(red: 112 / 255, green: 96 / 255, blue: 116 / 255, alpha: 1), + dark: UIColor(red: 203 / 255, green: 182 / 255, blue: 199 / 255, alpha: 1)) + /// Opal action rose #A64270 / #F0A8C8; retains the existing semantic API. + static let rosewood = Color(light: UIColor(red: 166 / 255, green: 66 / 255, blue: 112 / 255, alpha: 1), + dark: UIColor(red: 240 / 255, green: 168 / 255, blue: 200 / 255, 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)) @@ -35,10 +38,9 @@ enum Nearlight { 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 - } + /// Primary-action label: white on strong rose, deep plum on the dark-mode pink. + static let onRosewood = Color(light: .white, + dark: UIColor(red: 32 / 255, green: 24 / 255, blue: 32 / 255, alpha: 1)) // MARK: Spacing scale diff --git a/Sources/Features/Conversation/ConversationView.swift b/Sources/Features/Conversation/ConversationView.swift index 56fc9d6..ff78035 100644 --- a/Sources/Features/Conversation/ConversationView.swift +++ b/Sources/Features/Conversation/ConversationView.swift @@ -50,11 +50,19 @@ struct ConversationView: View { var body: some View { TabView(selection: $selectedTab) { NavigationStack { - ConversationContent(settings: settings, session: session) + ConversationContent(settings: settings, session: session, displayName: user.displayName ?? user.username) .toolbar { settingsLink ToolbarItem(placement: .topBarLeading) { - Button("Sign out") { Task { await auth.logout { await session.disconnect() } } } + Menu { + Button("Sign out", role: .destructive) { + Task { await auth.logout { await session.disconnect() } } + } + } label: { + Image(systemName: "person.crop.circle") + .frame(minWidth: 44, minHeight: 44) + } + .accessibilityLabel("Account") } } } @@ -165,6 +173,7 @@ private struct ConversationContent: View { @ObservedObject var session: VoiceSession @ObservedObject private var transcripts: TranscriptHandler @ObservedObject private var conversation: ConversationStateModel + let displayName: String @State private var composerText = "" @State private var sendError: String? @State private var isSending = false @@ -173,9 +182,10 @@ private struct ConversationContent: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.colorSchemeContrast) private var contrast - init(settings: SettingsStore, session: VoiceSession) { + init(settings: SettingsStore, session: VoiceSession, displayName: String) { self.settings = settings self.session = session + self.displayName = displayName transcripts = session.transcriptHandler conversation = session.stateModel } @@ -217,26 +227,66 @@ private struct ConversationContent: View { private var welcome: some 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.") - .font(NearlightFont.welcome(typeSize)) - .fixedSize(horizontal: false, vertical: true) - Text("Speak or type to your own assistant.") - .font(NearlightFont.support(typeSize)) + VStack(spacing: Nearlight.s16) { + VStack(spacing: Nearlight.s8) { + Text("A little room for you,") + .font(.largeTitle.weight(.regular)) + Text(greetingName + ".") + .font(.system(.largeTitle, design: .serif).italic()) + .foregroundStyle(Nearlight.rosewood) + Text("No perfect words needed. Start anywhere.") + .font(.subheadline) .foregroundStyle(Nearlight.quiet) } - statusLine + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, Nearlight.s24) + + LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode)) + .padding(.top, Nearlight.s8) + Text(statusTitle) + .font(.footnote) + .foregroundStyle(Nearlight.rosewood) + .accessibilityIdentifier("talk.status") + + if !typeSize.isAccessibilitySize { + VStack(spacing: Nearlight.s8) { + HStack(spacing: Nearlight.s8) { + prompt("Talk through my day", draft: "Help me talk through my day.") + prompt("Explore an idea", draft: "Help me explore an idea.") + } + prompt("Make a plan", draft: "Help me make a plan.") + } + .padding(.top, Nearlight.s8) + } } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity) .padding(.horizontal, Nearlight.s20) .padding(.bottom, Nearlight.s24) } } + private var greetingName: String { + displayName.split(separator: " ").first.map(String.init) ?? displayName + } + + private func prompt(_ title: String, draft: String) -> some View { + Button { + // Prepare an editable draft, never submit a prompt or enable capture implicitly. + composerText = draft + settings.textMode = true + composerFocused = true + } label: { + Text(title) + .font(.footnote) + .padding(.horizontal, Nearlight.s16) + .frame(minHeight: 44) + .background(Nearlight.selectedWash, in: Capsule()) + .foregroundStyle(Nearlight.rosewood) + } + .accessibilityHint("Prepares a message you can edit. Connect before sending.") + } + /// Status text 24 below the supporting copy on welcome. private var statusLine: some View { Text(statusTitle) @@ -369,21 +419,17 @@ private struct ConversationContent: View { else { HStack(spacing: Nearlight.s12) { muteButton; micMeter } } endButton } else { + if settings.textMode { draftPreview } 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)) - ) + .background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock)) + .padding(.horizontal, Nearlight.s16) + .padding(.bottom, Nearlight.s8) + } @ViewBuilder private var modeSelector: some View { @@ -431,6 +477,16 @@ private struct ConversationContent: View { .accessibilityAddTraits(selected ? .isSelected : []) } + private var draftPreview: some View { + TextField("What’s on your mind?", text: $composerText, axis: .vertical) + .lineLimit(1...3) + .font(.body) + .padding(Nearlight.s12) + .background(Nearlight.selectedWash, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl)) + .focused($composerFocused) + .accessibilityLabel("Message Hope") + } + private var startControls: some View { VStack(spacing: Nearlight.s8) { Text(settings.textMode ? "Your microphone stays off." : "Your microphone starts after you connect.") @@ -520,7 +576,7 @@ private struct ConversationContent: View { private var typeControls: some View { VStack(spacing: Nearlight.s12) { HStack(alignment: .bottom, spacing: Nearlight.s8) { - TextField("Message Hope", text: $composerText, axis: .vertical) + TextField("What’s on your mind?", text: $composerText, axis: .vertical) .lineLimit(1...5) .padding(Nearlight.s12) .background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl)) diff --git a/Sources/Features/Conversation/LightwellView.swift b/Sources/Features/Conversation/LightwellView.swift index 8c2ade6..0b2782d 100644 --- a/Sources/Features/Conversation/LightwellView.swift +++ b/Sources/Features/Conversation/LightwellView.swift @@ -18,8 +18,8 @@ struct LightwellView: View { @Environment(\.dynamicTypeSize) private var typeSize private var side: CGFloat { - if compact { return typeSize.isAccessibilitySize ? 88 : 136 } - return typeSize.isAccessibilitySize ? 160 : 220 + if compact { return typeSize.isAccessibilitySize ? 80 : 112 } + return typeSize.isAccessibilitySize ? 136 : 184 } private var animated: Bool { @@ -41,7 +41,7 @@ struct LightwellView: View { } } .frame(width: side, height: side) - .background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock)) + .background(Nearlight.personaSurface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock)) .overlay { RoundedRectangle(cornerRadius: Nearlight.radiusDock) .strokeBorder(contrast == .increased ? Nearlight.quiet : Nearlight.separator, lineWidth: 1) @@ -52,7 +52,7 @@ struct LightwellView: View { private var tint: Color { switch phase { case .failed: return Nearlight.alert - case .idle, .tearingDown, .readyNoInput: return Nearlight.quiet + case .tearingDown: return Nearlight.quiet default: return Nearlight.rosewood } } diff --git a/Sources/Features/Conversation/LoginView.swift b/Sources/Features/Conversation/LoginView.swift index 7e09fa9..218fbcd 100644 --- a/Sources/Features/Conversation/LoginView.swift +++ b/Sources/Features/Conversation/LoginView.swift @@ -7,13 +7,24 @@ struct LoginView: View { @State private var password = "" @State private var server = "" @State private var serverError: String? + @Environment(\.dynamicTypeSize) private var typeSize var body: some View { NavigationStack { Form { Section { - Text("Sign in to Hope").font(.title2.bold()) - Text("Use the account provided by your server administrator. Hermes is managed by your server.") - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: Nearlight.s12) { + Text("Sign in to Hope") + .font(NearlightFont.welcome(typeSize)) + .foregroundStyle(Nearlight.ink) + .accessibilityAddTraits(.isHeader) + Text("Use the account provided by your server administrator. Hermes is managed by your server.") + .font(.body) + .foregroundStyle(Nearlight.quiet) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, Nearlight.s16) + .listRowBackground(Nearlight.ground) + .listRowSeparator(.hidden) } Section("Account") { TextField("Username", text: $username).textContentType(.username) @@ -24,10 +35,20 @@ struct LoginView: View { password = "" Task { await auth.login(username: username, password: suppliedPassword) } } + .font(NearlightFont.control(typeSize)) + .buttonStyle(.borderedProminent) + .tint(Nearlight.rosewood) + .foregroundStyle(Nearlight.onRosewood) + .frame(minHeight: 44) + .padding(.vertical, Nearlight.s4) .disabled(auth.isBusy || username.isEmpty || password.isEmpty) - if auth.isBusy { ProgressView("Signing in…") } - if let error = auth.error { Text(error).foregroundStyle(.red) } + if auth.isBusy { ProgressView("Signing in…").tint(Nearlight.rosewood) } + if let error = auth.error { + Text(error).foregroundStyle(Nearlight.alert) + .fixedSize(horizontal: false, vertical: true) + } } + .listRowBackground(Nearlight.surface) Section("Server") { TextField("HTTPS server address", text: $server).keyboardType(.URL) .textInputAutocapitalization(.never).autocorrectionDisabled() @@ -37,17 +58,28 @@ struct LoginView: View { case .failure(let error): serverError = error.localizedDescription } }.disabled(auth.isBusy) - if let serverError { Text(serverError).foregroundStyle(.red) } + if let serverError { + Text(serverError).foregroundStyle(Nearlight.alert) + .fixedSize(horizontal: false, vertical: true) + } Text("Connections use HTTPS and secure WebSockets. A private server certificate must be trusted by this iPhone for conversations.") - .font(.footnote).foregroundStyle(.secondary) + .font(.footnote).foregroundStyle(Nearlight.quiet) + .fixedSize(horizontal: false, vertical: true) 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) + .font(.footnote).foregroundStyle(Nearlight.quiet) + .fixedSize(horizontal: false, vertical: true) } } + .listRowBackground(Nearlight.surface) } + .foregroundStyle(Nearlight.ink) + .tint(Nearlight.rosewood) + .scrollContentBackground(.hidden) + .background(Nearlight.ground) .navigationTitle("Hope") + .toolbarBackground(Nearlight.ground, for: .navigationBar) .onAppear { server = settings.serverURL } } } diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index 834d424..adf0955 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -102,7 +102,9 @@ struct SettingsView: View { } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) - .tint(Nearlight.quiet) + .scrollContentBackground(.hidden) + .background(Nearlight.ground) + .tint(Nearlight.rosewood) .sheet(isPresented: $showVoicePicker) { VoicePickerView(settings: settings, session: session) } diff --git a/UITests/VoiceUITests.swift b/UITests/VoiceUITests.swift index 0605f83..e5db4fc 100644 --- a/UITests/VoiceUITests.swift +++ b/UITests/VoiceUITests.swift @@ -15,6 +15,7 @@ final class VoiceUITests: XCTestCase { app.launchArguments.append("--ui-fixtures") app.launch() XCTAssertTrue(app.staticTexts["Help me plan a quiet weekend."].waitForExistence(timeout: 10)) + app.buttons["Account"].tap() app.buttons["Sign out"].tap() XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10)) let username = app.textFields["Username"] @@ -132,6 +133,23 @@ final class VoiceUITests: XCTestCase { capture("Accessibility text size") } + func testOpalPromptPreparesEditableDraftWithoutConnecting() { + app.launch() + XCTAssertTrue(app.staticTexts["A little room for you,"].waitForExistence(timeout: 10)) + app.buttons["Explore an idea"].tap() + let draft = app.textFields["Message Hope"] + XCTAssertTrue(draft.waitForExistence(timeout: 5)) + XCTAssertEqual(draft.value as? String, "Help me explore an idea.") + draft.tap() + draft.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: "Help me explore an idea.".count)) + XCTAssertTrue(draft.exists) + draft.typeText("My own thought") + XCTAssertEqual(draft.value as? String, "My own thought") + XCTAssertTrue(app.buttons["Start chatting"].exists) + XCTAssertTrue(app.staticTexts["Your microphone stays off."].exists) + capture("Opal editable prompt") + } + private func capture(_ name: String) { let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = name diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 60423f1..be03650 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -6,6 +6,38 @@ Reusable implementation, testing, device and deployment lessons are in [ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install results below are historical evidence, not a September 19 health check. +## Approved Opal pink redesign — 2026-10-02 + +User approved the Opal HTML direction with the existing square reactive persona. +Native implementation now uses luminous pink-white surfaces, readable plum text, +rose actions (#A64270), an account-derived personalized greeting, 184pt welcome / +112pt active Canvas presence, pastel starter prompts, and an inset floating dock. +The approved Opal overlapping-speech icon replaces the old glowing capsule. +Light/dark semantic roles, Dynamic Type and Reduce Motion remain supported. +Sign out moved into the Account menu; authentication, TLS, session lifetime, +account-scoped history, draft retention, transcript follow-latest behavior and +voice/text readiness gates are unchanged. Starter prompts prepare editable text +only: they never send automatically or enable microphone capture. + +Verification: simulator and signed generic-iPhone builds passed. Full offline run +passed **59 unit + 7 UI tests**, `/tmp/hope-opal-offline.xcresult`; three explicitly +opt-in TLS unit cases returned without live probes, not runtime TLS evidence. +Independent review identified editor disappearance on clearing a prompt; corrected +by keeping the Type draft editor mounted even when empty. Final focused draft-edit / +offline recovery run passed **2 UI tests**, `/tmp/hope-opal-draft-final.xcresult`. +Final pink idle-persona tint change rebuilt and **59 unit tests passed** again, +`/tmp/hope-opal-final-unit.log`. Native light/dark simulator screenshots inspected; +light screenshot `/Users/shane/dev/hope-voice-api/design-options/opal-native-light.png`. + +Initial physical install reset its transport, then retry was explicitly denied +because the iPhone was locked. After the user unlocked it, final signed build, +installation and launch **succeeded** on the paired iPhone 14 Pro Max at 13:27 local. +Artifact `/tmp/hope-opal-phone-build/Build/Products/Debug-iphoneos/Voice.app`, final +build log `/tmp/hope-opal-phone-install.log`. App data/credentials were preserved. +No backend, account data, saved credentials or production preferences were reset. +Real microphone capture, audible replies and interruptions were not requalified +by this UI work; successful installation is not a live voice-session claim. + ## Authorized Hermes migration — 2026-10-01 The direct user request supersedes the historical Qwen/client-model direction below.