Put these in constants/theme.ts as the single source of truth — never hard-code a hex value inside a component.
export const colors = {
primary: '#1F6F63', // deep teal — brand, primary buttons, active states
accent: '#E07A5F', // warm terracotta — CTAs, highlights, "practice today" card
background: '#FBF7F0', // soft cream — screen background
surface: '#FFFFFF', // cards, sheets
textPrimary: '#26313A', // charcoal
textSecondary: '#6B7580',// slate gray
success: '#7FA37A', // sage green — positive feedback, "what landed"
alert: '#D99A4E', // muted amber — escalation flags. Never use red; red reads as
// clinical-alarm in this context and works against the brand.
border: '#E7E0D4', // subtle card/divider borders
disabled: '#C9C2B4',
} as const;
Contrast check before finalizing: verify accent (#E07A5F) on background (#FBF7F0) meets 4.5:1 for body text; if a component needs accent-colored text at small sizes, darken the accent for that specific text use rather than changing the token globally.
Resolved (Sep 2026): canonical Tailwind/NativeWind class form. When wiring these into Tailwind’s @theme, use the camelCase key verbatim as the utility suffix — bg-textPrimary, text-textSecondary — not a kebab-case alias (text-text-primary). Do not define both forms. This isn’t a style preference: text-primary (the brand teal) and a kebab-cased text-text-primary (charcoal body text) are one keystroke apart and easy to confuse under time pressure; keeping textPrimary as one unbroken token avoids the collision entirely and matches the TS key above with zero transformation. If a kebab-case alias already exists in global.css, remove it and grep the codebase for any usage before it spreads further.
export const typography = {
display: { fontFamily: 'Sora_700Bold', fontSize: 28, lineHeight: 34 },
h1: { fontFamily: 'Sora_600SemiBold', fontSize: 24, lineHeight: 30 },
h2: { fontFamily: 'Sora_600SemiBold', fontSize: 20, lineHeight: 26 },
body: { fontFamily: 'Inter_400Regular', fontSize: 16, lineHeight: 24 },
bodyBold:{ fontFamily: 'Inter_600SemiBold', fontSize: 16, lineHeight: 24 },
caption: { fontFamily: 'Inter_400Regular', fontSize: 13, lineHeight: 18 },
} as const;
export const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48 } as const;
export const radius = { sm: 8, md: 12, lg: 16, pill: 999 } as const;
export const shadow = {
card: { shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 8, shadowOffset: { width: 0, height: 2 }, elevation: 2 },
} as const;
Fonts: use @expo-google-fonts/sora and @expo-google-fonts/inter (npx expo install @expo-google-fonts/sora @expo-google-fonts/inter expo-font), loaded once in the root layout before rendering any screen.
Do not hard-code font sizes inline anywhere else in the app. Every text style pulls from typography above so Dynamic Type / accessibility scaling stays consistent.
gluestack-ui v3+ is CLI-driven, not a traditional import-from-node_modules library:
npx gluestack-ui init # once, at project start
npx gluestack-ui add button
npx gluestack-ui add card
npx gluestack-ui add input
npx gluestack-ui add badge
npx gluestack-ui add spinner
npx gluestack-ui add avatar
Each add command copies the component’s actual source into components/ui/[component]/. This means the real prop signature for every component is sitting in your own repo after you add it — read that file before using the component, do not guess or assume props from memory. Theme these components using the tokens in §2–3, via NativeWind className props, not inline style overrides.
Component states required for every interactive element:
| Element | States to implement |
|—|—|
| Button | default, pressed (slightly darker/scaled), disabled (reduced opacity, disabled color), loading (spinner replaces label, button stays same size) |
| Card (scenario, history item) | default, pressed (subtle scale/opacity) |
| Input | default, focused (primary-colored border), error (alert-colored border + helper text) |
Each screen below lists: purpose, components used, and all three required states (loading / empty / error) where applicable, per the convention in 02-ARCHITECTURE-AND-DATA.md §7.
app/(onboarding)/role.tsx, unit.tsx, notifications.tsxradius.pill)app/(tabs)/home.tsxaccent background, one scenario suggested (simplest v1 logic: least-recently-practiced scenario)ScenarioCard components in a vertical list (name, one-line description, difficulty Badge, time estimate)shadow.card, no content) while scenario list initializes — this should be near-instant since scenario data is local, not fetched, but still render the state correctly if data is ever moved server-side laterconstants/scenarios.tsapp/scenario/[id]/prebrief.tsx04-SCENARIO-PROMPTS.md scenario metadata)useSubscriptionStatus) before navigating to roleplay; if exhausted, route to paywall.tsx insteadapp/scenario/[id]/roleplay.tsxreact-native-gifted-chat’s <GiftedChat /> componentTurn X/10) and a manual “End” action — added during implementation, not originally specified, formally adopted here rather than left unrecorded. The End action ends the session immediately (same persistence-then-navigate path as reaching resolution or the 10-turn cap) and does not require confirmation.02-ARCHITECTURE-AND-DATA.md §11.1) once with transcript_so_far: [] to fetch the persona’s opening line — every scenario’s persona is written to speak first. Render this as the first message in the thread once it returns. Show the typing indicator (below) during this initial call exactly as for any other turn.persona turn, nothing else. The [[BEGIN_SCENE]] token used internally to fetch it (§11.0) never existed on the client and must never appear in the locally-held transcript, in what gets sent back on the next turn, or in what’s persisted to scenario_sessions.transcript — the client’s transcript starts life as a single-element array containing only the returned opening line.primary teal bubble), persona messages left-aligned (neutral gray bubble)user prop on each messageisTyping prop, shown while awaiting the Edge Function response02-ARCHITECTURE-AND-DATA.md §11.1 for the exact contractapp/scenario/[id]/feedback.tsxsuccess accent) → “Try This Instead” card (accent terracotta) → confidence score (1-5, simple dot/star row, not a percentage)02-ARCHITECTURE-AND-DATA.md §11.2’s error contract — schema-validation and upstream failures both 502, anything else is a request problem, not a transient one, and should not be retried). If the retry also 502s, show a full-screen error state with a manual “Try again” button. Never render a partially-parsed feedback object.generate_feedback, check for an existing feedback_reports row for that session_id and render it if present rather than regenerating — avoids a duplicate LLM call and a duplicate row for a session the user already got feedback on.app/(tabs)/history.tsxConfidenceTrend sparkline at top (last up-to-10 sessions)/scenario/[id]/feedback?sessionId=<that session's id> to view its already-generated feedback. This was never specified but is the obvious minimum for a history screen to be useful — reuses step 8’s existing getFeedbackReport caching path rather than a separate mechanism, so it never re-triggers an LLM call for a session that already has feedback.scenario_sessions rows are created on scenario mount (before any conversation happens), so a user opening a scenario and backing out before finishing leaves a row with ended_at: null and an empty transcript. This is not a practice session and should never be tappable or counted: getUserSessionsWithFeedback() must filter to ended_at is not null (equivalently, a non-empty transcript). Do not build a separate “incomplete” UI state or route these anywhere — they simply don’t appear in the list, the same way they were never real activity to report on.app/paywall.tsxRevenueCatUI.presentPaywallIfNeeded({ requiredEntitlementIdentifier: 'pro' }) — this renders RevenueCat’s own Paywalls v2 UI, configured visually in the RevenueCat dashboard using the palette in §2onPurchaseCompleted, onDismiss)Button + Card, styled with tokens from §2-3, not part of the RevenueCat component itselfapp/(tabs)/settings.tsxPurchases.restorePurchases())Keep this simple for v1 — do not add react-native-reanimated custom animations unless every P0 feature (per PRD §6) is already done and there’s real time left. Use Expo Router’s default screen transitions as-is. The one exception: the typing indicator on the Roleplay screen and the skeleton-loading shimmer on Home/History, both of which are standard, low-effort additions that meaningfully improve perceived quality.
typography tokens