Refine native memory search and source navigation
Co-Authored-By: Codex <codex~paul@keymail.dev>
4 files changed,
+108
−16
native/UX-REVIEW.md+66 −0native/apple/Sources/MemoriaKit/AddNoteView.swift+1 −1native/apple/Sources/MemoriaKit/MemoriaWorkspace.swift+30 −11native/ios/UITests/NativeUITests.swift+11 −4
diff --git a/native/UX-REVIEW.md b/native/UX-REVIEW.md| new file mode 100644 |
| index 0000000..83d304d |
| --- /dev/null |
| +++ b/native/UX-REVIEW.md |
| @@ -0,0 +1,66 @@ |
| +# Memoria UX review · 12 September 2026 |
| + |
| +## MemoriaWorkspace.swift |
| + |
| +1. **Medium: give desktop results enough reading width.** The original narrow |
| + sidebar wrapped snippets excessively. Split-view width hints alone were |
| + ignored in the actual Mac render; a Mac-only minimum frame now enforces |
| + 280 points, with a 340-point ideal. The compact Answer action fits clearly. |
| + |
| + Before: column min 220 / ideal 260. |
| + After: column min 280 / ideal 340, plus `.frame(minWidth: 280, idealWidth: 340)` |
| + on Mac. iPhone keeps the native compact navigation layout. |
| + |
| +2. **Medium: remove internal indexing details.** Passage 0 and similar ordinals |
| + offered no useful navigation. Results now show a named source type, readable |
| + snippet and source number. An explicit results section separates matches |
| + from the Answer action. Result accessibility combines the row's description. |
| + |
| + Before: `Source [1] · Passage 0` |
| + After: `Source 1`, with source type and snippet above. |
| + |
| +3. **Medium: anchor answers to the current search.** The generic slogan is |
| + replaced by the query and a clear answer purpose. Before searching, a native |
| + ContentUnavailableView explains how to start. A stable thinking indicator |
| + stays visible while permission rechecks temporarily clear results. Sources |
| + have a divider, document labels and 44-point minimum tap areas. |
| + |
| +4. **Medium: prove source navigation in compact and split layouts.** The |
| + selected passage text has a detail-only `sourcePassage` identifier. Tests |
| + assert its existence and exact body rather than matching the sidebar snippet. |
| + Only compact navigation expects a Source navigation bar. The final test |
| + scopes its tap to the sidebar result cell: on iPad the same title also |
| + appears as the search-query heading, so a global text selector was ambiguous. |
| + |
| +## AddNoteView.swift |
| + |
| +**Medium: label the editable note body for assistive technology.** |
| + |
| +Before: `TextEditor(text: $bodyText)` |
| +After: `TextEditor(text: $bodyText).accessibilityLabel("Note")` |
| + |
| +Priority addressed: result readability and navigation, then contextual answers, |
| +progress feedback and editor accessibility. |
| + |
| +## Method and validation |
| + |
| +Applied `swiftui-pro` design, accessibility and navigation references, plus |
| +`swiftui-native-component-design` semantics/control/accessibility guidance. |
| +Reviewed actual SwiftUI renders on Mac, iPhone and iPad; checked light mode and |
| +accessibility-large Dynamic Type in dark mode on iPhone. Real forms/navigation |
| +are exercised through the isolated authenticated Go fixture and XCTest. |
| +No backend, auth, model retrieval or permission behavior changed. |
| + |
| +Independent reviewer: `/root/canary_release_review`, dispatched as `gpt-6-astra` |
| +with high reasoning; runtime identity not independently verified. Screenshots |
| +and source corrections were reviewed independently. Full VoiceOver walkthrough, |
| +localization and every accessibility size remain hands-on acceptance work. |
| + |
| +Final independent review approved. The scoped iPad test passed (15.496 seconds |
| +XCTest; 38.230 seconds complete fixture). The reviewer removed only the result |
| +cell tap: detail-only sourcePassage assertions failed as intended. The test was |
| +restored byte-for-byte to the passing version, then this mutation comment was |
| +added. The mutation's XCTest suite finished with the intended failures, but its |
| +xcodebuild watcher hung during teardown and was terminated; that watcher is not |
| +claimed as a completed normal test run. Evidence: `/tmp/memoria-ux-scoped-clean.log` |
| +and `/tmp/memoria-ux-scoped-mutation.log`. |
diff --git a/native/apple/Sources/MemoriaKit/AddNoteView.swift b/native/apple/Sources/MemoriaKit/AddNoteView.swift| index a99daa5..fec4aad 100644 |
| --- a/native/apple/Sources/MemoriaKit/AddNoteView.swift |
| +++ b/native/apple/Sources/MemoriaKit/AddNoteView.swift |
| @@ -13,7 +13,7 @@ struct AddNoteView: View { |
| NavigationStack { |
| Form { |
| Section { TextField("Title", text: $title) } |
| - Section("Note") { TextEditor(text: $bodyText).frame(minHeight: 220) } |
| + Section("Note") { TextEditor(text: $bodyText).frame(minHeight: 220).accessibilityLabel("Note") } |
| Section { Text("Stored in your team’s Memoria and available to people admitted to it.").font(.footnote).foregroundStyle(.secondary) } |
| if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } } |
| }.formStyle(.grouped).navigationTitle("Add note") |
diff --git a/native/apple/Sources/MemoriaKit/MemoriaWorkspace.swift b/native/apple/Sources/MemoriaKit/MemoriaWorkspace.swift| index 658a7f7..ed3f35f 100644 |
| --- a/native/apple/Sources/MemoriaKit/MemoriaWorkspace.swift |
| +++ b/native/apple/Sources/MemoriaKit/MemoriaWorkspace.swift |
| @@ -17,7 +17,9 @@ import SwiftUI |
| if !model.error.isEmpty { Label(model.error, systemImage: "exclamationmark.circle").foregroundStyle(.red) } |
| ForEach(model.notices, id: \.self) { Label($0, systemImage: "info.circle") } |
| if model.searched && !model.passages.isEmpty { |
| - Button("On-device answer", systemImage: "sparkles") { selection = nil; column = .detail } |
| + Section { |
| + Button("Answer", systemImage: "sparkles") { selection = nil; column = .detail } |
| + } |
| } |
| if !model.searched { |
| ContentUnavailableView("Find what your team knows", systemImage: "books.vertical", |
| @@ -25,16 +27,23 @@ import SwiftUI |
| } else if model.passages.isEmpty && !model.loading && model.error.isEmpty { |
| ContentUnavailableView.search(text: model.query) |
| } |
| + Section(model.passages.isEmpty ? "" : "\(model.passages.count) results") { |
| ForEach(Array(model.passages.enumerated()), id: \.element.id) { index, passage in |
| VStack(alignment: .leading, spacing: 6) { |
| Text(passage.title).font(.headline) |
| - Text(passage.heading.isEmpty ? passage.source : passage.heading).font(.caption).foregroundStyle(.secondary) |
| + Label(passage.source, systemImage: "doc.text").font(.subheadline).foregroundStyle(.secondary) |
| Text(passage.text).font(.callout).foregroundStyle(.secondary).lineLimit(3) |
| - Text("Source [\(index + 1)] · Passage \(passage.ordinal)").font(.caption).foregroundStyle(.tertiary) |
| + Text("Source \(index + 1)").font(.caption).foregroundStyle(.secondary) |
| }.padding(.vertical, 6).tag(passage.id) |
| + .accessibilityElement(children: .combine) |
| + } |
| } |
| } |
| - }.navigationTitle("Memoria").navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 340) |
| + } |
| + #if os(macOS) |
| + .frame(minWidth: 280, idealWidth: 340) |
| + #endif |
| + .navigationTitle("Memoria").navigationSplitViewColumnWidth(min: 280, ideal: 340, max: 440) |
| .searchable(text: $model.query, prompt: "Search your team’s memory") |
| .onSubmit(of: .search) { |
| selection = nil |
| @@ -56,13 +65,19 @@ import SwiftUI |
| ForEach(model.notices, id: \.self) { Label($0, systemImage: "info.circle").foregroundStyle(.secondary) } |
| if let passage = model.passages.first(where: { $0.id == selection }) { |
| Text(passage.title).font(.largeTitle.bold()) |
| - Label("\(passage.source) · Passage \(passage.ordinal)", systemImage: "doc.text").foregroundStyle(.secondary) |
| + Label(passage.source, systemImage: "doc.text").foregroundStyle(.secondary) |
| if !passage.path.isEmpty { Text(passage.path).font(.callout).foregroundStyle(.secondary) } |
| if !passage.heading.isEmpty { Text(passage.heading).font(.title2.bold()) } |
| - Text(passage.text).textSelection(.enabled) |
| + Text(passage.text).textSelection(.enabled).accessibilityIdentifier("sourcePassage") |
| } else { |
| - Text("Your team’s memory.").font(.largeTitle.bold()) |
| - Text("Search first. Follow the source. Keep the context.").foregroundStyle(.secondary) |
| + if !model.searched && !model.loading { |
| + ContentUnavailableView("Your team’s memory", systemImage: "books.vertical", |
| + description: Text("Search your notes and connected sources, then open a result to read it here.")) |
| + } else { |
| + Text(model.query).font(.largeTitle.bold()).textSelection(.enabled) |
| + Label("Answers grounded in your sources", systemImage: "sparkles").foregroundStyle(.secondary) |
| + } |
| + if model.answering { ProgressView("Thinking on this device…") } |
| if model.searched && !model.passages.isEmpty { |
| if model.localModelAvailable { |
| Button(model.answering ? "Thinking on this device…" : "Answer on this device", systemImage: "sparkles") { |
| @@ -78,15 +93,19 @@ import SwiftUI |
| if !model.answer.isEmpty { |
| Text("On-device answer · AI draft").font(.headline) |
| Text(model.answer).textSelection(.enabled) |
| + Divider() |
| Text("Sources").font(.headline) |
| ForEach(Array(model.passages.prefix(5).enumerated()), id: \.element.id) { index, passage in |
| - Button("[\(index + 1)] \(passage.title)") { selection = passage.id } |
| + Button { selection = passage.id } label: { |
| + Label("[\(index + 1)] \(passage.title)", systemImage: "doc.text") |
| + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) |
| + }.buttonStyle(.borderless) |
| } |
| } |
| } |
| }.frame(maxWidth: 720, alignment: .leading).padding(28).frame(maxWidth: .infinity, alignment: .leading) |
| - }.navigationTitle(selection == nil ? "Memory" : "Source") |
| - .toolbar { ToolbarItem { if selection != nil { Button("Overview", systemImage: "sparkles") { selection = nil } } } } |
| + }.navigationTitle(selection == nil ? "Answer" : "Source") |
| + .toolbar { ToolbarItem { if selection != nil { Button("Answer", systemImage: "sparkles") { selection = nil } } } } |
| } |
| .sheet(isPresented: $adding) { AddNoteView(model: model, client: client) } |
| .onChange(of: selection) { _, value in if value != nil { column = .detail } } |
diff --git a/native/ios/UITests/NativeUITests.swift b/native/ios/UITests/NativeUITests.swift| index 5dafcf1..53e981b 100644 |
| --- a/native/ios/UITests/NativeUITests.swift |
| +++ b/native/ios/UITests/NativeUITests.swift |
| @@ -9,10 +9,17 @@ import XCTest |
| XCTAssertTrue(title.waitForExistence(timeout: 5)); title.tap(); title.typeText("Native UI note") |
| let body=app.textViews.firstMatch;body.tap();body.typeText("A new note created through the native iPhone form.") |
| app.buttons["Save"].tap() |
| - XCTAssertTrue(app.staticTexts["Native UI note"].firstMatch.waitForExistence(timeout: 10)) |
| - app.staticTexts["Native UI note"].firstMatch.tap() |
| - XCTAssertTrue(app.navigationBars["Source"].waitForExistence(timeout: 5)) |
| - XCTAssertTrue(app.staticTexts["A new note created through the native iPhone form."].firstMatch.waitForExistence(timeout: 5)) |
| + let result = app.cells.containing(.staticText, identifier: "Native UI note").firstMatch |
| + XCTAssertTrue(result.waitForExistence(timeout: 10)) |
| + // Independent reviewer mutation, 2026-09-12: removing this tap fails the |
| + // detail-only sourcePassage assertions on iPad. Observed red, then restored. |
| + result.tap() |
| + if app.windows.firstMatch.frame.width < 600 { |
| + XCTAssertTrue(app.navigationBars["Source"].waitForExistence(timeout: 5)) |
| + } |
| + let source = app.staticTexts["sourcePassage"] |
| + XCTAssertTrue(source.waitForExistence(timeout: 5)) |
| + XCTAssertEqual(source.label, "A new note created through the native iPhone form.") |
| let capture=XCTAttachment(screenshot: app.screenshot());capture.lifetime = .keepAlways;add(capture) |
| } |