Skip to content

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)

TokenValueUsage
screenBg#FFFAF3Background gradient start
screenGradient['#FFFAF3', '#FFF8EE', '#FFF7E8']Full bg gradient
textPrimary#4F3E31Headings, active reader name
textSecondary#7A6548Subtexts, meta
textMuted#A28E7AInactive labels, eyebrow labels
placeholder#9E8B78Input placeholder
cardBg#FFFFFFInput bar, cards
pillBg#F3EDE4Category pills (inactive)
tabBarBgrgba(255,250,243,0.92)Tab bar background
tabInactive#9E8E7EInactive tab icons
headerBg#FFFAF3Sticky header
Brand.gold#D4A843Active 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, tint systemChromeMaterialLight) + 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. Resets cardOfDayStatus to { completed: false } for testing.
    • CardOfDayBadge (gold pill, hidden when completed)
    • CreditBadge (balance display)
  • Animation: FadeIn.duration(300)

Background Atmosphere

Two background layers sit behind all content:

  1. Screen gradient: LinearGradient ['#FFFAF3', '#FFF8EE', '#FFF7E8'] diagonal fill
  2. Warm top wash: LinearGradient 400px tall, pinned to top, rgba(240,216,166,0.25)rgba(240,216,166,0.08)transparent at 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" + horizontal ScrollView
  • Margin top: 4 (from greeting)
  • Gap: Section: 10, Avatars: 16
  • Avatar size: 48x48 circle
  • Active ring: 3px Brand.gold border
  • 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), add icon
  • "+" 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: #E8DFD3 circle + person icon
  • Data source: fetchTarotReaders(locale), filtered to r.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, 1px borderSubtle, cardBg background
  • 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: onPressIn starts recognition, onPressOut stops
  • 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_CN to zh-CN, en to en-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: pillBg background, 1px borderSubtle border
  • 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: #F8F4EF bg, 1px #E8E1D8 border, 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: LivingCard component (see below), wrapped in RitualPressable — only the card is tappable
  • Subtitle: "Awaken your daily guidance", Fonts.serif italic, 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, rotateX and rotateY animate at ORBIT_DURATION: 4800ms but 90° phase-offset, creating a circular orbit like a finger tracing the card edge
    • ORBIT_TILT: 4° max tilt angle
    • rotateX: sequence 0 → +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: scale 1.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: codCompletedWrapflexDirection: '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
  • 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-circle icon (14px, Brand.emerald)
    • Card name: Fonts.serif, 20px, lineHeight: 26, fontWeight: '600', #4F3E31
    • Keywords: Joined with ·, 13px, #8B7355, numberOfLines: 2
    • Emotional weather: Fonts.serif italic, 12px, #A28E7A, numberOfLines: 3, marginTop: 2. Conditional — only shown if entry.emotionalWeather exists.
  • Tap: Entire row is RitualPressablerouter.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: GradientBorderCard from decorative.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() then router.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.

GroupComponent(s)AnimationDelayDuration
--Top BarFadeIn0ms300ms
1GreetingFadeInDown100ms500ms
2Reader SelectorFadeInUp150ms400ms
3Compact InputFadeInUp250ms500ms
4Category Tabs + QuestionsFadeInUp300ms400ms
5Card of DayFadeInUp400ms400ms
--Recent ReadingNone----
--For You CardNone----

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.

FunctionEndpointReturns
fetchCredits()GET /api/v1/app/creditsTCreditsInfo
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

VariableTypeInitialNotes
loadingbooleantrueControls skeleton vs content
questionstring''User input text
creditsTCreditsInfo or nullnullBalance + tier info
cardOfDayStatusTCardOfDayStatus or nullnullIncludes entry when drawn
sampleQuestionsstring[][]Fetched per category
activeCategorystring'love'Currently selected category tab
readersTTarotReader[][]Owned readers only
recentReadingTReadingListItem or nullnullMost recent reading
placeholderIndexnumber0Rotating placeholder
userNamestring or nullderivedFrom authClient.useSession()
activeReaderTTarotReader or nullderiveduseMemo — first isActive, else first owned

Native Dependencies

PackagePurposeRequires Native Build
expo-speech-recognitionVoice-to-text for mic buttonYes
expo-blurSticky header translucencyYes
expo-imageOptimized image loadingYes
expo-linear-gradientBackground + CTA gradientsYes
react-native-reanimatedEntry animations + LivingCard 3D orbitYes
react-native-svgRadial gradients (installed, available)Yes

app.json plugin config for expo-speech-recognition:

json
[
  "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 LocaleSpeech Locale
enen-US
zh_CNzh-CN
zh_TWzh-TW
jaja-JP
koko-KR
ptpt-BR
eses-ES
frfr-FR
dede-DE
arar-SA
idid-ID
nlnl-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-day in 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

VariableExamplePurpose
EXPO_PUBLIC_API_URLhttp://localhost:3031Backend API base URL
EXPO_PUBLIC_APP_ENVdevelopmentControls 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.

Internal documentation for MysticX team