From dcd11c3a525e58bfaf6aa60a14a0a419c987be07 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 15:55:53 +0000 Subject: [PATCH] Add high-level spec for converting Tranche web app to mobile/iOS Evaluates four approaches (Capacitor, React Native, Flutter, Electron) with detailed migration plans, architecture analysis, and recommendations. Recommends Capacitor as the primary path for maximum code reuse with the existing Next.js/React/TypeScript stack. Resolves PK-117 Co-authored-by: Parvez Kose --- docs/MOBILE_CONVERSION_SPEC.md | 572 +++++++++++++++++++++++++++++++++ 1 file changed, 572 insertions(+) create mode 100644 docs/MOBILE_CONVERSION_SPEC.md diff --git a/docs/MOBILE_CONVERSION_SPEC.md b/docs/MOBILE_CONVERSION_SPEC.md new file mode 100644 index 0000000..0f77316 --- /dev/null +++ b/docs/MOBILE_CONVERSION_SPEC.md @@ -0,0 +1,572 @@ +# Tranche — Mobile / iOS Conversion Spec + +## 1. Executive Summary + +Tranche is a single-page stock allocation tool built with **Next.js 14 (App Router), React 18, TypeScript, Zustand, and Tailwind CSS**. It lets users set a dollar budget, add stock positions by ticker, fetch live prices from Yahoo Finance, adjust share counts, and view allocation percentages. All state persists to `localStorage`. + +This spec evaluates three paths for bringing Tranche to mobile / iOS and provides a recommended approach with a detailed migration plan. + +--- + +## 2. Current Architecture Inventory + +### 2.1 Tech Stack + +| Layer | Technology | +|-------|-----------| +| Framework | Next.js 14.2.5 (App Router) | +| Language | TypeScript 5, React 18 | +| State | Zustand 5 with `persist` middleware → `localStorage` | +| Styling | Tailwind CSS 3.4, shadcn/ui primitives (Button, Input, Progress, Skeleton, Sonner) | +| Fonts | IBM Plex Sans (`--font-ui`), JetBrains Mono (`--font-mono`) via `next/font/google` | +| API layer | Next.js Route Handlers (`/api/price`, `/api/perf`) | +| External data | Yahoo Finance Chart API (`query1.finance.yahoo.com/v8/finance/chart`) | +| IDs | `nanoid` | + +### 2.2 Application Features + +| Feature | Description | +|---------|-------------| +| **Budget editor** | Inline-editable dollar amount; Enter to commit, Escape to cancel | +| **Summary bar** | Allocated vs remaining totals, color-coded progress bar (green → amber → red) | +| **Positions table** | Ticker input, name/price display (with loading skeletons and error states), share count (+/- with Shift ×10 / Ctrl ×100 modifiers), row total, % of budget, remove row | +| **Price quotes** | Client fetches `/api/price?ticker=…` after ticker input; race-condition safe via sequence refs | +| **Performance popover** | Hover-triggered; fetches `/api/perf?ticker=…` for 1W / 3M / YTD / 1Y % changes displayed with colored bars | +| **Share URL** | Builds `?b=…&s=TICK:shares,…` query string, copies to clipboard via `navigator.clipboard`; reads URL state on load | +| **Persistence** | Zustand `persist` → `localStorage` with key `tranche-session`; persists `budget` and `positions` | +| **Theme** | Dark-only, CSS custom properties for shadcn tokens | + +### 2.3 Server-Side Dependencies + +The app has **two server-side API routes** that act as a proxy to Yahoo Finance: + +- **`/api/price`** — fetches `range: "1d"` chart data, returns `{ ticker, name, price, changePct1D }`. Sets `Cache-Control: s-maxage=60`. +- **`/api/perf`** — fetches `range: "1y"` chart data, computes 1W/3M/YTD/1Y % changes from closing prices. Sets `Cache-Control: s-maxage=3600`. + +Both routes use `lib/server/yahoo.ts` which sends requests with a custom `User-Agent` header. This proxy pattern exists because Yahoo Finance's API does not support CORS for browser-direct calls. + +### 2.4 Complexity Assessment + +- **Small surface area**: 1 page, 5 UI components (shadcn primitives), 1 store file, 2 API routes, 1 server utility. +- **No authentication / user accounts**. +- **No database** — all persistence is client-side `localStorage`. +- **No SSR-dependent rendering** — the page is `"use client"` and could run as a fully static SPA with an external API. + +--- + +## 3. Approach Evaluation + +### 3.1 Option A — Flutter (Full Rewrite) + +**What it is:** Rewrite the entire UI in Dart using Flutter's widget system, targeting iOS (and optionally Android). + +| Aspect | Assessment | +|--------|-----------| +| **Code reuse** | Near zero. All React components, Zustand store, Tailwind styles, and shadcn primitives must be rewritten in Dart. | +| **Native feel** | Excellent. Flutter compiles to native ARM code; smooth 60/120 fps, native gestures, Cupertino or Material widgets. | +| **API routes** | Cannot run Next.js Route Handlers in Flutter. Must either: (a) deploy the API proxy as a standalone service, or (b) call Yahoo Finance directly from the app (requires handling CORS-equivalent issues and User-Agent headers at the HTTP client level). | +| **State management** | Rewrite Zustand store using Riverpod, Bloc, or Provider. Persistence via `shared_preferences` or `hive`. | +| **Effort** | High. Equivalent to building the app from scratch in a different language. | +| **Maintenance** | Two completely separate codebases (web + mobile) to maintain. | +| **App Store** | Full native binary; straightforward App Store submission. | + +**When to choose:** If the goal is a premium, platform-native iOS experience and the team has Dart/Flutter expertise, or plans to abandon the web version. + +### 3.2 Option B — Electron + +**What it is:** Package the Next.js app as a desktop application using Electron (Chromium + Node.js). + +| Aspect | Assessment | +|--------|-----------| +| **Code reuse** | Very high. Existing codebase runs nearly as-is inside Electron's Chromium renderer. Next.js API routes can run in the main process or as a local server. | +| **Mobile / iOS** | **Electron does not target iOS or Android.** It produces macOS, Windows, and Linux desktop apps only. This option does **not** solve the mobile requirement. | +| **Effort** | Low for desktop; adds `electron`, `electron-builder`, main process bootstrap. | + +**When to choose:** Only if the actual goal is a **desktop** app (macOS/Windows), not mobile/iOS. Can complement a mobile strategy but does not replace it. + +### 3.3 Option C — Capacitor (Recommended for iOS with Minimal Effort) + +**What it is:** Use [Capacitor](https://capacitorjs.com/) (by the Ionic team) to wrap the existing Next.js web app in a native iOS WebView shell, with access to native APIs via plugins. + +| Aspect | Assessment | +|--------|-----------| +| **Code reuse** | ~95%. The existing React/Tailwind UI runs in a WKWebView. Only native-bridge touches (storage, clipboard, share sheet, haptics) require Capacitor plugin calls. | +| **API routes** | Export the Next.js app as a static SPA (`output: 'export'`) and deploy the API proxy separately (e.g., Vercel serverless, Cloudflare Worker, or a lightweight Express/Hono server). Alternatively, make Yahoo Finance calls directly from the app using Capacitor's native HTTP plugin (bypasses CORS). | +| **State management** | Zustand store works unchanged. Swap `localStorage` for Capacitor `Preferences` plugin for more reliable native storage (optional; `localStorage` works in WKWebView). | +| **Styling** | Tailwind works as-is. Add safe-area insets (`env(safe-area-inset-*)`) and touch-optimized tap targets. | +| **Native features** | Capacitor plugins for: Haptics, Share (native sheet instead of clipboard), Status Bar, Splash Screen, App (lifecycle events). | +| **Effort** | Low-to-medium. Primary work is (1) extracting the API proxy, (2) adapting interactions for touch, (3) adding safe-area handling, (4) configuring Xcode project. | +| **App Store** | Produces a real `.ipa`; submittable to App Store (Apple allows WKWebView apps as long as they provide sufficient native value). | + +**When to choose:** Best balance of effort vs result when the team is already invested in the React/TypeScript stack and wants to ship quickly. + +### 3.4 Option D — React Native / Expo (Alternative Native Path) + +**What it is:** Rewrite the UI using React Native components while sharing business logic (store, API calls, types) from the existing codebase. + +| Aspect | Assessment | +|--------|-----------| +| **Code reuse** | ~40-50%. TypeScript types, Zustand store logic, and utility functions can be shared. All JSX/HTML/Tailwind must be rewritten with React Native primitives (`View`, `Text`, `FlatList`, etc.). | +| **Native feel** | Very good. React Native renders actual native views; supports native navigation, gestures, and platform conventions. | +| **API routes** | Same as Capacitor — deploy proxy separately or call Yahoo Finance directly (no CORS in React Native). | +| **Effort** | Medium. Less than Flutter (same language, some code sharing) but more than Capacitor (full UI rewrite). | +| **App Store** | Native binary; straightforward submission. | + +**When to choose:** If a truly native UI is desired and the team wants to stay in TypeScript/React rather than learning Dart. + +--- + +## 4. Recommendation + +**Primary recommendation: Capacitor (Option C)** for the fastest path to an iOS app with the highest code reuse. + +**Secondary recommendation: React Native / Expo (Option D)** if the team determines that a WebView-based app does not meet performance or UX standards after prototyping. + +**Electron (Option B)** should be pursued only as a separate desktop initiative, not as a mobile solution. + +**Flutter (Option A)** is a strong choice only if the team plans a broader multi-platform strategy and is willing to invest in a full rewrite. + +--- + +## 5. Detailed Migration Plan — Capacitor Path + +### Phase 1: Decouple the API Proxy + +**Goal:** Make the client-side app independent of Next.js server-side Route Handlers. + +#### 1a. Extract API Proxy to a Standalone Service + +Create a lightweight API service (e.g., Hono on Cloudflare Workers, or Express on any Node host) that replicates the two routes: + +``` +POST/GET /api/price?ticker=AAPL → Yahoo Finance chart (1d) +POST/GET /api/perf?ticker=AAPL → Yahoo Finance chart (1y) +``` + +Files to extract: +- `lib/server/yahoo.ts` → core fetch logic +- `lib/constants/yahoo.ts` → base URL and user-agent +- `app/api/price/route.ts` → price endpoint logic +- `app/api/perf/route.ts` → perf endpoint logic + +The proxy should: +- Preserve the same JSON response shapes +- Set appropriate CORS headers (`Access-Control-Allow-Origin`) for the web version +- Maintain cache headers for CDN/edge caching +- Be deployable independently (Vercel serverless function, Cloudflare Worker, Fly.io, etc.) + +#### 1b. Alternative: Direct Yahoo Finance Calls via Capacitor HTTP + +Capacitor's `@capacitor/http` plugin makes native HTTP requests that bypass CORS. The mobile app could call Yahoo Finance directly: + +```typescript +import { CapacitorHttp } from '@capacitor/core'; + +const response = await CapacitorHttp.get({ + url: `https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?interval=1d&range=1d`, + headers: { 'User-Agent': YAHOO_FINANCE_USER_AGENT }, +}); +``` + +**Trade-off:** Simpler architecture (no proxy needed for mobile), but the web version still needs the proxy for CORS. Use an environment-aware fetch wrapper: + +```typescript +async function fetchQuote(ticker: string, range: string) { + if (Capacitor.isNativePlatform()) { + return fetchDirectFromYahoo(ticker, range); + } + return fetch(`${API_BASE_URL}/api/price?ticker=${ticker}`); +} +``` + +#### 1c. Update Client Fetch Calls + +Replace hardcoded `/api/price` and `/api/perf` paths with a configurable base URL: + +```typescript +const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? ''; + +// In fetchPrice: +const response = await fetch(`${API_BASE}/api/price?ticker=${encodeURIComponent(ticker)}`); + +// In fetchPerf: +const response = await fetch(`${API_BASE}/api/perf?ticker=${encodeURIComponent(ticker)}`); +``` + +### Phase 2: Convert to Static Export + +**Goal:** Produce a static SPA bundle that Capacitor can load from disk. + +#### 2a. Configure Next.js Static Export + +In `next.config.mjs`: + +```javascript +const nextConfig = { + output: 'export', + // Remove the headers() function (not supported in static export) +}; +``` + +Since the entire app is `"use client"` with no SSR data fetching, static export should work without changes to the page component. + +#### 2b. Verify the Build + +```bash +npm run build +# Produces `out/` directory with index.html + JS/CSS bundles +``` + +Confirm the SPA loads correctly by serving `out/` locally: + +```bash +npx serve out +``` + +### Phase 3: Add Capacitor + +#### 3a. Install and Initialize + +```bash +npm install @capacitor/core @capacitor/cli +npx cap init "Tranche" "com.tranche.app" --web-dir out + +npm install @capacitor/ios +npx cap add ios +``` + +This creates: +- `capacitor.config.ts` — Capacitor configuration +- `ios/` — Xcode project + +#### 3b. Configure `capacitor.config.ts` + +```typescript +import type { CapacitorConfig } from '@capacitor/core'; + +const config: CapacitorConfig = { + appId: 'com.tranche.app', + appName: 'Tranche', + webDir: 'out', + server: { + // For development: proxy to local Next.js dev server + // url: 'http://localhost:3000', + // cleartext: true, + }, + ios: { + contentInset: 'automatic', + backgroundColor: '#09090b', + }, + plugins: { + SplashScreen: { + backgroundColor: '#09090b', + launchAutoHide: true, + androidScaleType: 'CENTER_CROP', + }, + StatusBar: { + style: 'LIGHT', + backgroundColor: '#09090b', + }, + }, +}; + +export default config; +``` + +#### 3c. Build and Sync Workflow + +```bash +npm run build # Next.js static export → out/ +npx cap sync ios # Copy web assets + sync native plugins +npx cap open ios # Open Xcode project +``` + +### Phase 4: Mobile UX Adaptations + +#### 4a. Safe Area Insets + +Add safe-area padding to the root layout to respect iOS notch and home indicator: + +```css +/* In globals.css */ +main { + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); +} +``` + +Set the viewport meta tag for proper mobile rendering: + +```html + +``` + +#### 4b. Touch Interaction Overhaul + +| Current (Desktop) | Mobile Adaptation | +|-------------------|-------------------| +| Hover popover for performance data | Tap to toggle popover; tap outside to dismiss | +| Mouse hover delays (180ms open, 130ms close) | Immediate open on tap; swipe-to-dismiss or tap-outside | +| Shift/Ctrl+click for ×10/×100 share increment | Long-press for ×10; add a dedicated "step size" toggle or slider | +| `navigator.clipboard.writeText()` | Use Capacitor `Share` plugin for native share sheet | +| Inline budget edit on click | Same pattern works on tap; ensure keyboard type is `decimal-pad` | + +#### 4c. Responsive Layout Changes + +The current positions grid uses a fixed 6-column layout: + +``` +grid-cols-[88px_minmax(280px,1fr)_148px_112px_88px_36px] +``` + +For mobile (< 640px), switch to a stacked card layout: + +``` +┌─────────────────────────────┐ +│ AAPL × Remove│ +│ Apple Inc. — $213.25 +1.2% │ +│ ┌──┐ ┌────────┐ ┌──┐ │ +│ │ -│ │ 15 │ │ +│ │ +│ └──┘ └────────┘ └──┘ │ +│ Total: $3,198.75 14.8% │ +└─────────────────────────────┘ +``` + +Use Tailwind breakpoints (`sm:grid-cols-[...]` for desktop, stacked `flex-col` for mobile). + +#### 4d. Performance Popover → Bottom Sheet + +Replace the hover-positioned popover with a bottom sheet / modal drawer on mobile: + +- Tap the price/name area to open a slide-up sheet showing performance data +- Swipe down or tap backdrop to dismiss +- Can use a lightweight library like `vaul` (already React-compatible) or build with CSS transforms + +#### 4e. Native Feature Integrations + +| Feature | Plugin | Implementation | +|---------|--------|---------------| +| **Share** | `@capacitor/share` | Replace `navigator.clipboard.writeText` with `Share.share({ url })` for native share sheet | +| **Haptics** | `@capacitor/haptics` | Light impact on share +/- button presses; notification on budget exceeded | +| **Status Bar** | `@capacitor/status-bar` | Set light content, transparent background to match dark theme | +| **Splash Screen** | `@capacitor/splash-screen` | Dark background (#09090b) with Tranche logo | +| **Keyboard** | `@capacitor/keyboard` | Listen for keyboard show/hide to adjust layout when editing budget/shares | +| **Preferences** | `@capacitor/preferences` | Optional replacement for `localStorage`; more reliable on iOS | + +### Phase 5: Persistence Adaptation + +The current Zustand persistence uses `localStorage` via `createJSONStorage(() => localStorage)`. In a Capacitor WKWebView, `localStorage` works but may be cleared by iOS under storage pressure. + +For more robust persistence, create a Capacitor-aware storage adapter: + +```typescript +import { Preferences } from '@capacitor/preferences'; +import type { StateStorage } from 'zustand/middleware'; + +const capacitorStorage: StateStorage = { + getItem: async (name: string) => { + const { value } = await Preferences.get({ key: name }); + return value; + }, + setItem: async (name: string, value: string) => { + await Preferences.set({ key: name, value }); + }, + removeItem: async (name: string) => { + await Preferences.remove({ key: name }); + }, +}; +``` + +Update the store: + +```typescript +import { Capacitor } from '@capacitor/core'; + +const storage = Capacitor.isNativePlatform() + ? createJSONStorage(() => capacitorStorage) + : createJSONStorage(() => localStorage); +``` + +### Phase 6: App Store Preparation + +#### 6a. App Icons and Splash Screens + +Generate all required iOS icon sizes from a 1024×1024 source image. Use `@capacitor/assets` to auto-generate: + +```bash +npx @capacitor/assets generate --ios +``` + +#### 6b. App Store Metadata + +- **Bundle ID:** `com.tranche.app` +- **Display Name:** Tranche +- **Category:** Finance +- **Description:** Stock allocation tool — set a budget, add positions, and visualize your portfolio allocation. +- **Keywords:** stocks, portfolio, allocation, shares, budget, investment +- **Privacy Policy:** Required for finance-category apps. The app collects no user data; all data is stored on-device. + +#### 6c. iOS-Specific Configuration (`ios/App/App/Info.plist`) + +- `ITSAppUsesNonExemptEncryption` → `NO` (no custom encryption) +- `NSAppTransportSecurity` — allow Yahoo Finance domain if calling directly +- `UIStatusBarStyle` → `UIStatusBarStyleLightContent` + +#### 6d. Review Guidelines Considerations + +Apple requires WKWebView-based apps to provide "sufficient native functionality." Mitigation: +- Use native Share sheet instead of clipboard +- Add haptic feedback +- Implement native splash screen +- Ensure proper safe-area and Dynamic Type support +- Consider adding a simple native settings screen or widget in a future iteration + +--- + +## 6. Detailed Migration Plan — React Native Alternative + +If the Capacitor prototype does not meet quality standards, the React Native path involves: + +### Shareable Code (~40-50%) + +| Artifact | Reusable? | +|----------|-----------| +| `lib/store.ts` (Zustand store) | Yes — Zustand works in React Native. Replace `localStorage` with `@react-native-async-storage/async-storage`. | +| `lib/utils.ts` (`cn()`, `isNumber()`) | Partially — `cn()` (Tailwind merge) is not needed; `isNumber()` is reusable. | +| `lib/constants/yahoo.ts` | Yes | +| `lib/server/yahoo.ts` | Yes — HTTP calls work directly from React Native (no CORS). | +| TypeScript types (`Position`, `PositionPerf`, `Store`) | Yes | +| `Intl.NumberFormat` formatters | Yes | +| URL parse/build logic | Yes | + +### Must Rewrite + +| Artifact | React Native Equivalent | +|----------|------------------------| +| `app/page.tsx` (626 lines of JSX) | Rewrite with `View`, `Text`, `TextInput`, `Pressable`, `FlatList`, `ScrollView` | +| `components/ui/*` (shadcn primitives) | Replace with React Native Paper, NativeBase, or custom components | +| Tailwind CSS classes | Use `StyleSheet.create()`, NativeWind, or Tamagui | +| Performance popover | React Native bottom sheet (`@gorhom/bottom-sheet`) | +| `next/font/google` | `expo-font` with downloaded font files | +| Sonner toasts | `react-native-toast-message` or `burnt` | +| `navigator.clipboard` | `react-native-share` | + +### React Native Project Setup + +```bash +npx create-expo-app tranche-mobile --template blank-typescript +cd tranche-mobile +npx expo install zustand @react-native-async-storage/async-storage +npx expo install expo-haptics expo-font expo-splash-screen +npx expo install react-native-share @gorhom/bottom-sheet +``` + +--- + +## 7. Detailed Migration Plan — Electron (Desktop Only) + +For completeness, if a desktop app is also desired alongside mobile: + +### Setup + +```bash +npm install --save-dev electron electron-builder concurrently wait-on +``` + +### Architecture + +- **Main process:** Starts a local Next.js server on a random port, then opens a `BrowserWindow` pointed at `http://localhost:`. +- **Alternative:** Use `output: 'export'` and load `out/index.html` via `file://` protocol; deploy API proxy separately (same as Capacitor approach). +- **Preload script:** Expose `ipcRenderer` for native menu actions (e.g., File → New Allocation, Edit → Reset Budget). + +### Limitations + +- **Not suitable for iOS/mobile** — Electron targets macOS, Windows, Linux only. +- **Large binary size** (~150-200MB due to bundled Chromium). +- **No App Store for iOS** — only Mac App Store submission is possible. + +--- + +## 8. Development Workflow + +### Recommended Scripts (Capacitor Path) + +Add to `package.json`: + +```json +{ + "scripts": { + "dev": "next dev", + "build": "next build", + "build:mobile": "next build && npx cap sync ios", + "ios": "npx cap open ios", + "ios:run": "npx cap run ios", + "start": "next start", + "lint": "next lint" + } +} +``` + +### Development Cycle + +1. **Web development:** `npm run dev` — standard Next.js dev server +2. **Mobile preview:** `npm run build:mobile && npm run ios:run` — builds static, syncs, and runs on iOS simulator +3. **Live reload on device (dev):** Set `server.url` in `capacitor.config.ts` to point to the dev machine's IP; run `npm run dev` and `npx cap run ios` +4. **Production build:** `npm run build:mobile` → open Xcode → Archive → Upload to App Store Connect + +### Testing Strategy + +| Layer | Tool | +|-------|------| +| Unit tests (store logic) | Vitest or Jest | +| Component tests | React Testing Library | +| E2E (web) | Playwright | +| E2E (mobile) | Detox (React Native) or Appium (Capacitor) | +| Manual | iOS Simulator via Xcode, TestFlight for real devices | + +--- + +## 9. Risk Assessment + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Apple rejects WKWebView app for "not enough native functionality" | Medium | Add native plugins (Share, Haptics, Splash); ensure responsive design meets HIG; add native settings if needed | +| Yahoo Finance API changes or blocks mobile User-Agent | Medium | Proxy all requests through the API service; abstract the data source behind an interface for easy provider swap | +| `localStorage` data loss on iOS | Low | Migrate to Capacitor `Preferences` plugin for reliable native key-value storage | +| Performance: large position lists in WebView | Low | The app is lightweight; typical usage is < 50 positions. Virtualize with `react-window` if needed | +| Offline support | Low (future) | Current app requires network for price quotes. Add a "last fetched" timestamp and display cached data when offline | + +--- + +## 10. Future Enhancements (Post-Launch) + +| Enhancement | Description | +|-------------|-------------| +| **Push notifications** | Alert when a position hits a target price (requires a backend scheduler) | +| **Widgets** | iOS home screen widget showing portfolio summary (requires native Swift widget extension) | +| **Biometric lock** | Face ID / Touch ID via `@capacitor/biometrics` to protect portfolio data | +| **Watchlist** | Separate view for tracking tickers without allocating budget | +| **Multiple portfolios** | Tab-based or drawer navigation for managing multiple allocation scenarios | +| **Android** | Capacitor supports Android with the same web assets; add `npx cap add android` | +| **Dark/Light theme** | Respect iOS system appearance via `prefers-color-scheme` media query | +| **Offline mode** | Cache last-known prices; show staleness indicator | + +--- + +## 11. Summary Decision Matrix + +| Criteria | Capacitor | React Native | Flutter | Electron | +|----------|-----------|-------------|---------|----------| +| **Code reuse** | ~95% | ~45% | ~0% | ~98% | +| **iOS support** | Yes | Yes | Yes | No | +| **Android support** | Yes | Yes | Yes | No | +| **Desktop support** | No | Limited | Yes (desktop target) | Yes | +| **Native UX quality** | Good (WebView) | Very good | Excellent | N/A (desktop) | +| **Time to first build** | Days | Weeks | Weeks | Days | +| **Maintenance burden** | Low (single codebase) | Medium (shared logic, separate UI) | High (separate codebase) | Low (single codebase) | +| **App Store risk** | Medium (WKWebView policy) | Low | Low | N/A (no iOS) | +| **Team skill required** | Existing (React/TS) | Existing (React/TS) + RN specifics | New (Dart) | Existing (React/TS) | + +**Recommended path: Capacitor for iOS** — maximizes code reuse, minimizes time-to-ship, and keeps the team in the React/TypeScript ecosystem. Evaluate React Native if the WebView approach proves insufficient after prototyping.