Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/knowledge/gotchas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,8 @@
summary: The Log-income auto-move banner is a REAL feature (2026-08-15) — computed from goals, armed by YES, applied on save. FeatureFlags.autoMoveSuggestionsEnabled is its kill switch (ON).
why: It began as a hardcoded "$230 to Kyoto" placeholder whose YES did nothing. Now GoalModel persists autoMoveEnabled/autoMoveAmount (the AddGoalFlow wizard collected these but never saved them), AutoMoveSuggestion.compute picks one goal (favorite first; else highest required weekly pace = remaining/weeks-to-deadline; open-ended goals last by lowest progress) and caps the amount at min(pace, remaining target, income being logged, month margin = month incomes + this income - month expenses; an under-water month suggests nothing), and the deposit applies only together with the income save using the same clamp as the manual deposit flow.
how-to-apply: Any change to the suggestion policy goes in Utilities/AutoMoveSuggestion.swift (unit-tested in AutoMoveSuggestionTests). Never apply a move before the income is saved. The banner recomputes live from the keypad - do not cache a suggestion across amount edits.

- id: warmfont-not-system-size
summary: Text sizes go through .warmFont(size, weight:, design:) — never .font(.system(size:)) — and Toggle/icon-only Buttons carry a real label.
why: The design is specified in fixed point sizes; .system(size:) ignores Dynamic Type entirely, which broke the WCAG AA commitment (PRODUCT.md). .warmFont renders the exact design size at the default content size (unit-tested identity) and scales on the matching text-style curve; it is a ViewModifier that reads the size category so views re-render on change. Two Text concatenations in AddGoalFlow use @ScaledMetric because they need a Font value. VoiceOver read "Switch, on" and "Button" for unlabeled controls before PR D/D3.
how-to-apply: New text → .warmFont(...). Fixed widths around text → @ScaledMetric. Small controls → .tappable44(). Icon-only Button → .accessibilityLabel(...). Decorative Image/tile → .accessibilityHidden(true). Rows → .accessibilityElement(children: .combine) with a value. Presented roots (sheet/fullScreenCover) → .honorsReduceMotion(). Bars that cannot carry AX text (tab bar, keypad) cap with .dynamicTypeSize(...DynamicTypeSize.xxxLarge) like UIKit does. Spoken strings: literals or Text/String(localized:) so they land in the catalog with es-419.
1 change: 1 addition & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ A warm neutral ground with a single committed green; everything else is a soft t
- **Body** (regular 400 / strong 600, 14–15pt): row primary text, descriptions, button labels. Row titles are 600; supporting copy 400.
- **Label** (600, 11–13pt, tracking 0.8–1pt, uppercase): section kickers ("FAVORITE GOAL", "SETTINGS", "HISTORY", "7-MONTH TREND") and metadata lines. This is an incumbent, committed convention — one kicker style used consistently, not a new pattern to add elsewhere.
- **Figures**: every money figure uses `.monospacedDigit()`. Non-tabular money is a bug.
- **Dynamic Type**: every size above is applied with `.warmFont(size, weight:, design:)` (`Font+Warm.swift`), which is pixel-identical at the default content size and scales on the curve of the nearest system text style. Never `.font(.system(size:))`. The tab bar and the numeric keypad cap at `xxxLarge`, as UIKit's do.

### Named Rules
**The Serif-Names, Sans-Explains Rule.** If it is the name of a thing or the number that matters, it is serif. If it tells you what to do with it, it is sans.
Expand Down
8 changes: 8 additions & 0 deletions Savely.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
1DE9FE172D066EE20069AA64 /* ReportsPDFGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE9FE162D066EE20069AA64 /* ReportsPDFGenerator.swift */; };
1DEACCDE2D0BE6340084CC39 /* NetworkErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DEACCDD2D0BE6340084CC39 /* NetworkErrorView.swift */; };
1DF7228E2F9C182300320B38 /* Color+Warm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF7228D2F9C182300320B38 /* Color+Warm.swift */; };
A11A0020C0DE5AFE10000020 /* Font+Warm.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A001FC0DE5AFE1000001F /* Font+Warm.swift */; };
A11A0022C0DE5AFE10000022 /* WarmTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A0021C0DE5AFE10000021 /* WarmTypeTests.swift */; };
1DF722902F9C18FE00320B38 /* GoalDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF7228F2F9C18FE00320B38 /* GoalDetailView.swift */; };
1DF722922F9C19CE00320B38 /* MoneyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF722912F9C19CE00320B38 /* MoneyView.swift */; };
1DF722942F9C19E600320B38 /* AchievementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF722932F9C19E600320B38 /* AchievementsView.swift */; };
Expand Down Expand Up @@ -184,6 +186,8 @@
1DE9FE162D066EE20069AA64 /* ReportsPDFGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportsPDFGenerator.swift; sourceTree = "<group>"; };
1DEACCDD2D0BE6340084CC39 /* NetworkErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkErrorView.swift; sourceTree = "<group>"; };
1DF7228D2F9C182300320B38 /* Color+Warm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Warm.swift"; sourceTree = "<group>"; };
A11A001FC0DE5AFE1000001F /* Font+Warm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+Warm.swift"; sourceTree = "<group>"; };
A11A0021C0DE5AFE10000021 /* WarmTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WarmTypeTests.swift; sourceTree = "<group>"; };
1DF7228F2F9C18FE00320B38 /* GoalDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalDetailView.swift; sourceTree = "<group>"; };
1DF722912F9C19CE00320B38 /* MoneyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyView.swift; sourceTree = "<group>"; };
1DF722932F9C19E600320B38 /* AchievementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -239,6 +243,7 @@
1D3669882CC62F9100E84FF9 /* UIConstants.swift */,
1D36698A2CC62FD700E84FF9 /* Localizable.xcstrings */,
1DF7228D2F9C182300320B38 /* Color+Warm.swift */,
A11A001FC0DE5AFE1000001F /* Font+Warm.swift */,
);
path = Resources;
sourceTree = "<group>";
Expand Down Expand Up @@ -472,6 +477,7 @@
A11A0013C0DE5AFE10000013 /* GoalDepositsTests.swift */,
A11A0019C0DE5AFE10000019 /* GoalEditValidationTests.swift */,
A11A001DC0DE5AFE1000001D /* GoalPaceTests.swift */,
A11A0021C0DE5AFE10000021 /* WarmTypeTests.swift */,
62FA742D1E24F010F3273E30 /* AchievementEngineTests.swift */,
1DC258182CC07DCF0002DDE3 /* SavelyTests.swift */,
);
Expand Down Expand Up @@ -725,6 +731,7 @@
1DC013FB2CDB575F009A46E1 /* IncomesTrackerView.swift in Sources */,
1D3669872CC62F8200E84FF9 /* Strings.swift in Sources */,
1DF7228E2F9C182300320B38 /* Color+Warm.swift in Sources */,
A11A0020C0DE5AFE10000020 /* Font+Warm.swift in Sources */,
1DE9FE0E2D063AF20069AA64 /* NotificationSettingsStepView.swift in Sources */,
1D88B7CF2CC6EB62009B6538 /* CameraManagerExtension.swift in Sources */,
1DAF5D132CEDB421001C0AF3 /* ExpensesTrackerViewModel.swift in Sources */,
Expand Down Expand Up @@ -763,6 +770,7 @@
A11A0014C0DE5AFE10000014 /* GoalDepositsTests.swift in Sources */,
A11A001AC0DE5AFE1000001A /* GoalEditValidationTests.swift in Sources */,
A11A001EC0DE5AFE1000001E /* GoalPaceTests.swift in Sources */,
A11A0022C0DE5AFE10000022 /* WarmTypeTests.swift in Sources */,
510740529955B0B1E6EA730D /* AchievementEngineTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
140 changes: 140 additions & 0 deletions Savely/Resources/Font+Warm.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//
// Font+Warm.swift
// Savely
//
// Dynamic Type for a design specified in fixed point sizes. `.warmFont(14)`
// renders at exactly 14pt at the default content size — so nothing in the
// app changes for a user who never touched the setting — and follows the
// user's reading size from there, the way `.body` / `.title` do.
//
// Why a modifier and not `Font.warm(...)`: the modifier reads the
// size category from the environment, so the view re-renders when the
// user changes it. A `Font` value computed once would bake the old size.
//

import SwiftUI
import UIKit

extension View {
/// System font at `size` points (default content size), scaled with
/// Dynamic Type relative to `style`. When `style` is nil it is chosen
/// from the size so headings scale like headings and captions like
/// captions (large styles grow less at accessibility sizes than body).
func warmFont(
_ size: CGFloat,
weight: Font.Weight = .regular,
design: Font.Design = .default,
relativeTo style: Font.TextStyle? = nil
) -> some View {
modifier(WarmFontModifier(size: size, weight: weight, design: design, style: style ?? WarmType.style(for: size)))
}
}

/// The scaling policy, kept separate from the modifier so it can be tested.
enum WarmType {
/// The text style whose Dynamic Type curve a fixed size should follow.
static func style(for size: CGFloat) -> Font.TextStyle {
switch size {
case 30...: return .largeTitle
case 24..<30: return .title
case 20..<24: return .title2
case 17..<20: return .title3
case 15..<17: return .body
case 13..<15: return .subheadline
case 12..<13: return .footnote
default: return .caption
}
}

/// The point size to render `size` at for a given content size category.
static func scaledSize(_ size: CGFloat, relativeTo style: Font.TextStyle, sizeCategory: ContentSizeCategory) -> CGFloat {
let traits = UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(sizeCategory))
return UIFontMetrics(forTextStyle: style.uiTextStyle).scaledValue(for: size, compatibleWith: traits)
}
}

private struct WarmFontModifier: ViewModifier {
@Environment(\.sizeCategory) private var sizeCategory
let size: CGFloat
let weight: Font.Weight
let design: Font.Design
let style: Font.TextStyle

func body(content: Content) -> some View {
content.font(.system(
size: WarmType.scaledSize(size, relativeTo: style, sizeCategory: sizeCategory),
weight: weight,
design: design
))
}
}

extension View {
/// Enlarges the hit area of a small control to the 44×44 pt minimum
/// without changing how it looks. Layout may grow by a few points when
/// the visual is smaller than 44 — that is the point.
func tappable44() -> some View {
frame(minWidth: 44, minHeight: 44).contentShape(Rectangle())
}
}

extension Font.TextStyle {
var uiTextStyle: UIFont.TextStyle {
switch self {
case .largeTitle: return .largeTitle
case .title: return .title1
case .title2: return .title2
case .title3: return .title3
case .headline: return .headline
case .subheadline: return .subheadline
case .body: return .body
case .callout: return .callout
case .footnote: return .footnote
case .caption: return .caption1
case .caption2: return .caption2
@unknown default: return .body
}
}
}

extension UIContentSizeCategory {
init(_ category: ContentSizeCategory) {
switch category {
case .extraSmall: self = .extraSmall
case .small: self = .small
case .medium: self = .medium
case .large: self = .large
case .extraLarge: self = .extraLarge
case .extraExtraLarge: self = .extraExtraLarge
case .extraExtraExtraLarge: self = .extraExtraExtraLarge
case .accessibilityMedium: self = .accessibilityMedium
case .accessibilityLarge: self = .accessibilityLarge
case .accessibilityExtraLarge: self = .accessibilityExtraLarge
case .accessibilityExtraExtraLarge: self = .accessibilityExtraExtraLarge
case .accessibilityExtraExtraExtraLarge: self = .accessibilityExtraExtraExtraLarge
@unknown default: self = .large
}
}
}

// MARK: - Reduce Motion

extension View {
/// Under Reduce Motion every animation in this subtree becomes instant —
/// entrances, springs, ring fills, celebrations. Applied at each root
/// (app, sheets, full-screen covers) because presented views are their
/// own tree.
func honorsReduceMotion() -> some View {
modifier(ReduceMotionModifier())
}
}

private struct ReduceMotionModifier: ViewModifier {
@Environment(\.accessibilityReduceMotion) private var reduceMotion

func body(content: Content) -> some View {
content.transaction { transaction in
if reduceMotion { transaction.animation = nil }
}
}
}
Loading
Loading