feat: apply pink Opal design with reactive square persona

This commit is contained in:
snowspeeder committed 2026-10-02 13:28:10 -04:00
1 parent 5b41b3924a
commit 74d2b63614
8 files changed
+199 -57

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

After

Width:  |  Height:  |  Size: 183 KiB

+21 -19
View File
@@ -8,21 +8,24 @@ import UIKit
enum Nearlight { enum Nearlight {
// MARK: Palette (light / dark variants) // MARK: Palette (light / dark variants)
/// Screen background. /// Opal screen background: luminous pink-white #FFFDFD / deep plum #201820.
static let ground = Color(light: UIColor(red: 0.957, green: 0.949, blue: 0.965, alpha: 1), static let ground = Color(light: UIColor(red: 255 / 255, green: 253 / 255, blue: 253 / 255, alpha: 1),
dark: UIColor(red: 0.110, green: 0.098, blue: 0.125, alpha: 1)) dark: UIColor(red: 32 / 255, green: 24 / 255, blue: 32 / 255, alpha: 1))
/// Controls, grouped rows, dock. /// Controls, grouped rows, dock: #FFF7FA / #302330.
static let surface = Color(light: .white, static let surface = Color(light: UIColor(red: 255 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1),
dark: UIColor(red: 0.157, green: 0.137, blue: 0.180, alpha: 1)) dark: UIColor(red: 48 / 255, green: 35 / 255, blue: 48 / 255, alpha: 1))
/// Primary text. /// The square persona's pale pink stage: #FFF2F7 / #3B2836.
static let ink = Color(light: UIColor(red: 0.161, green: 0.137, blue: 0.184, alpha: 1), static let personaSurface = Color(light: UIColor(red: 255 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1),
dark: UIColor(red: 0.965, green: 0.937, blue: 0.969, alpha: 1)) dark: UIColor(red: 59 / 255, green: 40 / 255, blue: 54 / 255, alpha: 1))
/// Supporting text, inactive icons. /// Primary text: readable dark plum #302B40 / #FFF2F7.
static let quiet = Color(light: UIColor(red: 0.396, green: 0.365, blue: 0.424, alpha: 1), static let ink = Color(light: UIColor(red: 48 / 255, green: 43 / 255, blue: 64 / 255, alpha: 1),
dark: UIColor(red: 0.749, green: 0.698, blue: 0.776, alpha: 1)) dark: UIColor(red: 255 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1))
/// Action tint, selection, Lightwell. /// Supporting text, inactive icons: plum #706074 / #CBB6C7.
static let rosewood = Color(light: UIColor(red: 0.529, green: 0.278, blue: 0.380, alpha: 1), static let quiet = Color(light: UIColor(red: 112 / 255, green: 96 / 255, blue: 116 / 255, alpha: 1),
dark: UIColor(red: 0.890, green: 0.635, blue: 0.737, 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. /// Errors and destructive actions.
static let alert = Color(light: UIColor(red: 0.675, green: 0.208, blue: 0.282, alpha: 1), 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)) 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) } static var selectedWash: Color { rosewood.opacity(0.10) }
/// Error wash: Alert at 8% over Surface. /// Error wash: Alert at 8% over Surface.
static var errorWash: Color { alert.opacity(0.08) } static var errorWash: Color { alert.opacity(0.08) }
/// Primary-action label: Surface in light, Ground in dark. /// Primary-action label: white on strong rose, deep plum on the dark-mode pink.
static var onRosewood: Color { static let onRosewood = Color(light: .white,
UITraitCollection.current.userInterfaceStyle == .dark ? ground : surface dark: UIColor(red: 32 / 255, green: 24 / 255, blue: 32 / 255, alpha: 1))
}
// MARK: Spacing scale // MARK: Spacing scale
@@ -50,11 +50,19 @@ struct ConversationView: View {
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
NavigationStack { NavigationStack {
ConversationContent(settings: settings, session: session) ConversationContent(settings: settings, session: session, displayName: user.displayName ?? user.username)
.toolbar { .toolbar {
settingsLink settingsLink
ToolbarItem(placement: .topBarLeading) { 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 var session: VoiceSession
@ObservedObject private var transcripts: TranscriptHandler @ObservedObject private var transcripts: TranscriptHandler
@ObservedObject private var conversation: ConversationStateModel @ObservedObject private var conversation: ConversationStateModel
let displayName: String
@State private var composerText = "" @State private var composerText = ""
@State private var sendError: String? @State private var sendError: String?
@State private var isSending = false @State private var isSending = false
@@ -173,9 +182,10 @@ private struct ConversationContent: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast @Environment(\.colorSchemeContrast) private var contrast
init(settings: SettingsStore, session: VoiceSession) { init(settings: SettingsStore, session: VoiceSession, displayName: String) {
self.settings = settings self.settings = settings
self.session = session self.session = session
self.displayName = displayName
transcripts = session.transcriptHandler transcripts = session.transcriptHandler
conversation = session.stateModel conversation = session.stateModel
} }
@@ -217,26 +227,66 @@ private struct ConversationContent: View {
private var welcome: some View { private var welcome: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: Nearlight.s24) { VStack(spacing: Nearlight.s16) {
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode)) VStack(spacing: Nearlight.s8) {
.frame(maxWidth: .infinity) Text("A little room for you,")
.padding(.top, Nearlight.s32) .font(.largeTitle.weight(.regular))
VStack(alignment: .leading, spacing: Nearlight.s12) { Text(greetingName + ".")
Text("A moment with Hope.") .font(.system(.largeTitle, design: .serif).italic())
.font(NearlightFont.welcome(typeSize)) .foregroundStyle(Nearlight.rosewood)
.fixedSize(horizontal: false, vertical: true) Text("No perfect words needed. Start anywhere.")
Text("Speak or type to your own assistant.") .font(.subheadline)
.font(NearlightFont.support(typeSize))
.foregroundStyle(Nearlight.quiet) .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(.horizontal, Nearlight.s20)
.padding(.bottom, Nearlight.s24) .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. /// Status text 24 below the supporting copy on welcome.
private var statusLine: some View { private var statusLine: some View {
Text(statusTitle) Text(statusTitle)
@@ -369,21 +419,17 @@ private struct ConversationContent: View {
else { HStack(spacing: Nearlight.s12) { muteButton; micMeter } } else { HStack(spacing: Nearlight.s12) { muteButton; micMeter } }
endButton endButton
} else { } else {
if settings.textMode { draftPreview }
startControls startControls
} }
} }
.padding(.horizontal, Nearlight.s20) .padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s12) .padding(.top, Nearlight.s12)
.padding(.bottom, Nearlight.s12) .padding(.bottom, Nearlight.s12)
.background( .background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
// Dock: Surface, 16-point top corners, decorative top separator. .padding(.horizontal, Nearlight.s16)
VStack(spacing: 0) { .padding(.bottom, Nearlight.s8)
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 { @ViewBuilder private var modeSelector: some View {
@@ -431,6 +477,16 @@ private struct ConversationContent: View {
.accessibilityAddTraits(selected ? .isSelected : []) .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 { private var startControls: some View {
VStack(spacing: Nearlight.s8) { VStack(spacing: Nearlight.s8) {
Text(settings.textMode ? "Your microphone stays off." : "Your microphone starts after you connect.") 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 { private var typeControls: some View {
VStack(spacing: Nearlight.s12) { VStack(spacing: Nearlight.s12) {
HStack(alignment: .bottom, spacing: Nearlight.s8) { 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) .lineLimit(1...5)
.padding(Nearlight.s12) .padding(Nearlight.s12)
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl)) .background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
@@ -18,8 +18,8 @@ struct LightwellView: View {
@Environment(\.dynamicTypeSize) private var typeSize @Environment(\.dynamicTypeSize) private var typeSize
private var side: CGFloat { private var side: CGFloat {
if compact { return typeSize.isAccessibilitySize ? 88 : 136 } if compact { return typeSize.isAccessibilitySize ? 80 : 112 }
return typeSize.isAccessibilitySize ? 160 : 220 return typeSize.isAccessibilitySize ? 136 : 184
} }
private var animated: Bool { private var animated: Bool {
@@ -41,7 +41,7 @@ struct LightwellView: View {
} }
} }
.frame(width: side, height: side) .frame(width: side, height: side)
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock)) .background(Nearlight.personaSurface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
.overlay { .overlay {
RoundedRectangle(cornerRadius: Nearlight.radiusDock) RoundedRectangle(cornerRadius: Nearlight.radiusDock)
.strokeBorder(contrast == .increased ? Nearlight.quiet : Nearlight.separator, lineWidth: 1) .strokeBorder(contrast == .increased ? Nearlight.quiet : Nearlight.separator, lineWidth: 1)
@@ -52,7 +52,7 @@ struct LightwellView: View {
private var tint: Color { private var tint: Color {
switch phase { switch phase {
case .failed: return Nearlight.alert case .failed: return Nearlight.alert
case .idle, .tearingDown, .readyNoInput: return Nearlight.quiet case .tearingDown: return Nearlight.quiet
default: return Nearlight.rosewood default: return Nearlight.rosewood
} }
} }
+40 -8
View File
@@ -7,13 +7,24 @@ struct LoginView: View {
@State private var password = "" @State private var password = ""
@State private var server = "" @State private var server = ""
@State private var serverError: String? @State private var serverError: String?
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section { Section {
Text("Sign in to Hope").font(.title2.bold()) VStack(alignment: .leading, spacing: Nearlight.s12) {
Text("Use the account provided by your server administrator. Hermes is managed by your server.") Text("Sign in to Hope")
.foregroundStyle(.secondary) .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") { Section("Account") {
TextField("Username", text: $username).textContentType(.username) TextField("Username", text: $username).textContentType(.username)
@@ -24,10 +35,20 @@ struct LoginView: View {
password = "" password = ""
Task { await auth.login(username: username, password: suppliedPassword) } 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) .disabled(auth.isBusy || username.isEmpty || password.isEmpty)
if auth.isBusy { ProgressView("Signing in…") } if auth.isBusy { ProgressView("Signing in…").tint(Nearlight.rosewood) }
if let error = auth.error { Text(error).foregroundStyle(.red) } if let error = auth.error {
Text(error).foregroundStyle(Nearlight.alert)
.fixedSize(horizontal: false, vertical: true)
}
} }
.listRowBackground(Nearlight.surface)
Section("Server") { Section("Server") {
TextField("HTTPS server address", text: $server).keyboardType(.URL) TextField("HTTPS server address", text: $server).keyboardType(.URL)
.textInputAutocapitalization(.never).autocorrectionDisabled() .textInputAutocapitalization(.never).autocorrectionDisabled()
@@ -37,17 +58,28 @@ struct LoginView: View {
case .failure(let error): serverError = error.localizedDescription case .failure(let error): serverError = error.localizedDescription
} }
}.disabled(auth.isBusy) }.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.") 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 { 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")) 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.") 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") .navigationTitle("Hope")
.toolbarBackground(Nearlight.ground, for: .navigationBar)
.onAppear { server = settings.serverURL } .onAppear { server = settings.serverURL }
} }
} }
+3 -1
View File
@@ -102,7 +102,9 @@ struct SettingsView: View {
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.tint(Nearlight.quiet) .scrollContentBackground(.hidden)
.background(Nearlight.ground)
.tint(Nearlight.rosewood)
.sheet(isPresented: $showVoicePicker) { .sheet(isPresented: $showVoicePicker) {
VoicePickerView(settings: settings, session: session) VoicePickerView(settings: settings, session: session)
} }
+18
View File
@@ -15,6 +15,7 @@ final class VoiceUITests: XCTestCase {
app.launchArguments.append("--ui-fixtures") app.launchArguments.append("--ui-fixtures")
app.launch() app.launch()
XCTAssertTrue(app.staticTexts["Help me plan a quiet weekend."].waitForExistence(timeout: 10)) XCTAssertTrue(app.staticTexts["Help me plan a quiet weekend."].waitForExistence(timeout: 10))
app.buttons["Account"].tap()
app.buttons["Sign out"].tap() app.buttons["Sign out"].tap()
XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10)) XCTAssertTrue(app.secureTextFields["Password"].waitForExistence(timeout: 10))
let username = app.textFields["Username"] let username = app.textFields["Username"]
@@ -132,6 +133,23 @@ final class VoiceUITests: XCTestCase {
capture("Accessibility text size") 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) { private func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot()) let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name attachment.name = name
+32
View File
@@ -6,6 +6,38 @@ Reusable implementation, testing, device and deployment lessons are in
[ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install [ENGINEERING_LEARNINGS.md](ENGINEERING_LEARNINGS.md). September 12 test/install
results below are historical evidence, not a September 19 health check. 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 ## Authorized Hermes migration — 2026-10-01
The direct user request supersedes the historical Qwen/client-model direction below. The direct user request supersedes the historical Qwen/client-model direction below.