Archived
feat: apply pink Opal design with reactive square persona
This commit is contained in:
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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user