diff --git a/apps/native/Sources/VisionNativeCore/Formatting/VisionFormat+Chart.swift b/apps/native/Sources/VisionNativeCore/Formatting/VisionFormat+Chart.swift index abc227223..cd0ff655d 100644 --- a/apps/native/Sources/VisionNativeCore/Formatting/VisionFormat+Chart.swift +++ b/apps/native/Sources/VisionNativeCore/Formatting/VisionFormat+Chart.swift @@ -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 diff --git a/apps/native/Sources/VisionNativeCore/Settings/NativeAppSettings.swift b/apps/native/Sources/VisionNativeCore/Settings/NativeAppSettings.swift index 03bb34463..03947e30e 100644 --- a/apps/native/Sources/VisionNativeCore/Settings/NativeAppSettings.swift +++ b/apps/native/Sources/VisionNativeCore/Settings/NativeAppSettings.swift @@ -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, @@ -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 @@ -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 } diff --git a/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsDefaults.swift b/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsDefaults.swift index 621d26bec..303a639fa 100644 --- a/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsDefaults.swift +++ b/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsDefaults.swift @@ -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), ]) @@ -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), diff --git a/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsEnums.swift b/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsEnums.swift index 404235104..0bcc8fbdc 100644 --- a/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsEnums.swift +++ b/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsEnums.swift @@ -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 + } + } +} diff --git a/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsModel.swift b/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsModel.swift index 29248bdd8..c6759e694 100644 --- a/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsModel.swift +++ b/apps/native/Sources/VisionNativeCore/Settings/NativeSettingsModel.swift @@ -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 } diff --git a/apps/native/Sources/VisionNativeUI/AIChatView.swift b/apps/native/Sources/VisionNativeUI/AIChatView.swift index 5be6cd705..fba4affb0 100644 --- a/apps/native/Sources/VisionNativeUI/AIChatView.swift +++ b/apps/native/Sources/VisionNativeUI/AIChatView.swift @@ -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( diff --git a/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift b/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift index e33d468b8..dadf04e6b 100644 --- a/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift +++ b/apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift @@ -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 = diff --git a/apps/native/Sources/VisionNativeUI/Components/AuroraCanvas.swift b/apps/native/Sources/VisionNativeUI/Components/AuroraCanvas.swift index 0d6ddc412..1ce92d4ca 100644 --- a/apps/native/Sources/VisionNativeUI/Components/AuroraCanvas.swift +++ b/apps/native/Sources/VisionNativeUI/Components/AuroraCanvas.swift @@ -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) @@ -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 @@ -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.. 0 + let text = isOpen ? theme.primary : hovering ? theme.foreground : theme.secondaryLabel + HStack(spacing: 6) { + Image(systemName: "waveform.path.ecg").font(.system(size: 11, weight: .medium)) + .symbolEffect(.pulse, isActive: pending) + .foregroundStyle(pending ? theme.warning : text) + .accessibilityHidden(true) + Text(verbatim: "API") + if pending { + Text(String(inFlight)).monospacedDigit().foregroundStyle(theme.warning) + } + } + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(text) + .padding(.horizontal, 12) + .frame(height: VisionControlMetrics.heightSm) + .background( + Capsule().fill(Color(token: theme.palette.background, opacity: hovering ? 1 : 0.8)) + .visionElevation(DesignTokens.Elevation.level2)) + .overlay( + Capsule().strokeBorder( + isOpen ? theme.primary.opacity(0.5) : Color(token: theme.palette.input, opacity: 0.7), + lineWidth: VisionControlMetrics.borderWidth)) + .contentShape(Capsule()) + .onHover { hovering = $0 } + .animation(.easeOut(duration: DesignTokens.Duration.fast), value: hovering) + } +} + private struct NativeAPIInspectorPanel: View { @Bindable var model: NativeAPIInspectorModel @Environment(\.visionTheme) private var theme @@ -53,45 +80,71 @@ private struct NativeAPIInspectorPanel: View { var body: some View { VStack(spacing: 0) { header - Divider() - Picker("", selection: $model.tab) { - Text("Requests").tag(NativeAPIInspectorModel.Tab.requests) - Text("Metrics").tag(NativeAPIInspectorModel.Tab.metrics) - Text("Queries").tag(NativeAPIInspectorModel.Tab.queries) - } - .pickerStyle(.segmented) - .labelsHidden() - .padding(8) + Rectangle().fill(theme.border).frame(height: 1) + tabs switch model.tab { case .requests: requests case .metrics: NativeAPIMetricsView(model: model) case .queries: NativeAPIQueriesView(model: model) } } + // `rounded-card border border-border bg-background shadow-elevation-4`, 520 × 480. .frame(width: 520, height: 480) - .background(RoundedRectangle(cornerRadius: 12).fill(.regularMaterial)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.secondary.opacity(0.25))) - .shadow(radius: 16, y: 6) + .background(shape.fill(Color(token: theme.palette.background))) + .clipShape(shape) + .overlay(shape.strokeBorder(theme.border, lineWidth: 1)) + .background(shape.fill(Color(token: theme.palette.background)).visionElevation(DesignTokens.Elevation.level4)) + } + + private var shape: RoundedRectangle { + RoundedRectangle(cornerRadius: CGFloat(DesignTokens.Radius.card), style: .continuous) + } + + /// The `TabsList`: `h-8 px-2 border-b border-border`, each trigger `h-7 px-3 type-footnote` + /// with a 2 pt primary underline while active. + private var tabs: some View { + HStack(spacing: 0) { + ForEach(NativeAPIInspectorModel.Tab.allCases, id: \.self) { tab in + let active = model.tab == tab + let title = tab.rawValue.prefix(1).uppercased() + tab.rawValue.dropFirst() + Button { + model.tab = tab + } label: { + Text(verbatim: title).font(.footnote) + .foregroundStyle(active ? theme.foreground : theme.secondaryLabel) + .padding(.horizontal, 12).frame(height: 28) + .overlay(alignment: .bottom) { + Rectangle().fill(active ? theme.primary : .clear).frame(height: 2) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(active ? .isSelected : []) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 8).frame(height: 32) + .overlay(alignment: .bottom) { Rectangle().fill(theme.border).frame(height: 1) } } private var header: some View { HStack(spacing: 8) { - Text("API Inspector").font(.headline) + Text(verbatim: "API Inspector").font(.headline).foregroundStyle(theme.foreground) if !model.inFlight.isEmpty { - Text("\(model.inFlight.count) in-flight").font(.caption) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(Capsule().fill(theme.warning.opacity(0.18))) - .foregroundStyle(theme.warning) + VisionBadge(text: "\(model.inFlight.count) in-flight", tone: .warning, small: true, monospaced: true) } else if !(model.log.isEmpty && model.inFlight.isEmpty) { - Text("\(model.log.count + model.inFlight.count) requests").font(.caption) - .foregroundStyle(.secondary) + Text(verbatim: "\(model.log.count + model.inFlight.count) requests") + .font(.system(size: 11, design: .monospaced)).monospacedDigit() + .foregroundStyle(theme.secondaryLabel) } Spacer() - Button { model.isOpen = false } label: { Image(systemName: "minus") } - .buttonStyle(.borderless).help("Minimise").accessibilityLabel("Minimise") - Button { model.isOpen = false } label: { Image(systemName: "xmark") } - .buttonStyle(.borderless).help("Close").accessibilityLabel("Close") + HStack(spacing: 4) { + Button { model.isOpen = false } label: { Image(systemName: "minus") } + .help("Minimise").accessibilityLabel("Minimise") + Button { model.isOpen = false } label: { Image(systemName: "xmark") } + .help("Close").accessibilityLabel("Close") + } + .buttonStyle(.vision(.ghost, size: .iconXs)) } .padding(.horizontal, 12) .padding(.vertical, 8) @@ -107,7 +160,7 @@ private struct NativeAPIInspectorPanel: View { .padding(.bottom, 6) let events = model.visibleEvents if events.isEmpty { - Text("No requests yet").foregroundStyle(.secondary) + Text("No requests yet").foregroundStyle(theme.secondaryLabel) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(events) { event in @@ -140,7 +193,7 @@ private struct NativeAPIRequestRow: View { Text(event.method.rawValue).font(.caption.monospaced()).frame(width: 56, alignment: .leading) .foregroundStyle(methodColor) Text(event.endpoint).font(.caption.monospaced()).lineLimit(1).truncationMode(.middle) - .foregroundStyle(event.phase == .error ? theme.loss : Color.primary) + .foregroundStyle(event.phase == .error ? theme.loss : theme.foreground) Spacer(minLength: 4) if event.phase == .start { Text("…").foregroundStyle(theme.warning).symbolEffect(.pulse) @@ -151,7 +204,7 @@ private struct NativeAPIRequestRow: View { } if let duration = event.durationMs { Text(NativeAPIInspectorFormat.duration(duration)).font(.caption.monospacedDigit()) - .foregroundStyle(duration >= 1000 ? theme.warning : Color.secondary) + .foregroundStyle(duration >= 1000 ? theme.warning : theme.secondaryLabel) } } } @@ -161,11 +214,11 @@ private struct NativeAPIRequestRow: View { private var methodColor: Color { switch event.method { - case .get: return .blue - case .post: return theme.gain + case .get: return theme.info + case .post: return theme.success case .put, .patch: return theme.warning - case .delete: return theme.loss - case .head, .options: return .secondary + case .delete: return theme.destructive + case .head, .options: return theme.secondaryLabel } } } @@ -188,7 +241,7 @@ private struct NativeAPIRequestDetail: View { VStack(alignment: .leading, spacing: 6) { row("Method", event.method.rawValue) row("Endpoint", event.endpoint) - row("Request ID", event.requestId, color: .secondary) + row("Request ID", event.requestId, color: theme.secondaryLabel) row("Status", event.status.map(String.init) ?? "—", color: statusColor) row( "Duration", event.durationMs.map { String(format: "%.1f ms", $0) } ?? "—", @@ -207,10 +260,10 @@ private struct NativeAPIRequestDetail: View { return status < 300 ? theme.gain : status < 400 ? theme.warning : theme.loss } - private func row(_ label: String, _ value: String, color: Color = .primary) -> some View { + private func row(_ label: String, _ value: String, color: Color? = nil) -> some View { VStack(alignment: .leading, spacing: 1) { - Text(label).font(.caption2).foregroundStyle(.secondary) - Text(value).font(.caption.monospaced()).foregroundStyle(color).textSelection(.enabled) + Text(label).font(.caption2).foregroundStyle(theme.secondaryLabel) + Text(value).font(.caption.monospaced()).foregroundStyle(color ?? theme.foreground).textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } } @@ -256,7 +309,7 @@ private struct NativeAPIMetricsView: View { Spacer() Text("\(stats.count)×").font(.caption.monospacedDigit()) Text("p95:\(Int(stats.p95.rounded()))ms").font(.caption.monospacedDigit()) - .foregroundStyle(stats.p95 >= 1000 ? theme.warning : Color.secondary) + .foregroundStyle(stats.p95 >= 1000 ? theme.warning : theme.secondaryLabel) if stats.errorCount > 0 { Text("\(stats.errorCount)err").font(.caption.monospacedDigit()).foregroundStyle(theme.loss) } @@ -272,14 +325,15 @@ private struct NativeAPIMetricsView: View { private func stat(_ title: String, _ value: String, sub: String?, warn: Bool) -> some View { VStack(alignment: .leading, spacing: 2) { - Text(title).font(.caption).foregroundStyle(.secondary) + Text(title).font(.caption).foregroundStyle(theme.secondaryLabel) Text(value).font(.title3.monospacedDigit().weight(.semibold)) - .foregroundStyle(warn ? theme.warning : Color.primary) - if let sub { Text(sub).font(.caption2).foregroundStyle(.secondary) } + .foregroundStyle(warn ? theme.warning : theme.foreground) + if let sub { Text(sub).font(.caption2).foregroundStyle(theme.secondaryLabel) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(10) - .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.08))) + // A compact `Card`. + .visionCardBackground() } private func section(_ title: String, @ViewBuilder content: () -> Content) -> some View { @@ -299,7 +353,7 @@ private struct NativeAPIQueriesView: View { var body: some View { let entries = model.sortedQueries if entries.isEmpty { - Text("No queries yet").foregroundStyle(.secondary).frame(maxWidth: .infinity, maxHeight: .infinity) + Text("No queries yet").foregroundStyle(theme.secondaryLabel).frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(entries) { entry in VStack(alignment: .leading, spacing: 2) { @@ -316,7 +370,7 @@ private struct NativeAPIQueriesView: View { } } .font(.caption2) - .foregroundStyle(.secondary) + .foregroundStyle(theme.secondaryLabel) if let error = entry.lastError { Text(error).font(.caption2).foregroundStyle(theme.loss).lineLimit(2) } @@ -328,7 +382,7 @@ private struct NativeAPIQueriesView: View { } private func color(_ entry: NativeAPIInspectorModel.QueryEntry) -> Color { - if entry.isFetching { return .blue } + if entry.isFetching { return theme.info } return entry.lastError == nil ? theme.gain : theme.loss } } diff --git a/apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift b/apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift index b364f5daf..4fd0c34a3 100644 --- a/apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift +++ b/apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift @@ -3,14 +3,20 @@ import SwiftUI import VisionNativeCore /// AppLayout's topbar over native screens, drawn in the transparent titlebar strip: the -/// sidebar trigger and the search button while the sidebar is hidden (they sit right of the -/// traffic lights), and the page title once the page has scrolled past 96 pt, over a bar -/// material as a unified macOS toolbar does. It never takes clicks outside its buttons, so -/// the strip still drags the window. +/// `SidebarTrigger` (always, as on the web; right of the traffic lights while the sidebar is +/// hidden), the page header's title once the page has scrolled past 96 pt, and the bar veil +/// once it has scrolled past 8 pt. The web's search button is `md:hidden` (phones only); the +/// sidebar and its icon rail keep the search entry. It never takes clicks outside its buttons, +/// so the strip still drags the window. struct NativeTopbar: View { let model: NativeShellModel let height: CGFloat + /// `scrolled` (`scrollY > 8`): the veil and its hairline show. + let veiled: Bool + /// `titleVisible` (`scrollY > 96`): the inline page title shows. let scrolled: Bool + /// The page header's title (`usePageTitle`); the sidebar item's title when the page has none. + var pageTitle: String? @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.visionTheme) private var theme @@ -18,35 +24,32 @@ struct NativeTopbar: View { static let barHeight: CGFloat = 56 /// `INLINE_TITLE_THRESHOLD` in AppLayout. static let titleThreshold: CGFloat = 96 + /// AppLayout's `setScrolled(window.scrollY > 8)`. + static let veilThreshold: CGFloat = 8 /// Room for the traffic lights when the sidebar no longer holds them: the icon rail /// under them already takes its own width of it. - private static let trafficLightsWidth: CGFloat = 78 - NativeSidebarRail.width + /// The traffic lights end about 74 pt in (`trafficLightPosition` x 20); the trigger keeps a + /// 12 pt gap after them. + private static let trafficLightsWidth: CGFloat = 86 - NativeSidebarRail.width var body: some View { - HStack(spacing: 6) { - if !model.isSidebarVisible { - Button { - model.toggleSidebar() - } label: { - Image(systemName: "sidebar.left").frame(width: 26, height: 22).contentShape(Rectangle()) - } - .help(model.strings.t("menu.toggleSidebar")) - .accessibilityLabel(model.strings.t("menu.toggleSidebar")) - if model.commandPalette != nil { - Button { - model.openPalette() - } label: { - Image(systemName: "magnifyingglass").frame(width: 26, height: 22).contentShape(Rectangle()) - } - .help(model.strings.t("commandPalette.openLabel")) - .accessibilityLabel(model.strings.t("commandPalette.openLabel")) - } + HStack(spacing: 0) { + // ``: an aria-label, no tooltip. + // `variant="ghost" size="icon"` at `h-7 w-7` with the 16 pt PanelLeft glyph. + Button { + model.toggleSidebar() + } label: { + Image(systemName: "sidebar.left").font(.system(size: 14, weight: .regular)) } + .buttonStyle(.vision(.ghost, size: .iconXs)) + .accessibilityLabel(model.strings.t("menu.toggleSidebar")) + .padding(.trailing, 16) if let title { Text(title) .font(.visionDisplayHeadline) + .foregroundStyle(theme.foreground) .lineLimit(1) - .padding(.leading, model.isSidebarVisible ? 0 : 4) + .truncationMode(.tail) .opacity(scrolled ? 1 : 0) .offset(y: scrolled ? 0 : 4) .allowsHitTesting(false) @@ -54,30 +57,49 @@ struct NativeTopbar: View { } Spacer(minLength: 0) } - .buttonStyle(.borderless) - .foregroundStyle(.secondary) - .padding(.leading, model.isSidebarVisible ? 20 : Self.trafficLightsWidth) + // `px-4`. + .padding(.leading, model.isSidebarVisible ? 16 : Self.trafficLightsWidth) .padding(.trailing, 16) .frame(height: height) .frame(maxWidth: .infinity) // The strip drags the window like a titlebar; buttons above it take their own clicks. .background { WindowDragArea() } .background { - // The web topbar's `bg-background/80 backdrop-blur`: the page background over a blur. - // The glass detail tint read greener than the page beneath it. + // `.app-topbar::before` (index.css): a `--background` gradient (0.82 → 0.68 light, + // 0.7 → 0.56 dark) over a 16 px backdrop blur, the bottom border (`--border` at 0.5 / + // 0.7), the inset glass highlight and a soft drop shadow; hidden at scroll-top. Rectangle().fill(.ultraThinMaterial) - .overlay(Color(token: theme.palette.background, opacity: 0.88)) + .overlay( + LinearGradient( + colors: [ + Color(token: theme.palette.background, opacity: theme.isDark ? 0.7 : 0.82), + Color(token: theme.palette.background, opacity: theme.isDark ? 0.56 : 0.68), + ], startPoint: .top, endPoint: .bottom) + ) + .overlay(alignment: .top) { + Rectangle() + .fill(Color(token: theme.palette.glassHighlight, opacity: theme.isDark ? 0.06 : 0.3)) + .frame(height: 1) + } .overlay(alignment: .bottom) { - Rectangle().fill(theme.border.opacity(0.6)).frame(height: 1) + Rectangle().fill(theme.border.opacity(theme.isDark ? 0.7 : 0.5)).frame(height: 1) } - .opacity(scrolled ? 1 : 0) + .shadow( + color: Color(token: theme.palette.glassShadow, opacity: theme.isDark ? 0.38 : 0.05), + radius: theme.isDark ? 16 : 14, y: theme.isDark ? 14 : 12) + .opacity(veiled ? 1 : 0) .allowsHitTesting(false) } .animation(reduceMotion ? nil : .easeOut(duration: DesignTokens.Duration.normal), value: scrolled) + // `transition: opacity var(--duration-normal) var(--ease-glide)`. + .animation( + reduceMotion ? nil : .visionCurve(DesignTokens.Ease.glide, duration: DesignTokens.Duration.normal), + value: veiled) } private var title: String? { - NavigationRegistry.matchItem(path: model.route.path).map { model.strings.t($0.titleKey) } + if let pageTitle, !pageTitle.isEmpty { return pageTitle } + return NavigationRegistry.matchItem(path: model.route.path).map { model.strings.t($0.titleKey) } } } diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionCard.swift b/apps/native/Sources/VisionNativeUI/Components/VisionCard.swift index 7f70d6b2c..1652bb077 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionCard.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionCard.swift @@ -2,14 +2,14 @@ import AppKit import SwiftUI import VisionNativeCore -/// The web `` surface (`card-material glass-thin premium-frame rounded-card`, index.css; -/// components/ui/card.tsx): a vertical glass-surface gradient, the `--border`/0.7 frame, the -/// embossed inset edge (a top highlight and, in light mode, a bottom hairline) and the soft -/// drop shadow. Print reports keep a flat white card with a hairline. +/// The web `` surface (`card-material premium-frame rounded-card`, plus `glass-thin`, or +/// `glass-regular` under Full effects: index.css; components/ui/card.tsx): the material gradient, +/// the `--border`/0.7 frame, the embossed inset edge (a top highlight and, in light mode, a bottom +/// hairline) and the soft drop shadow. Print reports keep a flat white card with a hairline. /// -/// Full effects (`useFullEffects`, card.tsx) swap `glass-thin` for `glass-regular`: the 135 -/// degree regular gradient, with no blurred backdrop (`.card-material`). The premium frame (border, edge, -/// shadow) stays, as `.premium-frame` overrides the material's own. +/// Both materials are drawn as Core Animation layers (`VisionCardLayers`). The web filters a +/// card's backdrop with saturate alone (`.card-material`, phase D); SwiftUI has no backdrop +/// saturate, so the card draws its fill without a backdrop filter. /// /// An interactive card (`premium-frame-interactive`, index.css) tints its frame with /// `--primary` and raises the lift shadow while `hovering`; see `visionCardBackground(interactive:)`. @@ -35,90 +35,64 @@ struct VisionCardBackground: View { if theme.isPrintReport { shape.fill(theme.card).overlay(shape.strokeBorder(stroke ?? theme.border, lineWidth: 1)) } else { - let palette = theme.palette - let lifted = interactive && hovering - // `.premium-frame-interactive:hover` frame: `--primary` at 0.4, dark 0.5. - let frame: Color = - stroke - ?? (lifted - ? theme.primary.opacity(DesignTokens.Hover.cardBorderAlpha.value(isDark: theme.isDark)) - : theme.border.opacity(0.7)) - // `:root.fx-full .premium-frame-interactive:hover` raises the inset highlight to - // 0.22 (dark 0.12); otherwise the resting 0.16 (dark 0.06). - let highlightAlpha: Double = - fullEffects && lifted ? (theme.isDark ? 0.12 : 0.22) : (theme.isDark ? 0.06 : 0.16) - // `.premium-frame-interactive::after` / hover box-shadow, a fade in (index.css). - let lift = DesignTokens.Hover.cardShadow.value(isDark: theme.isDark).first - let liftAlpha = lifted ? (lift?.alpha ?? 0) : 0 - if !fullEffects { - // The same surface as Core Animation layers (`VisionCardLayers`), which the - // render server draws instead of SwiftUI rasterising the gradients on the CPU. - VisionCardLayers( - style: layerStyle( - frame: frame, highlightAlpha: highlightAlpha, lift: lift, liftAlpha: liftAlpha)) - } else { - fill(shape) - // premium-frame's inset edge: `0 1px 0 highlight inset` and, light only, - // `0 -1px 0 shadow/0.05 inset`. - .overlay( - shape.inset(by: 1).strokeBorder( - LinearGradient( - stops: [ - .init( - color: Color(token: palette.glassHighlight, opacity: highlightAlpha), - location: 0), - .init(color: .clear, location: 0.12), - .init(color: .clear, location: 0.88), - .init( - color: Color(token: palette.glassShadow, opacity: theme.isDark ? 0 : 0.05), - location: 1), - ], startPoint: .top, endPoint: .bottom), - lineWidth: 1) - ) - .overlay(shape.strokeBorder(frame, lineWidth: 1)) - // `0 4px 12px shadow/0.04` light, `0 5px 16px shadow/0.2` dark (CSS blur ≈ 2 × radius). - .shadow( - color: Color(token: palette.glassShadow, opacity: theme.isDark ? 0.2 : 0.04), - radius: theme.isDark ? 8 : 6, y: theme.isDark ? 5 : 4) - .modifier( - LiftShadow( - enabled: interactive, color: Color(token: palette.glassShadow, opacity: liftAlpha), - radius: CGFloat((lift?.blur ?? 0) / 2), x: CGFloat(lift?.x ?? 0), y: CGFloat(lift?.y ?? 0)) - ) - .animation(hoverAnimation, value: hovering) - } + VisionCardLayers(style: layerStyle()) } } - /// `glass-thin`'s surface for `VisionCardLayers`. SwiftUI shades a view's shadow by the view's - /// own alpha, so the shadows are scaled by the surface's alpha at the bottom edge, where they show. - private func layerStyle(frame: Color, highlightAlpha: Double, lift: ShadowLayer?, liftAlpha: Double) - -> VisionCardLayerStyle - { + private func layerStyle() -> VisionCardLayerStyle { let palette = theme.palette let dark = theme.isDark - let opaqueCard = Color(token: palette.card, opacity: DesignTokens.Glass.reducedTransparencyOpacity) - let fillTop = opaque ? opaqueCard : Color(token: palette.glassSurface, opacity: dark ? 0.64 : 0.72) - let bottomAlpha = opaque ? 1 : dark ? 0.78 : 0.56 - let fillBottom = - opaque - ? opaqueCard - : dark - ? Color(token: palette.glassSurfaceDeep, opacity: bottomAlpha) - : Color(token: palette.glassSurface, opacity: bottomAlpha) + let lifted = interactive && hovering + // card.tsx: `fullEffects ? "glass-regular" : "glass-thin"`. + let material = (fullEffects ? GlassMaterialSpec.regular : GlassMaterialSpec.thin).appearance(isDark: dark) + // `.premium-frame` sets `--border`/0.7, but in dark mode `.dark .glass-regular` (two + // classes) outranks it with `--glass-border`/0.08; the hover frame outranks both. + let restingFrame: Color = + fullEffects && dark && !opaque + ? Color(token: palette.color(material.border.color), opacity: material.border.alpha) + : theme.border.opacity(0.7) + // `.premium-frame-interactive:hover` frame: `--primary` at 0.4, dark 0.5. + let frame: Color = + stroke + ?? (lifted + ? theme.primary.opacity(DesignTokens.Hover.cardBorderAlpha.value(isDark: dark)) + : restingFrame) + // `:root.fx-full .premium-frame-interactive:hover` raises the inset highlight to + // 0.22 (dark 0.12); otherwise the resting 0.16 (dark 0.06). + let highlightAlpha: Double = fullEffects && lifted ? (dark ? 0.12 : 0.22) : (dark ? 0.06 : 0.16) + // `.premium-frame-interactive::after` / hover box-shadow, a fade in (index.css). + let lift = DesignTokens.Hover.cardShadow.value(isDark: dark).first + let liftAlpha = lifted ? (lift?.alpha ?? 0) : 0 + let fillColors: [CGColor] + let fillLocations: [Double] + if opaque { + // Reduce Transparency and the reduced tier replace the gradient with `--card`. + let card = NSColor(Color(token: palette.card, opacity: DesignTokens.Glass.reducedTransparencyOpacity)).cgColor + fillColors = [card, card] + fillLocations = [0, 1] + } else { + fillColors = material.fill.stops.map { + NSColor(Color(token: palette.color($0.color), opacity: $0.alpha)).cgColor + } + fillLocations = material.fill.stops.map(\.location) + } return VisionCardLayerStyle( cornerRadius: cornerRadius, - fillTop: NSColor(fillTop).cgColor, - fillBottom: NSColor(fillBottom).cgColor, + fillColors: fillColors, + fillLocations: fillLocations, + fillAngle: material.fill.angle, edgeTop: NSColor(Color(token: palette.glassHighlight, opacity: highlightAlpha)).cgColor, edgeBottom: NSColor(Color(token: palette.glassShadow, opacity: dark ? 0 : 0.05)).cgColor, frame: NSColor(frame).cgColor, - shadow: VisionCardShadow( - color: NSColor(Color(token: palette.glassShadow, opacity: (dark ? 0.2 : 0.04) * bottomAlpha)).cgColor, - radius: dark ? 8 : 6, x: 0, y: dark ? 5 : 4), + // `.premium-frame` outer shadow: `0 4px 12px shadow/0.04`, dark `0 5px 16px shadow/0.2`. + shadows: [ + VisionCardShadow( + color: NSColor(Color(token: palette.glassShadow, opacity: dark ? 0.2 : 0.04)).cgColor, + radius: dark ? 8 : 6, x: 0, y: dark ? 5 : 4) + ], lift: interactive ? VisionCardShadow( - color: NSColor(Color(token: palette.glassShadow, opacity: liftAlpha * bottomAlpha)).cgColor, + color: NSColor(Color(token: palette.glassShadow, opacity: liftAlpha)).cgColor, radius: CGFloat((lift?.blur ?? 0) / 2), x: CGFloat(lift?.x ?? 0), y: CGFloat(lift?.y ?? 0)) : nil, transitionDuration: hoverAnimation == nil ? nil : DesignTokens.Duration.normal, @@ -131,58 +105,45 @@ struct VisionCardBackground: View { guard interactive, !reduceMotion else { return nil } return .visionCurve(DesignTokens.Ease.glide, duration: DesignTokens.Duration.normal) } - - /// The fill: `glass-thin`'s surface gradient, or `glass-regular` under Full effects. - @ViewBuilder - private func fill(_ shape: RoundedRectangle) -> some View { - let palette = theme.palette - if fullEffects { - // card.tsx: `fullEffects ? "glass-regular" : "glass-thin"`. Reduce Transparency - // replaces the backdrop and gradient with `--card` (index.css reduced-transparency). - if opaque { - shape.fill(Color(token: palette.card, opacity: DesignTokens.Glass.reducedTransparencyOpacity)) - } else { - // `.card-material` (index.css, phase D): a card only sits over the canvas, whose - // soft gradients a blur returns unchanged, so the web filters it with saturate - // alone. SwiftUI has no backdrop saturate, and a system material would re-blur on - // every drift step, so the card draws the regular fill without a backdrop. - shape.fill( - GlassMaterialSpec.regular.appearance(isDark: theme.isDark).fill - .linearGradient(selection: theme.selection)) - } - } else { - shape.fill( - LinearGradient( - colors: theme.isDark - ? [ - Color(token: palette.glassSurface, opacity: 0.64), - Color(token: palette.glassSurfaceDeep, opacity: 0.78), - ] - : [ - Color(token: palette.glassSurface, opacity: 0.72), - Color(token: palette.glassSurface, opacity: 0.56), - ], - startPoint: .top, endPoint: .bottom)) - } - } } -/// The hover lift shadow of an interactive card. A static card has none (zero radius and alpha), -/// so it skips the extra shadow pass instead of drawing an invisible one. -private struct LiftShadow: ViewModifier { - let enabled: Bool - let color: Color - let radius: CGFloat - let x: CGFloat - let y: CGFloat +/// A glass box that is not a `` (no `premium-frame`): the material's own fill, border, inset +/// highlight and outer elevation, as Core Animation layers. Reduce Transparency and the reduced +/// tier make it an opaque `--card`, as they do every material. +struct VisionGlassBoxBackground: View { + let material: GlassMaterialSpec + var cornerRadius: CGFloat = DesignTokens.Radius.card + /// A frame that replaces the material border (an explicit `border-*` class on the box). + var frame: Color? + @Environment(\.visionTheme) private var theme + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.visionVisualEffects) private var visualEffects - @ViewBuilder - func body(content: Content) -> some View { - if enabled { - content.shadow(color: color, radius: radius, x: x, y: y) - } else { - content - } + var body: some View { + let palette = theme.palette + let dark = theme.isDark + let look = material.appearance(isDark: dark) + let opaque = reduceTransparency || visualEffects == .reduced + let card = NSColor(Color(token: palette.card, opacity: DesignTokens.Glass.reducedTransparencyOpacity)).cgColor + let inset = look.shadows.first(where: \.inset) + let border = frame ?? Color(token: palette.color(look.border.color), opacity: look.border.alpha) + VisionCardLayers( + style: VisionCardLayerStyle( + cornerRadius: cornerRadius, + fillColors: opaque + ? [card, card] + : look.fill.stops.map { NSColor(Color(token: palette.color($0.color), opacity: $0.alpha)).cgColor }, + fillLocations: opaque ? [0, 1] : look.fill.stops.map(\.location), + fillAngle: look.fill.angle, + edgeTop: NSColor(Color(token: palette.glassHighlight, opacity: inset?.alpha ?? 0)).cgColor, + edgeBottom: CGColor.clear, + frame: NSColor(border).cgColor, + shadows: look.shadows.filter { !$0.inset }.map { + VisionCardShadow( + color: NSColor(Color(token: palette.color($0.color), opacity: $0.alpha)).cgColor, + radius: CGFloat($0.blur / 2), x: CGFloat($0.x), y: CGFloat($0.y), spread: CGFloat($0.spread)) + }, + lift: nil, transitionDuration: nil, transitionCurve: DesignTokens.Ease.glide)) } } diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift b/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift index 973e557f0..d6e1ee61b 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionCardLayers.swift @@ -2,8 +2,8 @@ import AppKit import SwiftUI import VisionNativeCore -/// The `glass-thin` card surface as Core Animation layers: the surface gradient, the embossed -/// inset edge, the frame and the drop and lift shadows. SwiftUI drew the gradient fill and the +/// A glass card surface as Core Animation layers: the material gradient, the embossed inset +/// edge, the frame and the outer and lift shadows. SwiftUI drew the gradient fill and the /// gradient edge stroke into CPU bitmaps, re-rendered tile by tile while a tall card (the ledger) /// scrolled; the render server draws these layers on the GPU, as the browser compositor draws the /// CSS card. The look is `VisionCardBackground`'s; see there for the web sources. @@ -15,24 +15,28 @@ struct VisionCardLayers: NSViewRepresentable { func updateNSView(_ view: VisionCardLayerView, context: Context) { view.apply(style) } } -/// A SwiftUI `.shadow(color:radius:x:y:)` of the card. +/// One outer `box-shadow` layer: SwiftUI-style radius (half the CSS blur), offset and spread. struct VisionCardShadow: Equatable { let color: CGColor let radius: CGFloat let x: CGFloat let y: CGFloat + var spread: CGFloat = 0 } struct VisionCardLayerStyle: Equatable { let cornerRadius: CGFloat - /// Top and bottom of the surface gradient. - let fillTop: CGColor - let fillBottom: CGColor + /// The material's `linear-gradient(angle, stops…)`. + let fillColors: [CGColor] + let fillLocations: [Double] + /// CSS degrees: 180 runs top to bottom, 135 from the top-left to the bottom-right corner. + let fillAngle: Double /// The inset edge: the top highlight and the bottom hairline. let edgeTop: CGColor let edgeBottom: CGColor let frame: CGColor - let shadow: VisionCardShadow + /// The outer shadows, painted only outside the card. + let shadows: [VisionCardShadow] /// The interactive card's hover lift; `nil` on a static card. let lift: VisionCardShadow? /// The hover transition in seconds, or `nil` to change at once. @@ -43,7 +47,12 @@ struct VisionCardLayerStyle: Equatable { /// Hosts the card layers. The view takes no clicks, so the card's content stays interactive. @MainActor final class VisionCardLayerView: NSView { private let host = CALayer() - private let shadowLayer = CALayer() + /// Holds the shadows and cuts the card's own shape out of them: a CSS `box-shadow` is not + /// painted under its box, so the translucent surface stays as clear as the web card's + /// instead of darkening over its own shadow. + private let shadowHost = CALayer() + private let shadowMask = CAShapeLayer() + private var shadowLayers: [CALayer] = [] private let liftLayer = CALayer() private let fillLayer = CAGradientLayer() private let edgeLayer = CAGradientLayer() @@ -57,14 +66,16 @@ struct VisionCardLayerStyle: Equatable { layer = host wantsLayer = true host.masksToBounds = false - for shadow in [shadowLayer, liftLayer] { - shadow.shadowOpacity = 1 - host.addSublayer(shadow) - } - for gradient in [fillLayer, edgeLayer] { - gradient.startPoint = CGPoint(x: 0.5, y: 1) - gradient.endPoint = CGPoint(x: 0.5, y: 0) - } + shadowMask.fillRule = .evenOdd + shadowMask.fillColor = CGColor(gray: 0, alpha: 1) + shadowHost.mask = shadowMask + host.addSublayer(shadowHost) + // A layer without a colour still casts Core Animation's default opaque black shadow, so + // the lift is switched off by its opacity until a card has one. + liftLayer.shadowOpacity = 0 + shadowHost.addSublayer(liftLayer) + edgeLayer.startPoint = CGPoint(x: 0.5, y: 1) + edgeLayer.endPoint = CGPoint(x: 0.5, y: 0) fillLayer.cornerCurve = .continuous fillLayer.masksToBounds = true edgeMask.fillColor = nil @@ -89,14 +100,17 @@ struct VisionCardLayerStyle: Equatable { override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() let scale = window?.backingScaleFactor ?? 2 - for layer in [host, shadowLayer, liftLayer, fillLayer, edgeLayer, edgeMask, frameLayer] { + for layer in [host, shadowHost, shadowMask, liftLayer, fillLayer, edgeLayer, edgeMask, frameLayer] + + shadowLayers + { layer.contentsScale = scale } } func apply(_ style: VisionCardLayerStyle) { guard style != self.style else { return } - let duration = self.style == nil ? nil : style.transitionDuration + let previous = self.style + let duration = previous == nil ? nil : style.transitionDuration self.style = style CATransaction.begin() if let duration { @@ -108,14 +122,26 @@ struct VisionCardLayerStyle: Equatable { } else { CATransaction.setDisableActions(true) } - fillLayer.colors = [style.fillTop, style.fillBottom] + if shadowLayers.count != style.shadows.count { + for layer in shadowLayers { layer.removeFromSuperlayer() } + shadowLayers = style.shadows.map { _ in + let layer = CALayer() + layer.shadowOpacity = 1 + layer.contentsScale = window?.backingScaleFactor ?? 2 + return layer + } + for layer in shadowLayers { shadowHost.insertSublayer(layer, below: liftLayer) } + } + for (layer, shadow) in zip(shadowLayers, style.shadows) { Self.setShadow(layer, shadow) } + fillLayer.colors = style.fillColors + fillLayer.locations = style.fillLocations.map { NSNumber(value: $0) } edgeLayer.colors = [style.edgeTop, CGColor.clear, CGColor.clear, style.edgeBottom] frameLayer.borderColor = style.frame - Self.setShadow(shadowLayer, style.shadow) if let lift = style.lift { Self.setShadow(liftLayer, lift) + liftLayer.shadowOpacity = 1 } else { - liftLayer.shadowColor = nil + liftLayer.shadowOpacity = 0 } CATransaction.commit() needsLayout = true @@ -129,11 +155,22 @@ struct VisionCardLayerStyle: Equatable { let bounds = self.bounds let radius = style.cornerRadius let outline = Self.path(bounds, radius: radius) - for layer in [shadowLayer, liftLayer, fillLayer, edgeLayer, frameLayer] { + for layer in [shadowHost, shadowMask, liftLayer, fillLayer, edgeLayer, frameLayer] + shadowLayers { layer.frame = bounds } - shadowLayer.shadowPath = outline + for (layer, shadow) in zip(shadowLayers, style.shadows) { + layer.shadowPath = Self.path( + bounds.insetBy(dx: -shadow.spread, dy: -shadow.spread), radius: max(0, radius + shadow.spread)) + } liftLayer.shadowPath = outline + // Everything around the card, far enough out for the widest shadow, minus the card. + let around = CGMutablePath() + around.addRect(bounds.insetBy(dx: -Self.shadowReach, dy: -Self.shadowReach)) + around.addPath(outline) + shadowMask.path = around + let (start, end) = Self.gradientPoints(angle: style.fillAngle, size: bounds.size) + fillLayer.startPoint = start + fillLayer.endPoint = end fillLayer.cornerRadius = radius frameLayer.cornerRadius = radius // `shape.inset(by: 1).strokeBorder(…, lineWidth: 1)`: a 1 pt line just inside 1 pt of inset. @@ -142,6 +179,25 @@ struct VisionCardLayerStyle: Equatable { CATransaction.commit() } + /// How far outside the card a shadow may paint (offset plus blur of the largest lift). + private static let shadowReach: CGFloat = 96 + + /// The CSS gradient line of `angle` for a box of `size`, as unit points of a bottom-up layer: + /// through the centre, long enough that the perpendiculars through the corners mark 0 % and + /// 100 % (`|w sin a| + |h cos a|`). A unit-square diagonal would tilt the stripes on a wide card. + static func gradientPoints(angle: Double, size: CGSize) -> (CGPoint, CGPoint) { + let radians = angle * Double.pi / 180 + let dx = sin(radians) + let dy = cos(radians) + let width = max(1, Double(size.width)) + let height = max(1, Double(size.height)) + let half = (abs(width * dx) + abs(height * dy)) / 2 + // Offsets from the centre in points, y upwards (CSS 0 degrees points up). + let ox = dx * half / width + let oy = dy * half / height + return (CGPoint(x: 0.5 - ox, y: 0.5 - oy), CGPoint(x: 0.5 + ox, y: 0.5 + oy)) + } + /// SwiftUI's `.shadow(color:radius:x:y:)`: y grows downward there and upward here. private static func setShadow(_ layer: CALayer, _ shadow: VisionCardShadow) { layer.shadowColor = shadow.color diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionDialogChrome.swift b/apps/native/Sources/VisionNativeUI/Components/VisionDialogChrome.swift index 7b04c633b..f1726fd16 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionDialogChrome.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionDialogChrome.swift @@ -53,11 +53,14 @@ struct VisionFieldChromeModifier: ViewModifier { @Environment(\.isEnabled) private var isEnabled @Environment(\.isFocused) private var environmentFocus @Environment(\.visionFieldFocused) private var fieldFocused + @Environment(\.visionContainerOwnsFocus) private var containerOwnsFocus @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var hovering = false func body(content: Content) -> some View { - let focused = isFocused ?? (fieldFocused || environmentFocus) + // A page or panel container that holds focus itself makes `isFocused` true for every field + // in it; only a field the call site reports, or a focused wrapping control, draws focus. + let focused = isFocused ?? (fieldFocused || (environmentFocus && !containerOwnsFocus)) let shape = RoundedRectangle(cornerRadius: VisionControlMetrics.radius, style: .continuous) content .padding(.horizontal, horizontalPadding) diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionMenu.swift b/apps/native/Sources/VisionNativeUI/Components/VisionMenu.swift index ce015136b..0e0fdfe27 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionMenu.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionMenu.swift @@ -7,9 +7,12 @@ import VisionNativeCore /// colour (`variant="destructive"`, icon included). A `Label` whose title holds a second `Text` /// shows it as the item's helper line (`type-footnote text-label-secondary`). /// -/// Use it like `Menu` for menus of `Button`s and `Divider`s; a menu that holds `Toggle`s or -/// `Picker`s stays a `Menu`. Buttons keep their role and `.disabled`; choosing an item closes the -/// popover first, then runs its action. Escape or a click outside closes it. +/// Use it like `Menu` for menus of `Button`s, `Divider`s, `VisionMenuLabel`s and `Toggle`s; a +/// `Toggle` is a `DropdownMenuCheckboxItem` (a primary check in the `pl-8` gutter), or a +/// `DropdownMenuRadioItem` (a primary dot) under `.environment(\.visionMenuRadio, true)`. A menu +/// that holds `Picker`s stays a `Menu`. Buttons keep their role and `.disabled`; choosing an item +/// (a toggle too, as Radix does) closes the popover first, then runs its action. Escape or a +/// click outside closes it. /// /// VisionMenu { /// Button { edit() } label: { Label("Edit", systemImage: "pencil") } @@ -50,6 +53,7 @@ private struct VisionMenuPanel: View { content() } .buttonStyle(VisionMenuItemStyle(dismiss: dismiss)) + .toggleStyle(VisionMenuToggleStyle()) .labelStyle(VisionMenuItemLabelStyle()) .padding(6) .frame(minWidth: 128, alignment: .leading) @@ -130,7 +134,61 @@ private struct VisionMenuItemLabel: View { } } +/// `DropdownMenuLabel`: `px-2 py-1.5 eyebrow` (11 pt medium, upper case, 0.12 em tracking, +/// `muted-foreground/0.8`). +struct VisionMenuLabel: View { + let text: String + @Environment(\.visionTheme) private var theme + + var body: some View { + Text(text) + .font(.system(size: 11, weight: .medium)).tracking(11 * 0.12).textCase(.uppercase) + .foregroundStyle(Color(token: theme.palette.mutedForeground).opacity(0.8)) + .padding(.horizontal, 8).padding(.vertical, 6) + .accessibilityAddTraits(.isHeader) + } +} + +/// `DropdownMenuCheckboxItem` / `DropdownMenuRadioItem`: an item row (`py-1.5 pl-8 pr-2`) whose +/// `left-2` slot shows a 16 pt primary check, or an 8 pt primary dot for a radio item, while on. +private struct VisionMenuToggleStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + VisionMenuToggleRow(configuration: configuration) + } +} + +private struct VisionMenuToggleRow: View { + let configuration: ToggleStyleConfiguration + @Environment(\.visionMenuRadio) private var radio + @Environment(\.visionTheme) private var theme + + var body: some View { + Button { + configuration.isOn.toggle() + } label: { + HStack(spacing: 0) { + Group { + if configuration.isOn { + if radio { + Circle().fill(theme.primary).frame(width: 8, height: 8) + } else { + Image(systemName: "checkmark").font(.system(size: 12, weight: .semibold)) + .foregroundStyle(theme.primary) + } + } + } + .frame(width: 24, alignment: .leading).padding(.leading, radio ? 3 : 1) + .accessibilityHidden(true) + configuration.label + } + } + .accessibilityAddTraits(configuration.isOn ? .isSelected : []) + } +} + extension EnvironmentValues { /// Set by a `VisionMenu` item for its label: the icon takes the destructive colour. @Entry var visionMenuItemDestructive = false + /// Draws the `Toggle`s of a `VisionMenu` as radio items. + @Entry var visionMenuRadio = false } diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionMotion.swift b/apps/native/Sources/VisionNativeUI/Components/VisionMotion.swift index 4d9627b72..146ef256d 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionMotion.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionMotion.swift @@ -13,6 +13,28 @@ enum VisionMotion { static let reveal = DesignTokens.Duration.reveal } +/// `DonutChart`'s hover motion: the hovered arc grows by `whileHover: { scale: 1.045 }` over +/// `durations.slow` on out-expo, and the hollow's content swaps through `AnimatePresence +/// mode="wait"`: the old content fades out over `durations.fast`, then the new one fades and scales +/// in from 0.96 over the same time. Under Reduce Motion nothing grows and the swap is instant. +enum DonutHoverMotion { + static let restingOuter = 0.93 + static func outer(hovered: Bool, reduceMotion: Bool) -> Double { + hovered && !reduceMotion ? restingOuter * 1.045 : restingOuter + } + static func arc(reduceMotion: Bool) -> Animation? { + reduceMotion ? nil : VisionMotion.outExpo(DesignTokens.Duration.slow) + } + static func center(reduceMotion: Bool) -> AnyTransition { + guard !reduceMotion else { return .identity } + let fast = DesignTokens.Duration.fast + return .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.96)) + .animation(VisionMotion.outExpo(fast).delay(fast)), + removal: .opacity.animation(VisionMotion.outExpo(fast))) + } +} + /// PageTransition: each pathname enters with opacity 0 → 1 and y 14 → 0 over 0.52 s on the /// out-expo curve; enter-only, and none under Reduce Motion. The web's scale 0.995 → 1 is left /// out: an invisible half-percent, it made every layer of the page re-render each frame. diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionPageHeader.swift b/apps/native/Sources/VisionNativeUI/Components/VisionPageHeader.swift index 2712eca9a..3b6a8a409 100644 --- a/apps/native/Sources/VisionNativeUI/Components/VisionPageHeader.swift +++ b/apps/native/Sources/VisionNativeUI/Components/VisionPageHeader.swift @@ -16,6 +16,8 @@ struct VisionPageHeader: View { // One subview however many actions the page passes (`gap-2`). HStack(spacing: 8) { actions() } } + // `usePageTitle`: the topbar shows this title once the header has scrolled away. + .preference(key: VisionPageTitleKey.self, value: title) } private var titleBlock: some View { @@ -168,3 +170,21 @@ struct VisionEmptyState: View { .accessibilityElement(children: .contain) } } + +/// The canonical identity icon of a destination (`PAGE_ICONS`, lib/pageIcons.ts), as the +/// generated navigation registry maps it to an SF Symbol, so a page header shows the same glyph +/// as its sidebar row. +enum VisionPageSymbol { + static func of(_ path: String, fallback: String) -> String { + NavigationRegistry.matchItem(path: path)?.symbol ?? fallback + } +} + +/// The page header's title (`PageTitleProvider` / `usePageTitle`, AppLayout): the first header in +/// the page wins, so a header inside a print document or card cannot replace it. +struct VisionPageTitleKey: PreferenceKey { + static let defaultValue: String? = nil + static func reduce(value: inout String?, nextValue: () -> String?) { + if value == nil { value = nextValue() } + } +} diff --git a/apps/native/Sources/VisionNativeUI/HomeView.swift b/apps/native/Sources/VisionNativeUI/HomeView.swift index 02ca71153..608627674 100644 --- a/apps/native/Sources/VisionNativeUI/HomeView.swift +++ b/apps/native/Sources/VisionNativeUI/HomeView.swift @@ -1,3 +1,4 @@ +import AppKit import Charts import SwiftUI import VisionNativeCore @@ -575,7 +576,7 @@ public struct HomeView: View { id: "spending", label: strings.t("monthlyTrends.spending"), points: months.map { InsightChartPoint(key: $0.id, value: abs($0.total_spending)) }), ], currency: currency, settings: settings, title: strings.t("monthlyTrends.title"), - bars: true, xLabels: .appMonthYear, + bars: true, yAxis: .lengthAware, xLabels: .appMonthYear, selection: $monthSelection, rangeStart: $monthRange) // `mt-6 grid grid-cols-2 gap-3`: the totals as two tinted tiles. HStack(spacing: 12) { @@ -925,6 +926,10 @@ private struct HomeWidgetsSheet: View { let strings: any Localizing @Environment(\.visionDismissDialog) private var dismiss @Environment(\.visionTheme) private var theme + /// The list's natural height; the dialog is as tall as its content, as the web's is. + @State private var listHeight: CGFloat = 0 + /// `max-h-[60vh] overflow-y-auto`: the list scrolls only past 60 % of the window's height. + private var maxListHeight: CGFloat { (NSApp.keyWindow?.frame.height ?? 1050) * 0.6 } var body: some View { VStack(alignment: .leading, spacing: 20) { VisionDialogHeader( @@ -963,8 +968,10 @@ private struct HomeWidgetsSheet: View { } } .visionCardBackground() + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in listHeight = height } } - .frame(height: 420) + .scrollBounceBehavior(.basedOnSize) + .frame(height: listHeight > 0 ? min(listHeight, maxListHeight) : nil) if let error = model.widgetError { VStack(alignment: .leading, spacing: 2) { Text(strings.t("settings.saveFailed")).foregroundStyle(theme.destructive) diff --git a/apps/native/Sources/VisionNativeUI/InsightCharts.swift b/apps/native/Sources/VisionNativeUI/InsightCharts.swift index 8f79deecb..2e1c7043b 100644 --- a/apps/native/Sources/VisionNativeUI/InsightCharts.swift +++ b/apps/native/Sources/VisionNativeUI/InsightCharts.swift @@ -4,7 +4,9 @@ import VisionNativeCore /// How a timeline chart writes its value-axis ticks: `formatAxisCompact` ("6,5k €") or the full /// currency format with the app's decimal places (`formatCurrency`). -enum InsightYAxisFormat { case compact, full } +/// `compact` is `formatAxisCompact`, `lengthAware` is `formatCompact` (full while short), `full` +/// the plain currency format. +enum InsightYAxisFormat { case compact, lengthAware, full } /// How a timeline chart writes its x-axis ticks: `CHART_DATE_PATTERNS.monthTick` ("Nov 24"), /// `formatMonthYearWithAppSettings` ("05/2026"), the day number of a month ("2", "4", ...) or @@ -439,6 +441,10 @@ struct InsightTimelineChart: View { case .compact: return VisionFormat.currencyAxisCompact( Decimal(number), code: currency, locale: settings.numberFormat.locale) + case .lengthAware: + return VisionFormat.currencyCompact( + Decimal(number), code: currency, locale: settings.numberFormat.locale, + fractionDigits: settings.showDecimalPlaces) case .full: return VisionFormat.currency( Decimal(number), code: currency, locale: settings.numberFormat.locale, @@ -520,6 +526,7 @@ struct HomeCategoryChart: View { @State private var selectedCount: Int? @State private var selectedId: String? // lint:allow raw-id view selection key of chart series, not an API entity @Environment(\.visionTheme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion private var selected: HomeCategorySlice? { slices.first { $0.id == selectedId } } @@ -542,7 +549,8 @@ struct HomeCategoryChart: View { Chart(slices) { slice in SectorMark( angle: .value(slice.name, slice.count), innerRadius: .ratio(0.6), - outerRadius: .ratio(selectedId == slice.id ? 1 : 0.93), angularInset: 2 + outerRadius: .ratio(DonutHoverMotion.outer(hovered: selectedId == slice.id, reduceMotion: reduceMotion)), + angularInset: 2 ) .foregroundStyle(by: .value(strings.t("categories.title"), slice.name)) .accessibilityLabel(slice.name).accessibilityValue(String(slice.count)) @@ -557,8 +565,11 @@ struct HomeCategoryChart: View { Text(selected.name).font(.caption).multilineTextAlignment(.center) Text(String(selected.count)).font(.title2.monospacedDigit()) }.frame(maxWidth: 120) + .id(selected.id) + .transition(DonutHoverMotion.center(reduceMotion: reduceMotion)) } } + .animation(DonutHoverMotion.arc(reduceMotion: reduceMotion), value: selectedId) .frame(height: 232) .chartOverlay { proxy in GeometryReader { geometry in diff --git a/apps/native/Sources/VisionNativeUI/NetWorthView.swift b/apps/native/Sources/VisionNativeUI/NetWorthView.swift index 38219e851..a3eb8272c 100644 --- a/apps/native/Sources/VisionNativeUI/NetWorthView.swift +++ b/apps/native/Sources/VisionNativeUI/NetWorthView.swift @@ -24,7 +24,9 @@ public struct NetWorthView: View { public var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 20) { - VisionPageHeader(title: strings.t("networth.title"), subtitle: "", symbol: "chart.line.uptrend.xyaxis") + VisionPageHeader( + title: strings.t("networth.title"), subtitle: "", + symbol: VisionPageSymbol.of("/portfolio/net-worth", fallback: "wallet.pass")) {} if model.isLoading || model.currency != currency { ProgressView().frame(maxWidth: .infinity).padding(60) @@ -353,19 +355,19 @@ public struct NetWorthView: View { } private var table: some View { VStack(alignment: .leading, spacing: 8) { - ScrollView { - 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() } } - } - }.frame(maxHeight: 520) + 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) if let error = model.tableError { Text(error).foregroundStyle(theme.destructive) Button(strings.t("common.retry")) { diff --git a/apps/native/Sources/VisionNativeUI/PortfolioHoldingsCards.swift b/apps/native/Sources/VisionNativeUI/PortfolioHoldingsCards.swift index 6fc6f01ab..97aba4f45 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioHoldingsCards.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioHoldingsCards.swift @@ -60,7 +60,6 @@ struct PortfolioAssetCard: View { ).day ?? 0 } } - private var nearingMaturity: Bool { days.map { $0 <= 30 } ?? false } private var currency: CurrencyCode { shell.settings.portfolioCurrency } private func money(_ amount: Decimal, signed: Bool = false) -> String { MoneyText.text(amount, currency: currency, settings: shell.settings, signed: signed) @@ -97,27 +96,105 @@ struct PortfolioAssetCard: View { actionsMenu } } + private var isSavings: Bool { holding.assetClass == .savings } + private var isMatured: Bool { days.map { $0 <= 0 } ?? false } + private var maturingSoon: Bool { days.map { $0 > 0 && $0 <= 30 } ?? false } + /// SavingsPage `CardHeader`: a 40 pt tinted tile (PiggyBank on primary for savings, Shield on + /// accent for bonds), the title, then the type badge and the accent `x % p.a.` rate. private var savingsHeader: some View { - HStack(alignment: .top) { - Image(systemName: holding.assetClass == .bond ? "shield" : "banknote") - .font(.title2).foregroundStyle(theme.primary).accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - Button(holding.name) { Task { await model.openDetail(id: holding.id) } } - .buttonStyle(.plain).font(.headline) - VisionBadge(text: strings.t("portfolio.assetClass." + holding.assetClass.rawValue)) - if holding.interestRate != 0 { - let rate = holding.interestRate - Text( - VisionFormat.percent(rate, digits: 2, locale: shell.settings.numberFormat.locale) - + " " + strings.t("savings.pa") + let tint = isSavings ? theme.primary : theme.accent + return HStack(alignment: .top, spacing: 12) { + HStack(spacing: 12) { + Image(systemName: isSavings ? "banknote" : "shield").font(.system(size: 17, weight: .medium)) + .foregroundStyle(tint).frame(width: 40, height: 40) + .background( + tint.opacity(0.12), + in: RoundedRectangle(cornerRadius: DesignTokens.Radius.control, style: .continuous) ) - .font(.caption).foregroundStyle(theme.secondaryLabel) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Button(holding.name) { Task { await model.openDetail(id: holding.id) } } + .buttonStyle(.plain).font(Font.visionTitle3).lineLimit(1) + HStack(spacing: 8) { + VisionBadge(text: strings.t(isSavings ? "portfolio.savings" : "portfolio.bond"), small: true) + if holding.interestRate != 0 { + Text( + VisionFormat.percent( + holding.interestRate, digits: 2, locale: shell.settings.numberFormat.locale) + + " " + strings.t("savings.pa") + ) + .font(.callout).monospacedDigit().foregroundStyle(theme.accent) + } + } } } - Spacer() + Spacer(minLength: 0) actionsMenu } } + /// SavingsPage `CardContent`: the balance and the interest earned side by side as `type-title-2` + /// amounts, then the `FactRow` list between hairlines. + @ViewBuilder private var savingsBody: some View { + let earned = holding.totalIncome + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text(strings.t("portfolio.currentBalance")).font(.caption).foregroundStyle(theme.tertiaryLabel) + MoneyText( + amount: holding.currentValue, currency: currency, settings: shell.settings, signed: false, + colored: false + ).font(.visionCardTitle) + }.frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .trailing, spacing: 2) { + Text(strings.t("portfolio.interestEarned")).font(.caption).foregroundStyle(theme.tertiaryLabel) + Text(money(earned, signed: true)).font(.visionCardTitle).monospacedDigit() + .foregroundStyle(earned > 0 ? theme.gain : earned < 0 ? theme.loss : theme.foreground) + }.frame(maxWidth: .infinity, alignment: .trailing) + } + let projects = holding.interestRate != 0 && holding.projectedAnnualInterest > 0 + let charged = holding.totalFees > 0 || holding.totalTaxes > 0 + if projects || holding.maturityDate != nil || charged { + VStack(spacing: 0) { + Rectangle().fill(theme.border.opacity(0.5)).frame(height: 1) + if projects { + factRow( + strings.t("portfolio.projectedAnnualInterest"), + money(holding.projectedAnnualInterest, signed: true), tone: theme.primary) + if holding.accruedInterest > 0 { + factRow( + strings.t("portfolio.accruedUnpaid"), money(holding.accruedInterest, signed: true), + tone: theme.gain) + } + } + if let date = holding.maturityDate { + maturityRow(date) + } + if charged { + factRow( + strings.t("portfolio.feesAndTaxesPaid"), money(-(holding.feesAndTaxes ?? 0), signed: true), + tone: theme.loss) + } + } + } + } + private func maturityRow(_ date: String) -> some View { + let tone: Color? = isMatured ? theme.accent : maturingSoon ? theme.primary : nil + let label = strings.t(isMatured ? "portfolio.matured" : "portfolio.matures") + let detail = !isMatured ? days.map { strings.t("portfolio.daysRemaining", ["days": String($0)]) } : nil + return VStack(spacing: 0) { + HStack(alignment: .top, spacing: 16) { + Label(label, systemImage: "calendar").font(.footnote).foregroundStyle(theme.secondaryLabel) + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 0) { + Text(VisionFormat.appDate(date, format: shell.settings.dateFormat)).font(.body) + .monospacedDigit().foregroundStyle(tone ?? theme.foreground) + if let detail { + Text(detail).font(.caption).monospacedDigit().foregroundStyle(theme.tertiaryLabel) + } + }.multilineTextAlignment(.trailing) + }.padding(.vertical, 8) + Rectangle().fill(theme.border.opacity(0.5)).frame(height: 1) + } + } /// The web property body: purchase price and current value side by side as large serif /// amounts, then a `dl` of label-left / value-right rows between hairlines. @ViewBuilder private var propertyBody: some View { @@ -204,45 +281,11 @@ struct PortfolioAssetCard: View { AdminCard { VStack(alignment: .leading, spacing: 16) { if isProperty { propertyHeader } else { savingsHeader } - if isProperty { - propertyBody - } else { - TaxMoneyRow( - title: strings.t("portfolio.currentBalance"), amount: holding.currentValue, shell: shell, - strong: true) - TaxMoneyRow(title: strings.t("portfolio.interestEarned"), amount: holding.totalIncome, shell: shell) - if holding.interestRate != 0 { - Divider() - TaxMoneyRow( - title: strings.t("portfolio.projectedAnnualInterest"), - amount: holding.projectedAnnualInterest, shell: shell, tone: theme.gain) - if holding.accruedInterest > 0 { - TaxMoneyRow( - title: strings.t("portfolio.accruedUnpaid"), amount: holding.accruedInterest, - shell: shell) - } - } - if let date = holding.maturityDate { - HStack { - Text(strings.t((days ?? 1) <= 0 ? "portfolio.matured" : "portfolio.matures")) - Spacer(minLength: 0) - Text(VisionFormat.appDate(date, format: shell.settings.dateFormat)) - } - .foregroundStyle(nearingMaturity ? theme.warning : theme.secondaryLabel) - if let days, days > 0 { - Text(strings.t("portfolio.daysRemaining", ["days": String(days)])) - .font(.caption).foregroundStyle(nearingMaturity ? theme.warning : theme.secondaryLabel) - } - } - } - if !isProperty, holding.totalFees + holding.totalTaxes > 0 { - TaxMoneyRow( - title: strings.t("portfolio.feesAndTaxesPaid"), - amount: -(holding.feesAndTaxes ?? 0), shell: shell, signed: true, tone: theme.loss) - } + if isProperty { propertyBody } else { savingsBody } } }.overlay( RoundedRectangle(cornerRadius: DesignTokens.Radius.card).stroke( - nearingMaturity ? theme.warning.opacity(0.6) : .clear)) + isProperty ? Color.clear : isMatured ? theme.accent.opacity(0.6) + : maturingSoon ? theme.primary.opacity(0.6) : .clear)) } } diff --git a/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift b/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift index 08af21e54..c281aa2bb 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioHoldingsView.swift @@ -32,7 +32,11 @@ public struct PortfolioHoldingsView: View { default: "chart.line.uptrend.xyaxis" } } - private var holdings: [PortfolioHolding] { model.active.filter { classes.contains($0.assetClass) } } + /// `byAssetClass([...classes])`: grouped in the page's class order (savings before bonds). + private var holdings: [PortfolioHolding] { + let active = model.active + return classes.flatMap { assetClass in active.filter { $0.assetClass == assetClass } } + } private var isUnitBased: Bool { ![.realEstate, .savings, .bond].contains(assetClass) } private var isRealEstate: Bool { assetClass == .realEstate } /// `findHoldingTotals`: the page figures are the server's totals of these classes (ADR-216). diff --git a/apps/native/Sources/VisionNativeUI/PortfolioImportReviewView.swift b/apps/native/Sources/VisionNativeUI/PortfolioImportReviewView.swift index ee6c03c0d..cb1291638 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioImportReviewView.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioImportReviewView.swift @@ -16,7 +16,8 @@ public struct PortfolioImportReviewView: View { LazyVStack(alignment: .leading, spacing: 20) { VisionPageHeader( title: strings.t("portfolioImport.review.title"), - subtitle: strings.t("portfolioImport.review.subtitle"), symbol: "square.and.arrow.down" + subtitle: strings.t("portfolioImport.review.subtitle"), + symbol: VisionPageSymbol.of("/portfolio/import", fallback: "briefcase") ) { if model.preview != nil { VisionBadge( diff --git a/apps/native/Sources/VisionNativeUI/PortfolioImportView.swift b/apps/native/Sources/VisionNativeUI/PortfolioImportView.swift index 8c5443803..33e866f23 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioImportView.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioImportView.swift @@ -17,7 +17,7 @@ public struct PortfolioImportView: View { VStack(alignment: .leading, spacing: 24) { VisionPageHeader( title: strings.t("portfolioImport.title"), subtitle: strings.t("portfolioImport.desc"), - symbol: "tray.and.arrow.down") + symbol: VisionPageSymbol.of("/portfolio/import", fallback: "briefcase")) PortfolioImportSessionView( model: screens.session, shell: shell, history: screens.history, historyExpanded: $historyOpen, isLocked: screens.advanced.isBusy || screens.history.isBusy, diff --git a/apps/native/Sources/VisionNativeUI/PortfolioNewsView.swift b/apps/native/Sources/VisionNativeUI/PortfolioNewsView.swift index cfe6dedb7..515f9fc6e 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioNewsView.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioNewsView.swift @@ -6,63 +6,38 @@ struct PortfolioNewsView: View { let shell: NativeShellModel @State private var visible = false @Environment(\.scenePhase) private var scenePhase - @Environment(\.visionTheme) private var theme private var polling: Bool { visible && scenePhase == .active && model.isOnline && !UserPresence.shared.isAway } + private var articles: [PortfolioNewsArticle] { Array(model.news.prefix(6)) } + var body: some View { PortfolioCard(title: shell.strings.t("newsFeed.title")) { - if !model.news.isEmpty { - Text(shell.strings.t("newsFeed.articles", ["n": String(min(6, model.news.count))])) - .font(.caption).foregroundStyle(theme.secondaryLabel) + if !articles.isEmpty { + VisionBadge(text: shell.strings.t("newsFeed.articles", ["n": String(articles.count)]), small: true) } + } content: { if model.news.isEmpty { if !model.isOnline { - Text(shell.strings.t("newsFeed.offline")).foregroundStyle(theme.secondaryLabel) + VisionEmptyState( + symbol: "wifi.slash", title: shell.strings.t("newsFeed.offline"), compact: true) {} } else if model.errors["news|" + model.newsSymbols.joined(separator: ",")] != nil { - Text(shell.strings.t("newsFeed.unableToLoad")).foregroundStyle(theme.secondaryLabel) + VisionEmptyState( + symbol: "newspaper", title: shell.strings.t("newsFeed.unableToLoad"), compact: true) {} } else if model.isNewsLoading { ProgressView().frame(maxWidth: .infinity).padding(20) } else { - Text(shell.strings.t("newsFeed.noNews")).foregroundStyle(theme.secondaryLabel) + VisionEmptyState(symbol: "newspaper", title: shell.strings.t("newsFeed.noNews"), compact: true) {} } - } - if !model.news.isEmpty { + } else { ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(Array(model.news.prefix(6).enumerated()), id: \.offset) { _, article in - HStack(alignment: .top, spacing: 12) { - if let thumbnail = article.thumbnail.flatMap(PortfolioOverviewURL.safe) { - AsyncImage(url: thumbnail) { image in - image.resizable().scaledToFill() - } placeholder: { - Rectangle().fill(theme.muted) - } - .frame(width: 88, height: 60).clipped() - .clipShape(RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)) - .accessibilityHidden(true) - } - VStack(alignment: .leading, spacing: 6) { - if let link = article.link.flatMap(PortfolioOverviewURL.safe) { - Link(article.title ?? "—", destination: link).font(.callout.weight(.medium)) - } else { - Text(article.title ?? "—").font(.callout.weight(.medium)) - } - HStack { - if let publisher = article.publisher { Text(publisher) } - if let published = article.publishedAt { Text(relative(published)) } - }.font(.caption).foregroundStyle(theme.secondaryLabel) - if !article.relatedSymbols.isEmpty { - Text(article.relatedSymbols.joined(separator: " · ")).font( - .caption.monospaced() - ) - .foregroundStyle(theme.secondaryLabel) - } - } - Spacer(minLength: 0) - }.padding(.vertical, 8) + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(Array(articles.enumerated()), id: \.offset) { index, article in + PortfolioNewsRow( + article: article, last: index == articles.count - 1, + timeAgo: article.publishedAt.flatMap { $0 == 0 ? nil : relative($0) }) } } - }.frame(height: min(420, CGFloat(min(6, model.news.count)) * 100)) + }.frame(height: min(420, CGFloat(articles.count) * 100)) } } .onScrollVisibilityChange(threshold: 0.1) { visible = $0 } @@ -74,17 +49,96 @@ struct PortfolioNewsView: View { } } } + /// `formatDistanceToNow`: `Intl.RelativeTimeFormat(numeric: "auto")` in the largest unit under + /// its threshold, the count rounded ("3 hours ago", "yesterday"). private func relative(_ milliseconds: Decimal) -> String { let locale = LanguageLocale.locale(shell.strings.language) - let formatter = FormatterCache.shared("relative.abbreviated|\(locale.identifier)") { + let formatter = FormatterCache.shared("relative.named|\(locale.identifier)") { () -> RelativeDateTimeFormatter in let made = RelativeDateTimeFormatter() made.locale = locale - made.unitsStyle = .abbreviated + made.unitsStyle = .full + made.dateTimeStyle = .named return made } - let seconds = NSDecimalNumber(decimal: milliseconds / 1000).doubleValue - return formatter.localizedString(for: Date(timeIntervalSince1970: seconds), relativeTo: Date()) + let diff = NSDecimalNumber(decimal: milliseconds).doubleValue - Date().timeIntervalSince1970 * 1000 + let day = 86_400_000.0 + var parts = DateComponents() + switch abs(diff) { + case ..<60_000: parts.second = Int((diff / 1000).rounded()) + case ..<3_600_000: parts.minute = Int((diff / 60_000).rounded()) + case .. 0 ? gain / invested * 100 : 0)).monospacedDigit() - }.foregroundStyle(gain >= 0 ? theme.gain : theme.loss) + Text("(" + signedPercent(invested > 0 ? gain / invested * 100 : 0) + ")").monospacedDigit() + } + .font(.footnote).foregroundStyle(gain >= 0 ? theme.gain : theme.loss) Text(shell.strings.t("portfolio.invested", ["amount": money(invested)])) - .font(.caption).foregroundStyle(theme.secondaryLabel) + .font(.caption).monospacedDigit().foregroundStyle(theme.secondaryLabel) } + .visionCard(padding: 16) } } if !data.heatmap.years.isEmpty { @@ -297,11 +312,14 @@ struct PortfolioBreakdownView: View { value == nil || magnitude < Decimal(25) / 100 ? theme.foreground.opacity(0.04) : (amount >= 0 ? theme.gain : theme.loss).opacity(opacity) - return Text(text ?? "—") - .font(.caption.monospaced()).frame(minWidth: 48, maxWidth: .infinity).padding(.vertical, 8) - .background(color, in: RoundedRectangle(cornerRadius: DesignTokens.Radius.sm)) - .padding(.vertical, 2) - .visionTooltip(text ?? shell.strings.t("common.noData2")) + // `rounded-control px-1 py-1.5 … font-mono` with a native `title` tooltip; an empty month + // shows an en dash. + return Text(text ?? "–") + .font(.caption.monospaced()).frame(minWidth: 48, maxWidth: .infinity).padding(.vertical, 6) + .padding(.horizontal, 4) + .background(color, in: RoundedRectangle(cornerRadius: DesignTokens.Radius.control, style: .continuous)) + .padding(.vertical, 4) + .help(text ?? shell.strings.t("common.noData2")) } private func yearReturn(_ months: [Decimal?]) -> Decimal? { let known = months.compactMap { $0 } diff --git a/apps/native/Sources/VisionNativeUI/PortfolioTickerView.swift b/apps/native/Sources/VisionNativeUI/PortfolioTickerView.swift index cab1f4fb2..b222de63a 100644 --- a/apps/native/Sources/VisionNativeUI/PortfolioTickerView.swift +++ b/apps/native/Sources/VisionNativeUI/PortfolioTickerView.swift @@ -7,7 +7,10 @@ struct PortfolioTickerView: View { let shell: NativeShellModel @State private var manager = false @State private var hovered = false - @State private var visible = false + /// On screen until the scroll view says otherwise (the web starts active too). + @State private var visible = true + /// The measured width of one pass of the tape. + @State private var tapeWidth: CGFloat = 0 @State private var elapsed: TimeInterval = 0 @State private var startedAt: Date? @State private var overrides: [InvestmentID: Bool] = [:] @@ -37,49 +40,58 @@ struct PortfolioTickerView: View { } } + /// The bar's text when the tape is empty for a reason worth saying: every holding hidden + /// (the manage menu fixes that) or offline. While quotes load the bar stays quiet. + private var placeholder: String? { + if included.isEmpty { return shell.strings.t("portfolio.ticker.allHidden") } + if !model.isOnline { return shell.strings.t("portfolio.ticker.offline") } + return nil + } + var body: some View { if !quotable.isEmpty { HStack(spacing: 0) { - if quoted.isEmpty { - Text(shell.strings.t(model.isOnline ? "portfolio.ticker.allHidden" : "portfolio.ticker.offline")) - .font(.callout).foregroundStyle(theme.secondaryLabel).padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - } else if reducedMotion { - ScrollView(.horizontal) { tape }.scrollIndicators(.hidden) - } else { - GeometryReader { geometry in - // The tape is built once per pass, not per animation tick: the timeline only moves it. - let count = quoted.count - let width = CGFloat(count) * 250 - let duration = max(24, Double(count) * 4.5) - let strip = HStack(spacing: 0) { - ForEach(0.. 0) - } - } - TimelineView(.animation(minimumInterval: 1 / 30, paused: !moving)) { timeline in - let time = elapsed + (startedAt.map { timeline.date.timeIntervalSince($0) } ?? 0) - strip - .offset(x: -width * CGFloat(time.truncatingRemainder(dividingBy: duration) / duration)) - .frame(width: max(geometry.size.width, width * 2), alignment: .leading) - } - }.frame(height: 54).clipped() + Group { + if quoted.isEmpty { + Text(placeholder ?? "") + .font(.footnote).foregroundStyle(theme.secondaryLabel).padding(.horizontal, 20) + .frame(maxWidth: .infinity, alignment: .leading) + } else if reducedMotion { + ScrollView(.horizontal) { tape }.scrollIndicators(.hidden) + } else { + marquee + } } + // `min-h-[2.625rem]`. + .frame(minHeight: 42) + // `.ticker-mask:hover .ticker-track`: hover-to-read pauses the tape, not the manage button. + .onHover { hovered = $0 } + .accessibilityElement(children: .contain) + .accessibilityLabel(shell.strings.t("portfolio.ticker.aria")) + // `border-l border-border/40 px-1`. + Rectangle().fill(theme.border.opacity(0.4)).frame(width: 1) Button { manager = true } label: { Image(systemName: "slider.horizontal.3") } - .buttonStyle(.vision(.ghost, size: .iconSm)).padding(16) + .buttonStyle(.vision(.ghost, size: .iconSm)).padding(.horizontal, 4) + .foregroundStyle(theme.secondaryLabel) .accessibilityLabel(shell.strings.t("portfolio.ticker.manage")) + .visionTooltip(shell.strings.t("portfolio.ticker.manage")) .popover(isPresented: $manager) { management } } - .visionCardBackground() + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + // `card-material material-host … border border-border/60 liquid-glass`: the regular glass + // material with its elevation-2 shadows, not a Card. In dark mode `.dark .liquid-glass` + // (two classes) outranks the `border-border/60` utility; in light mode the utility's + // frame is used (inference: the cascade order of the two single-class rules). + .background( + VisionGlassBoxBackground( + material: .regular, frame: theme.isDark ? nil : theme.border.opacity(0.6))) .background { WindowLivenessProbe { windowLive = $0 } } - .accessibilityLabel(shell.strings.t("portfolio.ticker.aria")) - .onHover { hovered = $0 } - .onScrollVisibilityChange(threshold: 0.1) { visible = $0 } + .onScrollVisibilityChange(threshold: 0.01) { visible = $0 } .onChange(of: moving, initial: true) { _, moving in if moving { startedAt = Date() @@ -99,49 +111,103 @@ struct PortfolioTickerView: View { } } + /// `.ticker-track`: the tape and enough copies of it to cover the bar, sliding left by one + /// tape width every `max(24, 4.5 × tiles)` seconds, so the speed does not depend on the + /// number of holdings. The tiles keep their natural widths, as on the web; the tape's width + /// is measured once per set of quotes and only the offset changes per frame. + private var marquee: some View { + GeometryReader { geometry in + let duration = max(24, Double(quoted.count) * 4.5) + let copies = tapeWidth > 0 ? max(2, Int((geometry.size.width / tapeWidth).rounded(.up)) + 1) : 2 + let strip = HStack(spacing: 0) { + ForEach(0.. 0) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in + if index == 0, tapeWidth != width { tapeWidth = width } + } + } + } + .fixedSize() + TimelineView(.animation(minimumInterval: 1 / 30, paused: !moving)) { timeline in + let time = elapsed + (startedAt.map { timeline.date.timeIntervalSince($0) } ?? 0) + strip + .offset(x: -tapeWidth * CGFloat(time.truncatingRemainder(dividingBy: duration) / duration)) + .frame(width: geometry.size.width, height: geometry.size.height, alignment: .leading) + } + } + .clipped() + // `.ticker-mask`: the tiles fade in and out over the outer 4 % at each edge. + .mask { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), .init(color: .black, location: 0.04), + .init(color: .black, location: 0.96), .init(color: .clear, location: 1), + ], startPoint: .leading, endPoint: .trailing) + } + } + + /// One pass of the tape: `border-r border-border/40 px-5 py-2.5` tiles of symbol, price and + /// the day's change. private var tape: some View { HStack(spacing: 0) { ForEach(quoted, id: \.0.id) { investment, quote in HStack(spacing: 8) { PortfolioInlineDisclosure(label: investment.symbol ?? quote.symbol, explanation: investment.name) - .font(.callout.monospaced().weight(.medium)) + .font(.body.monospaced().weight(.medium)) if let price = quote.price { Text( ResearchMoney.text( price, currency: quote.currency, locale: shell.settings.numberFormat.locale, fractionDigits: shell.settings.showDecimalPlaces)) - .font(.callout).monospacedDigit().foregroundStyle(theme.secondaryLabel) + .monospacedDigit().foregroundStyle(theme.secondaryLabel) } - if let change = quote.changePercent { - Label(signedPercent(change), systemImage: change >= 0 ? "arrow.up.right" : "arrow.down.right") - .font(.caption.weight(.medium)).monospacedDigit() - .foregroundStyle(change >= 0 ? theme.gain : theme.loss) + // A quote without a change counts as flat and up, as on the web. + let change = quote.changePercent ?? 0 + HStack(spacing: 2) { + Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.system(size: 11, weight: .medium)).accessibilityHidden(true) + Text(signedPercent(change)).fontWeight(.medium).monospacedDigit() } - }.padding(.horizontal, 14).frame(width: 250, height: 54) - .accessibilityElement(children: .combine) + .foregroundStyle(change >= 0 ? theme.gain : theme.loss) + } + .font(.body) + .lineLimit(1) + .fixedSize() + .padding(.horizontal, 20).padding(.vertical, 10) + .overlay(alignment: .trailing) { Rectangle().fill(theme.border.opacity(0.4)).frame(width: 1) } + .accessibilityElement(children: .combine) } } } + /// `TickerManager`: a `w-72` popover with a bordered header and one switch per holding. private var management: some View { - VStack(alignment: .leading, spacing: 14) { - Text(shell.strings.t("portfolio.ticker.manageTitle")).font(.headline) - Text( - shell.strings.t( - "portfolio.ticker.manageCount", ["shown": String(included.count), "total": String(quotable.count)]) - ) - .font(.caption).foregroundStyle(theme.secondaryLabel) + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + Text(shell.strings.t("portfolio.ticker.manageTitle")).font(.body.weight(.medium)) + Text( + shell.strings.t( + "portfolio.ticker.manageCount", + ["shown": String(included.count), "total": String(quotable.count)]) + ) + .font(.caption).foregroundStyle(theme.secondaryLabel) + } + .padding(.horizontal, 12).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + Rectangle().fill(theme.border.opacity(0.6)).frame(height: 1) + // `max-h-72 overflow-y-auto`: a long list scrolls inside the popover. + if quotable.count > 7 { + ScrollView { managementRows }.frame(height: 288) + } else { + managementRows + } + } + .frame(width: 288) + } + private var managementRows: some View { + VStack(spacing: 0) { ForEach(quotable) { investment in - Toggle( - isOn: Binding( - get: { overrides[investment.id] ?? (investment.showInTicker != false) }, - set: { value in - overrides[investment.id] = value - Task { - let saved = await editing.setTicker(id: investment.id, visible: value) - if !saved { overrides.removeValue(forKey: investment.id) } - } - }) - ) { + HStack(spacing: 12) { HStack(spacing: 8) { if let symbol = investment.symbol, !symbol.isEmpty { Text(symbol).font(.caption.monospaced().weight(.medium)) @@ -149,16 +215,34 @@ struct PortfolioTickerView: View { Text(investment.name).font(.footnote).foregroundStyle(theme.secondaryLabel) .lineLimit(1).truncationMode(.tail) } + Spacer(minLength: 0) + Toggle( + isOn: Binding( + get: { overrides[investment.id] ?? (investment.showInTicker != false) }, + set: { value in + overrides[investment.id] = value + Task { + let saved = await editing.setTicker(id: investment.id, visible: value) + if !saved { overrides.removeValue(forKey: investment.id) } + } + }) + ) { EmptyView() } + .labelsHidden() + .toggleStyle(.visionSwitch) + .accessibilityLabel(investment.name) + .disabled(editing.isMutating) } - .toggleStyle(.visionSwitch) - .accessibilityLabel(investment.name) - .disabled(editing.isMutating) + .padding(8) } - }.padding(20).frame(width: 340) + } + .padding(6) } + private func symbol(_ investment: PortfolioInvestment) -> String { - (investment.priceProviderId ?? investment.symbol ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - .uppercased() + // `price_provider_id || symbol`: an empty provider id falls back to the symbol, as in + // `PortfolioModel.quoteSymbols`, so the tiles find the quotes the model fetched. + (investment.priceProviderId.flatMap { $0.isEmpty ? nil : $0 } ?? investment.symbol ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines).uppercased() } private func signedPercent(_ value: Decimal) -> String { (value > 0 ? "+" : "") + VisionFormat.percent(value, digits: 2, locale: shell.settings.numberFormat.locale) diff --git a/apps/native/Sources/VisionNativeUI/ResearchChartBuilderView.swift b/apps/native/Sources/VisionNativeUI/ResearchChartBuilderView.swift index 2d44ae8bb..13536d34d 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchChartBuilderView.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchChartBuilderView.swift @@ -68,7 +68,7 @@ public struct ResearchChartBuilderView: View { private var header: some View { VisionPageHeader( title: strings.t("research.builder.title"), subtitle: strings.t("research.builder.subtitle"), - symbol: "chart.xyaxis.line" + symbol: VisionPageSymbol.of("/research/charts", fallback: "chart.bar.xaxis") ) { Menu { Button(strings.t("research.builder.newChart")) { model.requestNew() } diff --git a/apps/native/Sources/VisionNativeUI/ResearchCompareView.swift b/apps/native/Sources/VisionNativeUI/ResearchCompareView.swift index 25a408a1f..1f480eeb6 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchCompareView.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchCompareView.swift @@ -15,7 +15,7 @@ public struct ResearchCompareView: View { VStack(alignment: .leading, spacing: VisionPageLayout.spacing) { VisionPageHeader( title: strings.t("research.compare.title"), subtitle: strings.t("research.compare.subtitle"), - symbol: "chart.line.uptrend.xyaxis") + symbol: VisionPageSymbol.of("/research/compare", fallback: "arrow.triangle.swap")) VStack(alignment: .leading, spacing: 10) { ViewThatFits(in: .horizontal) { HStack(spacing: 8) { selectedSymbols } @@ -47,7 +47,8 @@ public struct ResearchCompareView: View { if model.state.symbols.isEmpty { ResearchCard { VisionEmptyState( - symbol: "chart.line.uptrend.xyaxis", title: strings.t("research.compare.startTitle"), + symbol: VisionPageSymbol.of("/research/compare", fallback: "arrow.triangle.swap"), + title: strings.t("research.compare.startTitle"), description: strings.t("research.compare.startHint") ) {}.padding(.vertical, 28) } diff --git a/apps/native/Sources/VisionNativeUI/ResearchDossiersView.swift b/apps/native/Sources/VisionNativeUI/ResearchDossiersView.swift index d2551797f..459b0f330 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchDossiersView.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchDossiersView.swift @@ -18,7 +18,8 @@ public struct ResearchDossiersView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { VisionPageHeader( - title: strings.t("dossiers.title"), subtitle: strings.t("dossiers.subtitle"), symbol: "folder" + title: strings.t("dossiers.title"), subtitle: strings.t("dossiers.subtitle"), + symbol: VisionPageSymbol.of("/research/dossiers", fallback: "book") ) { Menu { Button(strings.t("dossiers.exportAll"), systemImage: "arrow.down.doc") { diff --git a/apps/native/Sources/VisionNativeUI/ResearchForecastView.swift b/apps/native/Sources/VisionNativeUI/ResearchForecastView.swift index c69f7d090..df16f87a0 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchForecastView.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchForecastView.swift @@ -26,7 +26,7 @@ public struct ResearchForecastView: View { VStack(alignment: .leading, spacing: 24) { VisionPageHeader( title: strings.t("research.forecast.title"), subtitle: strings.t("research.forecast.subtitle"), - symbol: "chart.line.uptrend.xyaxis") + symbol: VisionPageSymbol.of("/research/forecast", fallback: "chart.line.flattrend.xyaxis")) controls if !model.contributionInvalid && !model.targetInvalid { if model.error != nil { @@ -232,7 +232,7 @@ public struct ResearchForecastView: View { ResearchForecastStat( title: strings.t("research.forecast.volatility"), value: ResearchForecastDisplay.percent(forecast?.annualVolatility, shell: shell), - subtitle: strings.t("research.forecast.annualized"), symbol: "waveform.path", + subtitle: strings.t("research.forecast.annualized"), symbol: "waveform.path.ecg", loading: model.isFetching && forecast == nil) ResearchForecastStat( title: strings.t( diff --git a/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeNetWorthPrintDocument.swift b/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeNetWorthPrintDocument.swift index efde8132c..1a9f1c80b 100644 --- a/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeNetWorthPrintDocument.swift +++ b/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeNetWorthPrintDocument.swift @@ -304,7 +304,9 @@ public struct NativeNetWorthPrintSectionView: View { public var body: some View { switch section { case .header: - VisionPageHeader(title: strings.t("networth.title"), subtitle: "", symbol: "chart.line.uptrend.xyaxis") {} + VisionPageHeader( + title: strings.t("networth.title"), subtitle: "", + symbol: VisionPageSymbol.of("/portfolio/net-worth", fallback: "wallet.pass")) {} case .emptyState: AdminCard { Text(strings.t("networth.emptyTitle")).font(.headline) diff --git a/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeReportSheet.swift b/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeReportSheet.swift index 03ef4791d..ad187be6b 100644 --- a/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeReportSheet.swift +++ b/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeReportSheet.swift @@ -23,12 +23,13 @@ public struct NativeReportSheet: View { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 8) { sectionLabel(strings.t("export.reportType")) - Picker( - strings.t("export.reportType"), - selection: Binding(get: { model.type }, set: { value in model.setType(value) }) - ) { - ForEach(NativeReportType.allCases) { type in Text(strings.t(type.labelKey)).tag(type) } - }.pickerStyle(.segmented).labelsHidden().frame(maxWidth: .infinity) + // ExportDialog's `SegmentedControl`, not the system accent-coloured picker. + VisionSegmentedControl( + selection: Binding(get: { model.type }, set: { value in model.setType(value) }), + values: NativeReportType.allCases, title: { strings.t($0.labelKey) } + ) + .accessibilityLabel(strings.t("export.reportType")) + .frame(maxWidth: .infinity, alignment: .leading) } periodSection sectionsSection @@ -138,7 +139,7 @@ public struct NativeReportSheet: View { get: { model.selectedSections.contains(section.id) }, set: { model.setSection(section.id, selected: $0) }) ) - .toggleStyle(.checkbox).frame(maxWidth: .infinity, alignment: .leading) + .toggleStyle(.visionCheckbox).frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16).padding(.vertical, 10) } } diff --git a/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeTaxPrintDocument.swift b/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeTaxPrintDocument.swift index b323ed894..1d92c95c7 100644 --- a/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeTaxPrintDocument.swift +++ b/apps/native/Sources/VisionNativeUI/Screens/Reports/NativeTaxPrintDocument.swift @@ -80,7 +80,7 @@ public struct NativeTaxPrintSectionView: View { case .header: VisionPageHeader( title: strings.t("tax.page.title"), - subtitle: strings.t("tax.page.subtitle", ["year": String(snapshot.year)]), symbol: "percent" + subtitle: strings.t("tax.page.subtitle", ["year": String(snapshot.year)]), symbol: TaxOverviewView.symbol ) { EmptyView() } case .masthead: TaxMastheadView(overview: overview, actions: presentation.actions, shell: shell) diff --git a/apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAboutView.swift b/apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAboutView.swift index fdc5d69e0..09c17c11b 100644 --- a/apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAboutView.swift +++ b/apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAboutView.swift @@ -118,13 +118,15 @@ struct NativeSettingsAboutView: View { NativeSettingsSection(title: strings.t("settings.section.about")) { NativeSettingsGroup { HStack(spacing: 16) { - Image(systemName: "eye.fill").font(.system(size: 28)).foregroundStyle(theme.primaryForeground) + // `VisionMark` (`h-8 w-8`) on the `bg-primary` 56 pt tile. + VisionBrandMark().fill(theme.primaryForeground).frame(width: 32, height: 32) .frame(width: 56, height: 56).background( theme.primary, in: RoundedRectangle(cornerRadius: DesignTokens.Radius.card, style: .continuous) ).accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { - Text(model.appName).font(.visionCardTitle) + // `APP_NAME` (lib/appIdentity.ts): the product name, not the bundle variant. + Text(verbatim: "Vision").font(.visionCardTitle) Text(strings.t("settings.app.version", ["version": model.version ?? "—"])).font(.body) .foregroundStyle(theme.secondaryLabel) Text(strings.t("settings.app.license", ["license": model.license])).font(.footnote) @@ -132,11 +134,18 @@ struct NativeSettingsAboutView: View { } Spacer() VStack(alignment: .trailing, spacing: 8) { + // The label, then the `ExternalLink` icon. Link(destination: NativeSettingsAboutModel.repositoryURL) { - Label(strings.t("settings.app.sourceCode"), systemImage: "arrow.up.right") + HStack(spacing: 6) { + Text(strings.t("settings.app.sourceCode")) + Image(systemName: "arrow.up.forward.square").imageScale(.small).accessibilityHidden(true) + } } Link(destination: NativeSettingsAboutModel.documentationURL) { - Label(strings.t("settings.app.documentation"), systemImage: "arrow.up.right") + HStack(spacing: 6) { + Text(strings.t("settings.app.documentation")) + Image(systemName: "arrow.up.forward.square").imageScale(.small).accessibilityHidden(true) + } } }.buttonStyle(.visionOutline) }.padding(24) diff --git a/apps/native/Sources/VisionNativeUI/ShellRootView.swift b/apps/native/Sources/VisionNativeUI/ShellRootView.swift index e370ae8b4..cc386bf7f 100644 --- a/apps/native/Sources/VisionNativeUI/ShellRootView.swift +++ b/apps/native/Sources/VisionNativeUI/ShellRootView.swift @@ -17,8 +17,8 @@ public struct ShellRootView: View { /// the crossing is stored: a per-pixel offset here would re-run the whole shell body (and /// re-diff the sidebar) on every scroll tick and every resize frame of the sidebar animation. @State private var pageScrolled = false - /// The titlebar strip the topbar occupies. - @State private var titlebarHeight: CGFloat = 28 + @State private var pageVeiled = false + @State private var pageTitle: String? /// The titlebar safe area the detail column really receives (zero when the window has no /// toolbar), so the topbar clearance only adds what the system does not already reserve. @State private var titlebarSafeInset: CGFloat = 0 @@ -58,7 +58,8 @@ public struct ShellRootView: View { private func detailColumn(_ theme: VisionTheme) -> some View { HStack(spacing: 0) { if !model.isSidebarVisible { - NativeSidebarRail(model: model, topInset: max(0, 28 - titlebarSafeInset)) + // The rail's first button starts below the lowered traffic lights (bottom edge 34 pt). + NativeSidebarRail(model: model, topInset: max(0, 48 - titlebarSafeInset)) .environment(\.colorScheme, theme.colorScheme) } pageColumn(theme) @@ -102,6 +103,7 @@ public struct ShellRootView: View { } .overlay(alignment: .topLeading) { contentLanding } .background { PageScrollObserver { pageScrollDidChange($0) } } + .onPreferenceChange(VisionPageTitleKey.self) { title in pageTitle = title } .environment(\.colorScheme, theme.colorScheme) // The mounted destination receives cold CSV/new-transaction intents. .task(id: screen) { model.nativeIntentConsumerDidBecomeReady(for: screen) } @@ -131,15 +133,21 @@ public struct ShellRootView: View { } .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { inset in titlebarSafeInset = inset - titlebarHeight = max(inset, 28) } .overlay(alignment: .top) { if isNativeDetail { - NativeTopbar(model: model, height: titlebarHeight, scrolled: pageScrolled) + NativeTopbar( + // AppLayout's `h-14` topbar: its centre line carries the traffic lights + // (`trafficLightPosition` y 20) and the sidebar trigger. + model: model, height: NativeTopbar.barHeight, veiled: pageVeiled, scrolled: pageScrolled, + pageTitle: pageTitle) .ignoresSafeArea(.container, edges: .top) } } - .onChange(of: model.route.path) { pageScrolled = false } + .onChange(of: model.route.path) { + pageScrolled = false + pageVeiled = false + } } /// Holds the native page at one width while the sidebar column slides: AppKit resizes the @@ -160,6 +168,9 @@ public struct ShellRootView: View { private func pageScrollDidChange(_ offset: CGFloat) { let scrolled = offset > NativeTopbar.titleThreshold if scrolled != pageScrolled { pageScrolled = scrolled } + // AppLayout's `scrolled` (`window.scrollY > 8`) shows the bar veil before the title. + let veiled = offset > NativeTopbar.veilThreshold + if veiled != pageVeiled { pageVeiled = veiled } } private func unavailableView(_ theme: VisionTheme) -> some View { @@ -196,6 +207,9 @@ public struct ShellRootView: View { } .environment(\.visionTheme, theme) .environment(\.visionStrings, model.strings) + // No system scroll-edge band under the transparent titlebar: the page canvas runs up + // to the window's top edge as the web canvas does; the topbar veil appears on scroll. + .scrollEdgeEffectHidden(true, for: .top) .background { WindowAppearanceSync(isDark: theme.isDark) } // Prominent buttons, toggles and pickers take the web's emerald `--primary` (or the // chosen accent) instead of the system blue. diff --git a/apps/native/Sources/VisionNativeUI/StatisticsCharts.swift b/apps/native/Sources/VisionNativeUI/StatisticsCharts.swift index e2b2d3148..cb9250e71 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsCharts.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsCharts.swift @@ -115,6 +115,7 @@ struct StatisticsCategoryDonut: View { @State private var angle: Double? @FocusState private var focused: Bool @Environment(\.visionTheme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion init( rows: [StatisticsCategory], settings: NativeAppSettings, currency: CurrencyCode, strings: any Localizing, @@ -132,7 +133,8 @@ struct StatisticsCategoryDonut: View { Chart(rows) { row in SectorMark( angle: .value(row.name, NSDecimalNumber(decimal: row.total).doubleValue), innerRadius: .ratio(0.6), - outerRadius: .ratio(row.id == selected ? 1 : 0.93), angularInset: 2 + outerRadius: .ratio(DonutHoverMotion.outer(hovered: row.id == selected, reduceMotion: reduceMotion)), + angularInset: 2 ) .foregroundStyle(by: .value(strings.t("categories.title"), row.name)) .accessibilityLabel(row.name) @@ -157,10 +159,11 @@ struct StatisticsCategoryDonut: View { .font(.title3.monospacedDigit()).foregroundStyle(theme.foreground) } .frame(maxWidth: 170) - .transition(.opacity) + .id(row.id) + .transition(DonutHoverMotion.center(reduceMotion: reduceMotion)) } } - .animation(.easeOut(duration: DesignTokens.Duration.fast), value: selected) + .animation(DonutHoverMotion.arc(reduceMotion: reduceMotion), value: selected) .chartOverlay { proxy in GeometryReader { geometry in // The hovered slice is worked out from the pointer here and written straight to diff --git a/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift b/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift index 09b425711..9a902d21a 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift @@ -40,13 +40,21 @@ 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) - .stroke( - target.map { color($0.node) } ?? theme.mutedForeground, - style: StrokeStyle(lineWidth: max(1, CGFloat(link.width))) - ) + .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 + } + } } .accessibilityHidden(true) ForEach(layout.nodes) { node in @@ -60,6 +68,14 @@ 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 + } + } .position(x: bounds.midX, y: bounds.midY) .accessibilityLabel(label(node.node)) .accessibilityValue( @@ -72,6 +88,7 @@ struct StatisticsFlowView: View { .lineLimit(1).fixedSize() .frame(width: 0, height: 0, alignment: leading ? .trailing : .leading) .position(x: anchorX, y: bounds.midY) + .allowsHitTesting(false) .accessibilityHidden(true) // The value, 14 pt under the label, while the node is hovered. if isHovered { @@ -82,32 +99,15 @@ struct StatisticsFlowView: View { .lineLimit(1).fixedSize() .frame(width: 0, height: 0, alignment: leading ? .trailing : .leading) .position(x: anchorX, y: bounds.midY + 14) + .allowsHitTesting(false) .accessibilityHidden(true) } } } .frame(width: size.width, height: size.height, alignment: .topLeading) - .contentShape(Rectangle()) - // One hover tracker for the chart. As in the SVG, only a node's bar or a link's band - // reacts (labels take no pointer events), and the element drawn last wins. - .onContinuousHover { phase in - switch phase { - case .ended: - hoveredNode = nil - hoveredLink = nil - case .active(let location): - let node = layout.nodes.last { rect($0, margin: margin).contains(location) }?.id - let link = - node == nil - ? layout.links.last { link in - curve(link, margin: margin) - .strokedPath(StrokeStyle(lineWidth: max(1, CGFloat(link.width)))) - .contains(location) - }?.id : nil - if hoveredNode != node { hoveredNode = node } - if hoveredLink != link { hoveredLink = link } - } - } + // 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. }.frame(minHeight: 380) .onChange(of: hoveredNode) { _, _ in onHoverChange(hoveredNode, hoveredLink) } .onChange(of: hoveredLink) { _, _ in onHoverChange(hoveredNode, hoveredLink) } diff --git a/apps/native/Sources/VisionNativeUI/StatisticsView.swift b/apps/native/Sources/VisionNativeUI/StatisticsView.swift index 76d3e6bfd..a037b5a49 100644 --- a/apps/native/Sources/VisionNativeUI/StatisticsView.swift +++ b/apps/native/Sources/VisionNativeUI/StatisticsView.swift @@ -903,7 +903,7 @@ public struct StatisticsView: View { cardWithControls( "statsPage.sankey.title", subtitle: strings.t("statsPage.sankey.description", ["year": String(model.flowYear)]), - graph: "sankey" + graph: "sankey", exclusionLeading: true ) { if !isPrinting { VisionSelect( @@ -1168,8 +1168,11 @@ public struct StatisticsView: View { title, subtitle: subtitle, graph: graph, fillsHeight: fillsHeight, controls: { EmptyView() }, content: content) } + /// `exclusionLeading`: SankeyTab puts its exclusion chip before the year select; ChartCard + /// puts it after the card's own controls. @ViewBuilder private func cardWithControls( _ title: String, subtitle: String? = nil, graph: String? = nil, fillsHeight: Bool = false, + exclusionLeading: Bool = false, @ViewBuilder controls: @escaping () -> Controls, @ViewBuilder content: @escaping () -> Content ) -> some View { if isPrinting { @@ -1178,8 +1181,9 @@ public struct StatisticsView: View { InsightsCard( title: strings.t(title), subtitle: subtitle, fillsHeight: fillsHeight, actions: { + if exclusionLeading { exclusionToggle(graph) } controls() - exclusionToggle(graph) + if !exclusionLeading { exclusionToggle(graph) } }, content: content) } diff --git a/apps/native/Sources/VisionNativeUI/TaxYearViews.swift b/apps/native/Sources/VisionNativeUI/TaxYearViews.swift index b86a2c979..19e784944 100644 --- a/apps/native/Sources/VisionNativeUI/TaxYearViews.swift +++ b/apps/native/Sources/VisionNativeUI/TaxYearViews.swift @@ -37,13 +37,14 @@ struct TaxYearSwitcherView: View { ).sorted(by: >) } var body: some View { - Menu { - Text(strings.t("tax.yearSwitcher.menuLabel")) + VisionMenu { + VisionMenuLabel(text: strings.t("tax.yearSwitcher.menuLabel")) + Divider() ForEach(years, id: \.self) { year in Button { shell.replaceQuery("year", with: String(year)) } label: { - Label(String(year) + " · " + strings.t(badge(year)), systemImage: symbol(year)) + yearRow(year) } } if overview.isHistorical && !overview.hasSnapshot { @@ -64,27 +65,48 @@ struct TaxYearSwitcherView: View { .accessibilityHidden(true) } } - .menuStyle(.button) .buttonStyle(.vision(.outline)) - .menuIndicator(.hidden) .fixedSize() .accessibilityLabel(strings.t("tax.yearSwitcher.trigger")) .disabled(actions.isSaving) } + /// One `DropdownMenuItem` (`min-w-[240px]`): the year, its status icon, and the status badge. + private func yearRow(_ year: Int) -> some View { + let filed = meta(year)["filing"]?.objectValue != nil + let frozen = meta(year)["frozenCalculation"]?.objectValue != nil + let active = year == overview.year + return HStack(spacing: 12) { + HStack(spacing: 6) { + Text(String(year)).monospacedDigit() + .fontWeight(active ? .medium : .regular) + .foregroundStyle(active ? theme.foreground : theme.foreground.opacity(0.85)) + // `TaxYearStatusIcon`: a lock when filed, a snowflake when frozen. + if filed { + Image(systemName: "lock").font(.system(size: 10)).foregroundStyle(theme.warning) + } else if frozen { + Image(systemName: "snowflake").font(.system(size: 10)).foregroundStyle(theme.info) + } + } + Spacer(minLength: 0) + if let badge = badge(year) { + VisionBadge(text: strings.t(badge.key), tone: badge.tone, small: true) + } + } + .frame(minWidth: 216, alignment: .leading) + .accessibilityAddTraits(active ? .isSelected : []) + } private func meta(_ year: Int) -> TaxValue { overview.snapshotMetas[String(year)].flatMap { try? TaxValue(encoding: $0) } ?? .object([:]) } - private func badge(_ year: Int) -> String { - if year == overview.liveYear { return "tax.yearSwitcher.currentBadge" } - if meta(year)["filing"]?.objectValue != nil { return "tax.yearSwitcher.filedBadge" } - if meta(year)["frozenCalculation"]?.objectValue != nil { return "tax.yearSwitcher.frozenBadge" } - if overview.householdSnapshots[String(year)] != nil { return "tax.yearSwitcher.snapshotBadge" } - return "tax.yearSwitcher.transactionsBadge" - } - private func symbol(_ year: Int) -> String { - if meta(year)["filing"]?.objectValue != nil { return "lock" } - if meta(year)["frozenCalculation"]?.objectValue != nil { return "snowflake" } - return year == overview.year ? "checkmark" : "calendar" + /// `YearStatusBadge`, by precedence: current (unless filed) > filed > frozen > saved > data only. + private func badge(_ year: Int) -> (key: String, tone: BadgeTone)? { + let filed = meta(year)["filing"]?.objectValue != nil + if year == overview.liveYear && !filed { return ("tax.yearSwitcher.currentBadge", .primary) } + if filed { return ("tax.yearSwitcher.filedBadge", .warning) } + if meta(year)["frozenCalculation"]?.objectValue != nil { return ("tax.yearSwitcher.frozenBadge", .outline) } + if overview.householdSnapshots[String(year)] != nil { return ("tax.yearSwitcher.snapshotBadge", .secondary) } + if availableYears.contains(year) { return ("tax.yearSwitcher.transactionsBadge", .muted) } + return nil } } diff --git a/apps/native/Sources/VisionNativeUI/TransactionsView.swift b/apps/native/Sources/VisionNativeUI/TransactionsView.swift index 16e03aada..8589b2c31 100644 --- a/apps/native/Sources/VisionNativeUI/TransactionsView.swift +++ b/apps/native/Sources/VisionNativeUI/TransactionsView.swift @@ -438,7 +438,7 @@ struct TransactionsToolbar: View { private func dateMenu(_ filters: TransactionFilters) -> some View { let preset = filters.datePreset(today: model.today, calendar: model.calendar) - return Menu { + return VisionMenu { ForEach(TransactionDatePreset.allCases, id: \.self) { option in Toggle( strings.t(option.labelKey), @@ -451,16 +451,15 @@ struct TransactionsToolbar: View { } label: { chipLabel(model.dateChipLabel(), symbol: "calendar", active: preset != TransactionDatePreset.any) } - .menuStyle(.button) + .environment(\.visionMenuRadio, true) .buttonStyle(.plain) - .menuIndicator(.hidden) .fixedSize() .accessibilityLabel(strings.t("txPage.filter.dateAria")) .accessibilityValue(model.dateChipLabel()) } private func typeMenu(_ filters: TransactionFilters) -> some View { - Menu { + VisionMenu { Toggle( strings.t("txPage.filter.type.all"), isOn: Binding(get: { filters.transactionType == nil }, set: { _ in model.setType(nil) })) @@ -472,9 +471,8 @@ struct TransactionsToolbar: View { } label: { chipLabel(model.typeChipLabel(), symbol: "arrow.up.arrow.down", active: filters.transactionType != nil) } - .menuStyle(.button) + .environment(\.visionMenuRadio, true) .buttonStyle(.plain) - .menuIndicator(.hidden) .fixedSize() .accessibilityLabel(strings.t("txPage.filter.typeAria")) .accessibilityValue(model.typeChipLabel()) @@ -497,27 +495,25 @@ struct TransactionsToolbar: View { /// `txPage.view.menu`: Show excluded, the optional columns and Reset columns. private var viewMenu: some View { - Menu { + VisionMenu { Toggle( strings.t("txPage.view.showExcluded"), isOn: Binding(get: { model.showAll }, set: { value in Task { await model.setShowAll(value) } })) - Section(strings.t("txPage.view.columns")) { - ForEach(OptionalTransactionColumn.allCases, id: \.self) { column in - Toggle( - strings.t(column.labelKey), - isOn: Binding( - get: { model.columnVisibility.isVisible(column) }, - set: { model.setColumn(column, visible: $0) })) - } + Divider() + VisionMenuLabel(text: strings.t("txPage.view.columns")) + ForEach(OptionalTransactionColumn.allCases, id: \.self) { column in + Toggle( + strings.t(column.labelKey), + isOn: Binding( + get: { model.columnVisibility.isVisible(column) }, + set: { model.setColumn(column, visible: $0) })) } Divider() Button(strings.t("txPage.view.resetColumns")) { model.resetColumns() } } label: { chipLabel(strings.t("txPage.view.menu"), symbol: "slider.horizontal.3", active: model.showAll) } - .menuStyle(.button) .buttonStyle(.plain) - .menuIndicator(.hidden) .fixedSize() .accessibilityLabel(strings.t("txPage.view.menu")) } diff --git a/apps/native/Tests/VisionNativeCoreTests/NativeSettingsAppearanceTests.swift b/apps/native/Tests/VisionNativeCoreTests/NativeSettingsAppearanceTests.swift index 7f0d045e2..ee7bb9f0e 100644 --- a/apps/native/Tests/VisionNativeCoreTests/NativeSettingsAppearanceTests.swift +++ b/apps/native/Tests/VisionNativeCoreTests/NativeSettingsAppearanceTests.swift @@ -31,17 +31,26 @@ struct NativeSettingsAppearanceTests { ])) #expect(malformed == defaults) } - @Test func fullEffectsDefaultsOffAndKeepsOnlyABooleanLikeTheSourceStore() { - // ADR-244: `AppSettings.fullEffects`, off by default, validated on its own. - #expect(!NativeAppSettings.defaults.fullEffects) - #expect(NativeSettingsDefaults.app["fullEffects"] == .bool(false)) - #expect(NativeAppSettings.migrate(.object(["fullEffects": .bool(true)])).fullEffects) - #expect(!NativeAppSettings.migrate(.object(["fullEffects": .string("true")])).fullEffects) - #expect(!NativeAppSettings.migrate(.object(["fullEffects": .int(1)])).fullEffects) - // A stored blob without the key (older installs) migrates to off and keeps the other fields. + @Test func fullEffectsIsAutoByDefaultAndReadsTheOldBooleanLikeTheSourceStore() { + // ADR-255 (settingsStore.ts): `auto | on | off`, default `auto`; the pre-ADR-255 boolean + // reads as `on` for true and `auto` for false; anything else falls back to `auto`. + #expect(NativeAppSettings.defaults.fullEffects == .auto) + #expect(NativeSettingsDefaults.app["fullEffects"] == .string("auto")) + #expect(NativeAppSettings.migrate(.object(["fullEffects": .bool(true)])).fullEffects == .on) + #expect(NativeAppSettings.migrate(.object(["fullEffects": .bool(false)])).fullEffects == .auto) + #expect(NativeAppSettings.migrate(.object(["fullEffects": .string("off")])).fullEffects == .off) + #expect(NativeAppSettings.migrate(.object(["fullEffects": .string("true")])).fullEffects == .auto) + #expect(NativeAppSettings.migrate(.object(["fullEffects": .int(1)])).fullEffects == .auto) + // A stored blob without the key (older installs) migrates to auto and keeps the other fields. let migrated = NativeSettingsDefaults.migrateApp(.object(["visualEffects": .string("enhanced")])) - #expect(migrated["fullEffects"] == .bool(false) && migrated["visualEffects"] == .string("enhanced")) - #expect(NativeSettingsDefaults.appValue("fullEffects", raw: .object(["fullEffects": .string("on")])) == .bool(false)) + #expect(migrated["fullEffects"] == .string("auto") && migrated["visualEffects"] == .string("enhanced")) + #expect(NativeSettingsDefaults.appValue("fullEffects", raw: .object(["fullEffects": .bool(true)])) == .string("on")) + // `resolveFullEffects`: auto follows the chosen tier, on Standard only. + #expect(FullEffectsChoice.auto.resolved(chosenTier: .standard)) + #expect(!FullEffectsChoice.auto.resolved(chosenTier: .enhanced)) + #expect(!FullEffectsChoice.auto.resolved(chosenTier: .reduced)) + #expect(FullEffectsChoice.on.resolved(chosenTier: .reduced)) + #expect(!FullEffectsChoice.off.resolved(chosenTier: .standard)) } @Test func explicitAndSystemModesRetainVariantAccentAndGainLossSkin() { var settings = NativeAppSettings.defaults diff --git a/apps/native/Tests/VisionNativeCoreTests/NativeSettingsModelTests.swift b/apps/native/Tests/VisionNativeCoreTests/NativeSettingsModelTests.swift index 7899df607..fca461ed5 100644 --- a/apps/native/Tests/VisionNativeCoreTests/NativeSettingsModelTests.swift +++ b/apps/native/Tests/VisionNativeCoreTests/NativeSettingsModelTests.swift @@ -245,17 +245,18 @@ struct NativeSettingsModelTests { await model.flush() #expect(await transport.writes.count == 1) } - @Test func fullEffectsIsAnObservablePersistedSettingThatDefaultsOff() async throws { + @Test func fullEffectsIsAnObservablePersistedSettingThatFollowsStandardByDefault() async throws { let transport = NativeSettingsFixture() let model = settingsModel(transport) await model.load() - #expect(!model.fullEffects) - model.setFullEffects(true) - #expect(model.fullEffects && model.appValue("fullEffects") == .bool(true)) - await model.flush() - #expect(await transport.value("app_settings")?["fullEffects"] == .bool(true)) + // `auto` on the default Standard tier is on (ADR-255). + #expect(model.fullEffectsChoice == .auto && model.fullEffects) model.setFullEffects(false) - #expect(!model.fullEffects) + #expect(!model.fullEffects && model.appValue("fullEffects") == .string("off")) + await model.flush() + #expect(await transport.value("app_settings")?["fullEffects"] == .string("off")) + model.setFullEffects(true) + #expect(model.fullEffects && model.fullEffectsChoice == .on) } @Test func editsDuringForcedReadAreNotOverwrittenByEarlierSnapshot() async throws { let transport = NativeSettingsFixture(values: [ diff --git a/apps/native/VisionNative/ShellController.swift b/apps/native/VisionNative/ShellController.swift index a00b100d3..9a01868f5 100644 --- a/apps/native/VisionNative/ShellController.swift +++ b/apps/native/VisionNative/ShellController.swift @@ -1268,6 +1268,7 @@ final class ShellController: NSObject, NSWindowDelegate { self.container = container self.effectView = effectView self.launchView = launchView + Self.layoutTrafficLights(window) window.makeKeyAndOrderFront(nil) NSApp.activate() } @@ -1424,6 +1425,7 @@ final class ShellController: NSObject, NSWindowDelegate { } func windowDidResize(_ notification: Notification) { + if let window { Self.layoutTrafficLights(window) } scheduleFrameSave() resolveNativeSettingsAppearance() } @@ -1438,9 +1440,43 @@ final class ShellController: NSObject, NSWindowDelegate { } func windowDidExitFullScreen(_ notification: Notification) { + if let window { Self.layoutTrafficLights(window) } scheduleFrameSave() } + func windowDidBecomeKey(_ notification: Notification) { + if let window { Self.layoutTrafficLights(window) } + } + + /// Electron's `trafficLightPosition: { x: 20, y: 20 }` (packaging/electron/main.ts): the + /// close button's top-left corner 20 pt in from the window's left and top edges, so the + /// buttons sit on the 56 pt topbar's centre line beside the sidebar trigger. AppKit lays the + /// titlebar out again on resize, key changes and full-screen exit, so this runs after each. + /// The titlebar container grows to hold them; the content layout (safe area) is unchanged. + static func layoutTrafficLights(_ window: NSWindow) { + guard !window.styleMask.contains(.fullScreen), + let close = window.standardWindowButton(.closeButton), + let titlebar = close.superview, let container = titlebar.superview + else { return } + let inset = CGPoint(x: 20, y: 20) + let buttonHeight = close.frame.height + let height = buttonHeight + inset.y * 2 + var frame = container.frame + frame.size.height = height + frame.origin.y = window.frame.height - height + container.frame = frame + titlebar.frame = container.bounds + // AppKit's own spacing between the buttons is kept. + let spacing = + (window.standardWindowButton(.miniaturizeButton)?.frame.minX ?? close.frame.maxX + 6) - close.frame.minX + let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] + for (index, type) in buttons.enumerated() { + guard let button = window.standardWindowButton(type) else { continue } + button.setFrameOrigin( + NSPoint(x: inset.x + CGFloat(index) * spacing, y: inset.y)) + } + } + private func scheduleFrameSave() { frameSaveTask?.cancel() frameSaveTask = Task { [weak self] in diff --git a/docs/reference/native-parity/shell-and-settings.md b/docs/reference/native-parity/shell-and-settings.md index 4e0505229..1bfb97354 100644 --- a/docs/reference/native-parity/shell-and-settings.md +++ b/docs/reference/native-parity/shell-and-settings.md @@ -3,7 +3,7 @@ title: "Native parity inventory: App shell, navigation, settings and onboarding" type: reference status: active date: 2026-10-25 -updated: 2026-10-25 +updated: 2026-11-14 tags: [ reference, @@ -71,7 +71,7 @@ Component: `apps/frontend/src/components/layout/AppLayout.tsx` (mounted by `Rout | 9 | `/admin/*` routes | `RequireAdmin` gate | navigate with admin mode off | Redirects to `/` (renders nothing while settings load) | `apps/frontend/src/components/auth/RequireAdmin.tsx:18` | native | | 10 | Window | Drop a `.csv` anywhere outside a `[data-dropzone]` | drag and drop | Registers the file in the import handoff slot and navigates to `/import`; other files are swallowed (Electron only, see Cross-cutting) | `apps/frontend/src/components/layout/ElectronBridge.tsx:136` | native | -Native mapping: page error states with `common.error` and a Retry button live in the native screens (for example `TransactionsView.swift`, `PlannedPaymentsView.swift`, `ImportReviewView.swift`, `ExchangeRatesView.swift`); back links in `AccountDetailView.swift`, `TableDataEditorView.swift`, `ImportReviewView.swift` and `OwesView.swift`; the admin redirect in `NativeNavigationHistory.resolve(_:adminMode:)` (`apps/native/Sources/VisionNativeCore/Navigation/NativeNavigationHistory.swift`); and the window CSV drop in `NativeScreenContainer.dropDestination` (`ShellRootView.swift`) plus `ShellController.openFiles`. The skip link is `NativeSidebar.skipLink`: first in keyboard order, shown only while focused, it moves focus to a landing point at the top of the native screen (`ShellRootView.contentLanding`). The error-boundary fallback maps to the shell-level failure state (`NativeShellUnavailableView`): Retry reloads the settings and Reload page runs View › Reload (`NativeShellModel.reloadPage`). Section-level failures keep their own Retry, as `PageError` does. The topbar is `NativeTopbar` (`apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift`), drawn in the transparent titlebar strip over native screens: while the sidebar is hidden it holds the sidebar trigger and a search button that opens the command palette, right of the traffic lights. The search button follows the sidebar rather than the window width. Past 96 pt of page scroll, `PageScrollObserver` (the page-level `NSClipView`) fades in the page title over a bar material. +Native mapping: page error states with `common.error` and a Retry button live in the native screens (for example `TransactionsView.swift`, `PlannedPaymentsView.swift`, `ImportReviewView.swift`, `ExchangeRatesView.swift`); back links in `AccountDetailView.swift`, `TableDataEditorView.swift`, `ImportReviewView.swift` and `OwesView.swift`; the admin redirect in `NativeNavigationHistory.resolve(_:adminMode:)` (`apps/native/Sources/VisionNativeCore/Navigation/NativeNavigationHistory.swift`); and the window CSV drop in `NativeScreenContainer.dropDestination` (`ShellRootView.swift`) plus `ShellController.openFiles`. The skip link is `NativeSidebar.skipLink`: first in keyboard order, shown only while focused, it moves focus to a landing point at the top of the native screen (`ShellRootView.contentLanding`). The error-boundary fallback maps to the shell-level failure state (`NativeShellUnavailableView`): Retry reloads the settings and Reload page runs View › Reload (`NativeShellModel.reloadPage`). Section-level failures keep their own Retry, as `PageError` does. The topbar is `NativeTopbar` (`apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift`), AppLayout's 56 pt `h-14` bar drawn over native screens under the transparent titlebar: it always holds the sidebar trigger (right of the traffic lights while the sidebar is hidden); the web's search button is phone-only, so the sidebar and its rail keep search. `ShellController.layoutTrafficLights` places the traffic lights at Electron's `trafficLightPosition` (20, 20), on the bar's centre line. Past 8 pt of page scroll, `PageScrollObserver` (the page-level `NSClipView`) fades in the `.app-topbar` veil; past 96 pt, the page header's title. #### Sidebar (`AppSidebar`) @@ -275,18 +275,18 @@ Fields: - Reduce effects on large displays switch. - Gain & loss colours select with colour dots. -| # | Where | Element | Trigger | What it does | Source | Status | -| --- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| 1 | Theme variant group | `settings.appearance.variant` "Theme variant" cards `settings.appearance.variants.{default,dracula,solarized,nord,highContrast}` | click | Sets theme variant | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:105`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | -| 2 | `settings.group.sidebar` "Sidebar" | `settings.appearance.sidebar` "Sidebar" select: `settings.appearance.sidebar.labels` "Labels" / `settings.appearance.sidebar.icons` "Icons only" | change | Opens/collapses the sidebar (same persisted choice as ⌘B) | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:133`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | -| 3 | `settings.group.colorMode` "Color mode" | `settings.appearance.mode` "Mode" select (`settings.appearance.modes.*`) | change | Sets theme mode | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:148`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | -| 4 | Color mode (schedule) | `settings.appearance.lightFrom` "Light from" time input | change | Updates schedule `lightFrom` | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:166`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | -| 5 | Color mode (schedule) | `settings.appearance.darkFrom` "Dark from" time input | change | Updates schedule `darkFrom` | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:175`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | -| 6 | Color mode (macOS Electron) | `settings.appearance.systemAccent` "Use system accent color" switch | toggle | Overlays macOS accent on primary/ring tokens; live-updates on accent change | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:188`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | -| 7 | `settings.group.visualEffects` "Visual effects" | `settings.appearance.visualEffects` select: Reduced / Standard / Enhanced | change | Sets synced `visualEffects`, or a session-only override while a large display is capped | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:199`, `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:71`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | -| 8 | Visual effects | `settings.appearance.autoAdaptDisplay` "Reduce effects on large displays" switch | toggle | Sets `autoAdaptDisplay`, clears session override | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:222`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | -| 8a | Visual effects | `settings.appearance.fullEffects` "Full effects" switch (off by default; ADR-244, #348) | toggle | Sets `fullEffects`; the native design layer reads `NativeSettingsModel.fullEffects` (the effects themselves are a later phase) | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:226`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | -| 9 | `settings.group.accessibility` "Accessibility" | `settings.appearance.gainLossColors` "Gain & loss colors" select: `...colorblind` "Colorblind-safe (orange loss)" / `...classic` "Classic (red loss)" | change | Sets `colorblindGainLoss` | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:233`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | +| # | Where | Element | Trigger | What it does | Source | Status | +| --- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| 1 | Theme variant group | `settings.appearance.variant` "Theme variant" cards `settings.appearance.variants.{default,dracula,solarized,nord,highContrast}` | click | Sets theme variant | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:105`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | +| 2 | `settings.group.sidebar` "Sidebar" | `settings.appearance.sidebar` "Sidebar" select: `settings.appearance.sidebar.labels` "Labels" / `settings.appearance.sidebar.icons` "Icons only" | change | Opens/collapses the sidebar (same persisted choice as ⌘B) | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:133`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | +| 3 | `settings.group.colorMode` "Color mode" | `settings.appearance.mode` "Mode" select (`settings.appearance.modes.*`) | change | Sets theme mode | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:148`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | +| 4 | Color mode (schedule) | `settings.appearance.lightFrom` "Light from" time input | change | Updates schedule `lightFrom` | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:166`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | +| 5 | Color mode (schedule) | `settings.appearance.darkFrom` "Dark from" time input | change | Updates schedule `darkFrom` | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:175`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | +| 6 | Color mode (macOS Electron) | `settings.appearance.systemAccent` "Use system accent color" switch | toggle | Overlays macOS accent on primary/ring tokens; live-updates on accent change | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:188`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | +| 7 | `settings.group.visualEffects` "Visual effects" | `settings.appearance.visualEffects` select: Reduced / Standard / Enhanced | change | Sets synced `visualEffects`, or a session-only override while a large display is capped | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:199`, `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:71`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | +| 8 | Visual effects | `settings.appearance.autoAdaptDisplay` "Reduce effects on large displays" switch | toggle | Sets `autoAdaptDisplay`, clears session override | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:222`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | +| 8a | Visual effects | `settings.appearance.fullEffects` "Full effects" switch (`auto` by default, on with the chosen Standard tier; ADR-244, ADR-255) | toggle | Sets `fullEffects` to `on` or `off`; the native design layer reads the resolved `NativeSettingsModel.fullEffects` (`FullEffectsChoice`) | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:226`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | +| 9 | `settings.group.accessibility` "Accessibility" | `settings.appearance.gainLossColors` "Gain & loss colors" select: `...colorblind` "Colorblind-safe (orange loss)" / `...classic` "Classic (red loss)" | change | Sets `colorblindGainLoss` | `apps/frontend/src/features/settings/sections/AppearanceSection.tsx:233`; `apps/native/Sources/VisionNativeUI/Screens/Settings/NativeSettingsAppearanceView.swift` | native | ##### Section: Insights (`StatisticsSection`)