release: 1.0 - #47
Merged
Merged
release: 1.0#47
Conversation
Replaces the previous architecture-only CLAUDE.md with a session-front-door document: branch model, build/test commands, load-bearing constraints, secrets list, specialist routing table, and Definition of Done. The full orchestrator protocol lives at .claude/protocols/orchestrator.md; CLAUDE.md points there for non-trivial work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the agentic Claude Code architecture:
- .claude/protocols/orchestrator.md
classify -> plan -> dispatch -> validate -> PR flow
- .claude/agents/*.md (six specialists)
swiftui-feature, data-model, services, build-ci, qa-tester,
git-workflow. Each has owned paths, off-limits paths, and rules.
- .claude/knowledge/*
architecture, signing-and-ci, testing, localization, firebase,
common-rules. gotchas.yaml seeded with three discovered invariants:
only Savely.xcscheme is shared, iOS 26 is the deployment floor,
and Config.plist / GoogleService-Info.plist must never be committed.
- .claude/commands/orch.md
/orch slash command that loads the orchestrator protocol.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds local-quality tooling that the CI workflow also runs:
- .swiftlint.yml
Curated rule set: bug-finders enabled (force_unwrapping, first_where,
contains_over_filter_count, etc.), style-noise disabled (line_length,
function_body_length). Custom rule warns on hardcoded user-facing
Text("...") literals in Views/.
- .githooks/pre-commit
Runs SwiftLint on staged .swift files before each commit. Skips
gracefully if SwiftLint is not installed.
- .githooks/commit-msg
Enforces Conventional Commits (feat, fix, refactor, chore, docs, ci,
build, test, style, perf) with scope and 72-char subject limits.
- scripts/install-hooks.sh
One-time activation: chmod +x .githooks/* and
git config core.hooksPath .githooks. Run after cloning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First CI/CD pass for the project:
- .github/workflows/ci.yml
Triggers on PR (any base) and push to dev/main. Skips draft PRs.
Runner: macos-15. Steps: select Xcode -> cache SPM keyed on
Package.resolved hash -> SwiftLint --strict -> resolve packages ->
xcodebuild build -> xcodebuild test -> upload .xcresult on failure.
Uses xcbeautify for readable logs and CODE_SIGNING_ALLOWED=NO so
the runner does not need signing certs.
- .github/dependabot.yml
Weekly SPM bumps on Mondays (grouped patch+minor, individual majors)
and monthly GitHub Actions bumps. Both target the dev branch and
use Conventional Commit prefixes.
- .github/PULL_REQUEST_TEMPLATE.md
Summary / Why / Test plan / Risks / Checklist sections.
- .github/ISSUE_TEMPLATE/{bug_report,feature_request,config}
Two issue types and a config that disables blank issues.
The workflow's first run on this PR will likely need the Xcode version
or simulator name tweaked for whatever macos-15 ships — that is an
expected first task for build-ci-specialist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first CI run flagged 231 violations on the existing redesign code,
because --strict upgrades every warning to an error and the initial config
enabled style rules that don't catch bugs (comma spacing, vertical
whitespace, trailing closures in SwiftUI).
Two changes:
- .swiftlint.yml
Disable style rules that produce noise without finding bugs (comma,
colon, vertical_whitespace, trailing_newline, opening_brace,
statement_position, multiple_closures_with_trailing_closure,
legacy_objc_type, large_tuple, trailing_comma, implicit_optional_init,
static_over_final_class, etc.). Keep bug-finders: force_unwrapping,
first_where, contains_over_filter_count, identical_operands,
weak_delegate, sorted_first_last, etc. Move unused_declaration and
unused_import into analyzer_rules so SwiftLint stops complaining
they're misplaced.
- .github/workflows/ci.yml
Drop --strict. CI now fails only on error-severity violations
(force_cast, force_try). Warnings show up as inline annotations
via --reporter github-actions-logging.
force_unwrapping stays as warning so the ~10 existing force-unwraps
don't block merge today. After a dedicated cleanup PR, bump it to
error so new force-unwraps fail CI — that's the ratchet pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Config.plist is gitignored (it holds the OpenAI API key), but the Xcode
project declares it as a required build input. On a fresh checkout the
build fails before compilation:
error: Build input file cannot be found:
'.../Savely/Config.plist' (in target 'Savely' from project 'Savely')
CI never calls the OpenAI API, so the real key is not needed — only the
file. Add a step that writes a minimal plist with the OPENAI_API_KEY
field set to a placeholder value before the SPM resolve step.
Also append the rule to .claude/knowledge/gotchas.yaml so future Claude
sessions don't have to rediscover this. Same pattern will apply to
GoogleService-Info.plist once that PR (chore/stop-tracking-firebase-config)
removes it from the index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CI test step failed with:
compiling for iOS 17.5, but module 'Savely' has a minimum deployment
target of iOS 26.0
@testable import Savely
The Savely app target was bumped to iOS 26 during the redesign, but the
project default stayed at 17.0 and SavelyTests stayed at 17.5. The test
bundle was compiling against 17.5 while @testable importing a 26.0 module
— a guaranteed link failure.
Surgical fix in project.pbxproj — six IPHONEOS_DEPLOYMENT_TARGET values:
project Debug 17.0 -> 26.0
project Release 17.0 -> 26.0
Savely Debug 26.0 (already)
Savely Release 26.0 (already)
SavelyTests Debug 17.5 -> 26.0
SavelyTests Release 17.5 -> 26.0
SavelyUITests has no per-target override and inherits the project default,
which is now 26.0.
Updates the gotcha entry in .claude/knowledge/gotchas.yaml to reflect the
resolved state and to add the lesson: when bumping a project default, also
bump every per-target override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…urn 10min
Tests passed in 10 minutes, but most of that time was the Xcode template's
auto-generated launch performance tests:
SavelyUITestsLaunchTests::testLaunch (×8 iterations: 32s, 30s, 82s, 38s, 18s, 14s, 10s, 10s)
SavelyUITests::testLaunchPerformance (210s)
SavelyUITests::testExample (112s)
XCApplicationLaunchMetric runs the app multiple times measuring start-up
time, then fails if any iteration falls outside the std-dev threshold.
On shared CI runners one slow iteration is normal — and that's exactly
what flagged Process completed with exit code 65 here (one launch took
81.7s, blew the threshold).
Net signal: zero. The template tests assert nothing about app behavior —
they only measure how fast it launches, which is meaningless on a
contended runner. Burning ~10 min/run for that is a bad trade.
Add -skip-testing:SavelyUITests to the Test step. The Build step still
compiles the bundle so we catch breakage, just doesn't execute tests.
SavelyTests (the unit-test bundle, currently empty) still runs.
Document the rationale in .claude/knowledge/gotchas.yaml so qa-tester
knows to drop the flag once real UI tests exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chore: add agentic Claude Code architecture and CI/CD scaffold
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](actions/cache@v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com>
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](actions/upload-artifact@v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com>
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](actions/checkout@v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com>
The file was committed once on 2024-11-13 and has lived in the index ever
since, despite CLAUDE.md claiming it was gitignored. It now matches the
state of Config.plist: gitignored locally, generated as a placeholder in
CI.
The plist is not strictly a secret (Firebase iOS API keys are public —
they ship in every App Store binary, and protection comes from Firestore
Security Rules and App Check, not key secrecy). But while the repo is
private, hygiene-removing it stops env-specific config from drifting in
under the radar.
Three changes:
- git rm --cached Savely/GoogleService-Info.plist
File stays on disk locally so Xcode still reads it. .gitignore rule
(added in #22) now actually applies. Past commits still contain the
file — acceptable while the repo is private. If the repo ever goes
public, scrub history with git-filter-repo and rotate via App Check.
- .github/workflows/ci.yml
Renamed "Generate placeholder Config.plist" to "Generate placeholder
secrets" and added a structurally-valid GoogleService-Info.plist
alongside it. CI never calls FirebaseApp.configure() (it runs from
AppDelegate at app launch, and SavelyUITests is skipped), so a
placeholder with the right shape is sufficient. BUNDLE_ID matches
the app's PRODUCT_BUNDLE_IDENTIFIER so Firebase's parser would
accept it if it ever did run.
- .claude/knowledge/gotchas.yaml
Renamed the gotcha entry to ci-needs-placeholder-secrets and updated
the rationale to cover both plists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ions/cache-5 ci(deps): bump actions/cache from 4 to 5
…ions/upload-artifact-7 ci(deps): bump actions/upload-artifact from 4 to 7
…ions/checkout-6 ci(deps): bump actions/checkout from 4 to 6
chore: stop tracking GoogleService-Info.plist
The file was the original SwiftUI template scaffolding. It's not referenced by Savely.xcodeproj — the canonical ContentView lives at Savely/Views/ContentView.swift and is the one wired into the target. Removing the stale copy to avoid confusion in future searches.
refactor: drop orphan Savely/ContentView.swift template stub
The rule globally ignored every .xcscheme file. The existing Savely.xcscheme had been silently untracked since the repo started — CI worked only because xcodebuild auto-derives schemes when the file is missing. Dropping the rule and adding the scheme makes the build deterministic and lets future shared schemes (e.g. SavelyTests) commit naturally. User-specific scheme state continues to be excluded via xcuserdata/. Implements Option B from docs/plans/maintenance-cleanup.md Task 2, with the orchestrator's Path 2 expansion (track scheme alongside gitignore change) approved by the user.
chore: drop *.xcscheme gitignore rule and track Savely scheme
…forms Cleans up 3 force_unwrapping violations in: - Managers/CameraManager.swift:93 - Utilities/OpenAIClient.swift:26 - Utilities/SignInWithAppleHelper.swift:236 Each site uses the smallest fix that matches its semantics — guard let with early return, if let, ?? default, or guard let + fatalError when an invariant guarantees non-nil. No swiftlint:disable directives added. Part 1 of 4 in docs/plans/maintenance-cleanup.md Task 3 cleanup.
The --use-script-input-files flag reads from Xcode build-phase env vars (SCRIPT_INPUT_FILE_*), not stdin. The heredoc <<< was a no-op and every CLI git commit failed with "SCRIPT_INPUT_FILE_COUNT variable not set". Pass each staged file as a positional argument instead. Discovered while running the Task 3 force-unwrap cleanup.
Cleans up 2 force_unwrapping violations in: - Models/IncomeModel.swift:32 - Models/ExpenseModel.swift:32 Applied the fix inline in each file rather than introducing a shared helper, since deduplicating a single line across two files would have required adding a third file and a project.pbxproj edit. Part 2 of 4 in docs/plans/maintenance-cleanup.md Task 3 cleanup.
Cleans up 5 force_unwrapping violations in: - ViewModels/ProfileTab/ProfileViewModel.swift:125, 142 - ViewModels/Dashboard/ReportsViewModel.swift:77, 92 - Views/MainNavigationView.swift:659 Each site uses the smallest fix that matches its semantics — guard let with early return, if let, ?? default. No swiftlint:disable directives. Part 3 of 4 in docs/plans/maintenance-cleanup.md Task 3 cleanup.
The 10 force_unwrapping sites that previously triggered warnings have all been refactored in this PR to use safe forms (guard let, if let, ?? default, or fatalError for invariant-guaranteed values). Bumping severity to error means any new force-unwrap from this commit forward fails CI and the local pre-commit hook. Part 4 of 4 in docs/plans/maintenance-cleanup.md Task 3 cleanup.
refactor: clean up force-unwraps and enable lint ratchet
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](actions/checkout@v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com>
Doc updates written at the close of the 2026-04-25 maintenance session but never committed: lint-ratchet gotcha (force_unwrapping is now error), CI/SwiftLint descriptions brought in line with reality, completed open tasks removed from agent files, and the maintenance-cleanup plan marked DONE (all 3 tasks shipped as PRs #27-#29).
The repo is public and GoogleService-Info.plist lives in git history (committed in 80cf033, untracked since PR #26 — but history keeps it). Rather than scrub history, kill the dependency at the root: no more login, all data lives on-device. All domain data (Expense, Income, Goal, Tip) was already local SwiftData — Firestore only ever stored a 5-field user-profile doc, and auth gated nothing else. - Delete the auth layer: LoginView/SignUpView (+ view models), AuthenticationManager/UserManager (+ extensions), AuthDataResultModel, DBUserModel, SignInWithAppleHelper, the Login/SignUp vector assets, the Apple Sign-In entitlement, and the dead auth strings - AppViewModel: no auth listener; AppState is onboarding -> main (loading and loggedOut are gone — local state loads synchronously). Display name + onboarding flag live in UserDefaults - ProfileView/ProfileViewModel: local display name; email, sign-out and change-password rows die with auth (profile redesign deliberately deferred — this only keeps it compiling with local data) - Dependencies 3 -> 0: firebase-ios-sdk removed (whole transitive tree dies with it), IQKeyboardManager removed (iOS 26 handles keyboard avoidance natively), MarkdownUI replaced with native AttributedString(markdown:) at its single call site (TipHistoryView) - project.pbxproj: package references, product deps, and every GoogleService-Info.plist reference removed - CI: no more Firebase placeholder plist, SPM cache/resolve steps dropped (nothing left to resolve); Config.plist placeholder stays - Docs: CLAUDE.md/gotchas/common-rules updated; firebase.md archived as firebase-removed.md; plan in docs/plans/remove-auth-firebase.md Follow-ups (own PRs): CloudKit sync for the SwiftData container, profile redesign, deleting the Firebase project in the console so the plist in public git history points at a dead project. Verified: xcodebuild build + test green (iPhone 17 Pro sim), swiftlint lint --strict clean.
Categorization was fake at every layer: the models had no field, the quick-add sheets collected a chip and threw it away, and the Money tab guessed a category from English keywords in the description. - ExpenseModel.category / IncomeModel.source: optional strings, nil by default (additive — SwiftData migrates in place). The stored value is the chip label as-is. - MoneyCategories.swift: ExpenseCategory / IncomeSource enums are the single source for the chips, their storage keys, and their icon/tile pairing. ExpenseCategory.display(stored:description:) prefers the stored chip and falls back to the old keyword inference (kept only for legacy rows; the guess is never written back). - Quick-add sheets store the chip always; the chip stands in for an empty description as before, but description and category are now independent. The receipt scanner leaves category nil (out of scope). - Money tab: expense rows show the stored category (Food/Shopping/Other finally have their own icon and tile); income rows show a source icon and "Source · date" when one was stored, and stay unchanged when not. - ReportsView resurrected: reachable from Profile → Reports; the Environment-in-init anti-pattern is gone; income by source and expenses by category as horizontal bars (no pie — PRODUCT.md), refetch on date change, Warm Meadow layout. - CategoryPersistenceTests: in-memory ModelContainer round trips, nil defaults, and the display fallback (stored chip beats contradicting keywords; unknown stored value behaves like nil).
feat: real expense categories and income sources
Deposits used to mutate GoalModel.current in place in four different places, with no record: notes were collected and discarded, history was unrepresentable, and the payday auto-move's month-margin math could not see money already moved into goals this month. - DepositModel (new @model, additive): id, goalID, amount, date, note, source ("manual" | "auto-move"). Registered in the container. - GoalDeposits.record(goal:amount:note:source:context:) is now the only way money enters a goal: same clamp as before (min(current+amount, target)), inserts the ledger row (amount = intent, note trimmed / nil when empty), saves with do/catch. Throws on non-positive amounts. - All four writers migrated: GoalDetailView's DepositCard, the Dashboard DepositSheet, WarmQuickDepositView, and the auto-move apply path in WarmQuickIncomeView (AutoMoveSuggestion.apply() deleted; if the move fails after the income saved, the income stays and the sheet says so). Each flow surfaces a save error instead of `try?`. The two NOTE fields finally persist. Both quick sheets gain the .alert their view models already had state for. - AutoMoveSuggestion.compute gains monthDepositTotal, subtracted from the margin; WarmQuickIncomeView passes the current-month sum from a @query of deposits. - GoalDetailView: History section (deposits for the goal, newest first, relative date, note, AUTO tag for payday moves, honest empty state). - Tests: GoalDepositsTests (record adds / clamps — migrated from the two apply tests — ledger row shape, empty note → nil, non-positive throws and writes nothing, month total); AutoMoveSuggestionTests gains the deposits-reduce-the-margin case.
Nothing about a goal could be changed after the wizard — both edit pencils were empty closures. - GoalEditSheet: name, target, color (all thirteen GoalColor cases; the wizard shows six), target date with an on/off toggle, payday auto-move toggle + amount. One explicit Save; do/catch around the store save. - GoalEditValidation: pure, tested rules — name non-empty, target > 0, target not below money already saved (shrinking under saved money would read >100% and mean nothing), deadline in the future only when enabled, auto-move amount > 0 only when enabled. First broken rule is what the user hears. - Wired to GoalDetailView's toolbar pencil and the Dashboard hero card's pencil, both as sheets, both with an accessibility label. - GoalsViewModel.setFavorite is a toggle now: tapping the current favorite's star un-favorites it; tapping another goal moves the star.
Goal stats were theatre: "Per week" divided the remaining amount by a
hardcoded 24, the ETA was derived from that same number (circular), the
Dashboard PACE always said "On track", and the Goals list said "On track"
for anything past 50%.
- GoalPace: the one implementation of the pace policy. Required weekly =
remaining ÷ weeks-to-deadline (AutoMoveSuggestion now calls this same
function instead of its private copy). Actual weekly = average of the
goal's deposits over the last four weeks; a goal with no deposits ever
falls back to its configured auto-move as a weekly figure. On track ⇔
complete, or no deadline, or actual ≥ required; otherwise Behind. ETA
= remaining ÷ actual pace, shown as a date, "1+ year" past 52 weeks,
or "—" when the pace is zero — never a made-up date.
- GoalDetailView: status line under the ring ("On track · by Jun 12,
2027 · $150/wk", or "no target date"), pills become Remaining ·
Needed/wk · ETA. The deadline is finally shown somewhere.
- Dashboard hero card PACE and Goals list subtitle come from the policy;
Behind reads in clay.
- GoalsView: goals at 100% move to a Completed section (out of the
active count) with a one-time pop — same high-damping spring + amber
glow as AchievementRow, remembered per goal in UserDefaults
(GoalCelebrationStore mirrors AchievementStore), instant under Reduce
Motion.
- GoalPaceTests: required pace, no-deadline, this-week deadline, actual
pace window and per-goal filter, auto-move fallback, zero pace, behind
vs on track, complete, ETA date and the 1+ year cap.
- "No date" now means no date: AddGoalState.hasDeadline (the calendar's
Date stays non-optional so MiniCalendarView is untouched). The preset
flips it off; any day or duration flips it back on. plantGoal persists
deadline nil and autoMoveAmount 0 (pace is undefined without a date —
set later in the edit sheet). Step 3's pace card and Step 4's tiles /
recap say "—" / "No date" instead of quoting a stale 18-month default.
- weeklyPace goes through GoalPace.requiredWeeklyPace — the same
function the rest of the app uses.
- "Plant goal" is disabled (and dimmed) until name and amount are valid,
instead of guard-returning into nothing. Saving is do/catch with an
alert; a failed insert is rolled back.
- Success screen "Add a deposit" presents DepositSheet for the goal that
was just created (the reference used to be dropped), dismissing the
flow afterwards.
- Step 4's Skip was an empty closure — the header hides it there.
- The emoji picker and the Reminders recap row collected values nothing
persisted; both are gone (AddGoalState.emoji / showReminders deleted).
The three emoji renders become the goal-name initial on the goal color
(GoalInitialCircle — the pattern WarmGoalCard already uses). The wizard
stays four steps.
- Two Toggle("") in the wizard get real labels for VoiceOver.
feat(goals): deposit ledger, edit sheet, real pace, wizard fixes
Every text in the app was a fixed .system(size:) — Dynamic Type did nothing. .warmFont(size, weight:, design:) renders at exactly the design size at the default content size (unit-tested as an identity across the scale) and follows the user's reading size from there, on the curve of the text style closest to that size so headings grow less than body at accessibility sizes. It is a ViewModifier that reads the size category from the environment, so views re-render on change instead of baking a stale Font value. .tappable44() enlarges a small control's hit area to the 44pt minimum without changing its look.
The mechanical half of the pass: 285 `.font(.system(size:…))` sites across the app become `.warmFont(…)` with the same size, weight and design — pixel-identical at the default content size, scaling from there. The two Text concatenations in AddGoalFlow (which need a Font value, not a modifier) scale through @ScaledMetric instead. Fixed widths that would clip at accessibility sizes now scale with the text: the inline amount fields on the Money tab and the two number fields in the goal edit sheet.
- Every icon-only control has a name: the "+" shell (with a hint), tab bar items (isTabBar container, isSelected on the active one), sheet back/close chevrons, the keypad's ⌫ and decimal point, the Money tab's inline "+" buttons, the Goals "+" buttons, the favorite star (label flips with state), the wizard's back/close, clear-name and month arrows. All of them get a ≥44pt hit area via tappable44(). - Rows read as one element with a spoken value: recent transactions, expense and income rows (with the category/source and a long-press hint), monthly summary cells, goal cards, achievement rows. Rings and bars expose "N percent". The keypad amount reads "Expense amount, 500 dollars" instead of three fragments; the income trend badge reads "Month over month, up 12 percent". Decorative tiles/stars/glyphs are hidden from VoiceOver. - honorsReduceMotion(): under Reduce Motion every animation in a subtree becomes instant. Applied at the app root, the quick-add sheet, the goal wizard cover, the goal edit sheet and the deposit sheet — the presented roots the app-level modifier cannot reach. - Every new spoken string is localized: literals go through the String Catalog, interpolations through String(localized:) / Text so the key is a real format string; es-419 added for all 37 new keys.
At accessibility sizes the four tab labels wrapped to single letters and the keypad glyphs outgrew their 52pt cells. UITabBar and the system number pad do not scale into the accessibility range either — they rely on VoiceOver labels and hit targets — so both cap at xxxLarge. gotchas.yaml and DESIGN.md record the rules this pass introduced: warmFont over .system(size:), tappable44, labels on icon-only controls, honorsReduceMotion on presented roots, localized spoken strings.
feat(a11y): Dynamic Type, VoiceOver, 44pt targets and Reduce Motion across the app
…yboard The quick-add row "Scan a receipt" was wired to the expense keypad (target: .expense) — the scanner was only reachable from the Money tab's banner. QuickAction now has an explicit kind (screen / newGoal / scan); scan dismisses the sheet and presents CameraView full-screen, with the same 0.35s hand-off New goal uses. It writes through its own ExpenseTrackerViewModel (like the Money tab's banner), so the Money list refreshes through the posted notification. KeyboardDismisser installs one UITapGestureRecognizer on the key window (cancelsTouchesInView = false; ignores taps inside UITextField/UITextView) so tapping outside any field puts the keyboard away everywhere — forms, sheets, wizard steps — without per-screen wiring. Retries if called before the window exists.
…mera CameraManager.configureSession() called beginConfiguration() and returned early on every failure path without commitConfiguration() — on the simulator (no back camera) that left the session mid-configuration and startRunning() threw "startRunning may not be called between calls to beginConfiguration and commitConfiguration". It also re-ran the whole configuration on every startSession(), so a second open hit the same path once the inputs already existed. - configureSessionIfNeeded(): commit is deferred so every exit commits; configuration runs once; startRunning only if not already running. - Camera access is requested explicitly before configuring. - isCameraAvailable is published; CameraView shows "Camera not available" over the preview instead of a frozen black frame when there is no back camera or access was denied. - The preview layer follows the host view's bounds (sheet, full-screen, rotation) instead of UIScreen.main.bounds at creation.
It used a .sheet (rounded modal, drag to dismiss) while the "+" shortcut used a .fullScreenCover — same scanner, two looks. A camera is not a form; both entries are full-screen now.
fix: scan shortcut opens the scanner; tap outside dismisses the keyboard
Rewrite the receipt scanner around a pure, fixture-tested ReceiptParser
and fix the Vision pipeline around it. All on-device; no network.
Parser (Utilities/Receipt/): rows clustered from line boxes, a receipt
amount grammar (thousands separators, comma decimals, whole pesos, OCR
digit confusions, %/dates/ids ignored), es/en label hierarchy with
exclusions (SUBTOTAL, IVA, PROPINA, EFECTIVO, CAMBIO, TARJETA...),
scored candidates with alternatives, merchant, printed date, category
hint. Fixes the old heuristics that saved EFECTIVO instead of TOTAL,
dropped leading digits ("1234.56" -> 234.56) and rejected totals < 10.
OCR (Utilities/ReceiptOCR.swift): RecognizeDocumentsRequest with the
image orientation passed and es+en recognition languages; Apple's
money/date detectors as a second opinion; RecognizeTextRequest fallback.
Camera: RotationCoordinator so stills carry the right EXIF orientation,
guarded async capture (no crash on an unconfigured session), torch,
gallery import, no per-frame rectangle detection; VNDocumentCamera
behind FeatureFlags.useSystemDocumentCamera for a device A/B.
UI: ReceiptScanFlowView (capture -> reading -> review) replaces the
disabled-text-field card: editable amount, alternative chips, merchant,
date, category, thumbnail, honest empty/failed states, VoiceOver
announcements and modal overlays, Warm Meadow tokens, all strings in
Strings.swift with es-419.
Tests: 16 JSON fixtures + grammar/date/label unit tests, a real-Vision
integration test on a script-rendered receipt (upright and rotated),
and a Simulator UI test through the Photos picker.
feat(scan): on-device receipt parser, OCR fixes and a review screen
The App Store set from docs/plans/app-store-screenshots.md, produced end to end from real screens: - ScreenshotSeed (DEBUG, -SavelyScreenshotSeed) wipes the Simulator store and seeds the fictional dataset the captions have to prove: a goal on track with a real $125/wk pace, a second goal honestly behind with a "1+ year" ETA, a paycheck that offers an auto-move, and a month-over-month drop in the trend. - StoreScreenshotTourUITests walks the eight screens and attaches the raw 1320×2868 captures; Design/ScreenshotKit/collect.sh renames them. - Design/ScreenshotKit/compose.swift renders each page (warm notebook ground, green tracked kicker on a hairline rule, regular New York headline, the app as a flat photo in a warm-ink bezel, SproutMark growing one stage per page) with SwiftUI ImageRenderer, so the type is the real New York / SF Pro. - TARGETED_DEVICE_FAMILY = 1: iPhone-only for this release, so no iPad set is required.
chore(store): App Store screenshot kit and the 6.9" set
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.
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).
fix(a11y): audit findings before 1.0, plus Xcode Cloud release plumbing
Savely.xcodeproj/xcshareddata/xcodecloud/manifest.json is what Xcode wrote when the "Default" workflow was created (2026-08-18). It ties the workflow to the target so Integrate > Start Build finds it from any clone. Version stays 1.0; the shipped build number is Xcode Cloud's counter in App Store Connect, not CURRENT_PROJECT_VERSION (see docs/xcode-cloud.md).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
main(stuck atcda62cf"Redesing", 86 commits behind) up todevplus one commit that registers the Xcode Cloud workflow (xcshareddata/xcodecloud/manifest.json).dev: chore: add agentic Claude Code architecture and CI/CD scaffold #22 through fix(a11y): audit findings before 1.0, plus Xcode Cloud release plumbing #46. The shape of the app that ships: local-first, no auth, no Firebase, no SPM dependencies; goals with real pace, deposit ledger, expense categories and income sources, on-device receipt scanning with a review screen, adaptive Warm Meadow palette with dark mode, Dynamic Type and VoiceOver pass, App Store screenshot kit, accessibility audit fixes, Xcode Cloud post-clone script and export-compliance key.Why
Xcode Cloud's first run built
mainand failed on the SPMPackage.resolvedthat the oldmainstill needed (MarkdownUI, IQKeyboardManager).devhas no packages, so the archive has to come from amainthat equalsdev. This is that merge, done asrelease/1.0offdevper the branch model in CLAUDE.md.Test plan
mainwith the workflow set to Archive - iOS, build number counter at 2 in ASC > Xcode Cloud > AjustesRisks
mainhas not moved since the redesign; there is no production user on it, so this is a fast-forward in effect (0 commits onmainthatdevlacks).Checklist
release/)docs/xcode-cloud.mdcovers the release path