diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml
new file mode 100644
index 0000000..985d7eb
--- /dev/null
+++ b/.github/workflows/mobile-ci.yml
@@ -0,0 +1,36 @@
+name: Mobile CI
+
+on:
+ pull_request:
+ paths:
+ - 'mobile/**'
+ - '.github/workflows/mobile-ci.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'mobile/**'
+ - '.github/workflows/mobile-ci.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ mobile:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ defaults:
+ run:
+ working-directory: mobile
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22.13'
+ - name: Install dependencies
+ run: npm install --no-audit --no-fund
+ - name: Expo dependency and config health
+ run: npm run doctor
+ - name: Unit tests
+ run: npm run test:ci
+ - name: Export bundle smoke test
+ run: npx expo export --platform web --output-dir dist-ci
diff --git a/.gitignore b/.gitignore
index 1318423..e2cb691 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,10 @@ node_modules/
dist/
npm-debug.log*
+# Expo local state
+.expo/
+mobile/.expo/
+
# Logs
logs
*.log
diff --git a/docs/MOBILE_APP.md b/docs/MOBILE_APP.md
new file mode 100644
index 0000000..22d8726
--- /dev/null
+++ b/docs/MOBILE_APP.md
@@ -0,0 +1,100 @@
+# BragStack Mobile App
+
+BragStack Mobile is the native iOS and Android client for the BragStack career-evidence platform. It is under active pre-release development and is tracked by issue #200.
+
+## Product goal
+
+Mobile should make it easier to capture meaningful work while the details are still fresh without creating a separate account system, separate career history, or weaker privacy model.
+
+The mobile client reuses the existing FastAPI backend, authentication model, Impact Receipt semantics, profile model, and private-by-default trust principles.
+
+## Current foundation
+
+- React Native / Expo cross-platform application under `mobile/`
+- iOS bundle identifier and Android package: `com.bragstack.app`
+- canonical BragStack vector brandmark from `frontend/public/brandmark.svg`
+- authenticated-app palette from the BragStack Brand Guide
+- Home, Proof, Add, Profile, and Settings navigation
+- real password sign-in against `POST /auth/login`
+- verified-email enforcement inherited from the backend
+- encrypted access-token storage with Expo SecureStore
+- session restore through `GET /auth/me`
+- sign out that clears the local mobile token
+- shared authenticated Axios client
+- interactive private-by-default Impact Receipt preview
+- EAS preview and production build profiles
+- no unnecessary native permissions in the foundation
+
+## Authentication model
+
+Mobile does not maintain a separate identity system.
+
+1. The user signs in using the existing BragStack account.
+2. `/auth/login` returns the normal BragStack bearer token and serialized user.
+3. The token is stored through Expo SecureStore rather than plaintext application storage.
+4. On app launch, the client attempts `/auth/me` to restore the session.
+5. Invalid or expired sessions are cleared and the user returns to sign-in.
+6. Sign out removes the stored token.
+
+Registration, email verification, password reset, recovery deep links, and account-deletion UX must be completed and tested before store release.
+
+## Brand system
+
+The mobile product follows the authenticated BragStack app context rather than using the marketing palette as its primary UI.
+
+- app background: `#090909`
+- primary text: `#F7F4EE`
+- secondary text: `#AAA39A`
+- primary action / accent: `#FFB184`
+- canonical logo/brandmark retains the approved blue-purple-cyan gradient identity
+
+The mobile app must not substitute a generic lettermark or create a separate mobile-only visual identity.
+
+## Data and privacy model
+
+Mobile should remain another client for the same BragStack record.
+
+- private workplace evidence stays private by default
+- sharing is intentional and separate from capture
+- missing results, metrics, evidence, or confirmation are not invented
+- evidence permissions are requested only when a user initiates a feature that needs them
+- auth tokens and sensitive proof must not be written to logs, URLs, analytics payloads, or crash breadcrumbs
+- public profile behavior must preserve the same publication boundaries as web
+
+## Store-readiness gate
+
+Before the PR or later release work can be called store-ready, BragStack must complete:
+
+- live accomplishment and Impact Receipt reads/writes
+- registration, verification, reset, recovery, and deletion flows
+- loading, empty, retry, offline, and expired-session states
+- accessibility and dynamic-text validation
+- device/OS compatibility testing
+- production API configuration
+- app icon, splash, screenshots, and store metadata
+- Apple privacy disclosures and required-reason review
+- Google Play Data safety disclosure
+- Terms, Privacy, support, and account-deletion links
+- signing and release credentials
+- TestFlight and Play internal testing
+- mobile CI, dependency/security validation, and production build verification
+- security review of token lifecycle, deep links, logs, evidence handling, and third-party SDKs
+
+## Release truth
+
+The mobile app is **pre-release** until production listings are actually live. Documentation, marketing, support, and investor materials should distinguish between:
+
+- implemented mobile foundation
+- functionality still under development
+- internal/beta availability
+- public App Store / Google Play availability
+
+Do not describe the app as publicly downloadable before the applicable production listing is live.
+
+## Related documentation
+
+- `mobile/README.md` — local development and current implementation status
+- `docs/ROADMAP.md` — phased mobile delivery plan
+- `docs/MOBILE_CUSTOMER_GUIDE.md` — customer-facing mobile copy source
+- issue #200 — mobile program epic
+- PR #201 — initial mobile foundation
diff --git a/docs/MOBILE_CUSTOMER_GUIDE.md b/docs/MOBILE_CUSTOMER_GUIDE.md
new file mode 100644
index 0000000..3153a4c
--- /dev/null
+++ b/docs/MOBILE_CUSTOMER_GUIDE.md
@@ -0,0 +1,63 @@
+# BragStack Mobile — Customer Guide
+
+> **Status: pre-release.** BragStack Mobile is in active development for iOS and Android and is not yet publicly available in the Apple App Store or Google Play.
+
+## What BragStack Mobile is for
+
+BragStack Mobile is designed to help you capture meaningful work while the details are still fresh, then use the same professional proof across BragStack on web and mobile.
+
+The first public release is intended to support:
+
+- signing in with an existing BragStack account
+- restoring a secure signed-in session on your device
+- viewing a mobile dashboard and proof library
+- quickly capturing a new accomplishment
+- working with Impact Receipts using the same trust model as the web product
+- accessing profile and account settings
+- keeping private workplace evidence private unless you intentionally share it
+
+## What is already built into the mobile foundation
+
+The current mobile foundation includes the native iOS/Android app structure, official BragStack branding, the authenticated BragStack theme, real account sign-in, encrypted on-device session storage, session restore, sign out, mobile navigation, and internal preview/release build configuration.
+
+Some screens still use preview data while live mobile data flows are completed.
+
+## What is still being completed before launch
+
+Before public release, BragStack is completing and validating:
+
+- live accomplishment and Impact Receipt persistence across mobile screens
+- registration, email verification, password reset, and recovery flows
+- loading, retry, offline, expired-session, and API error states
+- accessibility and dynamic-text behavior
+- device and operating-system compatibility
+- account-deletion behavior and privacy/support links
+- Apple privacy disclosures and Google Play Data safety disclosures
+- production signing, TestFlight, Google Play internal testing, store assets, and final security/release review
+
+## Privacy and permissions
+
+BragStack Mobile follows the same private-first approach as the web product. Sensitive workplace evidence should not become public automatically.
+
+The app should ask for a device permission only when a feature genuinely needs it and, where possible, only after you start that action. A missing result, metric, or piece of evidence stays missing; BragStack should not invent professional outcomes to make a record look more complete.
+
+## Your account and data
+
+BragStack Mobile is being built to use the same BragStack account and backend as the web product rather than creating a separate mobile-only account system.
+
+At launch, supported mobile data should sync through the same BragStack record so you can move between devices without maintaining two separate career histories.
+
+## Support
+
+During pre-release testing, include the following when reporting a mobile problem when available:
+
+- device type
+- operating-system version
+- BragStack app/build version
+- the exact non-sensitive error message or behavior
+
+Never send BragStack passwords, access tokens, confidential employer evidence, or full payment information to support.
+
+## Store availability
+
+Installation links and supported OS versions will be added only after BragStack has completed internal testing and the production listings are live.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
new file mode 100644
index 0000000..8ae89b6
--- /dev/null
+++ b/docs/ROADMAP.md
@@ -0,0 +1,88 @@
+# BragStack Roadmap
+
+## Mobile initiative — iOS + Android
+
+BragStack Mobile is an active pre-release initiative tracked by issue #200. It is intended to extend the same BragStack account, proof record, trust model, and privacy defaults to a native cross-platform client rather than create a separate mobile product.
+
+### Phase 1: Foundation — substantially complete
+- Expo / React Native application scaffold
+- canonical BragStack brandmark
+- authenticated-app theme tokens from the BragStack Brand Guide
+- navigation shell for Home, Proof, Add, Profile, and Settings
+- real sign-in against the existing `/auth/login` endpoint
+- verified-email behavior inherited from the backend
+- secure on-device token storage with Expo SecureStore
+- session restore through `/auth/me`
+- sign out that clears the local mobile session
+- authenticated API client configuration
+- EAS preview / production build profiles
+- store-safe default permission posture
+- mobile engineering and customer documentation foundation
+
+### Phase 2: Core product parity — next
+- Dashboard backed by live API data
+- Accomplishments / Impact Receipts list and detail views backed by live data
+- Quick-add accomplishment persistence
+- Edit and delete flows with confirmation
+- Registration and email verification UX
+- Password reset / recovery deep-link flow
+- Profile editing and public-profile controls
+- Loading, empty, expired-session, offline, retry, and API error states
+- Automated tests for auth/session/data flows
+
+### Phase 3: Mobile-native value
+- Camera/file evidence capture with explicit privacy controls
+- Native share sheet for public profile and selected proof
+- Deep links into public profiles and selected mobile screens
+- Optional biometric re-entry protection where appropriate
+- Carefully scoped, opt-in notifications only when they provide clear user value
+- Mobile analytics focused on activation/friction without sending sensitive proof content
+
+### Phase 4: Store readiness
+- Accessibility and dynamic-text review
+- App icon, splash screen, screenshots, and store copy
+- Apple privacy disclosures / required-reason review
+- Google Play Data safety disclosure
+- Account deletion flow validation
+- Terms, Privacy Policy, and support links
+- Production API configuration
+- iOS signing / TestFlight
+- Android signing / Play internal testing
+- Crash reporting decision, implementation, and disclosure if adopted
+- Device / OS compatibility matrix
+- Third-party SDK and mobile dependency review
+- Mobile CI and production-build verification
+
+### Phase 5: Release and hardening
+- Beta feedback pass
+- Performance and crash-free-session targets
+- Security review of token lifecycle, deep links, logs, analytics, and evidence handling
+- App Store and Google Play production submission
+- Store-review issue handling
+- Support playbooks and customer-facing known limitations
+- Post-launch crash/auth/API/error monitoring
+
+## Mobile success criteria
+1. Reduce time from a real-world win to a useful BragStack record.
+2. Preserve reliable session behavior without weakening account security.
+3. Keep private workplace evidence private by default.
+4. Make web ↔ mobile movement feel like one BragStack account and record.
+5. Drive useful repeat capture/review behavior rather than notification spam.
+6. Ship only when store disclosures and customer documentation match actual production behavior.
+
+## Mobile product principles
+1. Keep private workplace evidence private by default.
+2. Reuse BragStack's existing backend and trust model instead of duplicating business logic in the client.
+3. Ask for device permissions only at the moment a feature genuinely needs them.
+4. Keep core accomplishment capture fast enough to use immediately after a win.
+5. Preserve user control over what becomes public, exported, or shared.
+6. Never invent missing professional results, evidence, verification, or metrics.
+7. Distinguish pre-release capability from publicly shipped store availability.
+
+## Documentation
+- `docs/MOBILE_APP.md` — architecture, auth, brand, privacy, and release gates
+- `docs/MOBILE_CUSTOMER_GUIDE.md` — source for customer-facing mobile guidance
+- `mobile/README.md` — local setup and implementation status
+
+Tracking epic: #200
+Foundation PR: #201
diff --git a/mobile/.env.example b/mobile/.env.example
new file mode 100644
index 0000000..d6ebadd
--- /dev/null
+++ b/mobile/.env.example
@@ -0,0 +1,4 @@
+# BragStack API reachable by the simulator/emulator/device.
+# Android emulator commonly uses http://10.0.2.2:8000 for a host-local API.
+# Physical devices need your computer's LAN address or a deployed HTTPS API.
+EXPO_PUBLIC_API_URL=http://localhost:8000
diff --git a/mobile/App.js b/mobile/App.js
new file mode 100644
index 0000000..2827b0c
--- /dev/null
+++ b/mobile/App.js
@@ -0,0 +1,816 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { NavigationContainer } from '@react-navigation/native';
+import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
+import { StatusBar } from 'expo-status-bar';
+import {
+ ActivityIndicator,
+ Linking,
+ Platform,
+ Pressable,
+ RefreshControl,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ useWindowDimensions,
+ View,
+} from 'react-native';
+import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
+import Svg, { Path } from 'react-native-svg';
+import Brandmark from './src/Brandmark';
+import { apiBaseURL } from './src/api';
+import {
+ getAuthErrorMessage,
+ login,
+ logout,
+ register,
+ requestPasswordReset,
+ resendVerification,
+ restoreSession,
+} from './src/authApi';
+import {
+ createPrivateEntry,
+ getProductErrorMessage,
+ loadProofOverview,
+ updateProfile,
+} from './src/productApi';
+import { colors, navigationTheme, radius } from './src/theme';
+
+const Tab = createBottomTabNavigator();
+const icons = { Home: '⌂', Proof: '✓', Add: '+', Profile: '◉', Settings: '⚙' };
+const EMPTY_OVERVIEW = { entries: [], receipts: [], totalEntries: 0, totalReceipts: 0 };
+
+function useLayoutProfile() {
+ const { width, height } = useWindowDimensions();
+ const isLandscape = width > height;
+ const isTablet = width >= 768;
+ const isWideTablet = width >= 900;
+ const isShort = height < 650;
+ const horizontalPadding = isWideTablet ? 36 : isTablet ? 28 : 14;
+ return { width, height, isLandscape, isTablet, isWideTablet, isShort, horizontalPadding };
+}
+
+function GoogleMark() {
+ return (
+
+ );
+}
+
+function GitHubMark() {
+ return (
+
+ );
+}
+
+function Brand({ small = false }) {
+ return (
+
+
+
+ BragStack
+ {!small && Proof of the impact you create.}
+
+
+ );
+}
+
+function AuthScreen({ onSuccess }) {
+ const layout = useLayoutProfile();
+ const compact = layout.isShort || layout.width < 390;
+ const split = layout.isWideTablet && layout.isLandscape;
+ const contentWidth = Math.max(
+ 280,
+ Math.min(layout.width - layout.horizontalPadding * 2, split ? 1120 : 560),
+ );
+ const [mode, setMode] = useState('login');
+ const [name, setName] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const [message, setMessage] = useState('');
+ const [verificationEmail, setVerificationEmail] = useState('');
+
+ const changeMode = (nextMode) => {
+ setMode(nextMode);
+ setError('');
+ setMessage('');
+ };
+
+ const submit = async () => {
+ if (busy) return;
+ setBusy(true);
+ setError('');
+ setMessage('');
+ try {
+ if (mode === 'register') {
+ if (!name.trim() || !email.trim() || password.length < 8) {
+ throw new Error('Enter your name, a valid email, and a password with at least 8 characters.');
+ }
+ const result = await register(name, email, password);
+ setVerificationEmail(email.trim().toLowerCase());
+ setMessage(
+ result?.email_sent === false
+ ? 'Account created. Verification email delivery needs another try.'
+ : 'Account created. Check your email to verify it, then sign in.',
+ );
+ return;
+ }
+ if (mode === 'reset') {
+ if (!email.trim()) throw new Error('Enter the email address on your BragStack account.');
+ await requestPasswordReset(email);
+ setMessage('If that email belongs to an account, a password reset link has been sent.');
+ return;
+ }
+ if (!email.trim() || !password) throw new Error('Enter your email and password.');
+ onSuccess(await login(email, password));
+ } catch (authError) {
+ setError(authError?.response ? getAuthErrorMessage(authError) : authError.message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const resend = async () => {
+ if (!verificationEmail || busy) return;
+ setBusy(true);
+ setError('');
+ try {
+ await resendVerification(verificationEmail);
+ setMessage('A fresh verification email has been requested. Check your inbox.');
+ } catch (authError) {
+ setError(getAuthErrorMessage(authError));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const startOAuth = async (provider) => {
+ if (Platform.OS !== 'web') return;
+ await Linking.openURL(`${apiBaseURL}/auth/${provider}/login`);
+ };
+
+ const title = mode === 'register'
+ ? 'Build proof that travels with your career.'
+ : mode === 'reset'
+ ? 'Get back into your proof.'
+ : 'Your proof is ready when you are.';
+
+ return (
+
+
+
+
+
+
+
+
+
+ ●
+ PRIVATE CAREER PROOF
+
+ {split ? (
+
+ Career proof that works wherever you work.
+ Capture evidence on the device in your hand, then keep using the same BragStack account on the web.
+
+ ) : null}
+
+
+
+ {mode === 'register' ? 'CREATE ACCOUNT' : mode === 'reset' ? 'ACCOUNT RECOVERY' : 'WELCOME BACK'}
+ {title}
+
+ {mode === 'register'
+ ? 'Create one BragStack account for web and mobile. Your career evidence stays private by default.'
+ : mode === 'reset'
+ ? 'We will send a secure reset link to your account email.'
+ : 'Open your private workspace and keep building evidence while the details are still fresh.'}
+
+
+ {mode === 'register' ? (
+ <>
+ NAME
+
+ >
+ ) : null}
+
+ EMAIL
+
+
+ {mode !== 'reset' ? (
+ <>
+ PASSWORD
+
+ >
+ ) : null}
+
+ {error ? {error} : null}
+ {message ? {message} : null}
+
+
+ {busy ? : {mode === 'register' ? 'Create BragStack account' : mode === 'reset' ? 'Send reset link' : 'Sign in to BragStack'}}
+
+
+ {verificationEmail ? (
+
+ Resend verification email
+
+ ) : null}
+
+ {mode === 'login' ? (
+ changeMode('reset')} style={styles.linkButton}>
+ Forgot password?
+
+ ) : null}
+
+ {Platform.OS === 'web' && mode === 'login' ? (
+ <>
+
+
+ OR CONTINUE WITH
+
+
+
+ startOAuth('google')} style={[styles.socialButton, styles.googleButton]}>
+
+ Continue with Google
+
+ startOAuth('github')} style={[styles.socialButton, styles.githubButton]}>
+
+ Continue with GitHub
+
+
+ >
+ ) : null}
+
+ {mode === 'login' ? (
+ changeMode('register')} style={styles.authSwitch}>
+ New to BragStack? Create an account
+
+ ) : (
+ changeMode('login')} style={styles.authSwitch}>
+ Already have an account? Sign in
+
+ )}
+
+
+ ⌁
+ Secure session storage • private by default
+
+
+
+
+
+ );
+}
+
+function Page({ kicker, title, children, refreshing = false, onRefresh, narrow = false }) {
+ const layout = useLayoutProfile();
+ const maxWidth = narrow ? 820 : layout.isWideTablet ? 1120 : layout.isTablet ? 900 : 760;
+ const contentWidth = Math.max(280, Math.min(layout.width - layout.horizontalPadding * 2, maxWidth));
+ return (
+
+ : undefined}
+ >
+
+
+
+ {kicker}
+ {title}
+ {children}
+
+
+
+
+ );
+}
+
+function Pill({ children }) {
+ return {children};
+}
+
+function ProofCard({ title, body, status = 'Private proof', isPublic = false }) {
+ return (
+
+
+ {status}
+ {isPublic ? '◉ Public' : '◌ Private'}
+
+ {title}
+ {body}
+
+ );
+}
+
+function LoadingCard({ label = 'Loading your proof…' }) {
+ return {label};
+}
+
+function Home({ navigation, user, overview, loading, error, refreshing, onRefresh }) {
+ const layout = useLayoutProfile();
+ const wide = layout.isWideTablet;
+ const verified = overview.receipts.filter((receipt) =>
+ (receipt.confirmations || []).some((confirmation) => confirmation.status === 'confirmed'),
+ ).length;
+ const recent = overview.entries[0];
+
+ return (
+
+
+
+
+ PROOF PULSE
+ Turn fresh wins into durable career proof.
+ Capture the work, result, evidence, skills, and credit while the details are fresh.
+
+ {overview.totalEntries}Wins
+ {overview.totalReceipts}Receipts
+ {verified}Confirmed
+
+
+
+
+
+ navigation.navigate('Add')}>
+ +
+
+ Capture a win
+ Save it privately to your real BragStack account.
+
+ ›
+
+
+ {loading ? : null}
+ {error ? {error} : null}
+ {!loading && recent ? (
+
+ ) : null}
+ {!loading && !recent && !error ? (
+ Your first win goes here.Use Add to capture a real accomplishment. Nothing becomes public automatically.
+ ) : null}
+
+
+
+ );
+}
+
+function ProofGrid({ items, renderItem }) {
+ const layout = useLayoutProfile();
+ const twoColumns = layout.isWideTablet;
+ return (
+
+ {items.map((item) => (
+
+ {renderItem(item.value)}
+
+ ))}
+
+ );
+}
+
+function Proof({ overview, loading, error, refreshing, onRefresh }) {
+ const receiptItems = overview.receipts.map((receipt) => ({ key: `receipt-${receipt.id}`, value: receipt }));
+ const entryItems = overview.entries.map((entry) => ({ key: `entry-${entry.id}`, value: entry }));
+ return (
+
+ {loading ? : null}
+ {error ? {error} : null}
+
+ {!loading && receiptItems.length > 0 ? IMPACT RECEIPTS : null}
+ {
+ const confirmed = (receipt.confirmations || []).some((confirmation) => confirmation.status === 'confirmed');
+ const evidenceCount = (receipt.evidence || []).length;
+ return (
+
+ );
+ }}
+ />
+
+ {!loading && entryItems.length > 0 ? ACCOMPLISHMENTS : null}
+ (
+
+ )}
+ />
+
+ {!loading && !error && entryItems.length === 0 && receiptItems.length === 0 ? (
+ No proof yet.Your mobile library is connected to production data. Add a win to start it.
+ ) : null}
+
+ );
+}
+
+function Field({ label, value, onChangeText, placeholder, multiline = false, autoCapitalize = 'sentences' }) {
+ return (
+
+ {label}
+
+
+ );
+}
+
+function Add({ navigation, onCreated }) {
+ const [form, setForm] = useState({ title: '', situation: '', action: '', impact: '', category: '', tags: '' });
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const [message, setMessage] = useState('');
+ const setField = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
+
+ const save = async () => {
+ if (busy) return;
+ setBusy(true);
+ setError('');
+ setMessage('');
+ try {
+ const created = await createPrivateEntry(form);
+ setForm({ title: '', situation: '', action: '', impact: '', category: '', tags: '' });
+ setMessage('Saved privately to BragStack.');
+ await onCreated(created);
+ navigation.navigate('Proof');
+ } catch (saveError) {
+ setError(saveError?.response ? getProductErrorMessage(saveError, 'Could not save this accomplishment.') : saveError.message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+ This saves a real accomplishment to your BragStack account. The four proof fields are required so the app never invents missing career evidence.
+
+
+
+
+
+
+ ⌁New mobile captures are private by default.
+ {error ? {error} : null}
+ {message ? {message} : null}
+
+ {busy ? : Save private accomplishment}
+
+
+
+ );
+}
+
+function Profile({ user, onUserChange }) {
+ const [form, setForm] = useState({ name: user?.name || '', headline: user?.headline || '', bio: user?.bio || '', location: user?.location || '' });
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const [message, setMessage] = useState('');
+
+ useEffect(() => {
+ setForm({ name: user?.name || '', headline: user?.headline || '', bio: user?.bio || '', location: user?.location || '' });
+ }, [user]);
+
+ const save = async () => {
+ if (busy) return;
+ setBusy(true);
+ setError('');
+ setMessage('');
+ try {
+ const updated = await updateProfile(user, form);
+ onUserChange(updated);
+ setMessage('Profile updated.');
+ } catch (saveError) {
+ setError(getProductErrorMessage(saveError, 'Could not update your profile.'));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {user?.name || 'BragStack Member'}
+ {user?.public_slug ? `Public proof: usebragstack.com/brag/${user.public_slug}` : 'Public sharing stays under your control.'}
+
+
+ setForm((current) => ({ ...current, name: value }))} placeholder="Your name" autoCapitalize="words" />
+ setForm((current) => ({ ...current, headline: value }))} placeholder="Platform Support Engineer" />
+ setForm((current) => ({ ...current, location: value }))} placeholder="City, region" autoCapitalize="words" />
+ setForm((current) => ({ ...current, bio: value }))} placeholder="A short professional summary" multiline />
+ {error ? {error} : null}
+ {message ? {message} : null}
+
+ {busy ? : Save profile}
+
+
+
+ );
+}
+
+function Settings({ user, onSignOut }) {
+ const layout = useLayoutProfile();
+ const split = layout.isTablet;
+ const open = (url) => Linking.openURL(url);
+ return (
+
+
+
+ Signed in{user?.email}
+
+
+ Production API{apiBaseURL}
+
+
+
+ Privacy & support
+ open('https://usebragstack.com/privacy')} style={styles.settingsLink}>Privacy policy›
+ open('https://usebragstack.com/terms')} style={styles.settingsLink}>Terms›
+ open('https://usebragstack.com/docs')} style={styles.settingsLink}>Help & documentation›
+
+ Sign out
+
+ );
+}
+
+function Tabs({ user, onUserChange, onSignOut }) {
+ const { width } = useWindowDimensions();
+ const tabletNavigation = width >= 768;
+ const [overview, setOverview] = useState(EMPTY_OVERVIEW);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [error, setError] = useState('');
+
+ const refresh = useCallback(async (quiet = false) => {
+ if (quiet) setRefreshing(true);
+ else setLoading(true);
+ setError('');
+ try {
+ setOverview(await loadProofOverview());
+ } catch (loadError) {
+ setError(getProductErrorMessage(loadError));
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ }, []);
+
+ useEffect(() => { void refresh(false); }, [refresh]);
+
+ const shared = useMemo(
+ () => ({ overview, loading, error, refreshing, onRefresh: () => refresh(true) }),
+ [overview, loading, error, refreshing, refresh],
+ );
+
+ return (
+
+
+ ({
+ headerShown: false,
+ tabBarActiveTintColor: colors.primary,
+ tabBarInactiveTintColor: colors.mutedStrong,
+ tabBarHideOnKeyboard: true,
+ tabBarPosition: tabletNavigation ? 'left' : 'bottom',
+ tabBarVariant: tabletNavigation ? 'material' : 'uikit',
+ tabBarLabelPosition: 'below-icon',
+ tabBarStyle: tabletNavigation ? styles.tabBarSide : styles.tabBarBottom,
+ tabBarItemStyle: tabletNavigation ? styles.tabItemSide : styles.tabItemBottom,
+ tabBarLabelStyle: tabletNavigation ? styles.tabLabelSide : styles.tabLabelBottom,
+ tabBarIcon: ({ color }) => {icons[route.name]},
+ })}
+ >
+ {(props) => }
+ {(props) => }
+ {(props) => refresh(true)} />}
+ {(props) => }
+ {(props) => }
+
+
+ );
+}
+
+export default function App() {
+ const [user, setUser] = useState(null);
+ const [booting, setBooting] = useState(true);
+
+ useEffect(() => {
+ if (Platform.OS !== 'web' || typeof document === 'undefined') return undefined;
+ const root = document.getElementById('root');
+ const nodes = [document.documentElement, document.body, root].filter(Boolean);
+ nodes.forEach((node) => {
+ node.style.width = '100%';
+ node.style.maxWidth = '100%';
+ node.style.height = '100%';
+ node.style.minWidth = '0';
+ node.style.minHeight = '0';
+ node.style.margin = '0';
+ node.style.padding = '0';
+ });
+ if (root) {
+ root.style.display = 'flex';
+ root.style.flexDirection = 'column';
+ root.style.overflow = 'hidden';
+ }
+ document.body.style.overflow = 'hidden';
+ return undefined;
+ }, []);
+
+ useEffect(() => {
+ let live = true;
+ restoreSession()
+ .then((restored) => { if (live) setUser(restored); })
+ .catch(() => {})
+ .finally(() => { if (live) setBooting(false); });
+ return () => { live = false; };
+ }, []);
+
+ const signOut = async () => {
+ await logout();
+ setUser(null);
+ };
+
+ const content = booting
+ ? Opening your BragStack…
+ : user
+ ?
+ : ;
+
+ return {content};
+}
+
+const styles = StyleSheet.create({
+ appRoot: { flex: 1, minWidth: 0, minHeight: 0, alignSelf: 'stretch', backgroundColor: colors.background },
+ safe: { flex: 1, minWidth: 0, minHeight: 0, alignSelf: 'stretch', backgroundColor: colors.background },
+ boot: { flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', gap: 20 },
+ flexOne: { flex: 1, minWidth: 0 },
+
+ loginScroll: { flex: 1, minWidth: 0, width: '100%' },
+ loginPage: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 34, width: '100%', minWidth: 0 },
+ loginPageCompact: { justifyContent: 'flex-start', paddingVertical: 14 },
+ loginShell: { alignSelf: 'center', gap: 20, minWidth: 0, maxWidth: '100%' },
+ loginShellSplit: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 48 },
+ brandHeader: { gap: 12, minWidth: 0 },
+ brandHeaderCompact: { gap: 9 },
+ authAside: { flex: 1, maxWidth: 440, justifyContent: 'center' },
+ authAsideCopy: { gap: 10, marginTop: 30 },
+ authAsideTitle: { color: colors.text, fontSize: 36, lineHeight: 41, fontWeight: '900', letterSpacing: -0.7 },
+ brand: { flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0 },
+ brandCopy: { flexShrink: 1, minWidth: 0 },
+ brandName: { color: colors.text, fontSize: 26, fontWeight: '900', flexShrink: 1 },
+ brandSmall: { fontSize: 20 },
+ brandTagline: { color: colors.muted, fontSize: 12, marginTop: 2 },
+ signalPill: { alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', gap: 7, paddingHorizontal: 11, paddingVertical: 7, borderRadius: 999, backgroundColor: 'rgba(105,228,246,0.08)', borderWidth: 1, borderColor: 'rgba(105,228,246,0.20)' },
+ signalDot: { color: colors.cyan, fontSize: 9 },
+ signalText: { color: colors.cyan, fontSize: 9, fontWeight: '900', letterSpacing: 1.4 },
+ orbBlue: { position: 'absolute', width: 250, height: 250, borderRadius: 125, backgroundColor: 'rgba(166,220,255,0.08)', top: -90, right: -100 },
+ orbPurple: { position: 'absolute', width: 220, height: 220, borderRadius: 110, backgroundColor: 'rgba(173,145,255,0.08)', bottom: -90, left: -100 },
+
+ pageFrame: { flexGrow: 1, alignItems: 'center', paddingBottom: 110, paddingTop: 10, width: '100%', minWidth: 0 },
+ pageFrameTablet: { paddingBottom: 42, paddingTop: 18 },
+ pageFrameLandscapePhone: { paddingBottom: 88, paddingTop: 4 },
+ pageShell: { alignSelf: 'center', maxWidth: '100%', minWidth: 0 },
+ page: { paddingTop: 8, gap: 14, minWidth: 0 },
+ kicker: { color: colors.primary, fontSize: 10, fontWeight: '900', letterSpacing: 1.8 },
+ title: { color: colors.text, fontSize: 34, lineHeight: 39, fontWeight: '900', letterSpacing: -0.5, flexShrink: 1 },
+ titleTablet: { fontSize: 40, lineHeight: 45 },
+ titleCompact: { fontSize: 29, lineHeight: 34 },
+ loginTitle: { color: colors.text, fontSize: 30, lineHeight: 35, fontWeight: '900', letterSpacing: -0.6, flexShrink: 1 },
+ loginTitleCompact: { fontSize: 26, lineHeight: 31 },
+ muted: { color: colors.muted, fontSize: 14, lineHeight: 21, flexShrink: 1 },
+ note: { color: colors.mutedStrong, fontSize: 11, textAlign: 'center', flexShrink: 1 },
+
+ card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 18, gap: 12, minWidth: 0, width: '100%' },
+ loginCard: { backgroundColor: 'rgba(13,21,38,0.96)', borderColor: 'rgba(173,145,255,0.26)', padding: 20, gap: 12, shadowColor: '#000000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.28, shadowRadius: 30, elevation: 12 },
+ loginCardCompact: { padding: 16, gap: 10, borderRadius: 22 },
+ loginCardSplit: { flex: 1, maxWidth: 540 },
+ label: { color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.4, marginTop: 2 },
+ fieldGroup: { gap: 7, width: '100%' },
+ input: { minHeight: 50, width: '100%', backgroundColor: 'rgba(19,30,51,0.92)', borderWidth: 1, borderColor: 'rgba(166,220,255,0.16)', borderRadius: 14, color: colors.text, paddingHorizontal: 14, paddingVertical: 12, fontSize: 15, minWidth: 0 },
+ textarea: { minHeight: 88 },
+ button: { minHeight: 52, width: '100%', borderRadius: radius.pill, backgroundColor: colors.primary, borderWidth: 1, borderColor: 'rgba(255,255,255,0.22)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 },
+ buttonText: { color: colors.background, fontWeight: '900', textAlign: 'center' },
+ secondaryButton: { minHeight: 46, width: '100%', borderRadius: radius.pill, borderWidth: 1, borderColor: colors.border, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 },
+ secondaryButtonText: { color: colors.text, fontWeight: '800' },
+ disabled: { opacity: 0.45 },
+ linkButton: { alignSelf: 'center', paddingVertical: 4, paddingHorizontal: 8 },
+ linkText: { color: colors.primary, fontWeight: '850' },
+ authSwitch: { alignSelf: 'center', paddingVertical: 2 },
+ error: { color: colors.danger, fontSize: 13, lineHeight: 19 },
+ success: { color: colors.cyan, fontSize: 13, lineHeight: 19 },
+ divider: { flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 2, minWidth: 0 },
+ dividerLine: { flex: 1, height: 1, backgroundColor: colors.border },
+ dividerText: { color: colors.mutedStrong, fontSize: 9, fontWeight: '900', letterSpacing: 1.1, flexShrink: 1 },
+ socialStack: { gap: 9 },
+ socialButton: { minHeight: 50, width: '100%', borderRadius: 14, paddingHorizontal: 16, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 },
+ googleButton: { backgroundColor: '#FFFFFF', borderWidth: 1, borderColor: '#DADCE0' },
+ googleText: { color: '#202124', fontWeight: '800', flexShrink: 1 },
+ githubButton: { backgroundColor: '#24292F', borderWidth: 1, borderColor: '#57606A' },
+ githubText: { color: '#FFFFFF', fontWeight: '800', flexShrink: 1 },
+ securityRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 2, flexWrap: 'wrap' },
+ securityIcon: { color: colors.cyan, fontSize: 15 },
+
+ homeGrid: { gap: 14, width: '100%' },
+ homeGridWide: { flexDirection: 'row', alignItems: 'stretch', gap: 18 },
+ homePrimary: { width: '100%' },
+ homePrimaryWide: { flex: 1.18, minWidth: 0 },
+ homeSecondary: { width: '100%', gap: 14 },
+ homeSecondaryWide: { flex: 0.82, minWidth: 0 },
+ hero: { backgroundColor: colors.surface, borderWidth: 1, borderColor: 'rgba(166,220,255,0.28)', borderRadius: radius.lg, padding: 18, gap: 10, width: '100%' },
+ heroLabel: { color: colors.primary, fontWeight: '900', fontSize: 10, letterSpacing: 1.8 },
+ heroTitle: { color: colors.text, fontSize: 23, lineHeight: 28, fontWeight: '900' },
+ heroTitleWide: { fontSize: 31, lineHeight: 36 },
+ metrics: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 6 },
+ metric: { flexGrow: 1, flexBasis: 88, backgroundColor: colors.surfaceElevated, borderRadius: radius.md, padding: 10 },
+ metricNum: { color: colors.primary, fontSize: 21, fontWeight: '900' },
+ metricText: { color: colors.muted, fontSize: 10 },
+ action: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: colors.surfaceElevated, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, padding: 15, minWidth: 0, width: '100%' },
+ plus: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.primary, color: colors.background, textAlign: 'center', textAlignVertical: 'center', fontSize: 27 },
+ arrow: { color: colors.primary, fontSize: 28 },
+ arrowSmall: { color: colors.primary, fontSize: 22 },
+ cardTitle: { color: colors.text, fontSize: 17, fontWeight: '900', flexShrink: 1 },
+ row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
+ pill: { borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(166,220,255,0.28)', backgroundColor: 'rgba(166,220,255,0.10)', paddingHorizontal: 10, paddingVertical: 6 },
+ pillText: { color: colors.text, fontSize: 10, fontWeight: '900' },
+ private: { color: colors.mutedStrong, fontSize: 11 },
+ loadingCard: { minHeight: 90, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, alignItems: 'center', justifyContent: 'center', gap: 10, padding: 16, width: '100%' },
+ emptyCard: { borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, padding: 18, gap: 8, width: '100%' },
+ sectionLabel: { color: colors.mutedStrong, fontSize: 10, fontWeight: '900', letterSpacing: 1.5, marginTop: 6 },
+
+ proofGrid: { width: '100%', gap: 12 },
+ proofGridWide: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'stretch', gap: 14 },
+ proofGridItem: { width: '100%' },
+ proofGridItemWide: { width: '48.9%' },
+
+ profileHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0, marginBottom: 2 },
+ profileName: { color: colors.text, fontSize: 20, fontWeight: '900', flexShrink: 1 },
+ settingsGrid: { gap: 12, width: '100%' },
+ settingsGridWide: { flexDirection: 'row', alignItems: 'stretch' },
+ settingsGridItem: { width: '100%' },
+ settingsGridItemWide: { flex: 1, minWidth: 0 },
+ settingsLink: { minHeight: 46, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: colors.border, gap: 10 },
+ signout: { minHeight: 52, borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(255,176,176,0.3)', alignItems: 'center', justifyContent: 'center', width: '100%' },
+ signoutText: { color: colors.danger, fontWeight: '900' },
+
+ tabBarBottom: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 76, paddingTop: 7, paddingBottom: 9 },
+ tabBarSide: { backgroundColor: colors.sidebar, borderTopWidth: 0, borderRightWidth: 1, borderRightColor: colors.border, width: 96, paddingVertical: 16 },
+ tabItemBottom: { minHeight: 54 },
+ tabItemSide: { minHeight: 70, marginVertical: 2, borderRadius: 18 },
+ tabLabelBottom: { fontSize: 10, fontWeight: '800' },
+ tabLabelSide: { fontSize: 10, fontWeight: '850', marginTop: 3 },
+ tabIcon: { fontSize: 18, fontWeight: '800' },
+});
diff --git a/mobile/README.md b/mobile/README.md
new file mode 100644
index 0000000..904decd
--- /dev/null
+++ b/mobile/README.md
@@ -0,0 +1,76 @@
+# BragStack Mobile
+
+Cross-platform iOS and Android client for BragStack, built with React Native and Expo.
+
+> **Status:** active pre-release development. The app is not yet publicly available in the Apple App Store or Google Play.
+
+## Run locally
+
+```bash
+cd mobile
+npm install
+EXPO_PUBLIC_API_URL=http://localhost:8000 npm start
+```
+
+For a physical device, set `EXPO_PUBLIC_API_URL` to an address the device can reach rather than `localhost`.
+
+Preview and production EAS profiles are configured to use the canonical BragStack production API at `https://api.usebragstack.com`.
+
+## Current implementation
+
+- React Native / Expo app for iOS and Android
+- Official BragStack vector brandmark and authenticated-app theme
+- Responsive phone/tablet layout with safe-area handling
+- Home, Proof, Add, Profile, and Settings navigation
+- Real password sign-in through `/auth/login`
+- In-app account registration through `/auth/register`
+- Verification-email resend support
+- Password-reset request support
+- Encrypted access-token storage through Expo SecureStore
+- Session restore through `/auth/me`
+- Sign out that clears the local token
+- Live accomplishments and Impact Receipts loaded from the production APIs
+- Pull-to-refresh, loading, empty, retry/error presentation
+- Real private-by-default accomplishment capture through `/entries`
+- Profile editing through `/auth/me/profile`
+- EAS preview and production build profiles
+- Mobile CI with Expo Doctor, unit/coverage tests, and bundle export smoke testing
+- No unnecessary native permissions
+
+## Authentication behavior
+
+BragStack Mobile shares the same identity system as the web app. Password login requires a verified email. Registration and reset requests use the existing BragStack email flows.
+
+Verification and password-reset emails currently finish in the BragStack web experience. Native deep-link completion remains a release-hardening task.
+
+Google and GitHub sign-in are intentionally not exposed in the native app yet. The current backend OAuth callback returns to the web app, and an iOS release that exposes third-party social login must also satisfy Apple's sign-in requirements. Web preview may continue to expose those providers.
+
+## Data behavior
+
+Home and Proof use live authenticated BragStack data. Quick Capture writes a real accomplishment to the signed-in account and always creates it as private. The capture form requires context, contribution, and impact instead of inventing missing career evidence.
+
+Impact Receipts are read live. Full receipt creation/editing, evidence attachment, and verification workflows remain later mobile slices.
+
+## Public-store blockers
+
+Do not call the app store-ready until these gates are complete:
+
+- in-app account deletion and end-to-end deletion verification
+- native verification/reset deep links or an explicitly validated web-return flow
+- full accessibility and dynamic-text review
+- app icon, splash, screenshots, store copy, and support metadata
+- Apple privacy disclosures / required-reason review
+- Google Play Data safety disclosure
+- iOS signing and TestFlight validation
+- Android signing and Play internal testing
+- device/OS compatibility matrix and release QA
+- security review of tokens, deep links, logs, analytics, and evidence handling
+- native social sign-in only after the callback flow and Apple requirements are satisfied
+
+## Documentation
+
+- `../docs/MOBILE_APP.md` — architecture, auth, brand, privacy, and store-readiness gates
+- `../docs/MOBILE_CUSTOMER_GUIDE.md` — customer-facing mobile guidance source
+- `../docs/ROADMAP.md` — phased delivery plan and success criteria
+- issue #200 — mobile program epic
+- PR #201 — mobile foundation and store-readiness work
diff --git a/mobile/TESTING.md b/mobile/TESTING.md
new file mode 100644
index 0000000..bb02ce8
--- /dev/null
+++ b/mobile/TESTING.md
@@ -0,0 +1,117 @@
+# BragStack Mobile Testing
+
+BragStack Mobile is pre-release. Use this checklist before treating a build as store-ready.
+
+## Codespaces / local code checks
+
+From the repository root:
+
+```bash
+cd mobile
+node --version
+npm install
+npm run doctor
+npm run test:ci
+npx expo export --platform web --output-dir dist-ci
+```
+
+Expo SDK 57 requires Node 22.13 or newer. If `node --version` is older, switch the environment to Node 22 before installing dependencies.
+
+Expected result: Expo Doctor passes, the Jest suites pass with coverage thresholds, and the web bundle export completes without an unhandled error.
+
+## Interactive Codespaces preview
+
+For the quickest end-to-end UI test in Codespaces, use the canonical BragStack production API rather than `localhost`:
+
+```bash
+cd mobile
+EXPO_PUBLIC_API_URL=https://api.usebragstack.com npm run web
+```
+
+Or use the repository shortcut:
+
+```bash
+npm run web:codespaces
+```
+
+Open the forwarded Expo web port shown by Codespaces (normally 8081). If the Ports panel does not open it automatically, open the forwarded port from the Codespaces **Ports** tab.
+
+Do not copy `.env.example` unchanged for a browser preview in Codespaces because `http://localhost:8000` would refer to the computer running the browser rather than the Codespace. If you intentionally run the backend inside the Codespace, either leave `EXPO_PUBLIC_API_URL` unset so the app can derive the forwarded `-8000.app.github.dev` host, or set it to the exact forwarded backend URL.
+
+Browser testing is useful for JavaScript/runtime/UI checks but does not validate native SecureStore, native rotation callbacks, iOS/Android lifecycle, signing, or store behavior.
+
+## Test phones and tablets in Chrome DevTools
+
+In the Expo web preview:
+
+1. Open Chrome DevTools.
+2. Toggle the device toolbar (`Ctrl+Shift+M`).
+3. Test each size in portrait.
+4. Rotate the device toolbar and repeat in landscape.
+5. Keep zoom at 100% while checking layout; use browser zoom separately only for accessibility checks.
+
+Minimum viewport matrix:
+
+| Device class | Portrait | Landscape |
+| --- | ---: | ---: |
+| Small phone / iPhone SE class | 375 × 667 | 667 × 375 |
+| Standard Android phone | 412 × 915 | 915 × 412 |
+| Large iPhone / Pro Max class | 440 × 956 | 956 × 440 |
+| Small tablet / iPad mini class | 744 × 1133 | 1133 × 744 |
+| Standard tablet / iPad class | 820 × 1180 | 1180 × 820 |
+| Large tablet / iPad Pro class | 1032 × 1376 | 1376 × 1032 |
+| Android tablet baseline | 800 × 1280 | 1280 × 800 |
+
+For every viewport, verify there is **no horizontal clipping**, no unreachable action, no content hidden behind the tab bar or safe area, and forms remain usable when the on-screen keyboard would reduce vertical space.
+
+## Landscape and tablet acceptance checks
+
+- The app can rotate between portrait and landscape; Expo configuration uses `orientation: default`.
+- iOS tablet support remains enabled with `supportsTablet: true`.
+- Login/register/reset content stays centered and scrollable on short landscape screens.
+- Home, Proof, Add, Profile, and Settings remain usable in both orientations.
+- Cards never extend beyond the visible viewport.
+- Long titles, email addresses, API URLs, tags, and proof text wrap instead of causing horizontal overflow.
+- Bottom navigation remains reachable on phones and does not cover page content.
+- Tablet layouts use the available width without stretching form controls to an unreadable line length.
+- Landscape phone layouts prioritize vertical space and remain scrollable.
+- Rotation does not discard unsaved form values.
+
+## Manual product acceptance checks
+
+- Official BragStack brandmark is visible and not replaced by a placeholder.
+- Authenticated UI uses the approved BragStack mobile visual system.
+- Login rejects empty credentials and surfaces backend errors.
+- A valid verified BragStack account can sign in.
+- Registration reaches the real backend and communicates verification state.
+- Password reset request reaches the real backend.
+- Relaunch restores a valid session on native builds.
+- Expired/invalid sessions return to sign-in instead of trapping the user.
+- Sign out clears the local session.
+- Tabs navigate without crashes or blank screens.
+- Home and Proof show real synced data rather than demo metrics/cards.
+- Quick Capture writes a real private accomplishment.
+- Missing professional results are never fabricated.
+- New proof remains private by default.
+- Profile edits persist through the production API.
+- Pull-to-refresh works on data screens.
+- Loading, empty, and API-error states remain legible at all target sizes.
+- No unexpected device permission prompt appears during normal launch/navigation.
+- No passwords, access tokens, or confidential evidence appear in logs/errors.
+
+## Real-device checks required before store release
+
+Codespaces responsive preview is not enough to certify device support. Run preview/internal builds on at least:
+
+- one smaller iPhone
+- one current large iPhone
+- one current Android phone
+- one iPad or iPad mini
+- one larger iPad/iPad Pro class device or simulator
+- one Android tablet class device or emulator
+
+Test both portrait and landscape where the OS/device supports rotation. Verify SecureStore persistence, keyboard behavior, safe areas, gestures, background/foreground lifecycle, network loss/retry behavior, deep links when implemented, accessibility/dynamic text, account deletion/recovery flows, and native rotation behavior.
+
+## Store gate
+
+Do not submit to App Store Connect or Google Play production until automated checks pass, the phone/tablet orientation matrix passes, real-device testing passes, production signing/configuration is complete, privacy/data-safety disclosures match actual behavior, account deletion is verified, Terms/Privacy links work, store metadata/screenshots are final, and the release candidate uses the production API.
diff --git a/mobile/__tests__/api.test.js b/mobile/__tests__/api.test.js
new file mode 100644
index 0000000..799aa1a
--- /dev/null
+++ b/mobile/__tests__/api.test.js
@@ -0,0 +1,42 @@
+const mockUseRequestInterceptor = jest.fn();
+const mockCreate = jest.fn(() => ({
+ interceptors: { request: { use: mockUseRequestInterceptor } },
+}));
+
+jest.mock('axios', () => ({ create: mockCreate }));
+jest.mock('../src/authStorage', () => ({ getAccessToken: jest.fn() }));
+
+describe('authenticated API client', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseRequestInterceptor.mockClear();
+ mockCreate.mockClear();
+ });
+
+ it('uses the configured API URL and a bounded timeout', () => {
+ process.env.EXPO_PUBLIC_API_URL = 'https://api.example.test';
+ jest.isolateModules(() => {
+ require('../src/api');
+ });
+ expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({
+ baseURL: 'https://api.example.test',
+ timeout: 15000,
+ }));
+ });
+
+ it('adds a bearer token when one exists and leaves anonymous requests alone', async () => {
+ const { getAccessToken } = require('../src/authStorage');
+ jest.isolateModules(() => {
+ require('../src/api');
+ });
+ const interceptor = mockUseRequestInterceptor.mock.calls[0][0];
+
+ getAccessToken.mockResolvedValueOnce('abc123');
+ const signed = await interceptor({ headers: {} });
+ expect(signed.headers.Authorization).toBe('Bearer abc123');
+
+ getAccessToken.mockResolvedValueOnce(null);
+ const anonymous = await interceptor({ headers: {} });
+ expect(anonymous.headers.Authorization).toBeUndefined();
+ });
+});
diff --git a/mobile/__tests__/authApi.test.js b/mobile/__tests__/authApi.test.js
new file mode 100644
index 0000000..ca2acf7
--- /dev/null
+++ b/mobile/__tests__/authApi.test.js
@@ -0,0 +1,102 @@
+jest.mock('../src/api', () => ({ api: { post: jest.fn(), get: jest.fn() } }));
+jest.mock('../src/authStorage', () => ({
+ setAccessToken: jest.fn(),
+ clearAccessToken: jest.fn(),
+}));
+
+import { api } from '../src/api';
+import { clearAccessToken, setAccessToken } from '../src/authStorage';
+import {
+ confirmEmailVerification,
+ confirmPasswordReset,
+ getAuthErrorMessage,
+ login,
+ logout,
+ register,
+ requestPasswordReset,
+ resendVerification,
+ restoreSession,
+} from '../src/authApi';
+
+describe('mobile auth API', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('normalizes email, stores the returned token, and returns the user on login', async () => {
+ api.post.mockResolvedValue({ data: { access_token: 'token-123', user: { email: 'tee@example.com' } } });
+
+ await expect(login(' Tee@Example.COM ', 'secret')).resolves.toEqual({ email: 'tee@example.com' });
+ expect(api.post).toHaveBeenCalledWith('/auth/login', 'username=tee%40example.com&password=secret', {
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ });
+ expect(setAccessToken).toHaveBeenCalledWith('token-123');
+ });
+
+ it('registers using trimmed identity data', async () => {
+ api.post.mockResolvedValue({ data: { verification_required: true, email_sent: true } });
+ await expect(register(' Tee ', ' Tee@Example.COM ', 'password123')).resolves.toEqual({ verification_required: true, email_sent: true });
+ expect(api.post).toHaveBeenCalledWith('/auth/register', {
+ name: 'Tee',
+ email: 'tee@example.com',
+ password: 'password123',
+ });
+ });
+
+ it('resends verification and requests password recovery with normalized email', async () => {
+ api.post
+ .mockResolvedValueOnce({ data: { message: 'verification sent' } })
+ .mockResolvedValueOnce({ data: { message: 'reset sent' } });
+
+ await expect(resendVerification(' Tee@Example.COM ')).resolves.toEqual({ message: 'verification sent' });
+ await expect(requestPasswordReset(' Tee@Example.COM ')).resolves.toEqual({ message: 'reset sent' });
+ expect(api.post).toHaveBeenNthCalledWith(1, '/auth/email-verification/resend', { email: 'tee@example.com' });
+ expect(api.post).toHaveBeenNthCalledWith(2, '/auth/password-reset/request', { email: 'tee@example.com' });
+ });
+
+ it('confirms email verification, stores the token, and returns the user', async () => {
+ api.post.mockResolvedValue({ data: { access_token: 'verified-token', user: { name: 'Tee' } } });
+ await expect(confirmEmailVerification('verify-token-value')).resolves.toEqual({ name: 'Tee' });
+ expect(api.post).toHaveBeenCalledWith('/auth/email-verification/confirm', { token: 'verify-token-value' });
+ expect(setAccessToken).toHaveBeenCalledWith('verified-token');
+ });
+
+ it('confirms a password reset', async () => {
+ api.post.mockResolvedValue({ data: { message: 'Password updated.' } });
+ await expect(confirmPasswordReset('reset-token-value', 'new-password')).resolves.toEqual({ message: 'Password updated.' });
+ expect(api.post).toHaveBeenCalledWith('/auth/password-reset/confirm', {
+ token: 'reset-token-value',
+ password: 'new-password',
+ });
+ });
+
+ it('restores the signed-in user from /auth/me', async () => {
+ api.get.mockResolvedValue({ data: { name: 'Tee' } });
+ await expect(restoreSession()).resolves.toEqual({ name: 'Tee' });
+ });
+
+ it('clears an expired token when /auth/me returns 401', async () => {
+ const error = { response: { status: 401 } };
+ api.get.mockRejectedValue(error);
+ await expect(restoreSession()).rejects.toBe(error);
+ expect(clearAccessToken).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves credentials for non-auth server errors', async () => {
+ const error = { response: { status: 503 } };
+ api.get.mockRejectedValue(error);
+ await expect(restoreSession()).rejects.toBe(error);
+ expect(clearAccessToken).not.toHaveBeenCalled();
+ });
+
+ it('clears local credentials on logout', async () => {
+ await logout();
+ expect(clearAccessToken).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns specific and safe auth error messages', () => {
+ expect(getAuthErrorMessage({ response: { data: { detail: 'Email verification required.' }, status: 403 } })).toBe('Email verification required.');
+ expect(getAuthErrorMessage({ response: { data: { detail: { message: 'Receipt already exists.' } }, status: 409 } })).toBe('Receipt already exists.');
+ expect(getAuthErrorMessage(new Error('network'))).toMatch(/Could not reach BragStack/);
+ expect(getAuthErrorMessage({ response: { data: {}, status: 503 } })).toMatch(/having trouble/);
+ expect(getAuthErrorMessage({ response: { data: {}, status: 400 } })).toMatch(/check your details/i);
+ });
+});
diff --git a/mobile/__tests__/authStorage.test.js b/mobile/__tests__/authStorage.test.js
new file mode 100644
index 0000000..fb38300
--- /dev/null
+++ b/mobile/__tests__/authStorage.test.js
@@ -0,0 +1,32 @@
+jest.mock('expo-secure-store', () => ({
+ getItemAsync: jest.fn(),
+ setItemAsync: jest.fn(),
+ deleteItemAsync: jest.fn(),
+}));
+
+import * as SecureStore from 'expo-secure-store';
+import { clearAccessToken, getAccessToken, setAccessToken } from '../src/authStorage';
+
+describe('encrypted auth storage', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('uses the stable BragStack token key', async () => {
+ SecureStore.getItemAsync.mockResolvedValue('abc');
+ await expect(getAccessToken()).resolves.toBe('abc');
+ expect(SecureStore.getItemAsync).toHaveBeenCalledWith('bragstack.accessToken');
+ });
+
+ it('stores and deletes the token through SecureStore', async () => {
+ await setAccessToken('abc');
+ expect(SecureStore.setItemAsync).toHaveBeenCalledWith('bragstack.accessToken', 'abc');
+
+ await clearAccessToken();
+ expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('bragstack.accessToken');
+ });
+
+ it('treats an empty token as a delete', async () => {
+ await setAccessToken('');
+ expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
+ expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('bragstack.accessToken');
+ });
+});
diff --git a/mobile/__tests__/productApi.test.js b/mobile/__tests__/productApi.test.js
new file mode 100644
index 0000000..9200bfb
--- /dev/null
+++ b/mobile/__tests__/productApi.test.js
@@ -0,0 +1,149 @@
+jest.mock('../src/api', () => ({
+ api: {
+ get: jest.fn(),
+ post: jest.fn(),
+ patch: jest.fn(),
+ },
+}));
+
+import { api } from '../src/api';
+import {
+ buildEntryPayload,
+ createPrivateEntry,
+ getEntries,
+ getImpactReceipts,
+ getProductErrorMessage,
+ loadProofOverview,
+ updateProfile,
+} from '../src/productApi';
+
+describe('mobile product API', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('loads entries and Impact Receipts with pagination', async () => {
+ api.get
+ .mockResolvedValueOnce({ data: { entries: [{ id: 'e1' }] } })
+ .mockResolvedValueOnce({ data: { receipts: [{ id: 'r1' }] } });
+
+ await expect(getEntries(5, 10)).resolves.toEqual({ entries: [{ id: 'e1' }] });
+ await expect(getImpactReceipts(7, 3)).resolves.toEqual({ receipts: [{ id: 'r1' }] });
+ expect(api.get).toHaveBeenNthCalledWith(1, '/entries', { params: { limit: 5, skip: 10 } });
+ expect(api.get).toHaveBeenNthCalledWith(2, '/impact-receipts', { params: { limit: 7, skip: 3 } });
+ });
+
+ it('combines proof pages into a dashboard overview', async () => {
+ api.get
+ .mockResolvedValueOnce({ data: { total_entries: 2, entries: [{ id: 'e1' }, { id: 'e2' }] } })
+ .mockResolvedValueOnce({ data: { total_receipts: 1, receipts: [{ id: 'r1' }] } });
+
+ await expect(loadProofOverview()).resolves.toEqual({
+ entries: [{ id: 'e1' }, { id: 'e2' }],
+ receipts: [{ id: 'r1' }],
+ totalEntries: 2,
+ totalReceipts: 1,
+ });
+ });
+
+ it('uses safe empty defaults when proof pages omit optional collections', async () => {
+ api.get
+ .mockResolvedValueOnce({ data: {} })
+ .mockResolvedValueOnce({ data: {} });
+
+ await expect(loadProofOverview()).resolves.toEqual({
+ entries: [],
+ receipts: [],
+ totalEntries: 0,
+ totalReceipts: 0,
+ });
+ });
+
+ it('builds a truthful private accomplishment payload', () => {
+ const payload = buildEntryPayload({
+ title: ' Fixed production deploys ',
+ situation: ' Deploys were failing ',
+ action: ' Added validation ',
+ impact: ' Restored reliable releases ',
+ category: ' Platform ',
+ tags: 'Docker, CI, Docker, ',
+ });
+
+ expect(payload).toEqual(expect.objectContaining({
+ title: 'Fixed production deploys',
+ situation: 'Deploys were failing',
+ action: 'Added validation',
+ impact: 'Restored reliable releases',
+ category: 'Platform',
+ entry_type: 'Current Job',
+ lesson: null,
+ tags: ['Docker', 'CI'],
+ is_public: false,
+ }));
+ expect(payload.entry_date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ });
+
+ it('defaults the category and rejects incomplete proof', () => {
+ expect(buildEntryPayload({ title: 'Win', situation: 'Context', action: 'Action', impact: 'Impact' }).category).toBe('General');
+ expect(() => buildEntryPayload({ title: 'Win', situation: '', action: 'Action', impact: 'Impact' })).toThrow(/required/);
+ });
+
+ it('persists a private accomplishment through /entries', async () => {
+ api.post.mockResolvedValue({ data: { id: 'entry-1', title: 'Win' } });
+ await expect(createPrivateEntry({
+ title: 'Win',
+ situation: 'Context',
+ action: 'Action',
+ impact: 'Impact',
+ tags: '',
+ })).resolves.toEqual({ id: 'entry-1', title: 'Win' });
+
+ expect(api.post).toHaveBeenCalledWith('/entries', expect.objectContaining({
+ title: 'Win',
+ is_public: false,
+ }));
+ });
+
+ it('updates profile while preserving fields not edited on mobile', async () => {
+ api.patch.mockResolvedValue({ data: { name: 'Tee', headline: 'Platform Engineer' } });
+ const user = {
+ name: 'Old Name',
+ headline: 'Old headline',
+ bio: 'Old bio',
+ location: 'Old location',
+ github_url: 'https://github.com/example',
+ portfolio_url: 'https://example.com',
+ resume_url: 'https://example.com/resume.pdf',
+ profile_theme: 'engineer',
+ profile_primary_color: '#112233',
+ profile_secondary_color: '#223344',
+ profile_background_color: '#334455',
+ };
+
+ await expect(updateProfile(user, {
+ name: ' Tee ',
+ headline: ' Platform Engineer ',
+ bio: ' Builds reliable systems ',
+ location: ' Georgia ',
+ })).resolves.toEqual({ name: 'Tee', headline: 'Platform Engineer' });
+
+ expect(api.patch).toHaveBeenCalledWith('/auth/me/profile', {
+ name: 'Tee',
+ headline: 'Platform Engineer',
+ bio: 'Builds reliable systems',
+ location: 'Georgia',
+ github_url: 'https://github.com/example',
+ portfolio_url: 'https://example.com',
+ resume_url: 'https://example.com/resume.pdf',
+ profile_theme: 'engineer',
+ profile_primary_color: '#112233',
+ profile_secondary_color: '#223344',
+ profile_background_color: '#334455',
+ });
+ });
+
+ it('uses backend detail, offline, and fallback product errors safely', () => {
+ expect(getProductErrorMessage({ response: { data: { detail: 'Specific problem' } } })).toBe('Specific problem');
+ expect(getProductErrorMessage({ response: { data: { detail: { message: 'Structured problem' } } } })).toBe('Structured problem');
+ expect(getProductErrorMessage(new Error('network'))).toMatch(/Could not reach BragStack/);
+ expect(getProductErrorMessage({ response: { data: {} } }, 'Try again later')).toBe('Try again later');
+ });
+});
diff --git a/mobile/__tests__/receiptDraft.test.js b/mobile/__tests__/receiptDraft.test.js
new file mode 100644
index 0000000..6b9035b
--- /dev/null
+++ b/mobile/__tests__/receiptDraft.test.js
@@ -0,0 +1,23 @@
+import { createReceiptDraft } from '../src/receiptDraft';
+
+describe('createReceiptDraft', () => {
+ it('requires an accomplishment', () => {
+ expect(createReceiptDraft(' ', 'Saved 20 minutes')).toBeNull();
+ });
+
+ it('normalizes user-entered accomplishment and result', () => {
+ expect(createReceiptDraft(' Shipped safer deploys ', ' 30% fewer rollbacks ')).toEqual({
+ win: 'Shipped safer deploys',
+ result: '30% fewer rollbacks',
+ resultDisplay: '30% fewer rollbacks',
+ visibility: 'private',
+ });
+ });
+
+ it('never fabricates a missing result', () => {
+ const draft = createReceiptDraft('Documented the incident response flow', '');
+ expect(draft.result).toBe('');
+ expect(draft.resultDisplay).toBe('Result not added yet — BragStack will not invent one.');
+ expect(draft.visibility).toBe('private');
+ });
+});
diff --git a/mobile/__tests__/theme.test.js b/mobile/__tests__/theme.test.js
new file mode 100644
index 0000000..698c7d3
--- /dev/null
+++ b/mobile/__tests__/theme.test.js
@@ -0,0 +1,18 @@
+import { colors, navigationTheme } from '../src/theme';
+
+describe('BragStack mobile brand tokens', () => {
+ it('uses the canonical mobile brand palette', () => {
+ expect(colors.background).toBe('#070B14');
+ expect(colors.text).toBe('#F8FAFC');
+ expect(colors.primary).toBe('#A6DCFF');
+ expect(colors.secondary).toBe('#AD91FF');
+ expect(colors.cyan).toBe('#69E4F6');
+ });
+
+ it('keeps navigation aligned with the same app palette', () => {
+ expect(navigationTheme.dark).toBe(true);
+ expect(navigationTheme.colors.primary).toBe(colors.primary);
+ expect(navigationTheme.colors.background).toBe(colors.background);
+ expect(navigationTheme.colors.notification).toBe(colors.secondary);
+ });
+});
diff --git a/mobile/app.json b/mobile/app.json
new file mode 100644
index 0000000..6444697
--- /dev/null
+++ b/mobile/app.json
@@ -0,0 +1,23 @@
+{
+ "expo": {
+ "name": "BragStack",
+ "slug": "bragstack",
+ "version": "0.1.0",
+ "orientation": "default",
+ "userInterfaceStyle": "dark",
+ "scheme": "bragstack",
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "com.bragstack.app"
+ },
+ "android": {
+ "package": "com.bragstack.app",
+ "adaptiveIcon": {
+ "backgroundColor": "#09090f"
+ }
+ },
+ "extra": {
+ "apiUrl": "${EXPO_PUBLIC_API_URL}"
+ }
+ }
+}
diff --git a/mobile/assets/brandmark.svg b/mobile/assets/brandmark.svg
new file mode 100644
index 0000000..e83deed
--- /dev/null
+++ b/mobile/assets/brandmark.svg
@@ -0,0 +1,19 @@
+
diff --git a/mobile/eas.json b/mobile/eas.json
new file mode 100644
index 0000000..3e7a1a8
--- /dev/null
+++ b/mobile/eas.json
@@ -0,0 +1,22 @@
+{
+ "cli": {
+ "appVersionSource": "remote"
+ },
+ "build": {
+ "preview": {
+ "distribution": "internal",
+ "env": {
+ "EXPO_PUBLIC_API_URL": "https://api.usebragstack.com"
+ }
+ },
+ "production": {
+ "autoIncrement": true,
+ "env": {
+ "EXPO_PUBLIC_API_URL": "https://api.usebragstack.com"
+ }
+ }
+ },
+ "submit": {
+ "production": {}
+ }
+}
diff --git a/mobile/index.js b/mobile/index.js
new file mode 100644
index 0000000..112bddf
--- /dev/null
+++ b/mobile/index.js
@@ -0,0 +1,37 @@
+if (typeof document !== 'undefined') {
+ let viewport = document.querySelector('meta[name="viewport"]');
+ if (!viewport) {
+ viewport = document.createElement('meta');
+ viewport.setAttribute('name', 'viewport');
+ document.head.appendChild(viewport);
+ }
+ viewport.setAttribute('content', 'width=device-width, initial-scale=1, viewport-fit=cover');
+
+ const style = document.createElement('style');
+ style.setAttribute('data-bragstack-viewport', 'true');
+ style.textContent = `
+ html, body, #root {
+ width: 100%;
+ min-width: 0;
+ height: 100%;
+ min-height: 100%;
+ margin: 0;
+ padding: 0;
+ overflow-x: hidden;
+ background: #070B14;
+ }
+ #root {
+ display: flex;
+ flex-direction: column;
+ }
+ *, *::before, *::after {
+ box-sizing: border-box;
+ }
+ `;
+ document.head.appendChild(style);
+}
+
+const { registerRootComponent } = require('expo');
+const App = require('./App').default;
+
+registerRootComponent(App);
diff --git a/mobile/package.json b/mobile/package.json
new file mode 100644
index 0000000..521beea
--- /dev/null
+++ b/mobile/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "bragstack-mobile",
+ "version": "0.1.0",
+ "private": true,
+ "main": "index.js",
+ "scripts": {
+ "start": "expo start",
+ "android": "expo start --android",
+ "ios": "expo start --ios",
+ "web": "expo start --web",
+ "web:codespaces": "EXPO_PUBLIC_API_URL=https://api.usebragstack.com expo start --web",
+ "doctor": "expo-doctor",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "test:ci": "jest --runInBand --coverage"
+ },
+ "dependencies": {
+ "@react-navigation/bottom-tabs": "^7.2.0",
+ "@react-navigation/native": "^7.1.0",
+ "axios": "^1.7.9",
+ "expo": "~57.0.9",
+ "expo-secure-store": "~57.0.2",
+ "expo-status-bar": "~57.0.1",
+ "react": "19.2.3",
+ "react-native": "0.86.3",
+ "react-native-safe-area-context": "~5.7.0",
+ "react-native-screens": "4.26.0",
+ "react-native-svg": "15.15.4",
+ "react-dom": "19.2.3",
+ "react-native-web": "^0.21.2"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.25.2",
+ "@testing-library/react-native": "^14.0.1",
+ "expo-doctor": "^1.20.4",
+ "jest": "^29.7.0",
+ "jest-expo": "~57.0.5"
+ },
+ "jest": {
+ "preset": "jest-expo",
+ "collectCoverageFrom": [
+ "src/**/*.js",
+ "!src/Brandmark.js"
+ ],
+ "coverageThreshold": {
+ "global": {
+ "branches": 65,
+ "functions": 75,
+ "lines": 75,
+ "statements": 75
+ }
+ }
+ }
+}
diff --git a/mobile/src/Brandmark.js b/mobile/src/Brandmark.js
new file mode 100644
index 0000000..34a0eba
--- /dev/null
+++ b/mobile/src/Brandmark.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import Svg, { Defs, LinearGradient, Path, Rect, Stop } from 'react-native-svg';
+
+// Exact path geometry and gradient stops from frontend/public/brandmark.svg.
+// Kept as a React Native component so the canonical vector renders natively.
+export default function Brandmark({ size = 48 }) {
+ return (
+
+ );
+}
diff --git a/mobile/src/api.js b/mobile/src/api.js
new file mode 100644
index 0000000..6b64ab4
--- /dev/null
+++ b/mobile/src/api.js
@@ -0,0 +1,31 @@
+import axios from 'axios';
+import { getAccessToken } from './authStorage';
+
+function resolveApiBaseURL() {
+ const configured = process.env.EXPO_PUBLIC_API_URL?.replace(/\/$/, '');
+ if (configured) return configured;
+
+ if (typeof window !== 'undefined') {
+ const host = window.location.hostname;
+ if (host.endsWith('.app.github.dev')) {
+ const backendHost = host.replace(/-\d+\.app\.github\.dev$/, '-8000.app.github.dev');
+ if (backendHost !== host) return `https://${backendHost}`;
+ }
+ }
+
+ return 'http://localhost:8000';
+}
+
+export const apiBaseURL = resolveApiBaseURL();
+
+export const api = axios.create({
+ baseURL: apiBaseURL,
+ timeout: 15000,
+ headers: { 'Content-Type': 'application/json' },
+});
+
+api.interceptors.request.use(async (config) => {
+ const token = await getAccessToken();
+ if (token) config.headers.Authorization = `Bearer ${token}`;
+ return config;
+});
diff --git a/mobile/src/authApi.js b/mobile/src/authApi.js
new file mode 100644
index 0000000..cb85647
--- /dev/null
+++ b/mobile/src/authApi.js
@@ -0,0 +1,76 @@
+import { api } from './api';
+import { clearAccessToken, setAccessToken } from './authStorage';
+
+function normalizeEmail(email) {
+ return String(email || '').trim().toLowerCase();
+}
+
+export async function login(email, password) {
+ const form = new URLSearchParams();
+ form.append('username', normalizeEmail(email));
+ form.append('password', password);
+
+ const response = await api.post('/auth/login', form.toString(), {
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ });
+
+ await setAccessToken(response.data.access_token);
+ return response.data.user;
+}
+
+export async function register(name, email, password) {
+ const response = await api.post('/auth/register', {
+ name: String(name || '').trim(),
+ email: normalizeEmail(email),
+ password,
+ });
+ return response.data;
+}
+
+export async function resendVerification(email) {
+ const response = await api.post('/auth/email-verification/resend', {
+ email: normalizeEmail(email),
+ });
+ return response.data;
+}
+
+export async function confirmEmailVerification(token) {
+ const response = await api.post('/auth/email-verification/confirm', { token });
+ await setAccessToken(response.data.access_token);
+ return response.data.user;
+}
+
+export async function requestPasswordReset(email) {
+ const response = await api.post('/auth/password-reset/request', {
+ email: normalizeEmail(email),
+ });
+ return response.data;
+}
+
+export async function confirmPasswordReset(token, password) {
+ const response = await api.post('/auth/password-reset/confirm', { token, password });
+ return response.data;
+}
+
+export async function restoreSession() {
+ try {
+ const response = await api.get('/auth/me');
+ return response.data;
+ } catch (error) {
+ if (error?.response?.status === 401) await clearAccessToken();
+ throw error;
+ }
+}
+
+export async function logout() {
+ await clearAccessToken();
+}
+
+export function getAuthErrorMessage(error) {
+ const detail = error?.response?.data?.detail;
+ if (typeof detail === 'string') return detail;
+ if (detail && typeof detail.message === 'string') return detail.message;
+ if (!error?.response) return 'Could not reach BragStack. Check your connection and try again.';
+ if (error.response.status >= 500) return 'BragStack is having trouble right now. Please try again in a moment.';
+ return 'That request could not be completed. Please check your details and try again.';
+}
diff --git a/mobile/src/authStorage.js b/mobile/src/authStorage.js
new file mode 100644
index 0000000..c4a2e63
--- /dev/null
+++ b/mobile/src/authStorage.js
@@ -0,0 +1,16 @@
+import * as SecureStore from 'expo-secure-store';
+
+const ACCESS_TOKEN_KEY = 'bragstack.accessToken';
+
+export async function getAccessToken() {
+ return SecureStore.getItemAsync(ACCESS_TOKEN_KEY);
+}
+
+export async function setAccessToken(token) {
+ if (!token) return clearAccessToken();
+ return SecureStore.setItemAsync(ACCESS_TOKEN_KEY, token);
+}
+
+export async function clearAccessToken() {
+ return SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
+}
diff --git a/mobile/src/productApi.js b/mobile/src/productApi.js
new file mode 100644
index 0000000..966e5cf
--- /dev/null
+++ b/mobile/src/productApi.js
@@ -0,0 +1,89 @@
+import { api } from './api';
+
+export function getProductErrorMessage(error, fallback = 'Could not load your BragStack data.') {
+ const detail = error?.response?.data?.detail;
+ if (typeof detail === 'string') return detail;
+ if (detail && typeof detail.message === 'string') return detail.message;
+ if (!error?.response) return 'Could not reach BragStack. Check your connection and try again.';
+ return fallback;
+}
+
+export async function getEntries(limit = 30, skip = 0) {
+ const response = await api.get('/entries', { params: { limit, skip } });
+ return response.data;
+}
+
+export async function getImpactReceipts(limit = 30, skip = 0) {
+ const response = await api.get('/impact-receipts', { params: { limit, skip } });
+ return response.data;
+}
+
+export async function loadProofOverview() {
+ const [entriesPage, receiptsPage] = await Promise.all([
+ getEntries(),
+ getImpactReceipts(),
+ ]);
+ return {
+ entries: entriesPage?.entries || [],
+ receipts: receiptsPage?.receipts || [],
+ totalEntries: Number(entriesPage?.total_entries || 0),
+ totalReceipts: Number(receiptsPage?.total_receipts || 0),
+ };
+}
+
+function todayIsoDate() {
+ return new Date().toISOString().slice(0, 10);
+}
+
+export function buildEntryPayload(input) {
+ const title = String(input?.title || '').trim();
+ const situation = String(input?.situation || '').trim();
+ const action = String(input?.action || '').trim();
+ const impact = String(input?.impact || '').trim();
+ const category = String(input?.category || '').trim() || 'General';
+ const tags = String(input?.tags || '')
+ .split(',')
+ .map((tag) => tag.trim())
+ .filter(Boolean);
+
+ if (!title || !situation || !action || !impact) {
+ throw new Error('Title, context, action, and impact are required.');
+ }
+
+ return {
+ title,
+ category,
+ entry_date: todayIsoDate(),
+ entry_type: 'Current Job',
+ situation,
+ action,
+ impact,
+ lesson: null,
+ tags: [...new Set(tags)],
+ is_public: false,
+ };
+}
+
+export async function createPrivateEntry(input) {
+ const response = await api.post('/entries', buildEntryPayload(input));
+ return response.data;
+}
+
+export async function updateProfile(user, changes) {
+ const payload = {
+ name: String(changes?.name ?? user?.name ?? '').trim(),
+ headline: String(changes?.headline ?? user?.headline ?? '').trim(),
+ bio: String(changes?.bio ?? user?.bio ?? '').trim(),
+ location: String(changes?.location ?? user?.location ?? '').trim(),
+ github_url: String(user?.github_url || '').trim(),
+ portfolio_url: String(user?.portfolio_url || '').trim(),
+ resume_url: String(user?.resume_url || '').trim(),
+ profile_theme: user?.profile_theme || 'default',
+ profile_primary_color: user?.profile_primary_color || '',
+ profile_secondary_color: user?.profile_secondary_color || '',
+ profile_background_color: user?.profile_background_color || '',
+ };
+
+ const response = await api.patch('/auth/me/profile', payload);
+ return response.data;
+}
diff --git a/mobile/src/receiptDraft.js b/mobile/src/receiptDraft.js
new file mode 100644
index 0000000..a9883a2
--- /dev/null
+++ b/mobile/src/receiptDraft.js
@@ -0,0 +1,13 @@
+export function createReceiptDraft(win, result) {
+ const accomplishment = String(win || '').trim();
+ const outcome = String(result || '').trim();
+
+ if (!accomplishment) return null;
+
+ return {
+ win: accomplishment,
+ result: outcome,
+ resultDisplay: outcome || 'Result not added yet — BragStack will not invent one.',
+ visibility: 'private',
+ };
+}
diff --git a/mobile/src/theme.js b/mobile/src/theme.js
new file mode 100644
index 0000000..fa1bc65
--- /dev/null
+++ b/mobile/src/theme.js
@@ -0,0 +1,55 @@
+export const colors = {
+ // BragStack product palette — no warm/brown accent drift.
+ background: '#070B14',
+ sidebar: '#0B1020',
+ surface: '#0D1526',
+ surfaceElevated: '#131E33',
+ border: 'rgba(166, 220, 255, 0.16)',
+ text: '#F8FAFC',
+ muted: '#A7B4C9',
+ mutedStrong: '#7F90AA',
+ primary: '#A6DCFF',
+ primarySoft: '#D8F1FF',
+ secondary: '#AD91FF',
+ cyan: '#69E4F6',
+ danger: '#FFB0B0',
+
+ // Canonical identity aliases retained for shared components.
+ brandBackground: '#070B14',
+ brandSurface: '#0D1526',
+ brandSurfaceLight: '#131E33',
+ brandText: '#F8FAFC',
+ brandMuted: '#A7B4C9',
+ brandBlue: '#A6DCFF',
+ brandPurple: '#AD91FF',
+ brandCyan: '#69E4F6',
+
+ success: '#86E3B2',
+};
+
+export const spacing = {
+ xs: 6,
+ sm: 10,
+ md: 16,
+ lg: 24,
+ xl: 32,
+};
+
+export const radius = {
+ sm: 12,
+ md: 18,
+ lg: 28,
+ pill: 999,
+};
+
+export const navigationTheme = {
+ dark: true,
+ colors: {
+ primary: colors.primary,
+ background: colors.background,
+ card: colors.sidebar,
+ text: colors.text,
+ border: colors.border,
+ notification: colors.secondary,
+ },
+};