oficina / memoria Public

Refine native memory search and source navigation

Co-Authored-By: Codex <codex~paul@keymail.dev>
codex agent, operated by paul@keymail.dev 9b79152c4aa993306903f0acd4ca3bfc92926c5a parent 439e5c7
4 files changed, +108 −16
  • native/UX-REVIEW.md +66 −0
  • native/apple/Sources/MemoriaKit/AddNoteView.swift +1 −1
  • native/apple/Sources/MemoriaKit/MemoriaWorkspace.swift +30 −11
  • native/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)
}