# iOS Build Issues — Running Notes

## Bug #5 — WatchConnectivity crashes Expo Go (KNOWN LIMITATION)

**Identified:** 2026-04-08
**Status:** Known limitation — will not fix

### What happens

`react-native-watch-connectivity` is a native module that Metro bundles even inside a `try/catch` dynamic import. When Expo Go tries to load the module, the missing native code crashes the JS runtime before the catch handler runs.

### Workaround

`watchBridge.ts` checks `Constants.appOwnership === 'expo'` and skips the native import entirely, falling back to the stub. This means **watch communication cannot be tested in Expo Go** — only in a native Xcode build running on a real paired iPhone + Apple Watch.

### Testing rules

- **Phone-only features** (vote persistence, UI, navigation): test in Expo Go simulator
- **Watch ↔ phone sync**: requires native Xcode build → real iPhone + real Apple Watch (WCSession is not supported in simulators either)
- **Watch UI**: Xcode watch simulator (standalone, no phone sync)

---

## Bug #4 — Missing/aliased theme token imports → SIGABRT on New Architecture (RESOLVED build 74)

**Identified:** 2026-03-30
**Status:** Fixed in `ios-debug-isolation` commit `37b5178`

### What happened

The color token sweep (build 71) replaced inline hex strings with named constants from `constants/theme.ts`. The auto-fix script:
1. Added missing tokens to import blocks — but in some cases added them as `TOKEN as ALIAS` even when the file body used the raw `TOKEN` name
2. Missed multi-line import blocks entirely (single-line regex)

Result: tokens like `BEIGE`, `DARK_GREY`, `WARM_MID_GREY`, `STONE` resolved to `undefined` at runtime.

On New Architecture, the native renderer calls `ObjCTurboModule::performVoidMethodInvocation` for color props. When a color is `undefined`, this method throws an Objective-C exception → SIGABRT. No JS stack frame appears in the crash report because the crash occurs synchronously during native render, before JS propagates the error.

### Diagnosis method

Xcode exception breakpoint on "Objective-C exceptions" → caught the throw. Then connected Metro debugger in simulator — LogBox showed exact `file:line` for each undefined token (e.g., `ProposeVoteModal.tsx:331:23 — backgroundColor: BEIGE — Property 'BEIGE' doesn't exist`).

### Files affected and fix

| File | Token | Imported as | Fix |
|------|-------|-------------|-----|
| `components/ProposeVoteModal.tsx` | `BEIGE` | `BORDER` | Added `BEIGE` alongside `BEIGE as BORDER` |
| `app/(tabs)/votd.legacy.tsx` | `DARK_GREY` | `LIGHT` | Added `DARK_GREY` alongside `DARK_GREY as LIGHT` |
| `app/(tabs)/profile.tsx` | `WARM_MID_GREY` | `GRAY` | Added `WARM_MID_GREY` alongside `WARM_MID_GREY as GRAY` |
| `app/(tabs)/votd.tsx` | `BEIGE` | `BORDER` | Added `BEIGE` alongside `BEIGE as BORDER` |
| `app/vote-item.tsx` | `STONE` | `DIVIDER` | Added `STONE` alongside `STONE as DIVIDER` |

Also fixed in `8c01c01` (first pass): `address-setup.tsx`, `profile.tsx`, `vote-item.tsx`, `index.tsx`, and 13 other files with missing token imports entirely.

---

## Pinned: SDK version mismatch in package-lock.json

**Identified:** 2026-03-23
**Status:** Not yet fixed — pinned for next session

### What's wrong

`package.json` declares SDK 54 versions for several expo packages, but `package-lock.json` has them locked at SDK 55 resolved URLs. When `npm install` runs it trusts the lock file and installs 55.x regardless of what package.json says.

Affected packages:

| Package | package.json declares | lock file resolves to |
|---|---|---|
| `expo-document-picker` | `~14.0.8` (SDK 54) | `55.0.8` (SDK 55) |
| `expo-image-picker` | `~17.0.10` (SDK 54) | `55.0.12` (SDK 55) |
| `expo-linear-gradient` | `~15.0.8` (SDK 54) | `55.0.8` (SDK 55) |
| `expo-image-loader` | (transitive) | `55.0.0` (SDK 55) |

### How it happened

At some point `package.json` was manually downgraded from SDK 55 back to SDK 54, but `package-lock.json` was never regenerated. The lock file still has `^55.x` ranges and SDK 55 tarball URLs as its resolved entries. npm's behaviour is to honour the lock file over package.json when a lock exists.

### The fix (when we pick this up)

Delete the lock file and regenerate it from scratch:

```bash
cd ~/votd/apps/mobile
rm package-lock.json
npm install
```

Then verify everything resolved to SDK 54:

```bash
npx expo-doctor
```

Should come back clean on SDK version warnings.

---

## Other open build issues

- **Duplicate React** — `react@19.1.0` in `apps/mobile/node_modules` and `react@19.2.4` at root. Root copy was hoisted by npm from a transitive dep. Fix already written: `overrides` block added to root `package.json`. Will take effect after `npm install` is re-run (blocked until lock file issue above is resolved first).

- **EXPO_ROUTER_APP_ROOT** — Fix applied in `eas.json` (env var) and `metro.config.js` (process.env fallback). Should be resolved for next build attempt.

---

## iOS Build Checklist — Local Xcode Builds

**Before every archive, run the bump script. One command updates both required places.**

```bash
cd ~/votd/apps/mobile
./bump-build.sh          # auto-increments by 1
# or: ./bump-build.sh 25  to set a specific number
```

The script updates:
- `app.json` → `expo.ios.buildNumber`
- `ios/votd.xcodeproj/project.pbxproj` → `CURRENT_PROJECT_VERSION` (Debug + Release)

Then commit and archive:
```bash
git add app.json ios/votd.xcodeproj/project.pbxproj
git commit -m "chore: bump iOS build number to XX"
# Open Xcode → Product → Archive → Distribute App → TestFlight Internal Only
```

> **Why two files?** `app.json` alone is not enough — Xcode reads `CURRENT_PROJECT_VERSION` from `project.pbxproj` at archive time. `expo prebuild` writes that value once; subsequent `app.json` changes don't propagate without re-running prebuild. The bump script keeps both in sync without needing a full prebuild.

Current build number: **26** (as of 2026-03-25)

> Note: EAS cloud builds auto-increment via `"autoIncrement": true` in `eas.json`.
> Local Xcode builds do not — always run `bump-build.sh` before archiving.

### Full local build sequence (only needed when native deps change)
```bash
cd ~/votd/apps/mobile
./bump-build.sh
npx expo prebuild --platform ios --clean
cd ios && pod install
open votd.xcworkspace
# Product → Archive → Distribute App → TestFlight Internal Only
```

### Quick rebuild (no native changes)
```bash
cd ~/votd/apps/mobile
./bump-build.sh
open ios/votd.xcworkspace
# Product → Archive → Distribute App → TestFlight Internal Only
```

---

## Build number strategy — research notes (2026-03-24)

Three approaches were considered for keeping incremental builds fast while bumping the build number cleanly.

### Strategy 1 — PlistBuddy in a Build Phase Run Script

The idea: add a Run Script build phase that reads `CFBundleVersion` from `Info.plist` and writes an incremented value to the built copy in `TARGET_BUILD_DIR`, leaving the source file untouched.

**Not suitable for this project.** Our `Info.plist` has `CFBundleVersion` hardcoded as a literal string (Expo generates it this way — it does not use `$(CURRENT_PROJECT_VERSION)` as a variable reference). A build-phase script running on every Cmd+B would increment the number on every debug build, not just on archives. Build numbers would tick up during normal development.

### Strategy 2 — User-Defined Build Setting (`CURRENT_BUILD_NUMBER`)

The idea: add a User-Defined Setting in Build Settings, set `CFBundleVersion` in `Info.plist` to `$(CURRENT_BUILD_NUMBER)`, and update only that variable from a script.

**Not suitable for this project.** Expo's `prebuild` regenerates `project.pbxproj` on any clean build and would silently drop User-Defined Settings that it doesn't know about, creating a maintenance trap.

### Strategy 3 — `agvtool` from the terminal ✅ (adopted)

`agvtool` ships with Xcode's command-line developer tools. It understands `VERSIONING_SYSTEM = "apple-generic"` and updates `CURRENT_PROJECT_VERSION` in `project.pbxproj` without touching any Swift or Objective-C source files. Because source files are unchanged, Xcode's incremental compiler has no reason to recompile anything — the next build after a bump is still fast.

Manual command (for reference):
```bash
cd ~/votd/apps/mobile/ios
xcrun agvtool new-version -all 21
```

`bump-build.sh` wraps this: when `agvtool` is available (i.e. on your Mac with Xcode installed) it uses that path; in CI or other environments it falls back to direct Python editing of `project.pbxproj`. Either way, `app.json` is always updated in the same pass.

### Why not tie the build number to git commit count?

`git rev-list --count HEAD` is a clean idea for greenfield projects. For this one the commit count (currently ~161) is already below build 20, and would collide rather than increment cleanly. Not worth the migration complexity.

---

## Build 20 — 2026-03-24

### Propose a Vote — Step 3 overhaul

- **Multiple research links** — Step 3 now supports up to 5 links. Each confirmed link renders as a compact preview card with a remove (✕) button. Input clears after each "Add Link" so the next URL can be pasted immediately. Upload path coexists alongside links (no longer mutually exclusive).
- **Required source field** — moved inside the `uploadedFile` block so it only appears after a file is picked, not before.
- **Next button removed from Step 3** — changed `step >= 2` to `step === 2`; Propose Your Vote is the only CTA on Step 3.
- **Root domain gate** — detects when a pasted URL is a root domain (pathname depth = 0) and shows inline nudge: "Please use a specific article or research link, not a root domain." Propose button is suppressed until a valid subpage URL is entered.
- **Link preview redesigned** — compact card layout: orange domain label + bold title + description + OG article image (logo/svg fallback stripped). A/B stacked comparison visible in this test build — will be resolved to winner in build 21.

### Propose a Vote — Confirmation screen

- Removed redundant "Congrats! We appreciate you." body block entirely.
- 🗳️ emoji added at r(64) — matches intro screen megaphone size.
- All copy scaled up to intro screen sizes: "Thanks for Proposing a Vote" at r(16), orange headline at r(28), note at r(16).
- Divider line removed between dots area and body.
- Copy updated: "Most votes receive feedback within 48 hours."

### Daily Feed — Backlog cards

- Pin emoji (📍) removed from meta row.
- Meta row (`jurisdictionShort · vote count`) now truncates with `…` on one line instead of wrapping and pushing the title to a third line.

### App icon

- iOS app icon updated to `fixed_diagonal_border_brighterplus_sat_plus.png`.

### Watch app (watchOS target — not included in build 20 iOS archive)

- New watchOS target added to Xcode project: `votd Watch App`, bundle ID `io.votd.app.watchkitapp`, minimum deployment watchOS 10.0.
- Watch icon assets added at all mipmap densities (mdpi → xxxhdpi) using `watch_diagonal_brighterplus_sat_plus_noborder.png`.
- Five SwiftUI files written:
  - `WatchModels.swift` — `WatchVoteItem`, `VoteChoice`, `WatchSessionManager` (WCSession), mock fallback data.
  - `VOTDCardView.swift` — VOTE OF THE DAY question + Vote Now → Yes/No flow + long-press Learn More overlay.
  - `TallyView.swift` — animated split bar, "You voted Yes/No" confirmation, Share/More/Done actions.
  - `CatchUpView.swift` — backlog queue with dot nav, skip, All Caught Up celebration screen.
  - `ContentView.swift` — root navigator: loading → votd → tally → catchUp → done.
- Watch target not yet embedded in iOS archive. Next step: add Embed Watch Content build phase to iOS target and confirm on-device before including in a future build.

---

## Build 21 — 2026-03-24 ✅ Distributed to TestFlight

### Link preview — larger format
- Removed `numberOfLines={2}` clamp on title and description in both the added-links list and the live preview card. Full title and description now render without truncation.
- Card image already renders full-width at 1.91 OG aspect ratio — no change needed.
- A/B comparison removed; single stacked full-width card is the resolved design.

### Info.plist — CFBundleVersion
- Changed hardcoded `"19"` to `$(CURRENT_PROJECT_VERSION)` so the build number is always read from `project.pbxproj` at archive time. `bump-build.sh` now controls the number in one place.

### Watch app — included in archive
- `Embed Watch Content` build phase confirmed present in `project.pbxproj`. Watch app bundled with iOS archive from build 21 onward.
- Watch target: `votd Watch App`, bundle ID `io.votd.app.watchkitapp`, watchOS 10.6 minimum.

### Build tooling
- `bump-build.sh` added to `apps/mobile/`. Single command to increment build number in both `app.json` and `project.pbxproj`. Uses `agvtool` on macOS (incremental-build-safe), falls back to Python elsewhere.

---

## Build 22 — 2026-03-24
- Upload failed — watch icon had alpha channel (RGBA). Apple requires RGB only for watch icons.

## Build 23 — 2026-03-24 ✅ Distributed to TestFlight

### Watch icon — alpha channel fix
- Flattened `watch_diagonal_brighterplus_sat_plus_noborder.png` from RGBA to RGB (white background composite) before copying into `AppIcon.appiconset`.
- Resolves both TestFlight validation errors: "contains an icon file with an alpha channel" and "large app icon can't be transparent or contain an alpha channel".

### Watch app — first build distributed
- Watch app bundled with iOS archive for the first time. `Embed Watch Content` build phase confirmed working.

---

## Watch app — full implementation notes (2026-03-24)

### Target setup
- Product name: `votd Watch App`
- Bundle ID: `io.votd.app.watchkitapp`
- Minimum deployment: watchOS 10.6
- Scheme: `votd Watch App votd Watch App votd Watch App Watch App`
- Embedded in iOS target via `Embed Watch Content` build phase — bundles automatically with every iOS archive

### Compiler fixes applied
Five errors on first build, all caused by two root issues:

1. **Missing `import Combine`** — `WatchSessionManager` uses `@Published` which requires Combine. Adding `import Combine` resolved "Static subscript not available" and "Initializer not available due to missing Combine" errors.
2. **`@MainActor` conflict with `WCSessionDelegate`** — `WCSessionDelegate` callbacks are called on a background thread. Using `@MainActor` on the class caused Swift concurrency conflicts. Fix: removed `@MainActor` from the class entirely, removed `nonisolated` keywords, replaced `Task { @MainActor in }` blocks with `DispatchQueue.main.async { }`.

### Files
All in `apps/mobile/ios/votd Watch App votd Watch App votd Watch App Watch App/`:

**`WatchModels.swift`**
- `WatchVoteItem` — codable struct: id, pollQuestion, jurisdictionShort, tier, yesCount, noCount, totalVotes, computed yesPct/noPct
- `VoteChoice` — enum: yes/no
- `JurisdictionTier` — enum with emoji icons: federal ⭐, state 🐻, county/municipal/etc 📍
- `WatchPayload` — codable envelope: today + backlog array
- `WatchSessionManager` — `NSObject, ObservableObject, WCSessionDelegate`. Activates WCSession on init, receives `applicationContext` pushes from phone, sends vote messages back, falls back to mock data when phone unreachable
- `formatCount()` — formats integers as 1.2K / 3.4M

**`VOTDCardView.swift`**
- Screen 1: orange gradient background, meta row (jurisdiction icon + name + vote count), "VOTE OF THE DAY" eyebrow, question text (lineLimit 4)
- "Vote Now" button → reveals Yes/No buttons with animation
- Long press → `LearnMoreOverlay` (open on phone / cancel)
- Brand colors defined here as globals used across all screens: `votdOrange`, `gradStart`, `gradEnd`, `onGrad`, `onGradMuted`, `onGradFaint`

**`TallyView.swift`**
- Screen 2: "You voted Yes/No" confirmation row, yes/no percentage row, animated split bar (GeometryReader, easeOut 0.8s), vote counts row, Share on Phone / More votes / Done buttons

**`CatchUpView.swift`**
- Screen 3: backlog queue with `currentIndex` state, dot navigation, Skip button per card
- Each card is `CatchUpCardView` — same Vote Now → Yes/No flow as main card
- `AllCaughtUpView` — auto-advances after 2.2s, "You're building a strong civic habit"

**`ContentView.swift`**
- `RootView` with `WatchScreen` enum: loading → votd → tally(choice) → catchUp → done
- `LoadingView`: 🗳️ + VOTD + ProgressView, requests today's vote from phone on appear
- `DoneView`: "You're all voted up / Come back tomorrow"

**`votd_Watch_App_votd_Watch_App_votd_Watch_AppApp.swift`**
- Entry point: `VOTDWatchApp` struct, injects `WatchSessionManager.shared` as `@StateObject` into environment

### Icon
- Source: `assets/images/watch_diagonal_brighterplus_sat_plus_noborder.png`
- Flattened RGBA → RGB (white background composite) before copying — Apple rejects watch icons with alpha channels
- Destination: `AppIcon.appiconset/AppIcon.png`, 1024×1024, `Contents.json` platform=watchos

### On-device status
- Build 23: first archive with watch included. Watch app visible in screenshot from physical device.
- Watch receives mock data (fallback) until phone-side `WCSession` push is implemented.
- Phone-side `WCSession` push not yet written — the watch will always show mock data until that is added to the iOS app.

### Known gaps / next steps
- Phone side has no `WCSession` handler yet — needs to respond to `["request": "todayVote"]` messages and push `applicationContext` with live vote data
- "Share on Phone" in TallyView does nothing yet — needs phone-side URL scheme handler
- Watch UI not yet QA'd on physical device for all screen sizes (41mm vs 45mm)

---

## Session — 2026-03-25

### Work completed this session

**Guest prompt flows (A/B/D/E1) — code implemented**
- `votd.tsx`: Feed taps for unverified users intercepted → `SkipOverlay` with two-tap escalation. First tap: "Verify your number to continue", second: "Voting requires phone verification."
- `vote-item.tsx`: Post-vote nudge for `phone_verified` users — after casting Yes/No, `SkipOverlay` rises with "Complete Registration / for votes to count and / to see live results". Scrim dismisses on tap.
- `profile.tsx`: Full-screen sign-up experience for guests — icon, headline, body, Sign Up CTA, Sign In link.
- `SkipOverlay.tsx`: Skip link now conditional on `skipLabel` — empty string hides it entirely.

**Nav chrome audit — all screens standardized**
- All top-left hit targets: `r(44)×r(44)` across every screen.
- Raw `‹` text characters replaced with `FontAwesome6` `chevron-left` at `r(20)` on: signup, verify-otp, address-setup, photo-setup.
- X mark on vote-item: `r(22)`. votes-detail backBtn: `r(44)`.

**Nav flyout menu — dots button (Daily Feed + Votes tabs)**
- Tapping three-dot stack opens a flyout anchored top-left.
- Items: About VOTD, Privacy, Help / FAQs, Terms and Conditions (all greyed out), Sign Out (active → routes to signup).
- Same white card / shadow / divider pattern as VoteCard overflow menu.

**Vote status model**
- Added `voteStatus?: 'upcoming' | 'open' | 'closed'` and `publishDate?` to `MockVoteItem`.
- `schema.md` updated with user-facing label table for both vote and proposal statuses.
- J Street (mock-001b) marked `upcoming`, `publishDate: '2026-04-22'`.
- Feed engine (votd.tsx + index.tsx) filters out `upcoming` items from all pools.
- Profile separates authored items into upcoming/open buckets; excludes authored items from voted list.

**Poll question length fixes**
- 8 questions were over the 85-char card limit. All trimmed to ≤85 chars.

**App icon swap**
- `app.json` icon updated to `diagonal_brighter_sat_plus.png`.
- Old `border`-named variants removed. New diagonal/horizontal/watch variant set committed.

**Breaker chart animations**
- `BreakerChart` (wave): fade + scale-up (0.94→1) on mount.
- `BreakerBarsViz` (horizontal bars): each bar grows from 0 with 60ms stagger, cubic ease-out.
- `BreakerRaceViz` (race bar): yes/no segments grow simultaneously from 0.
- `BreakerDistViz` (bell curve): curve fades in → marker line drops down → dot pops in (back easing).
- `react-native-gifted-charts` added to package.json (available for future chart work).
- `AnimatedCircle` + `AnimatedLine` created via `Animated.createAnimatedComponent`.

### Decisions / design rationale

- **"Upcoming"** chosen as user-facing label for votes in `draft` state with a publish date set. Consistent with archived CLAUDE.md "Coming Up" language.
- **Nav flyout scope**: Settings removed (lives in Profile), My Profile/Location removed (accessible via tabs). Final list is informational/legal + Sign Out only.
- **Breaker animations**: Used RN's built-in `Animated` API rather than gifted-charts' own rendering — preserves pixel-perfect VOTD palette and avoids library styling opinions. Gifted-charts available for future use on data-heavy screens.
- **pollQuestion 85-char limit**: Hard gate — enforced in mock data and in production LLM pipeline before `poll_ready = true`.


---

## Session — 2026-03-25 (continued) — Build 27

### Work completed

**Watch sizing — Series 10 (45mm)**
All font sizes bumped across `VOTDCardView.swift`, `TallyView.swift`, `CatchUpView.swift`, `ContentView.swift`. Full size table in `qa-punchlist-build27.md`. Horizontal padding 4pt → 8pt throughout.

**Watch complication**
New `votd Watch Complication` WidgetKit extension added to Xcode project. Rectangular + circular families. `VOTDProvider` reads `UserDefaults` keys written by `WatchSessionManager`. Count decrements on every vote and skip via `remainingCount` param. Cold start clears `votd_hasVotedToday` in app `init()`.

**Phone ↔ watch bridge — fully wired**
`react-native-watch-connectivity` v2 installed. Three payload mismatches fixed (key names, reply handler, String vs Data decode). All four watch→phone interactions now wired: openItem, openShare, vote cast, openProfile.

**Watch UX fixes**
- CatchUp swipe: right swipe now also skips (was left-only)
- Skip path: `WatchScreen.done(showCaughtUp: Bool)` — skip → `false` (no pill, no fireworks), vote all → `true`
- EndView: "View Profile on iPhone" upgraded from static text to tappable capsule button

**Build number**
Bumped to 27 in `app.json`.

### Decisions

- **Complication background**: `.black.opacity(0.55)` — matches frosted glass feel of daily activity complications. May need tuning against specific watch faces.
- **`transferUserInfo` deferred**: Vote messages sent when phone is closed are silently dropped. Acceptable for prototype; queue-based fallback deferred to production.
- **Date-stamp for `hasVotedToday` deferred**: Currently resets on every cold start. Production should only reset if stored date differs from today.
- **Watch direct deployment blocked**: iOS 26.3.1 / Xcode 26.3 device pairing issue prevented direct deploy to physical watch. Resolved by archiving via `votd` scheme and distributing through TestFlight.
