Skip to content

release: 1.0 - #47

Merged
Ivan-LB merged 87 commits into
mainfrom
release/1.0
Aug 19, 2026
Merged

Ivan-LB merged 87 commits into
mainfrom
release/1.0

Conversation

@Ivan-LB

@Ivan-LB Ivan-LB commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Summary

  • First release PR. Brings main (stuck at cda62cf "Redesing", 86 commits behind) up to dev plus one commit that registers the Xcode Cloud workflow (xcshareddata/xcodecloud/manifest.json).
  • Everything between is the merged PR history on 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 main and failed on the SPM Package.resolved that the old main still needed (MarkdownUI, IQKeyboardManager). dev has no packages, so the archive has to come from a main that equals dev. This is that merge, done as release/1.0 off dev per the branch model in CLAUDE.md.

Test plan

  • CI on every PR in the range; fix(a11y): audit findings before 1.0, plus Xcode Cloud release plumbing #46 verified with build, 102 unit tests, the accessibility audit and an AX5 pass
  • After merge: Xcode Cloud > Start Build on main with the workflow set to Archive - iOS, build number counter at 2 in ASC > Xcode Cloud > Ajustes
  • TestFlight install of build 2 on a device; VoiceOver pass on the tab bar and one goal card before declaring VoiceOver in the accessibility label

Risks

  • main has not moved since the redesign; there is no production user on it, so this is a fast-forward in effect (0 commits on main that dev lacks).
  • The workflow still needs two edits in ASC before it produces anything uploadable: action Build to Archive, and drop the "Cambios de rama: dev" trigger.

Checklist

  • Branch named with approved prefix (release/)
  • Commits follow Conventional Commits
  • No hardcoded user-facing strings added
  • No secrets staged
  • docs/xcode-cloud.md covers the release path
  • Tests added in the range (accessibility audit, receipt fixtures)

Ivan-LB and others added 30 commits April 25, 2026 00:41
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.
Ivan-LB and others added 28 commits August 15, 2026 22:58
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).
@Ivan-LB
Ivan-LB merged commit ba647cf into main Aug 19, 2026
1 check passed
@Ivan-LB
Ivan-LB deleted the release/1.0 branch August 19, 2026 02:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant