From 44f26f08c3b912b4ca72715211a7b79e9d67aedc Mon Sep 17 00:00:00 2001 From: Belli Date: Tue, 18 Aug 2026 15:21:48 -0700 Subject: [PATCH 1/2] fix(a11y): what XCUIAccessibilityAudit and an AX5 pass found before 1.0 An accessibility audit (Apple's XCUIApplication.performAccessibilityAudit over Home, Goals, a goal, Money, Me and Log expense) plus a Simulator pass at AX5 turned up six real problems. Each one is a claim App Store Connect's Accessibility Nutrition Label would otherwise make falsely. - Hidden system tab bar exposed to VoiceOver: `.toolbar(.hidden, for: .tabBar)` on the TabView hides the bar but leaves four unlabelled buttons in the accessibility tree ("Element has no description" x4). Applied per child instead, they are gone. - Category chip labels under 4.5:1: `tileColor` is a glyph color (3:1 on its tint). New `warmClayDeep` / `warmSkyDeep` / `warmLilacDeep` at 5:1 and `ExpenseCategory.tileTextColor` for text on the tint; both quick-add and receipt-review chips use it. Amber already had `warmAmberDeep`. - Goal detail stat pills at AX sizes: "$1,480" wrapped into "$1,48 / 0" in a third of the width. VStack at accessibility sizes via AnyLayout. - Goal card name at AX sizes: "Trip to Oaxaca" truncated to "Trip t...". Two-line name and the percent/favorite row drops below the identity when the type is an accessibility size. - Me tab: hero card labels were white at 0.7/0.75 alpha (4.0:1 light, 3.4:1 dark); full white now. Stat strip stacks at AX sizes and labels wrap instead of shrinking; "See all" hit areas were 41x16 and 50x16 pt. - Home greeting read to VoiceOver as the bare "Afternoon."; it is now one heading: "Good afternoon. Tuesday, August 18". Also: the inline add fields on Money grow with Dynamic Type instead of a fixed 40pt. SavelyUITests/AccessibilityAuditUITests.swift runs the audit on demand and documents the findings that were accepted and why, and what the audit cannot prove (VoiceOver itself). Verified: build, 102 unit tests, the audit, and the screenshot tour at AX5 (goal detail, goals list, log expense) on iPhone 17 Pro Max. --- Savely.xcodeproj/project.pbxproj | 4 + Savely/Resources/Color+Warm.swift | 12 +++ Savely/Utilities/MoneyCategories.swift | 13 +++ .../CameraScanner/ReceiptReviewView.swift | 2 +- Savely/Views/DashboardTab/DashboardView.swift | 7 ++ .../ExpensesTab/ExpenseTrackerView.swift | 4 +- Savely/Views/GoalsTab/GoalDetailView.swift | 10 +- Savely/Views/GoalsTab/GoalsView.swift | 84 +++++++++++------ .../Views/IncomesTab/IncomesTrackerView.swift | 4 +- Savely/Views/MainNavigationView.swift | 19 ++-- Savely/Views/ProfileTab/ProfileView.swift | 21 ++++- SavelyUITests/AccessibilityAuditUITests.swift | 93 +++++++++++++++++++ 12 files changed, 228 insertions(+), 45 deletions(-) create mode 100644 SavelyUITests/AccessibilityAuditUITests.swift diff --git a/Savely.xcodeproj/project.pbxproj b/Savely.xcodeproj/project.pbxproj index ea1fb0c..4d4d7f8 100644 --- a/Savely.xcodeproj/project.pbxproj +++ b/Savely.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 120AE56C41CBD54B070A8436 /* ScreenshotSeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11E18C2383669F09FCC4748 /* ScreenshotSeed.swift */; }; + A11Y0AUD17BUILDFILE00000 /* AccessibilityAuditUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11Y0AUD17FILEREF0000000 /* AccessibilityAuditUITests.swift */; }; F25F23C6EEAA47853BFA137D /* StoreScreenshotTourUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */; }; C5DDD46B40453371BD90C01A /* ReceiptScanUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */; }; 7479BDFD4C96ED1C9E05A119 /* ReceiptOCRIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */; }; @@ -123,6 +124,7 @@ /* Begin PBXFileReference section */ F11E18C2383669F09FCC4748 /* ScreenshotSeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotSeed.swift; sourceTree = ""; }; + A11Y0AUD17FILEREF0000000 /* AccessibilityAuditUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityAuditUITests.swift; sourceTree = ""; }; 46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreScreenshotTourUITests.swift; sourceTree = ""; }; C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptScanUITests.swift; sourceTree = ""; }; 4A7AF8AF4BCB197FCA023DC6 /* ReceiptOCRIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptOCRIntegrationTests.swift; sourceTree = ""; }; @@ -535,6 +537,7 @@ 1DC258212CC07DCF0002DDE3 /* SavelyUITests */ = { isa = PBXGroup; children = ( + A11Y0AUD17FILEREF0000000 /* AccessibilityAuditUITests.swift */, 46D0412010EDA0CEB30538B1 /* StoreScreenshotTourUITests.swift */, C696EE6BA0906CAD429D0881 /* ReceiptScanUITests.swift */, 1DC258222CC07DCF0002DDE3 /* SavelyUITests.swift */, @@ -841,6 +844,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A11Y0AUD17BUILDFILE00000 /* AccessibilityAuditUITests.swift in Sources */, F25F23C6EEAA47853BFA137D /* StoreScreenshotTourUITests.swift in Sources */, C5DDD46B40453371BD90C01A /* ReceiptScanUITests.swift in Sources */, 1DC258252CC07DCF0002DDE3 /* SavelyUITestsLaunchTests.swift in Sources */, diff --git a/Savely/Resources/Color+Warm.swift b/Savely/Resources/Color+Warm.swift index e03c773..c463a4a 100644 --- a/Savely/Resources/Color+Warm.swift +++ b/Savely/Resources/Color+Warm.swift @@ -41,6 +41,18 @@ extension Color { static let warmLilac = Color(light: 0x7D6AB8, dark: 0xB3A4DC) static let warmLilacSoft = Color(light: 0xECE8F4, dark: 0x2E2740) + // The accents above are glyph colors: they clear 3:1 on their own soft + // tint, which is the bar for an icon, and fail the 4.5:1 a *label* needs + // (clay 3.59, sky 3.61, lilac 3.76 in light). The "deep" pair is the same + // hue taken down in lightness until it clears 5:1 — use it whenever the + // accent carries words, as `warmAmberDeep` already did. Dark keeps the + // accent itself: on the dark tints it already reads (clay 4.94, sky 5.67, + // lilac 6.26). Ratios recomputed 2026-08-18 against Apple's Sufficient + // Contrast criteria after XCUIAccessibilityAudit flagged the chips. + static let warmClayDeep = Color(light: 0x944A35, dark: 0xD9846A) // 5.07 on clay-soft + static let warmSkyDeep = Color(light: 0x3C6487, dark: 0x7EA9D0) // 5.01 on sky-soft + static let warmLilacDeep = Color(light: 0x6953AD, dark: 0xB3A4DC) // 5.06 on lilac-soft + // Roles that used to be hardcoded at call sites. /// Content drawn on `warmGreenFill` (button labels, the income card copy). static let warmOnGreen = Color(light: 0xFFFFFF, dark: 0xFFFFFF) diff --git a/Savely/Utilities/MoneyCategories.swift b/Savely/Utilities/MoneyCategories.swift index 1706e4d..c3b220b 100644 --- a/Savely/Utilities/MoneyCategories.swift +++ b/Savely/Utilities/MoneyCategories.swift @@ -52,6 +52,19 @@ enum ExpenseCategory: String, CaseIterable, Identifiable { } } + /// `tileColor` is sized for a glyph (3:1 on the tint). A chip label is + /// text and needs 4.5:1, so the selected chips read from here instead — + /// see the "deep" tokens in Color+Warm.swift. + var tileTextColor: Color { + switch self { + case .coffee: return .warmAmberDeep + case .food: return .warmGreen + case .transit: return .warmSkyDeep + case .shopping: return .warmClayDeep + case .other: return .warmInkSoft + } + } + /// The category to *show* for a row: the stored chip when there is one, /// otherwise a keyword guess. The guess is display-only — it is never /// written back to the store. diff --git a/Savely/Views/Components/CameraScanner/ReceiptReviewView.swift b/Savely/Views/Components/CameraScanner/ReceiptReviewView.swift index f7fe31d..53aed50 100644 --- a/Savely/Views/Components/CameraScanner/ReceiptReviewView.swift +++ b/Savely/Views/Components/CameraScanner/ReceiptReviewView.swift @@ -237,7 +237,7 @@ struct ReceiptReviewView: View { HStack(spacing: 8) { ForEach(ExpenseCategory.allCases) { cat in let isOn = review.wrappedValue.category == cat - let fg = cat == .other ? Color.warmInkSoft : cat.tileColor + let fg = cat.tileTextColor Button(action: { review.wrappedValue.category = cat }) { Text(cat.label) .warmFont(13, weight: .semibold) diff --git a/Savely/Views/DashboardTab/DashboardView.swift b/Savely/Views/DashboardTab/DashboardView.swift index b8b8705..e7f90ed 100755 --- a/Savely/Views/DashboardTab/DashboardView.swift +++ b/Savely/Views/DashboardTab/DashboardView.swift @@ -64,6 +64,12 @@ struct DashboardView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 8) + // One heading for VoiceOver: "Good afternoon. Tuesday, August 18" + // instead of the bare "Afternoon." the audit flags as + // not human-readable. + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(.isHeader) + .accessibilityLabel(Text("Good \(greeting.lowercased()). \(formattedDate)")) // — Hero Goal Card — if let goal = favoriteGoal { @@ -94,6 +100,7 @@ struct DashboardView: View { Text("See all") .warmFont(13, weight: .medium) .foregroundStyle(Color.warmGreen) + .tappable44() } .buttonStyle(.plain) } diff --git a/Savely/Views/ExpensesTab/ExpenseTrackerView.swift b/Savely/Views/ExpensesTab/ExpenseTrackerView.swift index ca9b9de..c10e0f9 100755 --- a/Savely/Views/ExpensesTab/ExpenseTrackerView.swift +++ b/Savely/Views/ExpensesTab/ExpenseTrackerView.swift @@ -92,7 +92,7 @@ struct ExpenseTrackerView: View { .foregroundStyle(Color.warmInk) .padding(.horizontal, 12) .frame(maxWidth: .infinity) - .frame(height: 40) + .frame(minHeight: 40) .background(Color.warmBg) .cornerRadius(10) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.warmLineSoft, lineWidth: 1)) @@ -108,7 +108,7 @@ struct ExpenseTrackerView: View { .frame(width: amountFieldWidth) .focused($focusedField, equals: .amount) } - .frame(height: 40) + .frame(minHeight: 40) .background(Color.warmBg) .cornerRadius(10) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.warmLineSoft, lineWidth: 1)) diff --git a/Savely/Views/GoalsTab/GoalDetailView.swift b/Savely/Views/GoalsTab/GoalDetailView.swift index a1f070c..3f16bf6 100644 --- a/Savely/Views/GoalsTab/GoalDetailView.swift +++ b/Savely/Views/GoalsTab/GoalDetailView.swift @@ -15,6 +15,7 @@ struct GoalDetailView: View { } private var pace: GoalPace { GoalPace.compute(goal: goal, deposits: goalDeposits) } + @Environment(\.dynamicTypeSize) private var typeSize var body: some View { ScrollView { @@ -84,8 +85,13 @@ struct GoalDetailView: View { } .padding(.bottom, 20) - // — Stat pills — - HStack(spacing: 8) { + // — Stat pills — three across, or stacked once the type is + // an accessibility size: at AX5 a third of the width wraps + // "$1,480" onto two lines, which reads as "$1,48" and "0". + let statLayout = typeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(spacing: 8)) + : AnyLayout(HStackLayout(spacing: 8)) + statLayout { StatPill(label: "Remaining", value: formattedAmount(max(0, goal.target - goal.current)), accent: goal.color) StatPill(label: "Needed / wk", value: pace.requiredWeekly.map { formattedAmount($0) } ?? "—", accent: goal.color) StatPill(label: "ETA", value: pace.etaText(), accent: goal.color) diff --git a/Savely/Views/GoalsTab/GoalsView.swift b/Savely/Views/GoalsTab/GoalsView.swift index 4c0dcc2..bf1be94 100755 --- a/Savely/Views/GoalsTab/GoalsView.swift +++ b/Savely/Views/GoalsTab/GoalsView.swift @@ -264,6 +264,7 @@ struct WarmGoalCard: View { let onDelete: () -> Void @State private var showDeleteConfirmation = false @Query private var goalDeposits: [DepositModel] + @Environment(\.dynamicTypeSize) private var typeSize init(goal: GoalModel, onFavoriteToggle: @escaping () -> Void, onDelete: @escaping () -> Void) { self.goal = goal @@ -277,36 +278,26 @@ struct WarmGoalCard: View { var body: some View { VStack(spacing: 12) { - HStack(spacing: 12) { - RoundedRectangle(cornerRadius: 12) - .fill(goal.color).frame(width: 44, height: 44) - .overlay( - Text(goal.name.prefix(1).uppercased()) - .warmFont(22, weight: .regular, design: .serif).foregroundStyle(Color.warmOnGreen) - ) - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 5) { - Text(goal.name) - .warmFont(15, weight: .semibold).foregroundStyle(Color.warmInk).lineLimit(1) - if goal.isFavorite { - Image(systemName: "star.fill").warmFont(12).foregroundStyle(Color.warmAmber) - } + // Identity left, percent + favorite right — until the type is an + // accessibility size, where the name needs the whole width + // ("Trip to Oaxaca" became "Trip t…") and the figures drop below. + if typeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 10) { + identity + HStack(alignment: .firstTextBaseline) { + percent + Spacer(minLength: 8) + favoriteButton } - Text(pace.label) - .warmFont(12) - .foregroundStyle(pace.status == .behind ? Color.warmClay : Color.warmInkMuted) } - Spacer() - VStack(alignment: .trailing, spacing: 2) { - Text("\(Int(goal.progress * 100))%") - .warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) - Button(action: onFavoriteToggle) { - Image(systemName: goal.isFavorite ? "star.fill" : "star") - .warmFont(16) - .foregroundStyle(goal.isFavorite ? Color.warmAmber : Color.warmInkMuted) - .tappable44() + } else { + HStack(spacing: 12) { + identity + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 2) { + percent + favoriteButton } - .accessibilityLabel(goal.isFavorite ? "Remove from favorites" : "Make favorite") } } @@ -346,6 +337,45 @@ struct WarmGoalCard: View { } } + private var identity: some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 12) + .fill(goal.color).frame(width: 44, height: 44) + .overlay( + Text(goal.name.prefix(1).uppercased()) + .warmFont(22, weight: .regular, design: .serif).foregroundStyle(Color.warmOnGreen) + ) + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text(goal.name) + .warmFont(15, weight: .semibold).foregroundStyle(Color.warmInk).lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + if goal.isFavorite { + Image(systemName: "star.fill").warmFont(12).foregroundStyle(Color.warmAmber) + } + } + Text(pace.label) + .warmFont(12) + .foregroundStyle(pace.status == .behind ? Color.warmClay : Color.warmInkMuted) + } + } + } + + private var percent: some View { + Text("\(Int(goal.progress * 100))%") + .warmFont(18, weight: .regular, design: .serif).foregroundStyle(Color.warmInk) + } + + private var favoriteButton: some View { + Button(action: onFavoriteToggle) { + Image(systemName: goal.isFavorite ? "star.fill" : "star") + .warmFont(16) + .foregroundStyle(goal.isFavorite ? Color.warmAmber : Color.warmInkMuted) + .tappable44() + } + .accessibilityLabel(goal.isFavorite ? "Remove from favorites" : "Make favorite") + } + private func formattedAmount(_ v: Double) -> String { let f = NumberFormatter() f.numberStyle = .currency; f.currencySymbol = "$"; f.maximumFractionDigits = 0 diff --git a/Savely/Views/IncomesTab/IncomesTrackerView.swift b/Savely/Views/IncomesTab/IncomesTrackerView.swift index de4846f..9778dca 100755 --- a/Savely/Views/IncomesTab/IncomesTrackerView.swift +++ b/Savely/Views/IncomesTab/IncomesTrackerView.swift @@ -88,7 +88,7 @@ struct IncomesTrackerView: View { .warmFont(14) .padding(.horizontal, 12) .frame(maxWidth: .infinity) - .frame(height: 40) + .frame(minHeight: 40) .background(Color.warmBg) .cornerRadius(10) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.warmLineSoft, lineWidth: 1)) @@ -103,7 +103,7 @@ struct IncomesTrackerView: View { .frame(width: amountFieldWidth) .focused($focusedField, equals: .amount) } - .frame(height: 40) + .frame(minHeight: 40) .background(Color.warmBg) .cornerRadius(10) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.warmLineSoft, lineWidth: 1)) diff --git a/Savely/Views/MainNavigationView.swift b/Savely/Views/MainNavigationView.swift index 2dcc11e..b2bde4f 100755 --- a/Savely/Views/MainNavigationView.swift +++ b/Savely/Views/MainNavigationView.swift @@ -23,13 +23,20 @@ struct MainNavigationView: View { var body: some View { ZStack(alignment: .bottom) { + // `.toolbar(.hidden, for: .tabBar)` goes on each child, not on the + // TabView: applied outside, the system tab bar is invisible but + // still in the accessibility tree as four unlabelled buttons + // (XCUIAccessibilityAudit: "Element has no description" ×4). TabView(selection: $selectedTab) { - NavigationStack { DashboardView(onSeeAll: { selectedTab = 2 }) }.tag(0) - NavigationStack { GoalsView(showAddGoalFlow: $showAddGoalFlow) }.tag(1) - NavigationStack { MoneyView() }.tag(2) - NavigationStack { ProfileView() }.tag(3) + NavigationStack { DashboardView(onSeeAll: { selectedTab = 2 }) } + .toolbar(.hidden, for: .tabBar).tag(0) + NavigationStack { GoalsView(showAddGoalFlow: $showAddGoalFlow) } + .toolbar(.hidden, for: .tabBar).tag(1) + NavigationStack { MoneyView() } + .toolbar(.hidden, for: .tabBar).tag(2) + NavigationStack { ProfileView() } + .toolbar(.hidden, for: .tabBar).tag(3) } - .toolbar(.hidden, for: .tabBar) .tint(Color.warmGreen) WarmTabBar( @@ -359,7 +366,7 @@ func formatKeypadAmount(_ raw: String) -> String { /// The expense chips, straight from the canonical categories (what gets stored). private let expenseCats: [(label: String, bg: Color, fg: Color)] = ExpenseCategory.allCases.map { - ($0.label, $0.tileBackground, $0 == .other ? Color.warmInkSoft : $0.tileColor) + ($0.label, $0.tileBackground, $0.tileTextColor) } struct WarmQuickExpenseView: View { diff --git a/Savely/Views/ProfileTab/ProfileView.swift b/Savely/Views/ProfileTab/ProfileView.swift index 727d168..f6c6192 100755 --- a/Savely/Views/ProfileTab/ProfileView.swift +++ b/Savely/Views/ProfileTab/ProfileView.swift @@ -2,6 +2,7 @@ import SwiftUI import SwiftData struct ProfileView: View { + @Environment(\.dynamicTypeSize) private var typeSize @StateObject private var viewModel = ProfileViewModel() @EnvironmentObject var appViewModel: AppViewModel @Environment(\.modelContext) private var modelContext @@ -94,16 +95,19 @@ struct ProfileView: View { .offset(x: 30, y: -20) VStack(alignment: .leading, spacing: 4) { + // Full-opacity white on the green fill: 6.3:1 light, 5.0:1 + // dark. The .7/.75 washes read as "secondary" but landed at + // 4.0/3.4 in dark, under the 4.5 a label needs. Text("LIFETIME INCOME") .warmFont(11, weight: .semibold) - .foregroundStyle(Color.warmOnGreen.opacity(0.7)) + .foregroundStyle(Color.warmOnGreen) .tracking(0.8) Text(formattedAmount(lifetimeIncome)) .warmFont(40, weight: .regular, design: .serif) .foregroundStyle(Color.warmOnGreen) Text(savingSinceSubtitle) .warmFont(13) - .foregroundStyle(Color.warmOnGreen.opacity(0.75)) + .foregroundStyle(Color.warmOnGreen) } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) @@ -117,7 +121,12 @@ struct ProfileView: View { // MARK: - Stats private var statsStrip: some View { - HStack(spacing: 10) { + // Three across, or stacked at accessibility type sizes so the labels + // and figures keep one line each instead of wrapping into slivers. + let layout = typeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(spacing: 10)) + : AnyLayout(HStackLayout(spacing: 10)) + return layout { ProfileStatCell(label: Strings.Profile.statSavedLabel, value: formattedAmount(totalSaved)) ProfileStatCell(label: Strings.Profile.statMovementsLabel, value: "\(movementCount)") ProfileStatCell(label: Strings.Profile.statActiveGoalsLabel, value: "\(activeGoalCount)") @@ -157,6 +166,7 @@ struct ProfileView: View { Text("See all ›") .warmFont(13, weight: .medium) .foregroundStyle(Color.warmGreen) + .tappable44() } } @@ -275,13 +285,14 @@ struct ProfileStatCell: View { var body: some View { VStack(alignment: .leading, spacing: 6) { + // Wrap rather than shrink: a third of the width at AX sizes cannot + // hold "ACTIVE GOALS" on one line, and the audit flags the clip. Text(label) .warmFont(11, weight: .semibold) .foregroundStyle(Color.warmInkMuted) .tracking(0.8) .textCase(.uppercase) - .lineLimit(1) - .minimumScaleFactor(0.8) + .fixedSize(horizontal: false, vertical: true) Text(value) .warmFont(20, weight: .regular, design: .serif) .foregroundStyle(Color.warmInk) diff --git a/SavelyUITests/AccessibilityAuditUITests.swift b/SavelyUITests/AccessibilityAuditUITests.swift new file mode 100644 index 0000000..3fa943f --- /dev/null +++ b/SavelyUITests/AccessibilityAuditUITests.swift @@ -0,0 +1,93 @@ +// +// AccessibilityAuditUITests.swift +// SavelyUITests +// +// Runs Apple's own XCUIApplication accessibility audit over the screens that +// carry Savely's common tasks (home, goals, a goal, logging money, the month, +// the profile). It backs the Accessibility Nutrition Label in App Store +// Connect: a claim there has to be checkable, not remembered. +// +// Issues are reported, not asserted, so the audit stays a readable report +// rather than a flaky gate. Skipped on CI like the rest of SavelyUITests. +// +// Known, accepted findings (2026-08-18): +// · "Dynamic Type font sizes are partially unsupported" on the four tab +// captions: the tab bar is capped at xxxLarge on purpose, like UITabBar, +// and Apple's Larger Text criteria name tab bars as the exemption. +// · "Contrast failed" on a row whose frame sits at y ≥ 862 is a row +// scrolled under the floating tab bar (the audit samples the tab bar's +// pixels, not the row's). +// · "Contrast" on the disabled Save button is the 0.4-alpha inactive +// state, which the contrast rules exempt. +// · "Text clipped" on the Money tab's "What did you buy?" field (22pt +// tall inside its 40pt row) reproduces at the default size where the +// field is visibly fine. Not understood; not claimed as fixed. +// Everything else it printed on that date was real and is fixed in the +// same PR that added this file. +// +// What XCUITest shows is not what VoiceOver says. `app.buttons` lists the +// glyph and the caption inside each WarmTabBarItem as their own buttons +// (with the SF Symbol's stock name as the glyph's label); that is XCUI +// descending into a SwiftUI Button's content, and it survives +// `.accessibilityHidden(true)` on that content. VoiceOver treats a Button +// as one element and reads the `.accessibilityLabel`. Confirm on a device +// with VoiceOver on before declaring VoiceOver support in App Store +// Connect; this file cannot. +// + +import XCTest + +final class AccessibilityAuditUITests: XCTestCase { + + private var app: XCUIApplication = XCUIApplication() + + @MainActor + func testCommonTaskScreensPassTheAccessibilityAudit() throws { + app = XCUIApplication() + app.launchArguments += ["-isOnboardingComplete", "YES", "-SavelyScreenshotSeed", "-darkModeEnabled", "NO"] + app.launch() + XCTAssertTrue(app.buttons["Add"].waitForExistence(timeout: 25), "the main tab bar did not appear") + + audit("Home") + + app.buttons["Goals"].firstMatch.tap() + _ = app.staticTexts["Trip to Oaxaca"].waitForExistence(timeout: 8) + audit("Goals") + + app.staticTexts["Trip to Oaxaca"].tap() + _ = app.navigationBars["Goal"].waitForExistence(timeout: 8) + audit("Goal detail") + app.navigationBars.buttons.firstMatch.tap() + + app.buttons["Money"].firstMatch.tap() + _ = app.buttons["Income"].firstMatch.waitForExistence(timeout: 8) + audit("Money") + + app.buttons["Me"].firstMatch.tap() + _ = app.buttons["Me"].firstMatch.waitForExistence(timeout: 3) + audit("Me") + + app.buttons["Home"].firstMatch.tap() + app.buttons["Add"].tap() + let expense = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Log expense'")).firstMatch + XCTAssertTrue(expense.waitForExistence(timeout: 5)) + expense.tap() + _ = app.staticTexts["Log expense"].waitForExistence(timeout: 5) + audit("Log expense") + } + + /// Runs every audit type the platform offers and prints what it finds. + @MainActor + private func audit(_ screen: String) { + var found = 0 + try? app.performAccessibilityAudit { issue in + found += 1 + let e = issue.element + let where_ = [e?.elementType.rawValue.description, e?.identifier, e?.label, e?.frame.debugDescription] + .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " | ") + print("‼️ [\(screen)] \(issue.compactDescription) -> \(where_)") + return true // handled: report, do not fail the test + } + print(found == 0 ? "✅ [\(screen)] no issues" : "⚠️ [\(screen)] \(found) issue(s)") + } +} From 44878a98069657697389b4c0436140fa2aace244 Mon Sep 17 00:00:00 2001 From: Belli Date: Tue, 18 Aug 2026 15:21:49 -0700 Subject: [PATCH 2/2] chore(release): xcode cloud post-clone script and export-compliance key App Store Connect refused build 1: this Mac only has Xcode-beta, and Apple does not accept archives from a beta Xcode. Xcode Cloud archives with a released Xcode, so, as Fave did, Savely ships from there. ci_scripts/ci_post_clone.sh materialises the gitignored Savely/Config.plist (a required Resources build input) from the OPENAI_API_KEY workflow secret when present, or a placeholder when not, with the same printenv discipline as Fave: the value never binds to a variable or reaches xtrace. Because FeatureFlags.tipsEnabled is false the placeholder is what 1.0 should ship; the only refusal is archive + flag on + no key. Dry-run against a scratch clone with a canary key containing "&<>" under bash -x: zero hits in the trace, one XML-escaped hit in the plist, plutil OK, and the gate exits 1. Info.plist gains ITSAppUsesNonExemptEncryption = false: the app has no cryptography of its own and its one network call is HTTPS via URLSession. docs/xcode-cloud.md carries only what differs from Fave and the workflow settings (released Xcode, Archive - iOS, manual start, ASC build counter set to 2 because build 1 is taken). --- Savely/Info.plist | 5 ++ ci_scripts/ci_post_clone.sh | 154 ++++++++++++++++++++++++++++++++++++ docs/xcode-cloud.md | 68 ++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100755 ci_scripts/ci_post_clone.sh create mode 100644 docs/xcode-cloud.md diff --git a/Savely/Info.plist b/Savely/Info.plist index 392ffbb..17bc9a4 100644 --- a/Savely/Info.plist +++ b/Savely/Info.plist @@ -2,6 +2,11 @@ + + ITSAppUsesNonExemptEncryption + NSAppTransportSecurity NSExceptionDomains diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh new file mode 100755 index 0000000..2303b38 --- /dev/null +++ b/ci_scripts/ci_post_clone.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# +# ci_scripts/ci_post_clone.sh — Xcode Cloud post-clone hook for Savely. +# +# WHY IT EXISTS +# A fresh clone of this repo is missing exactly one file xcodebuild insists +# on: Savely/Config.plist. It is gitignored (it carries the OpenAI key for +# the Tips feature) but the Xcode project lists it as a Resources build +# input, so on a clean checkout xcodebuild stops with "Build input file +# cannot be found". .github/workflows/ci.yml solves this on GitHub Actions +# by writing a placeholder; this script is the same idea for Xcode Cloud, +# plus one honesty gate for archives (see section 2). +# +# Unlike Fave (docs/xcode-cloud.md over there is the origin of this file), +# Savely commits its .xcodeproj and its shared scheme, so there is no +# project generation step here. Everything about XcodeGen was dropped. +# +# APPLE'S CONTRACT — the parts this script leans on, from +# developer.apple.com/documentation/xcode/writing-custom-build-scripts and +# developer.apple.com/documentation/xcode/environment-variable-reference: +# · The file must be at ci_scripts/ci_post_clone.sh, in the same directory +# as the Xcode project, executable, with a shebang on line 1. Without +# the shebang or the +x bit, Xcode Cloud falls back to `zsh `. +# · "Xcode Cloud runs your custom build scripts with this directory as the +# root directory" — the working directory is ci_scripts/, NOT the repo +# root. Nothing below assumes otherwise. +# · The clone is at $CI_PRIMARY_REPOSITORY_PATH. +# · A nonzero exit fails the build, which is what every failure path here +# does, loudly, instead of letting xcodebuild fail later. +# · Writes into the clone survive into the xcodebuild step. +# +# SECRET DISCIPLINE (house rule: env var only, never committed, never logged) +# If a real key is provided it arrives as the SECRET workflow environment +# variable named in $SECRET_ENV_VAR. It is never expanded into a command +# argument, an assignment, or an echo: `printenv` writes it straight down a +# pipe, so even under `set -x` the trace shows the word "printenv" and not +# the value. Every check on the resulting file uses `grep -c`/`grep -q`, +# never a form that could print the line. +# +set -euo pipefail +set +x + +# ---------------------------------------------------------------- constants -- + +# The workflow environment variable that carries the OpenAI key. Must be +# marked SECRET in the Xcode Cloud workflow's Environment section. +readonly SECRET_ENV_VAR="OPENAI_API_KEY" +readonly PLIST_REL="Savely/Config.plist" +readonly FLAGS_REL="Savely/Utilities/FeatureFlags.swift" + +# ------------------------------------------------------------------ helpers -- + +log() { printf 'ci_post_clone: %s\n' "$*"; } +fail() { printf 'ci_post_clone: ERROR: %s\n' "$*" >&2; exit 1; } + +# ------------------------------------------------- 1. locate the repository -- +# CI_PRIMARY_REPOSITORY_PATH is the documented location of the clone. The +# script-relative fallback is what makes this runnable by hand outside Xcode +# Cloud; whichever candidate actually holds the project wins. + +repo_root="" +for candidate in "${CI_PRIMARY_REPOSITORY_PATH:-}" "$(cd "$(dirname "$0")/.." && pwd)"; do + if [ -n "$candidate" ] && [ -f "$candidate/Savely.xcodeproj/project.pbxproj" ]; then + repo_root="$candidate" + break + fi +done +[ -n "$repo_root" ] \ + || fail "could not find a repo root containing Savely.xcodeproj (tried CI_PRIMARY_REPOSITORY_PATH='${CI_PRIMARY_REPOSITORY_PATH:-}' and '$(cd "$(dirname "$0")/.." && pwd)')" + +cd "$repo_root" +log "repo root: $repo_root" +[ -f "$FLAGS_REL" ] || fail "$FLAGS_REL missing; the archive gate below reads it" + +# --------------------------------------------- 2. materialise Config.plist -- +# Three states, all of them valid: +# a) variable set and non-empty -> write the real key +# b) variable set but empty -> write the placeholder +# c) variable not set at all -> write the placeholder +# The placeholder is what a fresh local clone and GitHub Actions build with: +# the app runs, OpenAIClient.init? finds a string that is not a key, and the +# Tips call answers nothing. That is fine while the Tips feature is OFF. + +key_state="absent" +if printenv "$SECRET_ENV_VAR" >/dev/null 2>&1; then + key_state="empty" + key_chars="$(printenv "$SECRET_ENV_VAR" | tr -d '[:space:]' | wc -c | tr -d '[:space:]')" + if [ "${key_chars:-0}" -gt 0 ]; then + key_state="present" + fi +fi + +# The Tips feature is gated by FeatureFlags.tipsEnabled. While it is `false` +# the shipped app never touches OpenAI and a placeholder key is not merely +# acceptable, it is preferable: a real key inside a plist in the bundle can be +# lifted out of the IPA. The moment someone flips the flag on, an ARCHIVE +# without a real key would ship a Tips card that silently never loads, with a +# green checkmark on the workflow. Refuse that combination and nothing else. +tips_enabled="unknown" +if grep -Eq '^\s*static let tipsEnabled\s*=\s*true\b' "$FLAGS_REL"; then + tips_enabled="true" +elif grep -Eq '^\s*static let tipsEnabled\s*=\s*false\b' "$FLAGS_REL"; then + tips_enabled="false" +fi +[ "$tips_enabled" != "unknown" ] \ + || fail "could not read FeatureFlags.tipsEnabled from $FLAGS_REL; the line changed shape, update the grep here" + +if [ "${CI_XCODEBUILD_ACTION:-}" = "archive" ] && [ "$tips_enabled" = "true" ] && [ "$key_state" != "present" ]; then + fail "\$$SECRET_ENV_VAR is $key_state, CI_XCODEBUILD_ACTION=archive and FeatureFlags.tipsEnabled is true. + Refusing to ship a release with the Tips feature on and no OpenAI key: the + card would never load and nothing would say so. + Either add $SECRET_ENV_VAR as a SECRET environment variable on the workflow + (Xcode 26/27: Integrate > Manage Workflows > Environment) or turn the flag off." +fi + +mkdir -p "$(dirname "$PLIST_REL")" +umask 077 +{ + printf '\n' + printf '\n' + printf '\n\n' + printf '\t%s\n' "$SECRET_ENV_VAR" + printf '\t' + if [ "$key_state" = "present" ]; then + # Whitespace stripped (a stray CR pasted into the App Store Connect field + # would otherwise become part of the key), then XML-escaped. The value + # never binds to a shell variable on the way. + printenv "$SECRET_ENV_VAR" | tr -d '[:space:]' | sed -e 's/&/\&/g' -e 's//\>/g' + else + printf 'xcode-cloud-placeholder-not-a-real-key' + fi + printf '\n\n\n' +} > "$PLIST_REL" +chmod 600 "$PLIST_REL" + +if [ "$key_state" = "present" ]; then + log "$PLIST_REL written from \$$SECRET_ENV_VAR (value not logged)" +else + log "\$$SECRET_ENV_VAR is $key_state; $PLIST_REL written with a placeholder (tipsEnabled=$tips_enabled, action=${CI_XCODEBUILD_ACTION:-})" +fi + +# ------------------------------------------------------------- 3. verify it -- +# plutil -lint prints only OK / the error, never the values. +plutil -lint "$PLIST_REL" >/dev/null || fail "$PLIST_REL is not a valid plist" +[ "$(grep -c "${SECRET_ENV_VAR}" "$PLIST_REL")" -eq 1 ] \ + || fail "$PLIST_REL does not contain exactly one ${SECRET_ENV_VAR} entry" + +if [ -n "${CI_PROJECT_FILE_PATH:-}" ] && [ ! -e "$CI_PROJECT_FILE_PATH" ]; then + fail "Xcode Cloud expects the project at CI_PROJECT_FILE_PATH='$CI_PROJECT_FILE_PATH', which does not exist. + Recreate the workflow against Savely.xcodeproj at the repo root." +fi + +log "done — $PLIST_REL in place." +exit 0 diff --git a/docs/xcode-cloud.md b/docs/xcode-cloud.md new file mode 100644 index 0000000..2f03ea2 --- /dev/null +++ b/docs/xcode-cloud.md @@ -0,0 +1,68 @@ +# Xcode Cloud for Savely + +Written 2026-08-18. Savely's first upload (build 1) was archived from a beta +Xcode and App Store Connect refused it: "Esta compilación utiliza una versión +beta de Xcode y no se puede enviar." This Mac only has Xcode-beta installed, +so every archive from here has the same problem. Xcode Cloud archives on +Apple's machines with a released Xcode, which makes the local beta irrelevant. + +The long version, with the wrong turns and how each was found, is Fave's +`docs/xcode-cloud.md` (same author, same week). This file keeps only what is +different for Savely and the settings that matter. Where Fave's notes say +**observed once**, nothing here upgrades that. + +## What is different from Fave + +| Fave | Savely | +|---|---| +| Project is generated by XcodeGen, not committed | `Savely.xcodeproj` and its shared scheme are committed. No generation step, no XcodeGen, no scheme patch. | +| Secret is `Config/Secrets.xcconfig` (TMDB key), gitignored | Secret is `Savely/Config.plist` (OpenAI key), gitignored, **but registered as a Resources build input**, so a clean checkout fails to build without it. | +| An archive without the key is refused | The Tips feature is behind `FeatureFlags.tipsEnabled = false`, so the shipped app never calls OpenAI and a placeholder is preferable (a real key in a bundled plist can be lifted from the IPA). The archive is refused only if the flag is `true` AND no key was provided. | + +`ci_scripts/ci_post_clone.sh` writes `Savely/Config.plist` from the workflow +variable `OPENAI_API_KEY` if it exists, or a placeholder if it does not, and +enforces that one gate. It was dry-run against a scratch clone with a canary +key containing `&<>` under `bash -x`: zero hits in the trace, one hit in the +plist, XML-escaped, `plutil -lint` OK, and the tips-on/no-key/archive +combination exits 1 with a message that names the fix. + +The workflow does not need any environment variable for a 1.0 archive. Add +`OPENAI_API_KEY` as a SECRET variable only when the flag flips on. + +## Workflow settings + +Created from Xcode via **Integrate > Create Workflow…** (Xcode 26 and 27; older +Xcodes put it under Product). + +- **Environment: a released Xcode.** The field arrives preselected on + whatever Xcode created the workflow, and from Xcode-beta that is a beta, + which is the exact thing App Store Connect rejects. Fave shipped on 26.6 + (17F113). +- **Actions: Archive - iOS**, Deployment Preparation set so the build lands + in App Store Connect / TestFlight. A plain Build compiles and leaves nothing + to upload. +- **Start conditions: manual only.** The default "every push to the branch" + archives on every push and eats the 25 monthly compute hours. +- **Build number** comes from the workflow counter at App Store Connect > + Xcode Cloud > Ajustes > Número de la compilación, NOT from + `CURRENT_PROJECT_VERSION`. Build 1 is already taken by the rejected beta + archive, so set the counter to 2 before the first run. A rejected upload + wants a new build, not "Recompilar", because a rebuild can reuse the number + that was just refused. +- The `Savely` scheme's Test action includes `SavelyUITests`, which needs a + Simulator with the seed and takes minutes. Do not add a Test action to the + archive workflow; `.github/workflows/ci.yml` runs unit tests on every PR. + +## Export compliance + +`Savely/Info.plist` declares `ITSAppUsesNonExemptEncryption = false`. Savely +ships no cryptography of its own; the only network call is HTTPS through +URLSession, which is exempt. With the key present, App Store Connect skips +the per-upload encryption questionnaire. + +## What still cannot be done from this Mac + +Anything that needs a released Xcode locally: archiving, and the +`xcrun altool` / Transporter upload path. Xcode Cloud replaces both. Building, +testing and running on the Simulator with the beta is fine and is what every +verification in this repo did.