Skip to content

Stack Decisions — 2026-04-17

Status: Archived

Background research that produced the choices locked into CLAUDE.md.

Table of Contents

  1. Styling framework
  2. Font
  3. Color palette
  4. Icons
  5. Bottom sheet
  6. Date/time
  7. Phone
  8. Address
  9. Authentication
  10. KakaoTalk sharing
  11. Payments
  12. Notifications
  13. Analytics
  14. 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-native userEvent doesn't fire correct events — use fireEvent.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/pretendard on web, .otf via expo-font on 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 lists
  • a 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:

  1. First launch / signup: 4 toggles (필수 / 선택 / 마케팅 / 3rd-party), versioned records
  2. Privacy policy + ToS always accessible from settings
  3. Retention: 1-year inactivity → auto-delete or notify (Supabase cron)
  4. Data subject requests: export + withdrawal flows
  5. Minors <14: guardian consent required (gate with birth-year check)
  6. Marketing consent: opt-in only, revocable, annual re-confirmation
  7. Breach reporting: 72 hours to KISA
  8. 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

RejectedWhy
NativeWind v4Token contract weakly enforced; no compile-time web CSS
Unistyles 3Excellent on native, web support experimental
React Native PaperMaterial Design fights Korean aesthetic
Gluestack UIAdds component layer on top of NativeWind — no structural win
StripeNo Korean acquiring
Twilio SMS2-3x cost vs Solapi in Korea
Apple SD Gothic NeoiOS-only, breaks cross-platform parity
Noto Sans KR (full)16MB — use Pretendard subset instead
IoniconsInconsistent stroke weights (our v1 choice — migrating away)

Markdown remains the source of truth. Run yarn docs:check before handoff.