Home Tab Screen — Implementation Spec v4
Date: 2026-04-13 Status: Implemented File:
app/(tabs)/index.tsxReplaces: v3 spec (pre-living-card, pre-completed-redesign)
Design Philosophy
"The website sells the promise; the mobile app delivers the habit."
The home screen is optimized for returning users, not first-time visitors. Input is reachable by thumb, the daily hook (Card of the Day) is always visible, and the reader selector lets users switch personas without navigating away.
Color Palette (Light Mode)
| Token | Value | Usage |
|---|---|---|
| screenBg | #FFFAF3 | Background gradient start |
| screenGradient | ['#FFFAF3', '#FFF8EE', '#FFF7E8'] | Full bg gradient |
| textPrimary | #4F3E31 | Headings, active reader name |
| textSecondary | #7A6548 | Subtexts, meta |
| textMuted | #A28E7A | Inactive labels, eyebrow labels |
| placeholder | #9E8B78 | Input placeholder |
| cardBg | #FFFFFF | Input bar, cards |
| pillBg | #F3EDE4 | Category pills (inactive) |
| tabBarBg | rgba(255,250,243,0.92) | Tab bar background |
| tabInactive | #9E8E7E | Inactive tab icons |
| headerBg | #FFFAF3 | Sticky header |
| Brand.gold | #D4A843 | Active ring, CTA gradient, accents |
Section Layout (top to bottom)
1. Sticky Top Bar
Absolute-positioned, pinned to top with blur + gradient fade.
- Background:
BlurView(intensity 60, tintsystemChromeMaterialLight) +LinearGradient(rgba(255,250,243,0.9)to transparent). Opacity animated on scroll — fades in after 12px scroll threshold. - Padding:
paddingTop: insets.top(safe area) - Height: 44px content area
- Left: MysticX logo (
logo-and-text.png), 100x24,opacity: 0.45 - Right (left to right):
- Dev reset button (dev only,
EXPO_PUBLIC_APP_ENV === 'development'): 30x30 circle,rgba(0,0,0,0.06)bg, refresh icon 16px#8B7355. ResetscardOfDayStatusto{ completed: false }for testing. CardOfDayBadge(gold pill, hidden when completed)CreditBadge(balance display)
- Dev reset button (dev only,
- Animation:
FadeIn.duration(300)
Background Atmosphere
Two background layers sit behind all content:
- Screen gradient:
LinearGradient['#FFFAF3', '#FFF8EE', '#FFF7E8']diagonal fill - Warm top wash:
LinearGradient400px tall, pinned to top,rgba(240,216,166,0.25)→rgba(240,216,166,0.08)→transparentat locations[0, 0.35, 0.7]. Creates a warm golden tint at the top of the screen.
2. HomeGreeting
Centered, time-aware, personalized greeting.
- Padding:
paddingTop: 32,paddingBottom: 32,paddingHorizontal: 20 - Greeting text:
Fonts.serif, 22px,lineHeight: 30,fontWeight: '600',#4F3E31,letterSpacing: -0.3 - Subtext: 14px,
lineHeight: 20,#7A6548 - Animation:
FadeInDown.duration(500).delay(100) - Alignment: center
Time buckets (device local time via new Date().getHours()):
- morning (0-11): "Good morning" / "What needs clarity today?"
- afternoon (12-17): "Good afternoon" / "What needs clarity today?"
- evening (18-21): "Good evening" / "What's on your mind tonight?"
- night (22-23): "Good evening" / "What's on your mind tonight?"
Greetings use complete localized strings per locale — never concatenate "{greeting}, {name}" because name placement varies (e.g., Japanese: ${name}さん、おはようございます). Two translation sets: with-name (logged in) and without-name (guest). All 12 locales required.
3. ReaderSelector — Quick Reader Switching
NEW in v3. Horizontal avatar row for switching the active tarot reader without leaving the home screen.
- Layout:
EyebrowLabel"YOUR READER" + horizontalScrollView - Margin top: 4 (from greeting)
- Gap: Section: 10, Avatars: 16
- Avatar size: 48x48 circle
- Active ring: 3px
Brand.goldborder - Inactive ring: 3px
rgba(180,165,145,0.35)border - Active name:
#4F3E31,fontWeight: '600' - Inactive name:
#A28E7A - Name font: 11px,
lineHeight: 14, center-aligned,numberOfLines: 1 - "+" button: 48x48 dashed circle (
borderStyle: 'dashed',#D4CBB8),addicon - "+" label: "More" (i18n'd, 12 locales)
- Tap action: Calls
activateTarotReader(reader.id)with optimistic UI - "+" tap:
router.push('/market/readers') - Animation:
FadeInUp.delay(150).duration(400) - Fallback avatar:
#E8DFD3circle +personicon - Data source:
fetchTarotReaders(locale), filtered tor.owned === true
Optimistic update: On tap, immediately set isActive on the selected reader in local state. On API error, revert to previous active reader.
EyebrowLabel (from decorative.tsx): 11px uppercase, letterSpacing: 1.5, color #A28E7A.
4. CompactInput — Question Input + Voice + Submit
ChatGPT-style multiline input bar with mic button and submit arrow.
- Margin top: 16
- Border:
borderRadius: 24, 1pxborderSubtle,cardBgbackground - Padding: 18px horizontal, 17px top, 12px bottom
- Text: 16px,
lineHeight: 22,#4F3E31, placeholder#9E8B78 - Input height:
minHeight: 44,maxHeight: 96 - Bottom row:
justifyContent: 'space-between'— mic (left), submit (right) - Shadow: iOS:
shadowOpacity: 0.06,shadowRadius: 8; Android:elevation: 2 - Max length: 1000 characters
- Animation:
FadeInUp.delay(250).duration(500)
Mic button (voice input via expo-speech-recognition):
- Size: 42x42 circle
- Icon:
mic-none(idle) /mic(listening) - Color:
#A28E7A(idle) /Brand.gold(listening) - Background: transparent (idle) /
rgba(212,168,67,0.15)(listening) - Interaction: Long-press:
onPressInstarts recognition,onPressOutstops - Permission: First press checks via
getPermissionsAsync(). If not granted, requests permission and returns early — user presses again to start. Wrapped in try/catch for Expo Go / unsupported devices. - Locale: Maps app locale to BCP-47 via
SPEECH_LOCALE_MAP(e.g.,zh_CNtozh-CN,entoen-US) - Config:
interimResults: true,continuous: false,addsPunctuation: true - Result: Transcript replaces input text via
onChangeQuestion
Submit button: 42x42 circle, #E5B86A background, arrow-upward icon in #5A4126. Triggers reading flow: reset() then setContextQuestion(text) then router.push('/reading/select-cards').
Placeholder rotation: Cycles through sampleQuestions every 5 seconds. Falls back to static "What guidance do you seek today?" (i18n'd).
5. CategoryTabs — Intent Filter Pills
Horizontal scrollable pills. 6 categories.
- Categories: Love, Career, Healing, Decisions, Study, Wealth
- Active pill:
LinearGradient['#F0D8A6', '#F0C67C'], no border - Inactive:
pillBgbackground, 1pxborderSubtleborder - Icon:
MaterialCommunityIcons, 15px - Label: 13px,
lineHeight: 18,fontWeight: '600' - Gap: 8
- Padding:
paddingLeft: 20,paddingRight: 28 - Margin top: 14
- Animation:
FadeInUp.delay(300).duration(400) - Tap action: Fetches new sample questions for selected category
Each category maps to a categoryId API parameter: love-connection, career-abundance, healing-growth, decisions-manifestation, study-growth, wealth-prosperity.
6. QuickQuestions — 2x2 Grid
- Layout:
flexWrap: 'wrap', 2 columns, gap 10 - Card width:
(SCREEN_WIDTH - 40 - 10) / 2 - Card height: 68px
- Card style:
#F8F4EFbg, 1px#E8E1D8border,borderRadius: 16 - Text: 13px,
lineHeight: 18,fontWeight: '500',#5C4E40,numberOfLines: 2 - Count: 4 questions (sliced from full array)
- Tap action: Fills input + scrolls to top
- Margin top: 12
- Animation:
FadeInUp.delay(300).duration(400)(same as category tabs)
7. CardOfDaySection — Daily Habit CTA
Two states. Container: contentPad (20px horizontal).
State A: Not drawn yet — "Living Card"
Centered layout with decorative ornament, animated card back, and subtitle.
- Container:
codLivingWrap— centered,paddingTop: 16,paddingBottom: 20 - Ornament row:
── ✦ ──hairline dividers with gold star. Lines: 32px wide,hairlineWidth,rgba(212,168,67,0.4). Star: 10px,rgba(212,168,67,0.5). - Title: "Card of the Day",
Fonts.serif, 19px,lineHeight: 26,fontWeight: '600',#4F3E31,letterSpacing: 0.3 - Card:
LivingCardcomponent (see below), wrapped inRitualPressable— only the card is tappable - Subtitle: "Awaken your daily guidance",
Fonts.serifitalic, 13px,#A28E7A - Credits text:
✦ +{credits} credits, 11px,rgba(212,168,67,0.7) - Tap:
router.push('/card-of-day')
LivingCard — 3D orbit animation creating a "living, breathing" card back:
- Card size:
LIVING_CARD_WIDTH = 110, height = 110 / 0.6 = ~183px - 3D orbit tilt:
perspective: 800,rotateXandrotateYanimate atORBIT_DURATION: 4800msbut 90° phase-offset, creating a circular orbit like a finger tracing the card edgeORBIT_TILT: 4°max tilt anglerotateX: sequence0 → +4° → -4° → 0(1/4 + 1/2 + 1/4 cycle)rotateY: sequence+4° → -4°(reverse, starts ahead), creating the circular path- Easing:
Easing.inOut(Easing.ease)for smooth motion
- Float:
translateY±3pt over 6s cycle (3s up, 3s down) - Breathe:
scale1.0 ↔ 1.012 over 5.2s cycle (2.6s expand, 2.6s contract) - Glow: iOS native shadow on wrapper —
shadowColor: '#D4A843',shadowOffset: {0,0},shadowOpacity: 0.35,shadowRadius: 30 - Component: Uses
<TarotCard width={110} />(shows card back by default)
State B: Already drawn — Card Front + Reading Info
Horizontal row layout showing the actual card art with reading summary.
- Container:
codCompletedWrap—flexDirection: 'row',gap: 16,paddingVertical: 12 - Card:
<TarotCard cardIndex={entry.cardIndex} isReversed={entry.isReversed} showFront width={90} />— shows the actual drawn card art, front-facing- Shadow:
shadowColor: '#D4A843',shadowOffset: {0,4},shadowOpacity: 0.2,shadowRadius: 12
- Shadow:
- Info column (right side,
flex: 1,gap: 4):- Header row: "CARD OF THE DAY" eyebrow label (
Brand.gold, 11px,letterSpacing: 1.5, uppercase,fontWeight: '700') +check-circleicon (14px,Brand.emerald) - Card name:
Fonts.serif, 20px,lineHeight: 26,fontWeight: '600',#4F3E31 - Keywords: Joined with
·, 13px,#8B7355,numberOfLines: 2 - Emotional weather:
Fonts.serifitalic, 12px,#A28E7A,numberOfLines: 3,marginTop: 2. Conditional — only shown ifentry.emotionalWeatherexists.
- Header row: "CARD OF THE DAY" eyebrow label (
- Tap: Entire row is
RitualPressable→router.push('/card-of-day')
Data source: TCardOfDayEntry includes cardIndex, cardName, isReversed, keywords: string[], emotionalWeather: string, actions: string[], questions: string[].
Animation: FadeInUp.delay(400).duration(400), Margin top: 18
8. RecentReading — Continue Last Session
Conditional — only shown if user has at least 1 reading.
- Container:
GlassCard,contentPad(20px horizontal) - Left accent: 3px vertical bar,
Brand.gold,opacity: 0.5 - Header: Reader avatar (20x20 circle) +
EyebrowLabel"Continue Reading" - Spread name: 15px,
lineHeight: 20,fontWeight: '600',#4F3E31 - Meta: "{reader name} . {time ago}", 13px,
#7A6548 - Question: 13px,
#7A6548,numberOfLines: 2 - Right:
chevron-right, 20px,#B8A99A - Tap:
router.push('/reading/${reading.id}')(detail screen) - Margin top: 16
- Animation: None (below fold)
Time ago format: {n}m ago, {n}h ago, {n}d ago — i18n'd for all 12 locales.
Data: fetchReadings(1, 1, locale) takes first item from readings[]. Types: TReadingListItem with id, question, createdAt, spread.name, reader.name, reader.avatarUrl.
9. ForYouCard — Curated Recommendation
Always shown. Hard-coded "Three Card Spread" recommendation (personalization logic TBD).
- Container:
GradientBorderCardfromdecorative.tsx,contentPad - Header:
EyebrowLabel"For You" - Title:
Fonts.serif, 17px,#4F3E31 - Description: 13px,
#7A6548 - Visual: 3 mini cards (18x26,
borderRadius: 4, gold-tinted) + "Past . Present . Future" label - CTA: "Explore Spreads", gold gradient button
- Tap:
reset()thenrouter.push('/reading/spreads') - Margin top: 16
- Animation: None (below fold)
Component Tree
HomeScreen (default export)
+-- LinearGradient (background: screenGradient)
+-- LinearGradient (topWash: warm gold tint, 400px)
+-- Sticky Top Bar (absolute, z-index 10)
| +-- BlurView + LinearGradient fade (scroll-driven opacity)
| +-- Logo (logo-and-text.png)
| +-- [IS_DEV] DevResetBtn (refresh icon, resets CoD state)
| +-- CardOfDayBadge
| +-- CreditBadge
+-- ScrollView
+-- [loading] -> HomeSkeleton
+-- [loaded] ->
+-- HomeGreeting (time-aware, personalized)
+-- ReaderSelector (avatar row, tap-to-switch)
+-- CompactInput (text + mic + submit)
+-- CategoryTabs (6 horizontal pills)
+-- QuickQuestions (2x2 grid)
+-- GoldRadialGlow (decorative PNG donut, shifted 200px down)
+-- CardOfDaySection
| +-- [not drawn] LivingCard (3D orbit + breathing + floating)
| +-- [drawn] TarotCard (front-facing) + reading info
+-- RecentReading (conditional, single card)
+-- ForYouCard (curated recommendation)Animation Choreography
Max 5 animated groups above the fold. Below-fold sections render without entry animation.
| Group | Component(s) | Animation | Delay | Duration |
|---|---|---|---|---|
| -- | Top Bar | FadeIn | 0ms | 300ms |
| 1 | Greeting | FadeInDown | 100ms | 500ms |
| 2 | Reader Selector | FadeInUp | 150ms | 400ms |
| 3 | Compact Input | FadeInUp | 250ms | 500ms |
| 4 | Category Tabs + Questions | FadeInUp | 300ms | 400ms |
| 5 | Card of Day | FadeInUp | 400ms | 400ms |
| -- | Recent Reading | None | -- | -- |
| -- | For You Card | None | -- | -- |
Total cascade: ~0.8s.
Data Fetching
All fetched in parallel via useFocusEffect + Promise.all. Each request has .catch() fallback so one failure doesn't block others. No backend changes required.
| Function | Endpoint | Returns |
|---|---|---|
fetchCredits() | GET /api/v1/app/credits | TCreditsInfo |
fetchCardOfDayStatus(locale) | GET /api/v1/app/card-of-day?locale={locale} | TCardOfDayStatus |
fetchSampleQuestions(40, locale, categoryId) | GET /api/v1/app/questions/sample?count=40&... | { data: string[] } |
fetchTarotReaders(locale) | GET /api/v1/app/readers?locale={locale} | TTarotReader[] |
fetchReadings(1, 1, locale) | GET /api/v1/app/readings?page=1&limit=1&... | { readings: TReadingListItem[] } |
activateTarotReader(id) (on tap) | POST /api/v1/app/readers/{id}/activate | { success: boolean } |
Readers filtering: API returns all readers; client filters to r.owned === true to show only owned readers in the selector.
State Variables
| Variable | Type | Initial | Notes |
|---|---|---|---|
loading | boolean | true | Controls skeleton vs content |
question | string | '' | User input text |
credits | TCreditsInfo or null | null | Balance + tier info |
cardOfDayStatus | TCardOfDayStatus or null | null | Includes entry when drawn |
sampleQuestions | string[] | [] | Fetched per category |
activeCategory | string | 'love' | Currently selected category tab |
readers | TTarotReader[] | [] | Owned readers only |
recentReading | TReadingListItem or null | null | Most recent reading |
placeholderIndex | number | 0 | Rotating placeholder |
userName | string or null | derived | From authClient.useSession() |
activeReader | TTarotReader or null | derived | useMemo — first isActive, else first owned |
Native Dependencies
| Package | Purpose | Requires Native Build |
|---|---|---|
expo-speech-recognition | Voice-to-text for mic button | Yes |
expo-blur | Sticky header translucency | Yes |
expo-image | Optimized image loading | Yes |
expo-linear-gradient | Background + CTA gradients | Yes |
react-native-reanimated | Entry animations + LivingCard 3D orbit | Yes |
react-native-svg | Radial gradients (installed, available) | Yes |
app.json plugin config for expo-speech-recognition:
[
"expo-speech-recognition",
{
"microphonePermission": "MysticX needs microphone access for voice input.",
"speechRecognitionPermission": "MysticX needs speech recognition to transcribe your voice."
}
]Speech Locale Map
Maps app locale codes to BCP-47 speech recognition locale codes:
| App Locale | Speech Locale |
|---|---|
en | en-US |
zh_CN | zh-CN |
zh_TW | zh-TW |
ja | ja-JP |
ko | ko-KR |
pt | pt-BR |
es | es-ES |
fr | fr-FR |
de | de-DE |
ar | ar-SA |
id | id-ID |
nl | nl-NL |
Acceptance Criteria
- [x] Question input visible above the fold on iPhone 14/15/17 Pro
- [x] Reader selector row with tap-to-switch + gold active ring
- [x] Voice input via long-press mic button (requires native build)
- [x] Card of the Day section partially visible without scrolling
- [x] Category tabs filter quick questions (2x2 grid)
- [x] Tapping a question fills input and scrolls to top
- [x] Card of Day "Draw" shows LivingCard with 3D orbit animation
- [x] Card of Day "Completed" shows actual card front with name, keywords, emotional weather
- [x] Card of Day navigates to
/card-of-dayin both states - [x] Recent reading card navigates to
/reading/[id](detail, not chat) - [x] All strings have 12 locale translations (complete per-locale, no concatenation)
- [x] Greetings use device local time with locale-safe name placement
- [x] Skeleton loading state during data fetch
- [x] Below-fold sections (Recent Reading, For You) render without entry animation
- [x] No card fan animation on home (moved to onboarding/select-cards)
- [x] Optimistic reader switching with revert on API error
- [x] Mic permission handled gracefully (first press = request, second press = start)
- [x] Max 5 animated section groups on screen entry, ~0.8s total cascade
- [x] Warm gold top wash gradient for atmospheric depth
- [x] GoldRadialGlow PNG donut shifted 200px down from default position
- [x] iOS native shadow glow on LivingCard (
shadowColor: '#D4A843') - [x] Dev reset button (EXPO_PUBLIC_APP_ENV=development only) in header
Environment Variables
| Variable | Example | Purpose |
|---|---|---|
EXPO_PUBLIC_API_URL | http://localhost:3031 | Backend API base URL |
EXPO_PUBLIC_APP_ENV | development | Controls dev-only UI (reset buttons, debug info). Matches web app's NEXT_PUBLIC_APP_ENV pattern. |
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID | (from Google Cloud) | Google Sign In — web client |
EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID | (from Google Cloud) | Google Sign In — iOS client |
Dev mode (IS_DEV = process.env.EXPO_PUBLIC_APP_ENV === 'development'): Shows header reset button for Card of Day state. Should be set to production in release builds.