diff --git a/apps/native/Sources/VisionNativeCore/Navigation/NativeCommandPaletteModel.swift b/apps/native/Sources/VisionNativeCore/Navigation/NativeCommandPaletteModel.swift index 48a33ffa7..1fb613d2b 100644 --- a/apps/native/Sources/VisionNativeCore/Navigation/NativeCommandPaletteModel.swift +++ b/apps/native/Sources/VisionNativeCore/Navigation/NativeCommandPaletteModel.swift @@ -125,9 +125,29 @@ import Observation /// Dynamic answers/search hits are force-mounted, as in cmdk. Navigation/actions /// use cmdk's localized label + URL fuzzy score; groups/items keep stable ties. public func groups(strings: any Localizing) -> [NativeCommandPaletteGroup] { - var result: [NativeCommandPaletteGroup] = [] - func group(_ id: String, _ key: String, _ entries: [NativeCommandPaletteEntry]) { - if !entries.isEmpty { result.append(.init(id: id, title: strings.t(key), entries: entries)) } + // cmdk scores every item, force-mounted ones included, and orders the groups by their + // best item (ties keep mount order); force-mounted groups only stay visible at 0. So the + // ticker, answer, search and Payees groups take part in the ordering too, scored on the + // `value` strings the web items carry. + var filtered: [(group: NativeCommandPaletteGroup, score: Double, index: Int)] = [] + func group( + _ id: String, _ key: String, _ entries: [NativeCommandPaletteEntry], + value: (NativeCommandPaletteEntry) -> String + ) { + guard !entries.isEmpty else { return } + typealias Scored = (entry: NativeCommandPaletteEntry, score: Double, index: Int) + var scored: [Scored] = [] + for (index, entry) in entries.enumerated() { + let score: Double = + query.isEmpty ? 1 : NativeCommandPaletteQueries.score(value(entry), query: query) + scored.append((entry: entry, score: score, index: index)) + } + scored.sort { (lhs: Scored, rhs: Scored) -> Bool in + lhs.score == rhs.score ? lhs.index < rhs.index : lhs.score > rhs.score + } + let ordered: [NativeCommandPaletteEntry] = scored.map { (item: Scored) in item.entry } + let built = NativeCommandPaletteGroup(id: id, title: strings.t(key), entries: ordered) + filtered.append((group: built, score: scored[0].score, index: filtered.count)) } if let tickerSymbol, quote != nil || tickerLoading { let symbol = quote?.symbol ?? tickerSymbol @@ -140,7 +160,8 @@ import Observation action: .navigate( .init(.marketLookup, query: [.init(name: "symbol", value: symbol)])), quote: quote) - ]) + ] + ) { _ in "market \(symbol) \(self.quote?.name ?? "")" } } if let answer { group( @@ -150,7 +171,8 @@ import Observation id: "answer", title: answer.text, symbol: answer.kind == .fx ? "arrow.left.arrow.right" : "plus.forwardslash.minus", hint: strings.t("commandPalette.copyHint"), action: .copy(answer.text)) - ]) + ] + ) { _ in "result \(query)" } } if trimmedQuery.utf16.count >= 2 { group( @@ -162,7 +184,8 @@ import Observation symbol: "list.bullet.rectangle.portrait", hint: nil, action: .navigate( .init(.transactions, query: [.init(name: "search", value: trimmedQuery)]))) - ]) + ] + ) { _ in "tx-search \(query)" } } group( "recipients", "nav.recipients", @@ -176,7 +199,8 @@ import Observation .init(name: "recipient_id", value: String($0.id.rawValue)), .init(name: "filter_label", value: $0.name), ]))) - }) + } + ) { "recipient \($0.title) \(query)" } let pages = Self.pageGroups(adminMode: settings.adminMode).flatMap(\.pages) if trimmedQuery.isEmpty { group( @@ -184,17 +208,17 @@ import Observation recents.enumerated().compactMap { index, url in guard let page = pages.first(where: { $0.url == url }) else { return nil } return pageEntry(page, strings: strings, prefix: "recent-\(index)-") - }) + } + ) { "recent \($0.title) \(navigationURL($0.action))" } } - var filtered: [(group: NativeCommandPaletteGroup, score: Double, index: Int)] = [] - for (index, section) in Self.pageGroups(adminMode: settings.adminMode).enumerated() { + for section in Self.pageGroups(adminMode: settings.adminMode) { let entries = section.pages.map { pageEntry($0, strings: strings) } let ranked = ranked(entries) { $0.title + " " + navigationURL($0.action) } if !ranked.isEmpty { filtered.append( ( .init(id: section.key, title: strings.t(section.key), entries: ranked.map(\.entry)), - ranked[0].score, index + ranked[0].score, filtered.count )) } } @@ -221,12 +245,11 @@ import Observation ( .init( id: "actions", title: strings.t("commandPalette.actions"), entries: rankedActions.map(\.entry)), - rankedActions[0].score, filtered.count + 5 + rankedActions[0].score, filtered.count )) } if !query.isEmpty { filtered.sort { $0.score == $1.score ? $0.index < $1.index : $0.score > $1.score } } - result += filtered.map(\.group) - return result + return filtered.map(\.group) } private static func pageGroups(adminMode: Bool) -> [(key: String, pages: [NavItem])] { diff --git a/apps/native/Sources/VisionNativeUI/AIInvestigationDocumentsView.swift b/apps/native/Sources/VisionNativeUI/AIInvestigationDocumentsView.swift index f20e6900a..f55fa9699 100644 --- a/apps/native/Sources/VisionNativeUI/AIInvestigationDocumentsView.swift +++ b/apps/native/Sources/VisionNativeUI/AIInvestigationDocumentsView.swift @@ -49,9 +49,11 @@ struct AIInvestigationDocumentsView: View { } }.padding(.top, 12) } label: { - VStack(alignment: .leading, spacing: 4) { - Text(strings.t("aiResearch.evidenceOptions")).font(.callout.weight(.medium)) - Text(summary).font(.caption).foregroundStyle(theme.secondaryLabel) + // `DisclosureSummary className="flex flex-wrap items-baseline gap-x-2"`: the title and + // its footnote summary on one line, wrapping only when the row is too narrow. + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 8) { evidenceTitle; evidenceSummary } + VStack(alignment: .leading, spacing: 4) { evidenceTitle; evidenceSummary } } } .padding(.vertical, 10) @@ -84,6 +86,12 @@ struct AIInvestigationDocumentsView: View { .accessibilityLabel(strings.t("aiResearch.deleteDocument") + ": " + document.title) }.padding(.vertical, 10) } + private var evidenceTitle: some View { + Text(strings.t("aiResearch.evidenceOptions")).font(.callout.weight(.medium)) + } + private var evidenceSummary: some View { + Text(summary).font(.footnote).foregroundStyle(theme.secondaryLabel) + } private func dateField(_ key: String, value: Binding) -> some View { VStack(alignment: .leading, spacing: 6) { Text(strings.t(key)).font(.caption) diff --git a/apps/native/Sources/VisionNativeUI/AIInvestigationView.swift b/apps/native/Sources/VisionNativeUI/AIInvestigationView.swift index f553ffc0c..660277bb5 100644 --- a/apps/native/Sources/VisionNativeUI/AIInvestigationView.swift +++ b/apps/native/Sources/VisionNativeUI/AIInvestigationView.swift @@ -90,12 +90,19 @@ struct AIInvestigationView: View { .visionAccessibilityLabel(strings.t("aiResearch.researchMode")) }.padding(.top, 12) } label: { - VStack(alignment: .leading, spacing: 4) { - Text(strings.t("aiResearch.configure")).font(.callout.weight(.medium)) - Text(settingsSummary).font(.caption).foregroundStyle(theme.secondaryLabel) + // `flex flex-wrap items-baseline gap-x-2`: title and summary share one line. + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 8) { configureTitle; configureSummary } + VStack(alignment: .leading, spacing: 4) { configureTitle; configureSummary } } } } + private var configureTitle: some View { + Text(strings.t("aiResearch.configure")).font(.callout.weight(.medium)) + } + private var configureSummary: some View { + Text(settingsSummary).font(.footnote).foregroundStyle(theme.secondaryLabel) + } @ViewBuilder private var publicInputs: some View { if model.researchMode == .publicWeb { input( @@ -110,19 +117,27 @@ struct AIInvestigationView: View { Text(strings.t("aiResearch.publicSymbolsHelp")).font(.caption).foregroundStyle(theme.secondaryLabel) } } + /// The lifecycle row (`flex flex-wrap items-center gap-2`): the buttons, then the state note + /// in the same row, as a footnote. private var lifecycle: some View { - VStack(alignment: .leading, spacing: 12) { - ViewThatFits(in: .horizontal) { - HStack(spacing: 12) { lifecycleActions } - VStack(alignment: .leading, spacing: 10) { lifecycleActions } + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + lifecycleActions + lifecycleNote + } + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { lifecycleActions } + lifecycleNote } - Text( - model.job.map { strings.t("aiResearch.jobState", ["state": $0.state.rawValue]) } - ?? strings.t("aiResearch.localDefault") - ) - .font(.caption).foregroundStyle(theme.secondaryLabel) } } + private var lifecycleNote: some View { + Text( + model.job.map { strings.t("aiResearch.jobState", ["state": $0.state.rawValue]) } + ?? strings.t("aiResearch.localDefault") + ) + .font(.footnote).foregroundStyle(theme.secondaryLabel) + } @ViewBuilder private var lifecycleActions: some View { Button { Task { await model.run() } diff --git a/apps/native/Sources/VisionNativeUI/AccountDetailView.swift b/apps/native/Sources/VisionNativeUI/AccountDetailView.swift index a2fc403db..918af9c8a 100644 --- a/apps/native/Sources/VisionNativeUI/AccountDetailView.swift +++ b/apps/native/Sources/VisionNativeUI/AccountDetailView.swift @@ -378,13 +378,25 @@ struct AccountDetailContent: View { Text(strings.t("accounts.detail.details")) .font(Font.visionTitle3) .accessibilityAddTraits(.isHeader) - Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 6) { - ForEach(model.details(account)) { entry in - GridRow { - Text(strings.t(entry.labelKey)) - .font(.footnote) - .foregroundStyle(theme.secondaryLabel) - Text(entry.value) + // `
`: each entry is + // a footnote label over its body value. + VisionBreakpointReader(breakpoint: VisionBreakpoint.sm) { wide in + LazyVGrid( + columns: Array( + repeating: GridItem(.flexible(), spacing: 16, alignment: .topLeading), count: wide ? 4 : 2), + alignment: .leading, spacing: 12 + ) { + ForEach(model.details(account)) { entry in + VStack(alignment: .leading, spacing: 0) { + Text(strings.t(entry.labelKey)) + .font(.footnote) + .foregroundStyle(theme.secondaryLabel) + Text(entry.value) + .monospacedDigit() + .foregroundStyle(theme.foreground) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) } } } diff --git a/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift b/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift index dadf04e6b..877c2e92a 100644 --- a/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift +++ b/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift @@ -21,7 +21,7 @@ public struct AnalysisWorkspaceView: View { private var strings: any Localizing { shell.strings } public var body: some View { - GeometryReader { geometry in + GeometryReader { _ in ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: VisionPageLayout.spacing) { @@ -29,12 +29,10 @@ public struct AnalysisWorkspaceView: View { title: strings.t("analysis.title"), subtitle: strings.t("analysis.subtitle"), symbol: VisionPageSymbol.of("/analysis", fallback: "chart.bar")) workflow(proxy) - let wide = geometry.size.width >= 1140 - let layout = - wide - ? AnyLayout(HStackLayout(alignment: .top, spacing: 24)) - : AnyLayout(VStackLayout(alignment: .leading, spacing: 24)) - layout { + // The web page is one column: the editor and result cards, then + // `