Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6d199c9
fix(native): match the web aurora, glass cards, ticker tape and Full …
claude Nov 14, 2026
32b35a5
fix(native): match web page icons, topbar trigger and title, field fo…
claude Nov 14, 2026
dbbe6ce
fix(native): match web asset-class tiles, heat map cells, customize d…
claude Nov 14, 2026
349ed5e
fix(native): match the web About identity card
claude Nov 14, 2026
7832d86
docs(native): record the auto Full effects default in the shell parit…
claude Nov 14, 2026
b625663
fix(native): read the Full effects choice from the JSON settings blob
claude Nov 14, 2026
7bde635
fix(native): lay out savings and bond cards like the web SavingsPage
claude Nov 14, 2026
0cc943a
fix(native): match the portfolio news feed rows to the web NewsItem
claude Nov 14, 2026
bf53bc9
fix(native): write the Last 6 months axis with the web's length-aware…
claude Nov 14, 2026
7287bc7
fix(native): show the tax year menu with the web's label, status icon…
claude Nov 14, 2026
27d8db7
fix(native): draw the transactions filter and view menus as the web d…
claude Nov 14, 2026
4dd2e8c
fix(native): scroll the net worth daily breakdown rows under a fixed …
claude Nov 14, 2026
fdda145
fix(native): animate donut hover like the web DonutChart
claude Nov 14, 2026
b5391ec
fix(native): style the API inspector pill and panel like the web devt…
claude Nov 14, 2026
4d66876
fix(native): fade chart tooltips in and out like the web ChartTooltip
claude Nov 14, 2026
0458acc
fix(native): hover each Sankey bar and band on its own shape like the…
claude Nov 14, 2026
66e82e0
fix(native): match the web topbar and traffic-light position
claude Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ import Foundation

/// Chart axis formatting (apps/frontend/src/utils/currency.ts and lib/dateUtils.ts).
extension VisionFormat {
/// `formatCurrencyCompact`: the full amount while it is at most nine characters, otherwise the
/// locale's compact notation (one fraction digit at most) when that is shorter and the locale
/// has one for the magnitude (`de_DE` writes thousands in full, so it keeps the full amount).
public static func currencyCompact(
_ value: Decimal, code: CurrencyCode, locale: Locale, fractionDigits: Int
) -> String {
let full = currency(value, code: code, locale: locale, fractionDigits: fractionDigits)
guard !value.isNaN, full.utf16.count > 9 else { return full }
let plain = Decimal.FormatStyle.Currency(code: code.rawValue, locale: locale)
.precision(.fractionLength(0...1))
let compact = value.formatted(plain.notation(.compactName))
guard compact != value.formatted(plain), compact.utf16.count < full.utf16.count else { return full }
return compact
}

/// `formatCurrencyAxisCompact`: the amount scaled to k / M / B / T with at most one fraction
/// digit, the suffix placed right after the last digit so the locale keeps its own symbol
/// order ("6,5k €" in `de_DE`, "$6.5k" in `en_US`). Amounts under one thousand keep no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ public struct NativeAppSettings: Equatable, Sendable {
public var autoClearPlannedOnMatch: Bool
public var visualEffects: VisualEffects
public var autoAdaptDisplay: Bool
/// `fullEffects` (ADR-244): restores effects that were turned down to save GPU work.
/// Off by default; the design layer reads it through `NativeSettingsModel.fullEffects`.
public var fullEffects: Bool
/// `fullEffects` (ADR-244, ADR-255): restores effects that were turned down to save GPU work.
/// `auto` by default (on with the Standard tier); the design layer reads the resolved switch
/// through `NativeSettingsModel.fullEffects`.
public var fullEffects: FullEffectsChoice

public init(
defaultCurrency: CurrencyCode = .eur, dateFormat: AppDateFormat = .dayMonthYearSlash,
Expand All @@ -66,7 +67,7 @@ public struct NativeAppSettings: Equatable, Sendable {
startOfWeek: StartOfWeek = .monday, costBasisMethod: CostBasisMethod = .weightedAvg,
startupSection: StartupSection = .budgeting,
autoClearPlannedOnMatch: Bool = true, visualEffects: VisualEffects = .standard, autoAdaptDisplay: Bool = true,
fullEffects: Bool = false
fullEffects: FullEffectsChoice = .auto
) {
self.defaultCurrency = defaultCurrency
self.dateFormat = dateFormat
Expand Down Expand Up @@ -138,7 +139,7 @@ public struct NativeAppSettings: Equatable, Sendable {
settings.visualEffects = value
}
if let value = fields["autoAdaptDisplay"]?.boolValue { settings.autoAdaptDisplay = value }
if let value = fields["fullEffects"]?.boolValue { settings.fullEffects = value }
if let value = FullEffectsChoice(stored: fields["fullEffects"]) { settings.fullEffects = value }
return settings
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public enum NativeSettingsDefaults {
"defaultCurrency": .string("EUR"), "dateFormat": .string("DD/MM/YYYY"), "numberFormat": .string("eu"),
"defaultPageSize": .number(50), "startOfWeek": .string("monday"), "showDecimalPlaces": .number(2),
"language": .string("en"), "costBasisMethod": .string("weighted_avg"), "adminMode": .bool(false),
"visualEffects": .string("standard"), "autoAdaptDisplay": .bool(true), "fullEffects": .bool(false),
"visualEffects": .string("standard"), "autoAdaptDisplay": .bool(true), "fullEffects": .string(FullEffectsChoice.auto.rawValue),
"startupSection": .string("budgeting"),
"autoClearPlannedOnMatch": .bool(true), "colorblindGainLoss": .bool(false),
])
Expand Down Expand Up @@ -64,7 +64,9 @@ public enum NativeSettingsDefaults {
return value.stringValue.map(NativeAppSettings.isCurrencyCode) == true ? value : app[key]
case "defaultPageSize": return integer(value).map { $0 > 0 } == true ? value : app[key]
case "showDecimalPlaces": return integer(value).map { (0...20).contains($0) } == true ? value : app[key]
case "adminMode", "autoAdaptDisplay", "fullEffects", "autoClearPlannedOnMatch", "colorblindGainLoss":
case "fullEffects":
return FullEffectsChoice(stored: value).map { .string($0.rawValue) } ?? app[key]
case "adminMode", "autoAdaptDisplay", "autoClearPlannedOnMatch", "colorblindGainLoss":
return value.boolValue != nil ? value : app[key]
case "analysisBenchmark":
guard let raw = value.stringValue, let symbol = ResearchCompareURLState.normalizeSymbol(raw),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,42 @@ public enum VisualEffects: Codable, Hashable, Sendable, CaseIterable, RawReprese
try container.encode(rawValue)
}
}

/// `AppSettings.fullEffects` (`FullEffectsChoice`, settingsStore.ts; ADR-255): `auto` follows the
/// tier the user chose, `on` and `off` are the user's own choice from the Appearance switch.
public enum FullEffectsChoice: String, Codable, Hashable, Sendable, CaseIterable {
case auto
case on
case off

/// The stored value as the web store reads it: a choice, or the pre-ADR-255 boolean. A stored
/// `false` cannot be told apart from the old untouched default, so it becomes `auto`; `true` is
/// `on`. Anything else is `nil` (the caller falls back to the default).
public init?(stored: TaxValue?) {
self.init(raw: stored?.stringValue, flag: stored?.boolValue)
}

/// The same reading of the settings blob's JSON field.
public init?(stored: JSONValue?) {
self.init(raw: stored?.stringValue, flag: stored?.boolValue)
}

private init?(raw: String?, flag: Bool?) {
if let raw, let choice = Self(rawValue: raw) {
self = choice
} else if let flag {
self = flag ? .on : .auto
} else {
return nil
}
}

/// `resolveFullEffects`: `auto` is on for the chosen Standard tier only (not the display-capped one).
public func resolved(chosenTier: VisualEffects) -> Bool {
switch self {
case .auto: return chosenTier == .standard
case .on: return true
case .off: return false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,17 @@ public final class NativeSettingsModel {
sessionTierOverride = nil
}
}
/// `AppSettings.fullEffects` (ADR-244, `useFullEffects`): restores the effects that were turned
/// down to save GPU work. Off until the stored value says otherwise; the design layer reads this.
public var fullEffects: Bool { appBool("fullEffects", fallback: false) }
/// `AppSettings.fullEffects` as stored (ADR-255): `auto`, `on` or `off`.
public var fullEffectsChoice: FullEffectsChoice {
FullEffectsChoice(stored: NativeSettingsDefaults.appValue("fullEffects", raw: appRaw)) ?? .auto
}
/// `useFullEffects` (ADR-244, ADR-255): restores the effects that were turned down to save GPU
/// work. `auto` (the default) is on for the chosen Standard tier; the design layer reads this.
public var fullEffects: Bool { fullEffectsChoice.resolved(chosenTier: visualEffects) }
/// The Appearance switch writes the user's explicit choice (`v ? 'on' : 'off'`).
public func setFullEffects(_ enabled: Bool) {
guard !retired, !isSuspendedForRestore else { return }
updateApp(["fullEffects": .bool(enabled)])
updateApp(["fullEffects": .string((enabled ? FullEffectsChoice.on : .off).rawValue)])
}
public func setAutoAdaptDisplay(_ enabled: Bool) {
guard !retired, !isSuspendedForRestore else { return }
Expand Down
2 changes: 1 addition & 1 deletion apps/native/Sources/VisionNativeUI/AIChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public struct AIChatView: View {
VisionPageHeader(
title: strings.t("aiChat.title"),
subtitle: strings.t(isChat ? "aiChat.mode.chatHint" : "aiChat.mode.investigationHint"),
symbol: "bubble.left.and.bubble.right"
symbol: VisionPageSymbol.of("/ai-chat", fallback: "sparkles")
) {
// The web header's trailing Chat / Investigation toggle; the label stays for VoiceOver.
VisionSegmentedControl(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public struct AnalysisWorkspaceView: View {
VStack(alignment: .leading, spacing: VisionPageLayout.spacing) {
VisionPageHeader(
title: strings.t("analysis.title"), subtitle: strings.t("analysis.subtitle"),
symbol: "tablecells")
symbol: VisionPageSymbol.of("/analysis", fallback: "chart.bar"))
workflow(proxy)
let wide = geometry.size.width >= 1140
let layout =
Expand Down
56 changes: 21 additions & 35 deletions apps/native/Sources/VisionNativeUI/Components/AuroraCanvas.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,14 @@ struct AuroraCanvas: View {
}
}

/// `radial-gradient(120rem 60rem at 70% -20%, primary / wash, transparent 60%)`.
/// `radial-gradient(120rem 60rem at 70% -20%, primary / wash, transparent 60%)`: an ellipse of
/// 1920 by 960 pt radii that is clear from 60 % of them. `EllipticalGradient` counts its
/// radius fractions against the frame's width and height (0.5 reaches the edge), so the
/// 3840 by 1920 pt frame ends the fade at 0.3.
private func wash(_ size: CGSize) -> some View {
EllipticalGradient(
colors: [theme.primary.opacity(alphas.wash), .clear], center: .center, startRadiusFraction: 0,
endRadiusFraction: 0.6
colors: [theme.primary.opacity(alphas.wash), theme.primary.opacity(0)], center: .center,
startRadiusFraction: 0, endRadiusFraction: 0.3
)
.frame(width: 1920 * 2, height: 960 * 2)
.position(x: size.width * 0.7, y: -size.height * 0.2)
Expand Down Expand Up @@ -253,7 +256,11 @@ private struct AuroraBlobs: NSViewRepresentable {
}
}

/// `.liquid-canvas-grain`: a tiled 160×160 fractal-noise texture.
/// `.liquid-canvas-grain`: a tiled 160 by 160 film grain. The web tile is an SVG
/// `feTurbulence` (`fractalNoise`, base frequency 0.8, three octaves): at that frequency a noise
/// cell is about one pixel, so the tile reads as fine per-pixel grain with every channel,
/// alpha included, noisy around its middle value. Three octaves of white noise, weighted like
/// the turbulence sum, give the same grain; the pixels are premultiplied.
private struct AuroraGrain: View {
private static let tile: Image? = {
let side = 160
Expand All @@ -262,43 +269,22 @@ private struct AuroraGrain: View {
state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
return Double(state >> 33) / Double(UInt64(1) << 31)
}
var lattices: [[Double]] = []
for octave in 0..<3 {
let cells = 8 << octave
lattices.append((0..<(cells * cells)).map { _ in next() })
/// One channel: 0.5 plus the octave sum (amplitudes 1, 1/2, 1/4), halved as `fractalNoise` does.
func channel() -> Double {
let sum = (next() - 0.5) + (next() - 0.5) / 2 + (next() - 0.5) / 4
return max(0, min(1, 0.5 + sum / 2))
}
var pixels = [UInt8](repeating: 0, count: side * side * 4)
for y in 0..<side {
for x in 0..<side {
var value = 0.0
var amplitude = 0.5
for (octave, lattice) in lattices.enumerated() {
let cells = 8 << octave
let fx = Double(x) / Double(side) * Double(cells)
let fy = Double(y) / Double(side) * Double(cells)
let x0 = Int(fx) % cells
let y0 = Int(fy) % cells
let x1 = (x0 + 1) % cells
let y1 = (y0 + 1) % cells
let tx = fx - floor(fx)
let ty = fy - floor(fy)
let top = lattice[y0 * cells + x0] * (1 - tx) + lattice[y0 * cells + x1] * tx
let bottom = lattice[y1 * cells + x0] * (1 - tx) + lattice[y1 * cells + x1] * tx
value += amplitude * (top * (1 - ty) + bottom * ty)
amplitude *= 0.5
}
let level = UInt8(max(0, min(255, value / 0.875 * 255)))
let index = (y * side + x) * 4
pixels[index] = level
pixels[index + 1] = level
pixels[index + 2] = level
pixels[index + 3] = 255
}
for index in 0..<(side * side) {
let alpha = channel()
for offset in 0..<3 { pixels[index * 4 + offset] = UInt8((channel() * alpha * 255).rounded()) }
pixels[index * 4 + 3] = UInt8((alpha * 255).rounded())
}
guard let provider = CGDataProvider(data: Data(pixels) as CFData),
let image = CGImage(
width: side, height: side, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: side * 4,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue),
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)
else { return nil }
return Image(decorative: image, scale: 1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct ChartHoverTooltip: View {
@State private var size: CGSize = .zero
@Environment(\.visionTheme) private var theme
@Environment(\.visionFullEffects) private var fullEffects
@Environment(\.accessibilityReduceMotion) private var reduceMotion

private static let gap: CGFloat = 12
private static let pad: CGFloat = 8
Expand Down Expand Up @@ -89,6 +90,15 @@ struct ChartHoverTooltip: View {
.allowsHitTesting(false)
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.updatesFrequently)
// `AnimatePresence`: in from opacity 0, y + 4, scale 0.98; out to opacity 0, y + 2, over
// `durations.fast` on out-expo (the scale is left out: it would scale the whole overlay box).
.transition(
reduceMotion
? .identity
: .asymmetric(
insertion: .opacity.combined(with: .offset(y: 4)),
removal: .opacity.combined(with: .offset(y: 2))
).animation(VisionMotion.outExpo(DesignTokens.Duration.fast)))
}

@ViewBuilder
Expand Down
Loading
Loading