From 5bf2cb792d385816190b9825d59c52fdd9988e9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 17:11:26 +0000 Subject: [PATCH 1/6] fix(native): hover only the Sankey bar or band under the pointer SwiftUI's onHover tracks a view's frame, not its shape, so every band covered the whole chart and the topmost band lit wherever the pointer was. One tracker now hit-tests like the SVG: node bars by their rectangles first, then bands by their stroked outlines, last drawn first; empty space hovers nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QJtaMxiuFABVL2LiMKy5m --- .../VisionNativeUI/StatisticsFlowView.swift | 63 +++++++++++-------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift b/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift index 9a902d21a..d792580a8 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift @@ -40,25 +40,17 @@ struct StatisticsFlowView: View { hoveredLink == link.id || hoveredNode == link.link.source || hoveredNode == link.link.target // Stroked with the target node's colour, 25% opaque, 60% when highlighted. let target = layout.nodes.first { $0.id == link.link.target } - // The band as its stroked outline, so the view's own shape is the hit area: the - // pointer is on a link only over its band, as an SVG `` stroke is. - curve(link, margin: margin) + // The band as its stroked outline: the same path the hover test below uses. + Self.curve(link, margin: margin) .strokedPath(StrokeStyle(lineWidth: max(1, CGFloat(link.width)))) .fill(target.map { color($0.node) } ?? theme.mutedForeground) .opacity(isHovered ? 0.6 : 0.25) .animation(fade, value: isHovered) - .onHover { inside in - // `onMouseEnter` / `onMouseLeave`; a late leave never clears a newer link. - if inside { - if hoveredLink != link.id { hoveredLink = link.id } - } else if hoveredLink == link.id { - hoveredLink = nil - } - } + .allowsHitTesting(false) } .accessibilityHidden(true) ForEach(layout.nodes) { node in - let bounds = rect(node, margin: margin) + let bounds = Self.rect(node, margin: margin) let isHovered = hoveredNode == node.id // Labels 6 pt off the node edge at its vertical middle, like the web chart: nodes in // the right half carry theirs on the left (end-anchored), all others on the right. @@ -68,14 +60,7 @@ struct StatisticsFlowView: View { .opacity(isHovered ? 1 : 0.85) .animation(fade, value: isHovered) .frame(width: bounds.width, height: bounds.height) - .contentShape(Rectangle()) - .onHover { inside in - if inside { - if hoveredNode != node.id { hoveredNode = node.id } - } else if hoveredNode == node.id { - hoveredNode = nil - } - } + .allowsHitTesting(false) .position(x: bounds.midX, y: bounds.midY) .accessibilityLabel(label(node.node)) .accessibilityValue( @@ -105,23 +90,51 @@ struct StatisticsFlowView: View { } } .frame(width: size.width, height: size.height, alignment: .topLeading) - // Each node bar and link band tracks its own hover (SVG `onMouseEnter`/`onMouseLeave`), - // nodes drawn over links. One tracker for the whole chart, testing nodes first by their - // rectangles, lit a node's whole section over the band beside it. + .contentShape(Rectangle()) + // SwiftUI's `onHover` tracks a view's whole frame, not its shape, so per-band trackers + // all covered the chart and the topmost band won everywhere. One tracker hit-tests the + // SVG way instead: the topmost painted shape under the pointer, node bars (drawn over + // the bands) by their exact rectangles, then bands by their stroked outlines, last + // drawn first. Empty space hovers nothing. + .onContinuousHover { phase in + switch phase { + case .active(let point): + let hit = Self.hit(at: point, layout: layout, margin: margin) + if hoveredNode != hit.node { hoveredNode = hit.node } + if hoveredLink != hit.link { hoveredLink = hit.link } + case .ended: + if hoveredNode != nil { hoveredNode = nil } + if hoveredLink != nil { hoveredLink = nil } + } + } }.frame(minHeight: 380) .onChange(of: hoveredNode) { _, _ in onHoverChange(hoveredNode, hoveredLink) } .onChange(of: hoveredLink) { _, _ in onHoverChange(hoveredNode, hoveredLink) } } + /// The node or band painted topmost at `point`, as SVG `onMouseEnter` targets resolve. + static func hit( + at point: CGPoint, layout: StatisticsFlowLayout, margin: CGFloat + ) -> (node: String?, link: String?) { + for node in layout.nodes.reversed() where rect(node, margin: margin).contains(point) { + return (node.id, nil) + } + for link in layout.links.reversed() { + let band = curve(link, margin: margin).strokedPath(StrokeStyle(lineWidth: max(1, CGFloat(link.width)))) + if band.contains(point) { return (nil, link.id) } + } + return (nil, nil) + } + /// The node's rectangle in view coordinates; at least 1 pt tall, as on the web. - private func rect(_ node: StatisticsFlowNodeLayout, margin: CGFloat) -> CGRect { + private static func rect(_ node: StatisticsFlowNodeLayout, margin: CGFloat) -> CGRect { CGRect( x: margin + CGFloat(node.x0), y: margin + CGFloat(node.y0), width: CGFloat(node.x1 - node.x0), height: max(1, CGFloat(node.y1 - node.y0))) } /// The link's centre line: the web `sankeyLinkPath` curve, M sx,y0 C mx,y0 mx,y1 tx,y1. - private func curve(_ link: StatisticsFlowLinkLayout, margin: CGFloat) -> Path { + private static func curve(_ link: StatisticsFlowLinkLayout, margin: CGFloat) -> Path { let sx = margin + CGFloat(link.sourceX) let tx = margin + CGFloat(link.targetX) let mx = (sx + tx) / 2 From 66ff04ebe9a19eda18caa4c6bb36ef8339fad47c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 17:11:26 +0000 Subject: [PATCH 2/6] fix(native): keep the aurora canvas as one layer across page switches The canvas was the page container's background inside the zoom view, so a page switch re-laid it out with the new page and it stopped at the titlebar strip. It now sits beside the page in the detail column, runs under the titlebar strip as the web canvas does and is not rebuilt when the route changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QJtaMxiuFABVL2LiMKy5m --- .../native/Sources/VisionNativeUI/ShellRootView.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/native/Sources/VisionNativeUI/ShellRootView.swift b/apps/native/Sources/VisionNativeUI/ShellRootView.swift index 5e9ba056e..e62f74cf1 100644 --- a/apps/native/Sources/VisionNativeUI/ShellRootView.swift +++ b/apps/native/Sources/VisionNativeUI/ShellRootView.swift @@ -77,6 +77,13 @@ public struct ShellRootView: View { // and under the titlebar strip; the other states stack them above. if !isNativeDetail { shellBanners } ZStack { + // `.liquid-canvas` is one fixed layer under every route, never part of a page: the + // canvas sits beside the page here, so a page switch (a new `.id`, new page sizes) + // never rebuilds it, resizes its Metal drawable or restarts its drift. + if isNativeDetail { + FrozenWidthLayout(width: frozenDetailWidth) { DetailGlassBackground() } + .mask { Rectangle().padding(.vertical, -400) } + } if case .native(let screen) = model.detail { FrozenWidthLayout(width: frozenDetailWidth) { NativePageZoomView(factor: model.pageZoom) { @@ -766,11 +773,11 @@ struct NativeScreenContainer: View { var body: some View { content .environment(\.visionContainerOwnsFocus, focused) - // PageTransition keys on the pathname; the canvas behind it stays put. + // PageTransition keys on the pathname. .modifier(PageEntrance()) .id(model.route.path) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(DetailGlassBackground()) + // The canvas behind the page is `pageColumn`'s, so it outlives the page. .focusable() .focusEffectDisabled() .focused($focused) From 3bc0308951ef85164d4ceb75d601ec2488235abe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 17:11:26 +0000 Subject: [PATCH 3/6] fix(native): scroll narrow tables sideways instead of squeezing cells Like the web overflow-auto wrapper: VisionTable keeps its columns at their content widths and scrolls horizontally when the card is narrower (the diagnostics MAPE column no longer wraps), the transactions table stops at its column minimums and scrolls, and the grid tables on account detail, rebalance and the analysis report use HorizontalOverflowScroll. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QJtaMxiuFABVL2LiMKy5m --- .../VisionNativeUI/AccountDetailView.swift | 116 +++++++++--------- .../AnalysisWorkbenchReportView.swift | 12 +- .../Components/HorizontalOverflowScroll.swift | 46 +++++++ .../Components/VisionTable.swift | 38 ++++-- .../VisionNativeUI/RebalanceView.swift | 38 +++--- .../VisionNativeUI/TransactionsTable.swift | 24 +++- 6 files changed, 182 insertions(+), 92 deletions(-) create mode 100644 apps/native/Sources/VisionNativeUI/Components/HorizontalOverflowScroll.swift diff --git a/apps/native/Sources/VisionNativeUI/AccountDetailView.swift b/apps/native/Sources/VisionNativeUI/AccountDetailView.swift index 918af9c8a..1b76ae40c 100644 --- a/apps/native/Sources/VisionNativeUI/AccountDetailView.swift +++ b/apps/native/Sources/VisionNativeUI/AccountDetailView.swift @@ -336,27 +336,29 @@ struct AccountDetailContent: View { SkeletonBar(width: nil, height: 40) .accessibilityLabel(strings.t("common.loading")) } else { - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { - GridRow { - Text(strings.t("txPage.col.date")) - Text(strings.t("txPage.field.description")) - .gridColumnAlignment(.leading) - Text(strings.t("txPage.col.amount")) - .gridColumnAlignment(.trailing) - } - .font(.footnote.weight(.medium)) - .foregroundStyle(theme.secondaryLabel) - ForEach(model.plannedRows) { row in - Divider() + HorizontalOverflowScroll { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { - Text(VisionFormat.appDate(row.date, format: settings.dateFormat)) - .monospacedDigit() - Text(row.title ?? strings.t("accounts.detail.unlabelled")) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - MoneyText( - amount: row.amount, currency: (row.currency ?? account.currency), settings: settings, - signed: true) + Text(strings.t("txPage.col.date")) + Text(strings.t("txPage.field.description")) + .gridColumnAlignment(.leading) + Text(strings.t("txPage.col.amount")) + .gridColumnAlignment(.trailing) + } + .font(.footnote.weight(.medium)) + .foregroundStyle(theme.secondaryLabel) + ForEach(model.plannedRows) { row in + Divider() + GridRow { + Text(VisionFormat.appDate(row.date, format: settings.dateFormat)) + .monospacedDigit() + Text(row.title ?? strings.t("accounts.detail.unlabelled")) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + MoneyText( + amount: row.amount, currency: (row.currency ?? account.currency), settings: settings, + signed: true) + } } } } @@ -499,46 +501,48 @@ struct AccountLedgerCard: View { } private var table: some View { - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { - GridRow { - Text(strings.t("txPage.col.date")) - Text(strings.t("txPage.field.description")) - Text(strings.t("txPage.col.category")) - Text(strings.t("txPage.col.amount")) - .gridColumnAlignment(.trailing) - Text(strings.t("txPage.field.balance")) - .gridColumnAlignment(.trailing) - } - .font(.footnote.weight(.medium)) - .foregroundStyle(theme.secondaryLabel) - ForEach(model.visibleLedger) { row in - Divider() + HorizontalOverflowScroll { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { - Text(VisionFormat.appDate(row.date, format: settings.dateFormat)) - .monospacedDigit() - VStack(alignment: .leading, spacing: 1) { - Text(row.title ?? strings.t("accounts.detail.unlabelled")) - .lineLimit(1) - if let memo = row.subtitle { - Text(memo) - .font(.footnote) - .foregroundStyle(theme.secondaryLabel) + Text(strings.t("txPage.col.date")) + Text(strings.t("txPage.field.description")) + Text(strings.t("txPage.col.category")) + Text(strings.t("txPage.col.amount")) + .gridColumnAlignment(.trailing) + Text(strings.t("txPage.field.balance")) + .gridColumnAlignment(.trailing) + } + .font(.footnote.weight(.medium)) + .foregroundStyle(theme.secondaryLabel) + ForEach(model.visibleLedger) { row in + Divider() + GridRow { + Text(VisionFormat.appDate(row.date, format: settings.dateFormat)) + .monospacedDigit() + VStack(alignment: .leading, spacing: 1) { + Text(row.title ?? strings.t("accounts.detail.unlabelled")) .lineLimit(1) + if let memo = row.subtitle { + Text(memo) + .font(.footnote) + .foregroundStyle(theme.secondaryLabel) + .lineLimit(1) + } } - } - .frame(maxWidth: .infinity, alignment: .leading) - Text(row.categoryName ?? "—") - .foregroundStyle(theme.secondaryLabel) - .lineLimit(1) - Text(MoneyText.text(row.amount, currency: row.currency, settings: settings, signed: true)) - .monospacedDigit() - .foregroundStyle(row.amount < 0 ? theme.loss : (row.amount > 0 ? theme.gain : theme.secondaryLabel)) - if let balance = row.runningBalance { - MoneyText(amount: balance, currency: row.currency, settings: settings, signed: false, colored: false) - .foregroundStyle(theme.secondaryLabel) - } else { - Text(verbatim: "—") + .frame(maxWidth: .infinity, alignment: .leading) + Text(row.categoryName ?? "—") .foregroundStyle(theme.secondaryLabel) + .lineLimit(1) + Text(MoneyText.text(row.amount, currency: row.currency, settings: settings, signed: true)) + .monospacedDigit() + .foregroundStyle(row.amount < 0 ? theme.loss : (row.amount > 0 ? theme.gain : theme.secondaryLabel)) + if let balance = row.runningBalance { + MoneyText(amount: balance, currency: row.currency, settings: settings, signed: false, colored: false) + .foregroundStyle(theme.secondaryLabel) + } else { + Text(verbatim: "—") + .foregroundStyle(theme.secondaryLabel) + } } } } diff --git a/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift b/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift index 9ab50e02f..d8c74fba7 100644 --- a/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift +++ b/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift @@ -14,11 +14,13 @@ struct AnalysisWorkbenchReportView: View { VStack(alignment: .leading, spacing: 12) { errors(raw["errors"]?.arrayValue ?? []) if let summaries = raw["summaries"]?.objectValue { - Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) { - ForEach(summaries.keys.sorted(), id: \.self) { id in - GridRow { - Text(model.fieldLabel(id, strings: strings)) - Text(verbatim: display(summaries[id])).monospacedDigit().gridColumnAlignment(.trailing) + HorizontalOverflowScroll { + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) { + ForEach(summaries.keys.sorted(), id: \.self) { id in + GridRow { + Text(model.fieldLabel(id, strings: strings)) + Text(verbatim: display(summaries[id])).monospacedDigit().gridColumnAlignment(.trailing) + } } } }.font(.caption).textSelection(.enabled) diff --git a/apps/native/Sources/VisionNativeUI/Components/HorizontalOverflowScroll.swift b/apps/native/Sources/VisionNativeUI/Components/HorizontalOverflowScroll.swift new file mode 100644 index 000000000..a5485b293 --- /dev/null +++ b/apps/native/Sources/VisionNativeUI/Components/HorizontalOverflowScroll.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// The web `
` wrapper (`relative w-full overflow-auto`): the table fills the width it is +/// offered and, when its content is wider (a narrow window), keeps its content width and +/// scrolls sideways instead of squeezing its cells into piled-up lines. +/// +/// A plain `ScrollView(.horizontal)` proposes no width, so its content would stop filling the +/// card; this layout proposes the larger of the offered width and the content's own width. +struct HorizontalOverflowScroll: View { + @ViewBuilder var content: () -> Content + @State private var availableWidth: CGFloat? + + var body: some View { + ScrollView(.horizontal) { + HorizontalOverflowLayout(availableWidth: availableWidth) { content() } + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in + if availableWidth != width { availableWidth = width } + } + } +} + +/// Lays its one child out at `max(offered width, the child's ideal width)`. +struct HorizontalOverflowLayout: Layout { + var availableWidth: CGFloat? + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard let child = subviews.first else { return .zero } + let width = width(proposal: proposal, child: child) + return CGSize(width: width, height: child.sizeThatFits(ProposedViewSize(width: width, height: nil)).height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard let child = subviews.first else { return } + child.place( + at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(width: bounds.width, height: nil)) + } + + private func width(proposal: ProposedViewSize, child: LayoutSubview) -> CGFloat { + let ideal = child.sizeThatFits(.unspecified).width + let offered = proposal.width.flatMap { $0.isFinite ? $0 : nil } ?? availableWidth ?? ideal + return max(offered, ideal) + } +} diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift index fdd711845..f3cd36047 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift @@ -7,7 +7,9 @@ import VisionNativeCore /// against the leading edge. This layout fills the width it is offered, as `w-full` does: each /// column starts at the widest of its cells and the room left over is shared between the /// columns in proportion to those widths (CSS automatic table layout). When the content is -/// wider than the card the columns shrink in the same proportion. +/// wider than the card the columns keep their content widths and the table scrolls sideways +/// inside its card, as the web `
`'s `overflow-auto` wrapper does: cells never squeeze +/// into piled-up lines. /// /// The cells are the layout's direct children in row order, one per column. A child marked /// with `visionTableSpan()` takes a whole row (a divider, an expanded detail row, an empty @@ -35,11 +37,25 @@ struct VisionTable: View { var rowHover = true @ViewBuilder var content: () -> Content @State private var hoverY: CGFloat? + /// The width the table is offered: the scroll view proposes no width to its content. + @State private var availableWidth: CGFloat? @Environment(\.visionTheme) private var theme var body: some View { + ScrollView(.horizontal) { + table + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in + if availableWidth != width { availableWidth = width } + } + } + + private var table: some View { VisionTableLayout( - columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns, hoverY: rowHover ? hoverY : nil + columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns, hoverY: rowHover ? hoverY : nil, + availableWidth: availableWidth ) { if rowHover { Rectangle().fill(theme.foreground.opacity(0.03)).allowsHitTesting(false) @@ -48,7 +64,6 @@ struct VisionTable: View { } content() } - .frame(maxWidth: .infinity, alignment: .leading) .onContinuousHover { phase in guard rowHover else { return } let y: CGFloat? @@ -81,6 +96,8 @@ struct VisionTableLayout: Layout { let fixedColumns: Set /// The pointer's y in the table, for the hover band; nil hides it. var hoverY: CGFloat? = nil + /// The width to fill when the proposal has none (inside the horizontal scroll view). + var availableWidth: CGFloat? = nil struct Cache { var width: CGFloat? @@ -174,8 +191,8 @@ struct VisionTableLayout: Layout { } /// CSS automatic table layout for `available` points: the content widths, plus a share of - /// the spare room in proportion to them (fixed columns keep theirs), or scaled down in - /// proportion when the content is wider. + /// the spare room in proportion to them (fixed columns keep theirs). Content wider than the + /// room keeps its widths: the table overflows and scrolls sideways. static func columnWidths(ideal: [CGFloat], available: CGFloat?, spacing: CGFloat, fixed: Set) -> [CGFloat] { guard let available, !ideal.isEmpty else { return ideal } let room = available - spacing * CGFloat(ideal.count - 1) @@ -194,11 +211,12 @@ struct VisionTableLayout: Layout { fixed.contains(index) ? width : width + spare * width / flexible } } - return ideal.map { $0 * room / total } + return ideal } private func plan(width: CGFloat?, subviews: Subviews, cache: inout Cache) -> Plan { - if let plan = cache.plan, cache.width == width { return plan } + let finite = width.flatMap { $0.isFinite ? $0 : nil } ?? availableWidth + if let plan = cache.plan, cache.width == finite { return plan } let count = max(1, columns.count) let cells = subviews.indices.filter { !subviews[$0][VisionTableBandKey.self] } let grouped = Self.rows(spans: cells.map { subviews[$0][VisionTableSpanKey.self] }, columnCount: count) @@ -209,9 +227,9 @@ struct VisionTableLayout: Layout { ideal[column] = max(ideal[column], subviews[index].sizeThatFits(.unspecified).width) } } - let finite = width.flatMap { $0.isFinite ? $0 : nil } let widths = Self.columnWidths(ideal: ideal, available: finite, spacing: columnSpacing, fixed: fixedColumns) - let tableWidth = finite ?? (widths.reduce(0, +) + columnSpacing * CGFloat(count - 1)) + let contentWidth = widths.reduce(0, +) + columnSpacing * CGFloat(count - 1) + let tableWidth = max(finite ?? 0, contentWidth) var rows: [Plan.Row] = [] var top: CGFloat = 0 for row in grouped { @@ -232,7 +250,7 @@ struct VisionTableLayout: Layout { top += height } let plan = Plan(columnWidths: widths, rows: rows, size: CGSize(width: tableWidth, height: top)) - cache.width = width + cache.width = finite cache.plan = plan return plan } diff --git a/apps/native/Sources/VisionNativeUI/RebalanceView.swift b/apps/native/Sources/VisionNativeUI/RebalanceView.swift index 439c127aa..92723c24c 100644 --- a/apps/native/Sources/VisionNativeUI/RebalanceView.swift +++ b/apps/native/Sources/VisionNativeUI/RebalanceView.swift @@ -203,25 +203,27 @@ public struct RebalanceView: View { } } card("rebalance.deploymentPlan") { - Grid(horizontalSpacing: 24, verticalSpacing: 12) { - GridRow { - Text(strings.t("rebalance.sleeve")).frame(maxWidth: .infinity, alignment: .leading) - Text(strings.t("rebalance.current")) - Text(strings.t("rebalance.target")) - Text(strings.t("rebalance.deploy")) - }.font(.callout.weight(.medium)).foregroundStyle(theme.secondaryLabel) - Divider().gridCellUnsizedAxes(.horizontal) - ForEach(result.sleeves, id: \.self) { sleeve in + HorizontalOverflowScroll { + Grid(horizontalSpacing: 24, verticalSpacing: 12) { GridRow { - Text(label(sleeve)).frame(maxWidth: .infinity, alignment: .leading) - Text(money(result.actualValues[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment(.trailing) - Text(percent(result.targetWeights[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment( - .trailing) - if let deploy = result.deployment[sleeve], deploy > 0 { - VisionBadge(text: money(deploy, signed: true)).monospacedDigit() - .gridColumnAlignment(.trailing) - } else { - Text("—").foregroundStyle(theme.secondaryLabel).gridColumnAlignment(.trailing) + Text(strings.t("rebalance.sleeve")).frame(maxWidth: .infinity, alignment: .leading) + Text(strings.t("rebalance.current")) + Text(strings.t("rebalance.target")) + Text(strings.t("rebalance.deploy")) + }.font(.callout.weight(.medium)).foregroundStyle(theme.secondaryLabel) + Divider().gridCellUnsizedAxes(.horizontal) + ForEach(result.sleeves, id: \.self) { sleeve in + GridRow { + Text(label(sleeve)).frame(maxWidth: .infinity, alignment: .leading) + Text(money(result.actualValues[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment(.trailing) + Text(percent(result.targetWeights[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment( + .trailing) + if let deploy = result.deployment[sleeve], deploy > 0 { + VisionBadge(text: money(deploy, signed: true)).monospacedDigit() + .gridColumnAlignment(.trailing) + } else { + Text("—").foregroundStyle(theme.secondaryLabel).gridColumnAlignment(.trailing) + } } } } diff --git a/apps/native/Sources/VisionNativeUI/TransactionsTable.swift b/apps/native/Sources/VisionNativeUI/TransactionsTable.swift index dee9cee6d..e953bf411 100644 --- a/apps/native/Sources/VisionNativeUI/TransactionsTable.swift +++ b/apps/native/Sources/VisionNativeUI/TransactionsTable.swift @@ -56,9 +56,19 @@ struct TransactionsTableCard: View { searchBar .zIndex(1) if !model.columnFilters.isEmpty { chips } - headerRow - Divider() - rowsList + // The web table keeps its columns' widths and scrolls sideways inside the card when + // the window is narrower; the rows scroll vertically under the fixed header. + ScrollView(.horizontal) { + VStack(alignment: .leading, spacing: 0) { + headerRow + Divider() + rowsList + } + .containerRelativeFrame(.horizontal, alignment: .leading) { length, _ in + max(length, minimumTableWidth) + } + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) if model.isFetchingMore { HStack(spacing: 8) { ProgressView().controlSize(.small) @@ -224,6 +234,14 @@ struct TransactionsTableCard: View { ).mapValues { CGFloat($0) } } + /// The width under which the columns stop shrinking and the table scrolls sideways: the + /// chrome plus every column at its fitted width. + private var minimumTableWidth: CGFloat { + let fitted = fittedWidths + return Self.selectWidth + Self.editWidth + 32 + (isCompact ? Self.summaryWidth : 0) + + dataColumns.reduce(0) { $0 + width($1, fitted: fitted) } + } + private func width(_ column: TransactionColumn, fitted: [String: CGFloat]) -> CGFloat { fitted[column.rawValue] ?? CGFloat(column.width) } From fba59bc9ab4f953d3edee13f96b639b821b054ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 17:11:26 +0000 Subject: [PATCH 4/6] fix(native): draw the net worth, payee and holding history tables in cards The web VirtualDataTable is always a Card; these three LocalDataTables were painted straight on the page background. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QJtaMxiuFABVL2LiMKy5m --- .../Sources/VisionNativeUI/NetWorthView.swift | 27 ++++++------ .../PortfolioDetailHistoryTable.swift | 21 +++++---- .../VisionNativeUI/StatisticsView.swift | 43 ++++++++++--------- 3 files changed, 50 insertions(+), 41 deletions(-) diff --git a/apps/native/Sources/VisionNativeUI/NetWorthView.swift b/apps/native/Sources/VisionNativeUI/NetWorthView.swift index 593ce20a0..acf0a413f 100644 --- a/apps/native/Sources/VisionNativeUI/NetWorthView.swift +++ b/apps/native/Sources/VisionNativeUI/NetWorthView.swift @@ -371,19 +371,22 @@ public struct NetWorthView: View { } private var table: some View { VStack(alignment: .leading, spacing: 8) { - LocalDataTable( - title: strings.t("networth.dailyBreakdown"), subtitle: "", columns: columns, rows: model.tableRows, - totalCount: model.tableSnapshots.count, - query: Binding(get: { model.tableQuery }, set: { model.tableQuery = $0 }), - filterValues: model.tableFilterValues, strings: strings, serverTotal: model.tableTotal, - isFetchingMore: model.isFetchingMore, hasMore: model.hasMore - ) { - Text(strings.t(model.tableQuery.isNarrowed ? "table.noFilterResults" : "table.noData")) - }.rowAppeared { id in - if model.tableRows.suffix(5).contains(where: { $0.id == id }) { Task { await model.loadMore() } } + // `VirtualDataTable` is always a `Card` on the web. + AdminCard(padding: 0) { + LocalDataTable( + title: strings.t("networth.dailyBreakdown"), subtitle: "", columns: columns, rows: model.tableRows, + totalCount: model.tableSnapshots.count, + query: Binding(get: { model.tableQuery }, set: { model.tableQuery = $0 }), + filterValues: model.tableFilterValues, strings: strings, serverTotal: model.tableTotal, + isFetchingMore: model.isFetchingMore, hasMore: model.hasMore + ) { + Text(strings.t(model.tableQuery.isNarrowed ? "table.noFilterResults" : "table.noData")) + }.rowAppeared { id in + if model.tableRows.suffix(5).contains(where: { $0.id == id }) { Task { await model.loadMore() } } + } + // `VirtualDataTable maxHeight={520}`: the rows scroll under the fixed header. + .bodyMaxHeight(520) } - // `VirtualDataTable maxHeight={520}`: the rows scroll under the fixed header. - .bodyMaxHeight(520) if let error = model.tableError { Text(error).foregroundStyle(theme.destructive) Button(strings.t("common.retry")) { diff --git a/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift b/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift index cc6b166a2..1e06134b9 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift @@ -33,16 +33,19 @@ struct PortfolioDetailHistoryTable: View { } else { // The web history is a virtualised `max-h-[400px]` scroller: the table's header and // search stay put and only the rows scroll, drawn lazily. - LocalDataTable( - title: "", subtitle: "", columns: columns, rows: rows, totalCount: model.transactions.count, - query: $query, - filterValues: { key in - LocalTableQuery.uniqueValues(model.transactions, key: key, value: rawValue) - }, strings: strings - ) { - Text(strings.t("table.noFilterResults")) + // `VirtualDataTable` is always a `Card` on the web. + AdminCard(padding: 0) { + LocalDataTable( + title: "", subtitle: "", columns: columns, rows: rows, totalCount: model.transactions.count, + query: $query, + filterValues: { key in + LocalTableQuery.uniqueValues(model.transactions, key: key, value: rawValue) + }, strings: strings + ) { + Text(strings.t("table.noFilterResults")) + } + .bodyMaxHeight(400) } - .bodyMaxHeight(400) } } } diff --git a/apps/native/Sources/VisionNativeUI/StatisticsView.swift b/apps/native/Sources/VisionNativeUI/StatisticsView.swift index c560de060..f93818149 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsView.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsView.swift @@ -652,29 +652,32 @@ public struct StatisticsView: View { let visible = Array(model.recipientRows.prefix(model.recipientVisibleCount)) let ranks = Dictionary( visible.enumerated().map { index, row in (row.id, index + 1) }, uniquingKeysWith: { first, _ in first }) - return LocalDataTable( - title: strings.t("insights.detailsTitle"), subtitle: strings.t("insights.detailsSubtitle"), - columns: [recipientRankColumn(ranks)] + recipientColumns, - rows: visible, - totalCount: data.topMerchants.count, - query: Binding(get: { model.recipientQuery }, set: { model.recipientQuery = $0 }), - filterValues: model.recipientFilterValues, strings: strings, - serverTotal: model.recipientRows.count, - hasMore: model.recipientVisibleCount < model.recipientRows.count - ) { - Text( - strings.t( - model.recipientQuery.isNarrowed ? "table.noFilterResults" : "insights.detailsEmpty")) - } - .rowAppeared { id in - if model.recipientRows.prefix(model.recipientVisibleCount).suffix(5).contains(where: { - $0.id == id - } + // `VirtualDataTable` is always a `Card` on the web. + return AdminCard(padding: 0) { + LocalDataTable( + title: strings.t("insights.detailsTitle"), subtitle: strings.t("insights.detailsSubtitle"), + columns: [recipientRankColumn(ranks)] + recipientColumns, + rows: visible, + totalCount: data.topMerchants.count, + query: Binding(get: { model.recipientQuery }, set: { model.recipientQuery = $0 }), + filterValues: model.recipientFilterValues, strings: strings, + serverTotal: model.recipientRows.count, + hasMore: model.recipientVisibleCount < model.recipientRows.count ) { - model.loadMoreRecipients(pageSize: settings.defaultPageSize) + Text( + strings.t( + model.recipientQuery.isNarrowed ? "table.noFilterResults" : "insights.detailsEmpty")) + } + .rowAppeared { id in + if model.recipientRows.prefix(model.recipientVisibleCount).suffix(5).contains(where: { + $0.id == id + } + ) { + model.loadMoreRecipients(pageSize: settings.defaultPageSize) + } } + .bodyMaxHeight(700) } - .bodyMaxHeight(700) } /// The "#" column: the row's position in the list, not sortable or filterable. private func recipientRankColumn(_ ranks: [RecipientID: Int]) -> LocalDataColumn { From fcb490063b36e182d227973ad7ccb495c2fcb9e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 17:11:26 +0000 Subject: [PATCH 5/6] fix(native): set the account row title and drift chip like the web ListRow Title in type-body regular, subtitle lines in type-footnote and the drift chip at the default badge size, as AccountsPage renders them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QJtaMxiuFABVL2LiMKy5m --- .../Sources/VisionNativeUI/AccountsView.swift | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/native/Sources/VisionNativeUI/AccountsView.swift b/apps/native/Sources/VisionNativeUI/AccountsView.swift index 7f0bae275..a9294e9a3 100644 --- a/apps/native/Sources/VisionNativeUI/AccountsView.swift +++ b/apps/native/Sources/VisionNativeUI/AccountsView.swift @@ -267,8 +267,9 @@ struct AccountHubRow: View { private var titleLine: some View { HStack(spacing: 6) { + // ListRow's title: `type-body` (Inter 14, regular), not medium. Text(label) - .font(.body.weight(.medium)) + .font(VisionFonts.font(DesignTokens.Typography.body, relativeTo: .body)) .lineLimit(1) if !account.isActive { VisionBadge(text: strings.t("accounts.archived"), tone: .outline, small: true) @@ -279,13 +280,16 @@ struct AccountHubRow: View { } } + /// ListRow's subtitle line: `type-footnote` (Inter 12). + private static var footnote: Font { VisionFonts.font(DesignTokens.Typography.footnote, relativeTo: .footnote) } + @ViewBuilder private var subtitle: some View { if let drift = model.drift(account) { AccountDriftChip(drift: drift, strings: strings) { model.openReconcile(account) } } Text(model.meta(account)) - .font(.footnote) + .font(Self.footnote) .foregroundStyle(theme.secondaryLabel) .lineLimit(1) if isPortfolio { @@ -297,26 +301,26 @@ struct AccountHubRow: View { Text(verbatim: "· \(provenance)") } } - .font(.footnote) + .font(Self.footnote) .foregroundStyle(theme.secondaryLabel) .lineLimit(1) } } else if account.computedBalance != nil, let provenance = model.provenance(account) { Text(provenance) - .font(.footnote) + .font(Self.footnote) .foregroundStyle(theme.secondaryLabel) .lineLimit(1) } if !isPortfolio && account.balanceIncomplete == true { Text(strings.t("accounts.balanceIncomplete")) - .font(.footnote) + .font(Self.footnote) .foregroundStyle(theme.warning) ForEach(model.excludedParts(account), id: \.currency) { part in HStack(spacing: 4) { MoneyText(amount: part.balance, currency: part.currency, settings: settings, signed: false, colored: false) Text(strings.t("accounts.balanceExcluded")) } - .font(.footnote) + .font(Self.footnote) .foregroundStyle(theme.warning) } } @@ -410,7 +414,8 @@ struct AccountDriftChip: View { var body: some View { Button(action: open) { - VisionBadge(text: drift.label, tone: drift.stale ? .warning : .destructive, small: true) + // `badgeVariants({ variant })` without a size: the default `px-2.5`, as on the web. + VisionBadge(text: drift.label, tone: drift.stale ? .warning : .destructive) } .buttonStyle(.plain) .visionTooltip(strings.t(drift.tooltipKey)) From e0be8a9550c26f0e45d57fdd000747108ba7657c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 17:11:26 +0000 Subject: [PATCH 6/6] fix(native): line the text editor caret up with its placeholder NSTextView pads its text horizontally only; taking that 5 pt off the vertical padding too put the caret and typed text 5 pt above the placeholder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QJtaMxiuFABVL2LiMKy5m --- .../Components/VisionTextEditorStyle.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift index e0865d69e..62a8a78d3 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift @@ -18,10 +18,12 @@ struct VisionTextEditorStyle: ViewModifier { func body(content: Content) -> some View { VisionFieldChrome( isFocused: isFocused, - // `px-3` of the chrome minus the 5 pt `NSTextView` text container inset, so text lines up - // with a `VisionTextFieldStyle` field. - horizontalPadding: VisionControlMetrics.fieldHorizontalPadding - Self.textContainerInset, - verticalPadding: VisionControlMetrics.textAreaVerticalPadding - Self.textContainerInset, + // `px-3` of the chrome minus the 5 pt line fragment padding `NSTextView` adds on each + // side, so text lines up with a `VisionTextFieldStyle` field. That padding is horizontal + // only: the text view adds nothing above the first line, so `py-2` is taken in full. + // Taking 5 pt off it too put the caret and typed text 5 pt above the placeholder. + horizontalPadding: VisionControlMetrics.fieldHorizontalPadding - Self.lineFragmentPadding, + verticalPadding: VisionControlMetrics.textAreaVerticalPadding, minHeight: minHeight, alignment: .topLeading ) { content @@ -31,8 +33,8 @@ struct VisionTextEditorStyle: ViewModifier { } } - /// The horizontal inset `NSTextView` adds around its text. - private static var textContainerInset: CGFloat { 5 } + /// The horizontal inset `NSTextView` adds on each side of its text (`lineFragmentPadding`). + private static var lineFragmentPadding: CGFloat { 5 } } /// The web `Textarea`'s `placeholder`: shown over an empty editor, in the field's font and inset,