Stack Decisions — 2026-04-17
Status: Archived
Background research that produced the choices locked into CLAUDE.md.
Table of Contents
- Styling framework
- Font
- Color palette
- Icons
- Bottom sheet
- Date/time
- Phone
- Address
- Authentication
- KakaoTalk sharing
- Payments
- Notifications
- Analytics
- PIPA compliance
Styling framework
Chosen: Tamagui v1.144.3 stable
Considered: Plain StyleSheet, NativeWind v4, Unistyles 3, Tamagui v2 RC, React Native Paper, Gluestack UI.
Why Tamagui:
- Universal (native + web) from one component tree — Tamagui is the only mature option for full web equivalence in 2026
- Compile-time CSS extraction for web
- SSR-supported (critical for Next.js web app)
- Type-enforced design tokens via
createTokens()— violations are compile errors - Strong primitive library (Button, Card, Sheet, Dialog, Popover, Tooltip)
Why v1 stable, not v2 RC: v2 has been in RC since late 2025. Production use means RC churn. v1.144.3 (Jan 2026) is the last pre-v2 stable. Migrate to v2 after it ships GA (expected mid-2026).
Why not Unistyles 3: Stronger performance on native, but web support is experimental. No full-equivalent web story.
Why not NativeWind: Token contract is weakly enforced (arbitrary [#hex] bypasses it). No compile-time extraction for web. Our v1 experience: invented new spacing values every session because the system didn't stop us.
Known risks:
- Tamagui v1 has a history of breaking changes at minor versions — pin exact versions, upgrade carefully
- Testing story is weaker:
@testing-library/react-nativeuserEvent doesn't fire correct events — usefireEvent.press() - Learning curve on
styled()vs inline props
Sources:
Font
Chosen: Pretendard (self-hosted, OFL)
Considered: Pretendard, Noto Sans KR, Apple SD Gothic Neo, Spoqa Han Sans Neo.
Why Pretendard:
- De facto Korean fintech default (2025+)
- Designed on Inter + Source Han Sans + M PLUS 1p — clean Latin + Korean harmony
- 9 static weights + variable
- Self-hostable (
@fontsource/pretendardon web,.otfviaexpo-fonton native) - Tighter letter-spacing than Noto/Spoqa — cleaner at small sizes
Font weights bundled: Regular (400), Medium (500), SemiBold (600), Bold (700). Four covers 95% of UI.
Why not Noto Sans KR: Full family is 16MB. Pretendard variable subset is lighter.
Why not Apple SD Gothic Neo: iOS-only. Cross-platform consistency requires one font everywhere.
Color palette
Chosen: classic forest green + green-gray neutrals
Hex values ported from the v1 grass-court theme:
- Primary:
#006633(forest green) - Primary dark:
#004D26 - Primary light:
#339966 - Gray 50–900: hue-shifted ~15° toward green (same luminosity as Tailwind gray)
- Accent warm:
#FEF3C7 / #B45309(amber) - Accent cool:
#EDE9FE / #4B0082(deep violet) - Status: success = primary green, warning = amber, error = red, info = purple
Korean convention note: Red = loss/negative, blue = gain/positive in Korean finance apps. For tennis, we use neutral red for errors only.
Why green, not Toss Blue: Single brand hue + tennis context. Grounded, classic, and distinct from the obvious "Korean fintech = blue" pattern.
Icons
Chosen: @tamagui/lucide-icons
Considered: Lucide, Ionicons, SF Symbols, custom SVG.
Why Lucide:
- 1500+ icons, clean outline geometry matches Pretendard
- Official Tamagui package — no bridging required
- MIT license
Why not Ionicons (our v1 choice): Inconsistent stroke weights, dated aesthetic.
Bottom sheet
Chosen: @gorhom/bottom-sheet v5
Considered: @gorhom/bottom-sheet, Tamagui Sheet.
Why @gorhom: Toss-style rounded 20-24px top corners, grabber handle, snap points. Korean UX convention. Tamagui Sheet uses Animated.spring() (no Reanimated), @gorhom uses Reanimated v3 for smoother gestures.
Date/time
Chosen: date-fns + ko locale
Considered: date-fns, dayjs, luxon.
Why date-fns: Tree-shakeable (ship only formatters used), immutable, first-class TS. Korean locale mature.
Common formats:
yyyy년 M월 d일 (EEE)→2026년 4월 17일 (금)M/d(EEE)→4/17(금)for compact listsa h:mm→오후 7:00
Centralize in packages/app/src/utils/date-format.ts — no inline getMonth() / getDay() in components (ESLint-enforced).
Phone
Chosen: libphonenumber-js with AsYouType('KR')
Considered: libphonenumber-js, react-phone-number-input, inline regex.
Why libphonenumber-js: Validates 010-XXXX-XXXX reliably. Live formatting via AsYouType('KR'). ~145KB.
Why no country picker: Single-country app. Plain TextInput + AsYouType matches Toss UX.
Address
Chosen: @thewoowon/react-native-daum-postcode (native) + react-daum-postcode (web)
Why: Daum Postcode is the Korean standard. Returns { zonecode, address, addressType, bname, buildingName }. WebView-based, works in Expo managed.
Separate concern: REGION_LABELS for 17 시/도 coarse filtering (club discovery). Daum Postcode only when we need a real street address (home court venue entry).
Authentication
Target: @react-native-kakao/user (from mym0404's all-in-one SDK). Current ported auth code still uses @react-native-seoul/kakao-login until the auth rebuild slice migrates it.
Considered: @react-native-kakao/user (mym0404), @react-native-seoul/kakao-login, Supabase built-in OAuth.
Why mym0404's SDK: Unified versioning across login/share/maps, Expo config plugin support, New Architecture ready.
Why not Supabase built-in: No Kakao provider out of the box. Pattern: native Kakao SDK → id_token → Edge Function verifies Kakao signature → custom Supabase JWT. Auth adapter in packages/app/src/adapters/supabase/auth.adapter.ts owns this seam.
Apple Sign-In required on iOS (App Store rule). Google via expo-auth-session.
KakaoTalk sharing
Chosen: @react-native-kakao/share with @react-native-kakao/core
Why: React Native bridge to Kakao Share custom templates, Expo config plugin support through the core package, template message support with pre-approved Kakao Developers templateId, and useWebBrowserIfKakaoTalkNotAvailable fallback when KakaoTalk is not installed.
Critical use cases: RSVP invite, match result share, 번개 invite — these are growth loops.
Payments
Chosen: Portone v2 WebView
Considered: Portone, Toss Payments direct SDK, Stripe (rejected — no KR acquiring).
Why Portone: One integration covers Toss, Kakao, Naver, NicePay, KG Inicis. Less maintenance than per-PG SDKs. Small additional fee (~0.3%) is worth the dev velocity.
Notifications
Push: expo-notifications + FCM (Android) + APNs (iOS). Standard.
Alimtalk (알림톡): Server-side only via Solapi (coolsms) or NHN Cloud. Templates pre-approved by Kakao. RN app does nothing; Edge Function calls provider REST API. Cost ~₩8-15/message.
SMS OTP: Solapi (not Twilio — 2-3x more expensive in Korea).
Analytics
Chosen: Firebase Analytics from day 1, Airbridge when paid acquisition starts.
Why Firebase: Universal GA4 baseline. Free.
Why Airbridge later: Korean MMP with better Korean ad-network coverage (네이버 GFA, 카카오 모먼트, 몰로코).
PIPA gate: No analytics events fire before consent is granted and logged in signal_consent_log.
PIPA compliance
Korean privacy law. Required touchpoints:
- First launch / signup: 4 toggles (필수 / 선택 / 마케팅 / 3rd-party), versioned records
- Privacy policy + ToS always accessible from settings
- Retention: 1-year inactivity → auto-delete or notify (Supabase cron)
- Data subject requests: export + withdrawal flows
- Minors <14: guardian consent required (gate with birth-year check)
- Marketing consent: opt-in only, revocable, annual re-confirmation
- Breach reporting: 72 hours to KISA
- Access logs: keep 1 year (2 if >1M users)
Our existing signal_consent_log table + signal system covers most of this — port unchanged.
Rejected options summary
| Rejected | Why |
|---|---|
| NativeWind v4 | Token contract weakly enforced; no compile-time web CSS |
| Unistyles 3 | Excellent on native, web support experimental |
| React Native Paper | Material Design fights Korean aesthetic |
| Gluestack UI | Adds component layer on top of NativeWind — no structural win |
| Stripe | No Korean acquiring |
| Twilio SMS | 2-3x cost vs Solapi in Korea |
| Apple SD Gothic Neo | iOS-only, breaks cross-platform parity |
| Noto Sans KR (full) | 16MB — use Pretendard subset instead |
| Ionicons | Inconsistent stroke weights (our v1 choice — migrating away) |