A friendly, whimsical pixel-art iOS shopping list. Adding is frictionless (a pinned bottom bar with history-backed suggestions); checking an item off pops a spark burst and an animated strikethrough, then slides it into a faded "Got it" section that clears itself after an hour.
Built with SwiftUI + SwiftData, on-device only (no account, no backend).
The look is Pastel Dots — creamy cards on a soft green pixel-dot backdrop, with pixel fonts (VT323 + Silkscreen) and fresh fruity accents.
-
Quick add — always-visible bottom bar with a green + add button; as you type, suggestions float up as one-tap chips: your personal history first (things you've bought in the last month, ranked by frequency + recency), then a built-in food dictionary (
SuggestionDictionary) for instant autocomplete. That dictionary unifies the grocery + regional corpora with the emoji table's whole vocabulary, so anything the app can put an emoji on it can also suggest (e.g. "cord" → Cordial). Items already on the list are filtered out. -
Tap the check circle to check an item off — the check pops with a burst of gold sparks while a strikethrough draws left-to-right, then the row glides into a dimmed "Got it" section (tap it to restore it). Check several off at once and they hold their place until the last spark finishes, then glide down together — so the list never shuffles under your taps.
-
Tap the row (or its faint "+ Qty" chip) to set a quantity. An inline stepper slides down with a smart default unit guessed from the item — pour-y things start in ml, weighed things in g, everything else as a plain count. You can switch units freely from a pill row: every item can be counted in plain "units", and unrecognised items offer the full set (ml/L/g/kg/units), since we can't always know what you mean ("300 ml of milk" vs "1 bottle"). Switching ml↔L or g↔kg keeps the amount; switching to a different kind of unit starts fresh (so it never shows "500 units"). Tap the number itself (between the − and +) to type an exact amount on the keyboard — so a big quantity doesn't mean tapping + over and over. The field is forgiving: it ignores the unit letters ("750 ml" → 750), takes a comma or dot decimal, rounds plain counts to whole numbers, and quietly keeps the old amount if you clear it or type nonsense. The amount shows as a small chip on the row; long names truncate so the chip keeps its place.
-
1-hour TTL on the "Got it" section, so it tidies itself between shops — or tap Clear all in the section header to empty it immediately.
-
Duplicate-aware — re-adding something already listed bumps + flashes the existing row instead of creating a copy.
-
About sheet — the ⓘ in the header opens a small sheet with the app version and an optional tip jar (☕ / 🥪 / 🎁, in-app purchases via StoreKit). Basket is free; tipping unlocks nothing — but once you've tipped, the Basket title turns into a per-letter rainbow with a solid red heart; tap it to toggle between the rainbow and classic looks (your choice persists), as a small thank-you.
-
Little touches — a sub-second basket flourish on cold launch (never on resume), a full-screen "All done!" celebration when you check off the last item, and quiet living details: a faint time-of-day tint and the occasional seasonal accent (🎃, 🎄…) on the empty state.
-
Playful auto-emoji per item, via a three-stage cascade (
Sources/Services/Emoji.swift):- Curated table (
EmojiTable, ~1750 entries, generated bytools/gen_emoji.pyfrom inline data +tools/emoji_supplement.txt) covering produce, proteins, dairy, bakery, grains, pantry, drinks, snacks, frozen, prepared dishes, household/toiletry/baby/pet/health goods, and global cuisines — East/South/Southeast Asian, Middle Eastern, African, Latin American and European staples. The matcher prefers the longest keyword match, so prefix collisions resolve correctly (peach≠pea, ginger≠gin, hamburger≠ham). - Semantic fallback (
SemanticEmoji) — Apple's on-device word embeddings (NLEmbedding, fully offline) map novel items to the nearest "anchor" food word's emoji (e.g. "Flounder" → 🐟). This also collapses variants: "Frozen peas" resolves to the same glyph as "Peas". - Basket default (🧺) when nothing else fits.
Coverage is audited against a ~3650-item global grocery corpus (
tools/corpus/*.txt, spanning world cuisines) withtools/audit_coverage.swift— currently 100% (0 fall-throughs; ~91% curated, ~9% semantic). - Curated table (
-
A warm tri-colour (green/yellow/tomato) background bloom.
-
VoiceOver support — every interactive control (check circle, quantity stepper and unit pills, add bar, tip buttons) has a proper label, hint, and action, and purely decorative flourishes (sparkles, the launch splash, empty state's basket emoji) are hidden from the accessibility tree instead of cluttering it.
Requires Xcode's command-line tools and XcodeGen
(brew install xcodegen).
./build_run.sh # generate → build → install → launch → screenshot
./build_run.sh "iPhone 17" # target a different simulatorbuild_run.sh builds by -target with an explicit SUPPORTED_PLATFORMS because
this machine's Xcode generates a scheme whose supported-platforms list is empty.
Simulator builds need no signing. To run on a physical device, drop your Apple Team ID into a git-ignored override (the committed project stays team-agnostic):
echo 'DEVELOPMENT_TEAM = ABCDE12345' > Signing.local.xcconfig
xcodegen generateSigning.xcconfig (committed) optionally includes Signing.local.xcconfig, so
without the local file the build still works for the simulator.
SwiftLint (brew install swiftlint) checks style
consistency, configured in .swiftlint.yml:
swiftlint lintGenerated files (Sources/Services/EmojiTable.swift,
Sources/Services/SuggestionDictionary.swift), tools/, build/, and
Tests/__Snapshots__/ are excluded. Errors fail the build (CI runs swiftlint lint
before the build/test steps); warnings are advisory.
Coverage is layered like a pyramid — pure logic at the bottom (fast, no simulator), SwiftData/StoreKit/snapshot tests in the middle, full UI flows on top:
-
Tests/— the XCTest suite, run on the simulator:xcodegen generate xcodebuild test -project Basket.xcodeproj -scheme Basket \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro'
This same command also runs
UITests/(below) — both are wired into theBasketscheme's test action, so onexcodebuild testcovers both (with code coverage gathered; view it in Xcode's Report Navigator or viaxcrun xccov). Beyond the pure-logic tests inBasketTests.swift, this target holds the middle layer:ListLogicTests.swift— the section partitioning (to-get / recently-got / TTL-expired) and the check-off spark→commit state machine (CheckOffChoreography), extracted fromShoppingListViewintoServices/ListLogic.swiftprecisely so they're testable without a simulator.ModelTests.swift— SwiftData semantics against an in-memoryModelContainer: the first-launch seed (BasketApp.seedIfEmpty), theKnownItems.rememberAddsuggestion-memory upsert, and theGroceryItem.unitraw-string round-trip.TipJarTests.swift— the tip jar's product loading through a localSKTestSession(StoreKitTest) onStoreKit/Basket.storekit. Purchases themselves can't run in a plain unit-test host (no UI anchor for the confirmation; transaction injection needs a test-plan-level StoreKit config) — see the scope note in the file.SnapshotTests.swift— pixel-level reference images (swift-snapshot-testing) of the core views (ItemRowstates,QuantityEditor, the empty state), stored inTests/__Snapshots__/. Unlike the flow tests' screenshots (review aids), these are assertions — a visual regression fails the build. Recorded on iOS 26.x and skipped (XCTSkip) on other majors, where OS text rendering would diff without a real regression. The empty state's background (BasketBackground) takes an injectablenow:for exactly the same reason asEmptyStateView.now— it renders a subtle time-of-day tint, and without pinning it the snapshot flakes against whatever the real wall clock says when the test happens to run.
-
tools/main.swift— the same source files run natively on macOS (fast, no simulator needed):swiftc Sources/Services/Emoji.swift Sources/Services/EmojiTable.swift \ Sources/Services/SemanticEmoji.swift Sources/Services/Suggestions.swift \ Sources/Services/SuggestionDictionary.swift Sources/Models/Suggestion.swift \ Sources/Services/Formatting.swift \ Sources/Services/Measure.swift Sources/Services/Seasonality.swift \ tools/main.swift -o /tmp/basket_check && /tmp/basket_check -
tools/audit_coverage.swift— two modes for emoji emoji mapping quality:- Coverage audit (default): counts how many of the ~3900-item corpus resolve
via curated table vs semantic embedding vs fall back to the basket default.
mkdir -p /tmp/ab && cp tools/audit_coverage.swift /tmp/ab/main.swift swiftc Sources/Services/EmojiTable.swift Sources/Services/SemanticEmoji.swift \ Sources/Services/Emoji.swift /tmp/ab/main.swift -o /tmp/audit && /tmp/audit
- Correctness mode (
-correctnessflag): regression testing. Runs ~180 golden item → emoji pairs spanning 8+ categories (food staples, UK/US synonyms, household, toiletries, pharmacy, baby, pet, brands, and fixed regressions from earlier tasks) through the real cascade and fails non-zero if any expectation mismatches. Use this to ensure semantic changes don't regress prior emoji assignments:/tmp/audit -correctness
- Coverage audit (default): counts how many of the ~3900-item corpus resolve
via curated table vs semantic embedding vs fall back to the basket default.
-
UITests/— XCUITest flow tests (add an item, suggestions, check one off, restore/clear "Got it", edit quantity, empty state, "All done!" celebration, keyboard dismiss, persistence across relaunch) driving a real simulator through the actual UI, backed by an isolated in-memory SwiftData store (see-uiTesting/-uiTestingEmptyinBasketApp.init; the persistence tests pointUITEST_STORE_URLat their own temp file instead).Tests run deterministically by default (
Services/TestHooks.swift):-uiTestingDisableAnimationsturns off UIKit/SwiftUI animations and shrinks the 0.55s check-commit delay, andUITEST_FROZEN_DATEfreezes the wall clock (an ordinary July morning) so TTL cutoffs, holiday flourishes, and the day-rotating empty-state line render identically on every run — one check-off flow opts back intorealTimingto keep the production choreography covered. Assertions never read live UI state bare: every state check is a bounded wait (waitForLabel/waitForValue/waitForGone/waitForToGetCountinBasketUITestCase), because XCUITest gives no guarantee a tap's effects have rendered by the next line — a bare assert can fail on a slow run or falsely pass on a stale read. Every step attaches a screenshot to the test report (XCTAttachment,.lifetime = .keepAlways), viewable in Xcode's Report Navigator — or export them as plain PNGs:./tools/export_ui_screenshots.sh # → screenshots/ui-tests/ ./tools/export_ui_screenshots.sh "iPhone 17" # target a different simulator
Interactive elements carry stable
.accessibilityIdentifiers (addBar.*,itemRow.*,quantityEditor.*,header.*) so tests query by identifier rather than matching on copy, which is free to change independently.UI tests run against the simulator via the accessibility tree (XCUITest injects synthetic touch events into the simulator process) — they don't drive your physical mouse/trackpad, and can run with no visible Simulator window (
xcrun simctl bootwithout openingSimulator.app).AccessibilityAuditTests.swiftruns XCTest's built-inperformAccessibilityAudit()over the main list, quantity editor, empty state, and About sheet — catching hit-region/label/trait regressions automatically..contrast,.textClipped, and.dynamicTypeare excluded (with reasons documented inline: the soft/pastel palette and colour emoji don't fit those heuristics, and the pixel fonts are deliberately fixed-size), not silently ignored.TapPrecisionTests.swiftstress-tests the app's smallest controls (the +/- stepper buttons, unit pills, the check circle) with taps offset from dead-center at a fixed, seeded jitter — standing in for a real finger's imprecision, since XCUITest's default tap always lands exactly on center. Every trial is asserted individually; nothing is averaged into a pass-rate threshold, so a real hit-target problem fails the suite instead of being tolerated.
Note:
xcodebuild testand app-icon (asset catalog) compilation require an installed iOS simulator runtime matching the SDK. If you hit "No simulator runtime version … available", runxcodebuild -downloadPlatform iOS.
.github/workflows/ci.yml runs the full suite (simulator tests + native
harness) on every branch push — pre-merge signal for worktree branches,
post-merge backstop on main. On failure it uploads the .xcresult bundle
(which contains the failure screenshots and audit logs) as a workflow
artifact. The merge-gating job never retries: a flaky test should fail
loudly. Instead, a scheduled nightly flake-hunt job runs every test up to
5 times (-test-iterations 5 -run-tests-until-failure) to surface the
only-fails-sometimes kind before it wastes anyone's day.
.github/workflows/release.yml is a manually-triggered (workflow_dispatch)
workflow — run it from the Actions tab when you want a new build on
TestFlight. It re-runs the test suite as a gate, then archives, signs, and
uploads straight to App Store Connect via xcodebuild -exportArchive
(method: app-store-connect, destination: upload — no altool/Transporter/
fastlane needed). CFBundleVersion (build number) is bumped to the run number
on the runner only, not committed back to the repo. Every job first selects
the newest Xcode 26 (.github/actions/select-xcode) so the archive builds
against the iOS 26 SDK Apple requires, rather than whatever the runner's
default Xcode happens to be.
Bumping the marketing version. App Store Connect closes a version's
pre-release train once that version is approved, so before uploading again you
must raise CFBundleShortVersionString (e.g. 1.0 → 1.0.1). Set it in
project.yml (targets.Basket.info.properties), not in
Sources/Info.plist — the plist is generated by xcodegen generate (run
in every build and CI job) and any hand-edit is overwritten on the next
generate, with unlisted keys reset to XcodeGen's defaults. After editing
project.yml, run xcodegen generate and commit the regenerated plist too.
Requires four repo secrets, generated once from an App Store Connect API key (Users and Access → Integrations → App Store Connect API). Auto-managed signing for the archive needs the key's role to be Admin (App Manager can upload but can't create the distribution profile):
APPSTORE_API_KEY— the downloaded.p8key file's contentsAPPSTORE_KEY_ID/APPSTORE_ISSUER_ID— shown alongside the keyAPPSTORE_TEAM_ID— Membership details on developer.apple.com (also theDEVELOPMENT_TEAMvalue in your local, git-ignoredSigning.local.xcconfig)
Once uploaded, the build appears under App Store Connect → TestFlight after Apple finishes processing (~10-30 min) and is immediately installable by internal testers.
The repo carries an autonomous build harness in .harness/:
a sequential loop that builds .harness/tracking/TASKS.json one fully-verified
task at a time, gated on the same CI suite above. Current backlog status:
| Task | Title | Status |
|---|---|---|
| T001 | Fix tautological Seasonality assertion in the native harness | pending |
| T002 | UI-test launch arguments accumulate across relaunches | pending |
| T003 | flash() reads a possibly-deleted SwiftData model after 0.9s | pending |
| T004 | Log silent try? failures in persistence paths | pending |
| T005 | Model initializer defaults bypass AppClock | pending |
| T006 | Remove non-default themes; hardcode the Pastel Dots look | pending |
| T007 | Stable tie-break in emoji keyword sort (generator) | pending |
| T008 | Fix ~20 semantically wrong emoji mappings + generator duplicate warning | pending |
| T009 | Exact-word matching for short emoji keywords (kills pap/paper class) | pending |
| T010 | Head-noun preference in compound emoji matching | pending |
| T011 | Household/toiletries/pharmacy/baby/pet/brands vocabulary + corpus files | pending |
| T012 | Golden-subset correctness mode for the emoji coverage audit | pending |
| T013 | Port Measure/Seasonality/Formatting checks into XCTest | pending |
| T014 | Measure.parse rejects negative and malformed input | pending |
| T015 | SwiftLint config + CI lint step | pending |
| T016 | Delete superseded icon/art generator tools (~1,900 lines) | pending |
| T017 | Shared A11yID constants between app and UI tests | pending |
| T019 | Rename an item by tapping its name | pending |
| T020 | Undo toast for Clear all | pending |
| T021 | 'Usuals' suggestion chips on empty add-bar focus | pending |
| T023 | Success haptic on the All done! celebration + restore haptic | pending |
| T024 | Formatting cleanup: wrap over-long lines + consistent MARK usage | pending |
| T025 | Refactor ShoppingListView (extract sections + quantity handlers) | pending |
| T026 | App Group + shared SwiftData container (foundation for Siri/widgets) 🔒 | pending |
| T027 | 'Add to Basket' App Intent + App Shortcut (Siri add) | pending |
| T028 | Quick-add deep link (basket://add focuses the add bar + keyboard) | pending |
| T029 | View-only Home Screen widget (small + medium) | pending |
| T030 | Interactive widget: tap an item to check it off | pending |
| T031 | Quick-add '+' widget button + add/combined widget variants | pending |
Preview with DRY_RUN=1 .harness/scripts/loop.sh; run with
.harness/scripts/supervise.sh (from a real terminal).