From 2fdd4ca393e8ed52e9e1a50ee1c5f64353cc00c3 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 23:02:43 -0400 Subject: [PATCH 01/64] feat(mobile): scaffold Expo app --- mobile/package.json | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 mobile/package.json diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..ff3b613 --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,28 @@ +{ + "name": "bragstack-mobile", + "version": "0.1.0", + "private": true, + "main": "node_modules/expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web" + }, + "dependencies": { + "@react-navigation/bottom-tabs": "^7.2.0", + "@react-navigation/native": "^7.1.0", + "@react-navigation/native-stack": "^7.3.0", + "axios": "^1.7.9", + "expo": "~54.0.0", + "expo-secure-store": "~15.0.7", + "expo-status-bar": "~3.0.8", + "react": "19.1.0", + "react-native": "0.81.4", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0" + }, + "devDependencies": { + "@babel/core": "^7.25.2" + } +} From 02953124f45865016e362e875f0346c296ac1bb8 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 23:02:48 -0400 Subject: [PATCH 02/64] feat(mobile): add iOS and Android app config --- mobile/app.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 mobile/app.json diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..205a2a9 --- /dev/null +++ b/mobile/app.json @@ -0,0 +1,23 @@ +{ + "expo": { + "name": "BragStack", + "slug": "bragstack", + "version": "0.1.0", + "orientation": "portrait", + "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}" + } + } +} From 17795dfae1a7f3486da5aa7b1401abe526d3208a Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 23:02:54 -0400 Subject: [PATCH 03/64] feat(mobile): add BragStack theme tokens --- mobile/src/theme.js | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 mobile/src/theme.js diff --git a/mobile/src/theme.js b/mobile/src/theme.js new file mode 100644 index 0000000..c155578 --- /dev/null +++ b/mobile/src/theme.js @@ -0,0 +1,40 @@ +export const colors = { + background: '#09090f', + surface: '#0f172a', + surfaceElevated: '#111827', + border: 'rgba(148, 163, 184, 0.18)', + text: '#f8fafc', + muted: '#94a3b8', + primary: '#38bdf8', + primarySoft: '#bae6fd', + violet: '#a855f7', + success: '#22c55e', + danger: '#f87171', +}; + +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.surface, + text: colors.text, + border: colors.border, + notification: colors.violet, + }, +}; From 0c8c2cb746f2bb42d65b8a9436189ae3ee29aa1f Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 23:03:01 -0400 Subject: [PATCH 04/64] feat(mobile): add secure session storage --- mobile/src/authStorage.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 mobile/src/authStorage.js 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); +} From 05c3875971609a10f30dd107d62e28293c1b4598 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 23:03:09 -0400 Subject: [PATCH 05/64] feat(mobile): add authenticated API client --- mobile/src/api.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 mobile/src/api.js diff --git a/mobile/src/api.js b/mobile/src/api.js new file mode 100644 index 0000000..425aca8 --- /dev/null +++ b/mobile/src/api.js @@ -0,0 +1,16 @@ +import axios from 'axios'; +import { getAccessToken } from './authStorage'; + +const baseURL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:8000'; + +export const api = axios.create({ + baseURL, + 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; +}); From 77edaa1c6dc10372b51c2b448c5ec4f0a9fcc87f Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 23:03:26 -0400 Subject: [PATCH 06/64] feat(mobile): add branded tab navigation and starter screens --- mobile/App.js | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 mobile/App.js diff --git a/mobile/App.js b/mobile/App.js new file mode 100644 index 0000000..45e3cbd --- /dev/null +++ b/mobile/App.js @@ -0,0 +1,114 @@ +import React from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { StatusBar } from 'expo-status-bar'; +import { Pressable, SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { colors, navigationTheme, radius, spacing } from './src/theme'; + +const Tab = createBottomTabNavigator(); + +function Screen({ eyebrow, title, children }) { + return ( + + + {eyebrow} + {title} + {children} + + + ); +} + +function Card({ title, body, value }) { + return ( + + {value ? {value} : null} + {title} + {body} + + ); +} + +function HomeScreen() { + return ( + + + + + ); +} + +function AccomplishmentsScreen() { + return ( + + + + + ); +} + +function AddScreen() { + return ( + + + + Start capture + + + ); +} + +function ProfileScreen() { + return ( + + + + ); +} + +function SettingsScreen() { + return ( + + + + + ); +} + +export default function App() { + return ( + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: colors.background }, + page: { padding: spacing.lg, gap: spacing.md }, + eyebrow: { color: colors.primarySoft, fontSize: 12, fontWeight: '800', letterSpacing: 2, textTransform: 'uppercase' }, + title: { color: colors.text, fontSize: 38, lineHeight: 42, fontWeight: '900', letterSpacing: -1.5, marginBottom: spacing.sm }, + card: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1, borderRadius: radius.lg, padding: spacing.lg, gap: spacing.sm }, + metric: { color: colors.primary, fontSize: 34, fontWeight: '900' }, + cardTitle: { color: colors.text, fontSize: 18, fontWeight: '800' }, + body: { color: colors.muted, fontSize: 15, lineHeight: 22 }, + button: { backgroundColor: colors.primary, borderRadius: radius.pill, paddingVertical: 16, paddingHorizontal: 22, alignItems: 'center', marginTop: spacing.sm }, + buttonText: { color: colors.background, fontWeight: '900', fontSize: 16 }, + tabBar: { backgroundColor: colors.surface, borderTopColor: colors.border, height: 74, paddingTop: 8, paddingBottom: 10 }, + tabLabel: { fontSize: 11, fontWeight: '700' }, +}); From 034de28fcbe4802711c191fed9d04cf5a2d3ef3c Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 23:03:33 -0400 Subject: [PATCH 07/64] docs(mobile): add local development guide --- mobile/README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 mobile/README.md diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..6254603 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,32 @@ +# BragStack Mobile + +Cross-platform iOS and Android client for BragStack, built with React Native and Expo. + +## 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`. + +## Foundation included + +- BragStack dark brand theme with sky-blue and violet accents +- iOS and Android application identifiers +- Bottom-tab navigation +- Starter Home, Accomplishments, Add, Profile, and Settings screens +- Axios API client +- Encrypted access-token storage through Expo SecureStore +- No unnecessary native permissions + +## Next implementation slices + +1. Connect login/session bootstrap to the existing FastAPI auth endpoints. +2. Connect accomplishments and Impact Receipts to real API data. +3. Implement quick-add validation and editing. +4. Add profile controls and public-profile deep links. +5. Add accessibility, offline/error states, automated tests, and release QA. +6. Complete App Store / Google Play metadata, privacy disclosures, screenshots, signing, and internal testing. From bd9905927fd92a1495e7a96ab7dbcb30c8d1b0a7 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 23:03:45 -0400 Subject: [PATCH 08/64] docs: add mobile roadmap --- docs/ROADMAP.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/ROADMAP.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..d4c1c2a --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,55 @@ +# BragStack Roadmap + +## Mobile initiative — iOS + Android + +### Phase 1: Foundation — in progress +- Expo / React Native application scaffold +- Shared BragStack theme tokens +- Navigation shell for Home, Accomplishments, Add, Profile, and Settings +- Secure on-device token storage +- Authenticated API client configuration +- Store-safe default permission posture + +### Phase 2: Core product parity +- Login, logout, and session restore +- Dashboard backed by live API data +- Accomplishments / Impact Receipts list and detail views +- Quick-add accomplishment flow +- Edit and delete flows with confirmation +- Profile editing and public-profile controls +- Loading, empty, offline, retry, and API error states + +### 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 + +### Phase 4: Store readiness +- Accessibility and dynamic-text review +- App icon, splash screen, screenshots, and store copy +- Apple privacy disclosures / required reasons review +- Google Play Data safety disclosure +- Account deletion flow validation +- Terms and Privacy Policy links +- Production API configuration +- iOS signing / TestFlight +- Android signing / Play internal testing +- Crash reporting decision, implementation, and disclosure if adopted + +### Phase 5: Release and hardening +- Beta feedback pass +- Device / OS compatibility matrix +- Performance and crash-free-session targets +- Security review of token lifecycle, deep links, logs, and evidence handling +- App Store and Google Play production submission + +## 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. + +Tracking epic: #200 From 099d9ed4e36ffb673b44c8a0b968460dda810aac Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 23:04:09 -0400 Subject: [PATCH 09/64] chore(mobile): align scaffold with current Expo SDK 57 --- mobile/package.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/mobile/package.json b/mobile/package.json index ff3b613..3cfab9a 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -7,20 +7,20 @@ "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", - "web": "expo start --web" + "web": "expo start --web", + "doctor": "expo-doctor" }, "dependencies": { "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.1.0", - "@react-navigation/native-stack": "^7.3.0", "axios": "^1.7.9", - "expo": "~54.0.0", - "expo-secure-store": "~15.0.7", - "expo-status-bar": "~3.0.8", - "react": "19.1.0", - "react-native": "0.81.4", - "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0" + "expo": "~57.0.9", + "expo-secure-store": "~56.0.4", + "expo-status-bar": "~57.0.1", + "react": "19.2.3", + "react-native": "0.86.2", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "4.26.0" }, "devDependencies": { "@babel/core": "^7.25.2" From e350fb8d633026154fda89bcbb614c910f48b122 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 23:04:15 -0400 Subject: [PATCH 10/64] docs: add changelog with mobile foundation entry --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..76ee742 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable BragStack product changes are recorded here. + +## Unreleased + +### Added +- Started the BragStack mobile initiative for iOS and Android under tracking issue #200. +- Added a React Native / Expo mobile scaffold with BragStack branding and dark theme tokens. +- Added starter Home, Accomplishments, Add, Profile, and Settings navigation. +- Added secure on-device access-token storage and a reusable authenticated API client. +- Added a mobile roadmap covering product parity, native features, store readiness, security, QA, and release. + +### Security / privacy +- Mobile foundation requests no unnecessary native permissions. +- Sensitive session tokens are stored with Expo SecureStore rather than plaintext application storage. +- Mobile product principles preserve BragStack's private-by-default handling of workplace evidence. From 73d6ee6ab49640e16d19587b5242866aa96080b0 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:47:15 -0400 Subject: [PATCH 11/64] feat(mobile): polish product preview and quick capture --- mobile/App.js | 352 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 312 insertions(+), 40 deletions(-) diff --git a/mobile/App.js b/mobile/App.js index 45e3cbd..921d343 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -1,17 +1,29 @@ -import React from 'react'; +import React, { useState } from 'react'; import { NavigationContainer } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { StatusBar } from 'expo-status-bar'; -import { Pressable, SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { colors, navigationTheme, radius, spacing } from './src/theme'; const Tab = createBottomTabNavigator(); -function Screen({ eyebrow, title, children }) { +const tabIcons = { + Home: '⌂', + Proof: '✓', + Add: '+', + Profile: '◉', + Settings: '⚙', +}; + +function Screen({ eyebrow, title, children, demo = false }) { return ( - - - {eyebrow} + + + + {eyebrow} + {demo ? : null} + {title} {children} @@ -19,58 +31,260 @@ function Screen({ eyebrow, title, children }) { ); } -function Card({ title, body, value }) { +function Pill({ label, tone = 'primary' }) { + const violet = tone === 'violet'; + const success = tone === 'success'; + return ( + + + {label} + + + ); +} + +function Stat({ value, label, accent = false }) { + return ( + + {value} + {label} + + ); +} + +function ProofCard({ title, result, status, privacy = 'Private' }) { return ( - {value ? {value} : null} + + + ◌ {privacy} + {title} - {body} + {result} ); } -function HomeScreen() { +function HomeScreen({ navigation }) { return ( - - - + + + + PROOF PULSE + Your wins are getting stronger. + + Capture outcomes while they are fresh, then turn them into evidence-backed career stories. + + + + + + + + + + Keep the streak alive + This week + + [styles.actionCard, pressed && styles.pressed]} + onPress={() => navigation.navigate('Add')} + accessibilityRole="button" + accessibilityLabel="Capture a new accomplishment" + > + + + + + + Capture a win in 30 seconds + Start with what happened. Add proof when you have it. + + + + + + Recent proof + Preview data + + ); } -function AccomplishmentsScreen() { +function ProofScreen() { return ( - - - + + + + + + + + + ); } function AddScreen() { + const [win, setWin] = useState(''); + const [result, setResult] = useState(''); + const [draft, setDraft] = useState(null); + const canSave = Boolean(win.trim()); + + function previewDraft() { + if (!canSave) return; + setDraft({ win: win.trim(), result: result.trim() }); + } + return ( - - - - Start capture - + + + WHAT HAPPENED? + + + WHAT CHANGED? + + + + 🔒 Drafts are private by default. Evidence and credit can be added in the full flow. + + [ + styles.button, + !canSave && styles.buttonDisabled, + pressed && canSave && styles.pressed, + ]} + onPress={previewDraft} + disabled={!canSave} + accessibilityRole="button" + > + Preview Impact Receipt + + + + {draft ? ( + + + + ◌ Private + + IMPACT RECEIPT PREVIEW + {draft.win} + + {draft.result || 'Result not added yet — BragStack will keep the draft without inventing one.'} + + + ) : null} ); } function ProfileScreen() { + const [publicPreview, setPublicPreview] = useState(false); + return ( - - + + + + BS + + BragStack Member + Platform • Reliability • Automation + + + + + + + + + + Public profile preview + See the shareable version without exposing private workplace evidence. + + setPublicPreview((value) => !value)} + style={[styles.toggle, publicPreview && styles.toggleActive]} + accessibilityRole="switch" + accessibilityState={{ checked: publicPreview }} + > + + + + {publicPreview ? ( + + VISIBLE IN PREVIEW + 3 selected accomplishments • 5 skills • no private evidence + + ) : null} + ); } function SettingsScreen() { return ( - - - + + + + + Privacy defaults are on + Secure session storage • Private evidence • Explicit sharing + + + + Appearance + BragStack dark • Sky-blue + violet accents + + + Mobile foundation + No unnecessary device permissions requested. Store disclosures and account controls are tracked in the mobile roadmap. + ); } @@ -80,16 +294,21 @@ export default function App() { ({ headerShown: false, tabBarActiveTintColor: colors.primary, tabBarInactiveTintColor: colors.muted, tabBarStyle: styles.tabBar, tabBarLabelStyle: styles.tabLabel, - }} + tabBarIcon: ({ color, focused }) => ( + + {tabIcons[route.name]} + + ), + })} > - + @@ -100,15 +319,68 @@ export default function App() { const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: colors.background }, - page: { padding: spacing.lg, gap: spacing.md }, - eyebrow: { color: colors.primarySoft, fontSize: 12, fontWeight: '800', letterSpacing: 2, textTransform: 'uppercase' }, - title: { color: colors.text, fontSize: 38, lineHeight: 42, fontWeight: '900', letterSpacing: -1.5, marginBottom: spacing.sm }, + page: { paddingHorizontal: spacing.lg, paddingTop: spacing.md, paddingBottom: 110, gap: spacing.md }, + flex: { flex: 1 }, + headingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', minHeight: 28 }, + eyebrow: { color: colors.primarySoft, fontSize: 11, fontWeight: '900', letterSpacing: 2.2, textTransform: 'uppercase' }, + title: { color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '900', letterSpacing: -1.4, marginBottom: spacing.sm }, + heroCard: { position: 'relative', overflow: 'hidden', backgroundColor: colors.surface, borderColor: 'rgba(56, 189, 248, 0.28)', borderWidth: 1, borderRadius: radius.lg, padding: spacing.lg, gap: spacing.sm }, + heroGlow: { position: 'absolute', width: 180, height: 180, borderRadius: 90, backgroundColor: 'rgba(168, 85, 247, 0.16)', top: -70, right: -55 }, + heroLabel: { color: colors.primary, fontSize: 11, fontWeight: '900', letterSpacing: 2 }, + heroTitle: { color: colors.text, fontSize: 25, lineHeight: 30, fontWeight: '900', maxWidth: '85%' }, + statRow: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.md }, + stat: { flex: 1, minHeight: 78, backgroundColor: 'rgba(255,255,255,0.035)', borderRadius: radius.md, padding: spacing.sm, justifyContent: 'center' }, + statAccent: { backgroundColor: 'rgba(56, 189, 248, 0.10)' }, + statValue: { color: colors.text, fontSize: 22, fontWeight: '900' }, + statValueAccent: { color: colors.primary }, + statLabel: { color: colors.muted, fontSize: 11, fontWeight: '700', marginTop: 2 }, + sectionHeader: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', marginTop: spacing.sm }, + sectionTitle: { color: colors.text, fontSize: 18, fontWeight: '900' }, + sectionHint: { color: colors.muted, fontSize: 11, fontWeight: '700' }, card: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1, borderRadius: radius.lg, padding: spacing.lg, gap: spacing.sm }, - metric: { color: colors.primary, fontSize: 34, fontWeight: '900' }, - cardTitle: { color: colors.text, fontSize: 18, fontWeight: '800' }, - body: { color: colors.muted, fontSize: 15, lineHeight: 22 }, - button: { backgroundColor: colors.primary, borderRadius: radius.pill, paddingVertical: 16, paddingHorizontal: 22, alignItems: 'center', marginTop: spacing.sm }, - buttonText: { color: colors.background, fontWeight: '900', fontSize: 16 }, - tabBar: { backgroundColor: colors.surface, borderTopColor: colors.border, height: 74, paddingTop: 8, paddingBottom: 10 }, - tabLabel: { fontSize: 11, fontWeight: '700' }, + cardTopRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm }, + cardTitle: { color: colors.text, fontSize: 18, fontWeight: '850' }, + body: { color: colors.muted, fontSize: 14, lineHeight: 21 }, + privacyLabel: { color: colors.muted, fontSize: 11, fontWeight: '700' }, + pill: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 6, borderRadius: radius.pill, backgroundColor: 'rgba(56, 189, 248, 0.10)', borderWidth: 1, borderColor: 'rgba(56, 189, 248, 0.22)' }, + pillViolet: { backgroundColor: 'rgba(168, 85, 247, 0.10)', borderColor: 'rgba(168, 85, 247, 0.26)' }, + pillSuccess: { backgroundColor: 'rgba(34, 197, 94, 0.10)', borderColor: 'rgba(34, 197, 94, 0.22)' }, + pillText: { color: colors.primarySoft, fontSize: 10, fontWeight: '900', letterSpacing: 0.4 }, + pillTextViolet: { color: '#e9d5ff' }, + pillTextSuccess: { color: '#bbf7d0' }, + actionCard: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, backgroundColor: colors.surfaceElevated, borderColor: colors.border, borderWidth: 1, borderRadius: radius.lg, padding: spacing.md }, + actionIcon: { width: 48, height: 48, borderRadius: 24, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center' }, + actionIconText: { color: colors.background, fontSize: 30, fontWeight: '600', marginTop: -2 }, + actionTitle: { color: colors.text, fontSize: 15, fontWeight: '900', marginBottom: 2 }, + chevron: { color: colors.primary, fontSize: 30, fontWeight: '300' }, + filterRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.xs }, + captureCard: { backgroundColor: colors.surface, borderColor: 'rgba(56, 189, 248, 0.24)', borderWidth: 1, borderRadius: radius.lg, padding: spacing.lg, gap: spacing.md }, + fieldLabel: { color: colors.primarySoft, fontSize: 10, fontWeight: '900', letterSpacing: 1.5 }, + input: { minHeight: 58, color: colors.text, fontSize: 16, lineHeight: 22, backgroundColor: 'rgba(255,255,255,0.035)', borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, padding: spacing.md, textAlignVertical: 'top' }, + inputTall: { minHeight: 80 }, + helperText: { color: colors.muted, fontSize: 12, lineHeight: 18 }, + button: { backgroundColor: colors.primary, borderRadius: radius.pill, paddingVertical: 16, paddingHorizontal: 22, alignItems: 'center' }, + buttonDisabled: { opacity: 0.35 }, + buttonText: { color: colors.background, fontWeight: '900', fontSize: 15 }, + previewCard: { backgroundColor: 'rgba(168, 85, 247, 0.08)', borderColor: 'rgba(168, 85, 247, 0.28)', borderWidth: 1, borderRadius: radius.lg, padding: spacing.lg, gap: spacing.sm }, + previewLabel: { color: '#e9d5ff', fontSize: 10, fontWeight: '900', letterSpacing: 1.3, marginTop: spacing.xs }, + profileCard: { alignItems: 'center', backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1, borderRadius: radius.lg, padding: spacing.xl, gap: spacing.sm }, + avatar: { width: 72, height: 72, borderRadius: 36, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(56, 189, 248, 0.12)', borderColor: 'rgba(56, 189, 248, 0.36)', borderWidth: 1 }, + avatarText: { color: colors.primary, fontSize: 22, fontWeight: '900' }, + profileName: { color: colors.text, fontSize: 21, fontWeight: '900', marginTop: spacing.xs }, + profileMeta: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm }, + toggle: { width: 50, height: 30, padding: 3, borderRadius: 15, justifyContent: 'center', backgroundColor: 'rgba(148,163,184,0.22)' }, + toggleActive: { backgroundColor: colors.primary }, + toggleKnob: { width: 24, height: 24, borderRadius: 12, backgroundColor: colors.text }, + toggleKnobActive: { alignSelf: 'flex-end', backgroundColor: colors.background }, + publicPreview: { marginTop: spacing.sm, borderTopWidth: 1, borderTopColor: colors.border, paddingTop: spacing.md }, + publicPreviewText: { color: colors.text, fontSize: 13, lineHeight: 19, marginTop: spacing.xs }, + securityCard: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, backgroundColor: 'rgba(34, 197, 94, 0.08)', borderColor: 'rgba(34, 197, 94, 0.22)', borderWidth: 1, borderRadius: radius.lg, padding: spacing.lg }, + securityIcon: { width: 40, height: 40, textAlign: 'center', textAlignVertical: 'center', color: '#bbf7d0', fontSize: 22, fontWeight: '900', borderRadius: 20, backgroundColor: 'rgba(34, 197, 94, 0.12)' }, + pressed: { opacity: 0.72, transform: [{ scale: 0.99 }] }, + tabBar: { backgroundColor: '#0c1220', borderTopColor: colors.border, height: 82, paddingTop: 8, paddingBottom: 10 }, + tabLabel: { fontSize: 10, fontWeight: '800' }, + tabIconWrap: { minWidth: 32, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center' }, + tabIconWrapActive: { backgroundColor: 'rgba(56, 189, 248, 0.10)' }, + tabIcon: { fontSize: 18, fontWeight: '900' }, }); From 3fbff927420b3f2d51a31c1679f62dd446cf5ba7 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:47:37 -0400 Subject: [PATCH 12/64] build(mobile): add store build profiles --- mobile/eas.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 mobile/eas.json diff --git a/mobile/eas.json b/mobile/eas.json new file mode 100644 index 0000000..9a814a0 --- /dev/null +++ b/mobile/eas.json @@ -0,0 +1,16 @@ +{ + "cli": { + "appVersionSource": "remote" + }, + "build": { + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} From 1ecca29d2382b549bfcae1b7c639be816b6d39fe Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:47:44 -0400 Subject: [PATCH 13/64] docs(mobile): add API environment example --- mobile/.env.example | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 mobile/.env.example 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 From de7880aaaa79cefff87dea43904a6adfd396cd90 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:47:54 -0400 Subject: [PATCH 14/64] docs: record mobile preview polish --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ee742..8550ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,15 @@ All notable BragStack product changes are recorded here. ### Added - Started the BragStack mobile initiative for iOS and Android under tracking issue #200. - Added a React Native / Expo mobile scaffold with BragStack branding and dark theme tokens. -- Added starter Home, Accomplishments, Add, Profile, and Settings navigation. +- Added starter Home, Proof, Add, Profile, and Settings navigation. +- Added a polished mobile product preview with Proof Pulse metrics, proof-status badges, recent proof cards, and mobile-first navigation. +- Added an interactive private-by-default Impact Receipt capture preview and public-profile visibility preview. - Added secure on-device access-token storage and a reusable authenticated API client. +- Added EAS preview/production build profiles and an example mobile API environment configuration. - Added a mobile roadmap covering product parity, native features, store readiness, security, QA, and release. ### Security / privacy - Mobile foundation requests no unnecessary native permissions. - Sensitive session tokens are stored with Expo SecureStore rather than plaintext application storage. - Mobile product principles preserve BragStack's private-by-default handling of workplace evidence. +- Demo capture explicitly avoids inventing missing result data and keeps draft proof private by default. From aa73d40b3e469bcdcfd5adb8d5ef84a6bf26269b Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:50:58 -0400 Subject: [PATCH 15/64] fix: align mobile theme with official BragStack brand guide --- mobile/src/theme.js | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/mobile/src/theme.js b/mobile/src/theme.js index c155578..d38b5ae 100644 --- a/mobile/src/theme.js +++ b/mobile/src/theme.js @@ -1,15 +1,29 @@ export const colors = { - background: '#09090f', - surface: '#0f172a', - surfaceElevated: '#111827', - border: 'rgba(148, 163, 184, 0.18)', - text: '#f8fafc', - muted: '#94a3b8', - primary: '#38bdf8', - primarySoft: '#bae6fd', - violet: '#a855f7', - success: '#22c55e', - danger: '#f87171', + // Authenticated app/admin palette — BragStack Brand Guide + background: '#090909', + sidebar: '#0B0B0B', + surface: '#111111', + surfaceElevated: '#121212', + border: 'rgba(247, 244, 238, 0.12)', + text: '#F7F4EE', + muted: '#AAA39A', + mutedStrong: '#817A73', + primary: '#FFB184', + primarySoft: '#FFD2B8', + danger: '#FFB0B0', + + // Canonical brand/marketing accents retained for the official logo and + // occasional identity moments, not as the authenticated app's main UI color. + brandBackground: '#070B14', + brandSurface: '#0D1526', + brandSurfaceLight: '#131E33', + brandText: '#F8FAFC', + brandMuted: '#A7B4C9', + brandBlue: '#A6DCFF', + brandPurple: '#AD91FF', + brandCyan: '#69E4F6', + + success: '#86E3B2', }; export const spacing = { @@ -32,9 +46,9 @@ export const navigationTheme = { colors: { primary: colors.primary, background: colors.background, - card: colors.surface, + card: colors.sidebar, text: colors.text, border: colors.border, - notification: colors.violet, + notification: colors.primary, }, }; From 97133da6bcd45b57f0fd01386475f630cc723876 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:51:09 -0400 Subject: [PATCH 16/64] feat: add official BragStack vector brandmark to mobile --- mobile/src/Brandmark.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 mobile/src/Brandmark.js 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 ( + + + + + + + + + + + + + + + + + + ); +} From 03d2176d9be4f848b5e02f10c0eee3e84657e32b Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:51:18 -0400 Subject: [PATCH 17/64] feat: wire mobile auth to BragStack backend --- mobile/src/authApi.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 mobile/src/authApi.js diff --git a/mobile/src/authApi.js b/mobile/src/authApi.js new file mode 100644 index 0000000..2a5accf --- /dev/null +++ b/mobile/src/authApi.js @@ -0,0 +1,36 @@ +import { api } from './api'; +import { clearAccessToken, setAccessToken } from './authStorage'; + +export async function login(email, password) { + const form = new URLSearchParams(); + form.append('username', email.trim().toLowerCase()); + 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 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 (!error?.response) return 'Could not reach BragStack. Check your connection and API configuration.'; + return 'Sign in failed. Please try again.'; +} From eab9451ec637546e95a90ca45d41d5fc507a386c Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:51:27 -0400 Subject: [PATCH 18/64] chore: add vector logo support for mobile --- mobile/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mobile/package.json b/mobile/package.json index 3cfab9a..422bcd1 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -20,7 +20,8 @@ "react": "19.2.3", "react-native": "0.86.2", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.26.0" + "react-native-screens": "4.26.0", + "react-native-svg": "^15.15.1" }, "devDependencies": { "@babel/core": "^7.25.2" From 00523f6d4a6901be4f1a6616c06705484e7f3a99 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:53:18 -0400 Subject: [PATCH 19/64] feat: add official branding and real mobile authentication --- mobile/App.js | 398 +++++--------------------------------------------- 1 file changed, 40 insertions(+), 358 deletions(-) diff --git a/mobile/App.js b/mobile/App.js index 921d343..269bb52 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -1,386 +1,68 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { NavigationContainer } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { StatusBar } from 'expo-status-bar'; -import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import Brandmark from './src/Brandmark'; +import { getAuthErrorMessage, login, logout, restoreSession } from './src/authApi'; import { colors, navigationTheme, radius, spacing } from './src/theme'; const Tab = createBottomTabNavigator(); +const icons = { Home: '⌂', Proof: '✓', Add: '+', Profile: '◉', Settings: '⚙' }; -const tabIcons = { - Home: '⌂', - Proof: '✓', - Add: '+', - Profile: '◉', - Settings: '⚙', -}; - -function Screen({ eyebrow, title, children, demo = false }) { - return ( - - - - {eyebrow} - {demo ? : null} - - {title} - {children} - - - ); -} - -function Pill({ label, tone = 'primary' }) { - const violet = tone === 'violet'; - const success = tone === 'success'; - return ( - - - {label} - - - ); +function Brand({ small = false }) { + return BragStack{!small && Proof of the impact you create.}; } -function Stat({ value, label, accent = false }) { - return ( - - {value} - {label} - - ); +function Login({ onSuccess }) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const submit = async () => { + if (!email.trim() || !password || busy) return; + setBusy(true); setError(''); + try { onSuccess(await login(email, password)); } + catch (e) { setError(getAuthErrorMessage(e)); } + finally { setBusy(false); } + }; + return WELCOME BACKYour proof is waiting.Sign in with your existing BragStack account.EMAILPASSWORD{error ? {error} : null}{busy ? : Sign in}🔒 Session tokens are kept in encrypted device storage.; } -function ProofCard({ title, result, status, privacy = 'Private' }) { - return ( - - - - ◌ {privacy} - - {title} - {result} - - ); +function Page({ kicker, title, children }) { + return {kicker}{title}{children}; } -function HomeScreen({ navigation }) { - return ( - - - - PROOF PULSE - Your wins are getting stronger. - - Capture outcomes while they are fresh, then turn them into evidence-backed career stories. - - - - - - - - - - Keep the streak alive - This week - - [styles.actionCard, pressed && styles.pressed]} - onPress={() => navigation.navigate('Add')} - accessibilityRole="button" - accessibilityLabel="Capture a new accomplishment" - > - - + - - - Capture a win in 30 seconds - Start with what happened. Add proof when you have it. - - - +function Pill({ children }) { return {children}; } +function ProofCard({ title, body, verified }) { return {verified ? 'Verified' : 'Private draft'}◌ Private{title}{body}; } - - Recent proof - Preview data - - - - ); +function Home({ navigation, user }) { + return PROOF PULSETurn fresh wins into durable career proof.Capture the work, result, evidence, skills, and credit while the details are fresh.12Receipts4Verified3With proof navigation.navigate('Add')}>+Capture a winStart private. Add evidence when you have it.; } -function ProofScreen() { - return ( - - - - - - - - - - - ); -} - -function AddScreen() { - const [win, setWin] = useState(''); - const [result, setResult] = useState(''); - const [draft, setDraft] = useState(null); - const canSave = Boolean(win.trim()); - - function previewDraft() { - if (!canSave) return; - setDraft({ win: win.trim(), result: result.trim() }); - } +function Proof() { return ; } - return ( - - - WHAT HAPPENED? - - - WHAT CHANGED? - - - - 🔒 Drafts are private by default. Evidence and credit can be added in the full flow. - - [ - styles.button, - !canSave && styles.buttonDisabled, - pressed && canSave && styles.pressed, - ]} - onPress={previewDraft} - disabled={!canSave} - accessibilityRole="button" - > - Preview Impact Receipt - - - - {draft ? ( - - - - ◌ Private - - IMPACT RECEIPT PREVIEW - {draft.win} - - {draft.result || 'Result not added yet — BragStack will keep the draft without inventing one.'} - - - ) : null} - - ); +function Add() { + const [win, setWin] = useState(''); const [result, setResult] = useState(''); const [draft, setDraft] = useState(null); + return WHAT HAPPENED?WHAT CHANGED?🔒 Drafts stay private by default. setDraft({ win: win.trim(), result: result.trim() })} style={[styles.button, !win.trim() && styles.disabled]}>Preview Impact Receipt{draft && }; } -function ProfileScreen() { - const [publicPreview, setPublicPreview] = useState(false); - - return ( - - - - BS - - BragStack Member - Platform • Reliability • Automation - - - - - - - - - - Public profile preview - See the shareable version without exposing private workplace evidence. - - setPublicPreview((value) => !value)} - style={[styles.toggle, publicPreview && styles.toggleActive]} - accessibilityRole="switch" - accessibilityState={{ checked: publicPreview }} - > - - - - {publicPreview ? ( - - VISIBLE IN PREVIEW - 3 selected accomplishments • 5 skills • no private evidence - - ) : null} - - - ); -} +function Profile({ user }) { return {user?.name || 'BragStack Member'}{user?.headline || 'Your evidence-backed professional story'}{user?.public_slug ? `Public profile: /${user.public_slug}` : 'Public profile ready when you choose to share.'}; } +function Settings({ user, onSignOut }) { return Signed in{user?.email}Official app themeNear-black • warm ivory • BragStack peachSign out; } -function SettingsScreen() { - return ( - - - - - Privacy defaults are on - Secure session storage • Private evidence • Explicit sharing - - - - Appearance - BragStack dark • Sky-blue + violet accents - - - Mobile foundation - No unnecessary device permissions requested. Store disclosures and account controls are tracked in the mobile roadmap. - - - ); +function Tabs({ user, onSignOut }) { + return ({ headerShown: false, tabBarActiveTintColor: colors.primary, tabBarInactiveTintColor: colors.mutedStrong, tabBarStyle: styles.tabBar, tabBarLabelStyle: styles.tabLabel, tabBarIcon: ({ color }) => {icons[route.name]} })}>{p => }{p => }{p => }; } export default function App() { - return ( - - - ({ - headerShown: false, - tabBarActiveTintColor: colors.primary, - tabBarInactiveTintColor: colors.muted, - tabBarStyle: styles.tabBar, - tabBarLabelStyle: styles.tabLabel, - tabBarIcon: ({ color, focused }) => ( - - {tabIcons[route.name]} - - ), - })} - > - - - - - - - - ); + const [user, setUser] = useState(null); const [booting, setBooting] = useState(true); + useEffect(() => { let live = true; restoreSession().then(u => live && setUser(u)).catch(() => {}).finally(() => live && setBooting(false)); return () => { live = false; }; }, []); + const signOut = async () => { await logout(); setUser(null); }; + if (booting) return Opening your BragStack…; + return user ? : ; } const styles = StyleSheet.create({ - safe: { flex: 1, backgroundColor: colors.background }, - page: { paddingHorizontal: spacing.lg, paddingTop: spacing.md, paddingBottom: 110, gap: spacing.md }, - flex: { flex: 1 }, - headingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', minHeight: 28 }, - eyebrow: { color: colors.primarySoft, fontSize: 11, fontWeight: '900', letterSpacing: 2.2, textTransform: 'uppercase' }, - title: { color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '900', letterSpacing: -1.4, marginBottom: spacing.sm }, - heroCard: { position: 'relative', overflow: 'hidden', backgroundColor: colors.surface, borderColor: 'rgba(56, 189, 248, 0.28)', borderWidth: 1, borderRadius: radius.lg, padding: spacing.lg, gap: spacing.sm }, - heroGlow: { position: 'absolute', width: 180, height: 180, borderRadius: 90, backgroundColor: 'rgba(168, 85, 247, 0.16)', top: -70, right: -55 }, - heroLabel: { color: colors.primary, fontSize: 11, fontWeight: '900', letterSpacing: 2 }, - heroTitle: { color: colors.text, fontSize: 25, lineHeight: 30, fontWeight: '900', maxWidth: '85%' }, - statRow: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.md }, - stat: { flex: 1, minHeight: 78, backgroundColor: 'rgba(255,255,255,0.035)', borderRadius: radius.md, padding: spacing.sm, justifyContent: 'center' }, - statAccent: { backgroundColor: 'rgba(56, 189, 248, 0.10)' }, - statValue: { color: colors.text, fontSize: 22, fontWeight: '900' }, - statValueAccent: { color: colors.primary }, - statLabel: { color: colors.muted, fontSize: 11, fontWeight: '700', marginTop: 2 }, - sectionHeader: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', marginTop: spacing.sm }, - sectionTitle: { color: colors.text, fontSize: 18, fontWeight: '900' }, - sectionHint: { color: colors.muted, fontSize: 11, fontWeight: '700' }, - card: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1, borderRadius: radius.lg, padding: spacing.lg, gap: spacing.sm }, - cardTopRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm }, - cardTitle: { color: colors.text, fontSize: 18, fontWeight: '850' }, - body: { color: colors.muted, fontSize: 14, lineHeight: 21 }, - privacyLabel: { color: colors.muted, fontSize: 11, fontWeight: '700' }, - pill: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 6, borderRadius: radius.pill, backgroundColor: 'rgba(56, 189, 248, 0.10)', borderWidth: 1, borderColor: 'rgba(56, 189, 248, 0.22)' }, - pillViolet: { backgroundColor: 'rgba(168, 85, 247, 0.10)', borderColor: 'rgba(168, 85, 247, 0.26)' }, - pillSuccess: { backgroundColor: 'rgba(34, 197, 94, 0.10)', borderColor: 'rgba(34, 197, 94, 0.22)' }, - pillText: { color: colors.primarySoft, fontSize: 10, fontWeight: '900', letterSpacing: 0.4 }, - pillTextViolet: { color: '#e9d5ff' }, - pillTextSuccess: { color: '#bbf7d0' }, - actionCard: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, backgroundColor: colors.surfaceElevated, borderColor: colors.border, borderWidth: 1, borderRadius: radius.lg, padding: spacing.md }, - actionIcon: { width: 48, height: 48, borderRadius: 24, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center' }, - actionIconText: { color: colors.background, fontSize: 30, fontWeight: '600', marginTop: -2 }, - actionTitle: { color: colors.text, fontSize: 15, fontWeight: '900', marginBottom: 2 }, - chevron: { color: colors.primary, fontSize: 30, fontWeight: '300' }, - filterRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.xs }, - captureCard: { backgroundColor: colors.surface, borderColor: 'rgba(56, 189, 248, 0.24)', borderWidth: 1, borderRadius: radius.lg, padding: spacing.lg, gap: spacing.md }, - fieldLabel: { color: colors.primarySoft, fontSize: 10, fontWeight: '900', letterSpacing: 1.5 }, - input: { minHeight: 58, color: colors.text, fontSize: 16, lineHeight: 22, backgroundColor: 'rgba(255,255,255,0.035)', borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, padding: spacing.md, textAlignVertical: 'top' }, - inputTall: { minHeight: 80 }, - helperText: { color: colors.muted, fontSize: 12, lineHeight: 18 }, - button: { backgroundColor: colors.primary, borderRadius: radius.pill, paddingVertical: 16, paddingHorizontal: 22, alignItems: 'center' }, - buttonDisabled: { opacity: 0.35 }, - buttonText: { color: colors.background, fontWeight: '900', fontSize: 15 }, - previewCard: { backgroundColor: 'rgba(168, 85, 247, 0.08)', borderColor: 'rgba(168, 85, 247, 0.28)', borderWidth: 1, borderRadius: radius.lg, padding: spacing.lg, gap: spacing.sm }, - previewLabel: { color: '#e9d5ff', fontSize: 10, fontWeight: '900', letterSpacing: 1.3, marginTop: spacing.xs }, - profileCard: { alignItems: 'center', backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1, borderRadius: radius.lg, padding: spacing.xl, gap: spacing.sm }, - avatar: { width: 72, height: 72, borderRadius: 36, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(56, 189, 248, 0.12)', borderColor: 'rgba(56, 189, 248, 0.36)', borderWidth: 1 }, - avatarText: { color: colors.primary, fontSize: 22, fontWeight: '900' }, - profileName: { color: colors.text, fontSize: 21, fontWeight: '900', marginTop: spacing.xs }, - profileMeta: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm }, - toggle: { width: 50, height: 30, padding: 3, borderRadius: 15, justifyContent: 'center', backgroundColor: 'rgba(148,163,184,0.22)' }, - toggleActive: { backgroundColor: colors.primary }, - toggleKnob: { width: 24, height: 24, borderRadius: 12, backgroundColor: colors.text }, - toggleKnobActive: { alignSelf: 'flex-end', backgroundColor: colors.background }, - publicPreview: { marginTop: spacing.sm, borderTopWidth: 1, borderTopColor: colors.border, paddingTop: spacing.md }, - publicPreviewText: { color: colors.text, fontSize: 13, lineHeight: 19, marginTop: spacing.xs }, - securityCard: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, backgroundColor: 'rgba(34, 197, 94, 0.08)', borderColor: 'rgba(34, 197, 94, 0.22)', borderWidth: 1, borderRadius: radius.lg, padding: spacing.lg }, - securityIcon: { width: 40, height: 40, textAlign: 'center', textAlignVertical: 'center', color: '#bbf7d0', fontSize: 22, fontWeight: '900', borderRadius: 20, backgroundColor: 'rgba(34, 197, 94, 0.12)' }, - pressed: { opacity: 0.72, transform: [{ scale: 0.99 }] }, - tabBar: { backgroundColor: '#0c1220', borderTopColor: colors.border, height: 82, paddingTop: 8, paddingBottom: 10 }, - tabLabel: { fontSize: 10, fontWeight: '800' }, - tabIconWrap: { minWidth: 32, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center' }, - tabIconWrapActive: { backgroundColor: 'rgba(56, 189, 248, 0.10)' }, - tabIcon: { fontSize: 18, fontWeight: '900' }, + safe: { flex: 1, backgroundColor: colors.background }, boot: { flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', gap: 20 }, loginPage: { flexGrow: 1, justifyContent: 'center', padding: 24, gap: 30 }, page: { paddingHorizontal: 24, paddingTop: 16, paddingBottom: 110, gap: 16 }, brand: { flexDirection: 'row', alignItems: 'center', gap: 14 }, brandName: { color: colors.text, fontSize: 28, fontWeight: '900' }, brandSmall: { fontSize: 20 }, kicker: { color: colors.primary, fontSize: 11, fontWeight: '900', letterSpacing: 2 }, title: { color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '900' }, loginTitle: { color: colors.text, fontSize: 31, fontWeight: '900' }, muted: { color: colors.muted, fontSize: 14, lineHeight: 21 }, card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 20, gap: 12 }, label: { color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.5, marginTop: 5 }, input: { minHeight: 52, backgroundColor: colors.surfaceElevated, borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, color: colors.text, padding: 14, fontSize: 15 }, tall: { minHeight: 85, textAlignVertical: 'top' }, button: { minHeight: 52, borderRadius: radius.pill, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center' }, disabled: { opacity: 0.4 }, buttonText: { color: colors.background, fontWeight: '900' }, note: { color: colors.mutedStrong, fontSize: 11, textAlign: 'center' }, error: { color: colors.danger, fontSize: 13 }, hero: { backgroundColor: colors.surface, borderWidth: 1, borderColor: 'rgba(255,177,132,0.28)', borderRadius: radius.lg, padding: 20, gap: 10 }, heroLabel: { color: colors.primary, fontWeight: '900', fontSize: 10, letterSpacing: 1.8 }, heroTitle: { color: colors.text, fontSize: 24, lineHeight: 29, fontWeight: '900' }, metrics: { flexDirection: 'row', gap: 8, marginTop: 8 }, metric: { flex: 1, backgroundColor: colors.surfaceElevated, borderRadius: radius.md, padding: 10 }, metricNum: { color: colors.primary, fontSize: 22, fontWeight: '900' }, metricText: { color: colors.muted, fontSize: 10 }, action: { flexDirection: 'row', alignItems: 'center', gap: 14, backgroundColor: colors.surfaceElevated, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, padding: 16 }, plus: { width: 42, height: 42, borderRadius: 21, backgroundColor: colors.primary, color: colors.background, textAlign: 'center', textAlignVertical: 'center', fontSize: 28 }, arrow: { color: colors.primary, fontSize: 30 }, cardTitle: { color: colors.text, fontSize: 17, fontWeight: '900' }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, pill: { borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(255,177,132,0.28)', backgroundColor: 'rgba(255,177,132,0.10)', paddingHorizontal: 10, paddingVertical: 6 }, pillText: { color: colors.text, fontSize: 10, fontWeight: '900' }, private: { color: colors.mutedStrong, fontSize: 11 }, profileName: { color: colors.text, fontSize: 20, fontWeight: '900' }, signout: { minHeight: 52, borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(255,176,176,0.3)', alignItems: 'center', justifyContent: 'center' }, signoutText: { color: colors.danger, fontWeight: '900' }, tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 78, paddingTop: 8, paddingBottom: 10 }, tabLabel: { fontSize: 10, fontWeight: '800' }, tabIcon: { fontSize: 18, fontWeight: '800' } }); From 9210cc73c2a86ab2071ddf1ce096635837e9ecc7 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:53:42 -0400 Subject: [PATCH 20/64] docs: record official mobile branding and auth --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8550ffc..72c7412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,13 @@ All notable BragStack product changes are recorded here. ### Added - Started the BragStack mobile initiative for iOS and Android under tracking issue #200. -- Added a React Native / Expo mobile scaffold with BragStack branding and dark theme tokens. +- Added a React Native / Expo mobile scaffold for iOS and Android. - Added starter Home, Proof, Add, Profile, and Settings navigation. - Added a polished mobile product preview with Proof Pulse metrics, proof-status badges, recent proof cards, and mobile-first navigation. -- Added an interactive private-by-default Impact Receipt capture preview and public-profile visibility preview. -- Added secure on-device access-token storage and a reusable authenticated API client. +- Added an interactive private-by-default Impact Receipt capture preview. +- Added real mobile sign-in against the existing `/auth/login` API, encrypted token storage, `/auth/me` session restore, and sign out. +- Added the canonical BragStack vector brandmark from `frontend/public/brandmark.svg` to the native mobile UI. +- Aligned mobile UI tokens with the official authenticated-app palette from the BragStack Brand Guide: near-black, warm ivory, and BragStack peach, while preserving blue/purple/cyan as brand identity accents. - Added EAS preview/production build profiles and an example mobile API environment configuration. - Added a mobile roadmap covering product parity, native features, store readiness, security, QA, and release. From 371f59c612fe7306414a6447027c642128148dc5 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:54:14 -0400 Subject: [PATCH 21/64] docs: document mobile auth and canonical branding --- mobile/README.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 6254603..2ff2363 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -14,19 +14,29 @@ For a physical device, set `EXPO_PUBLIC_API_URL` to an address the device can re ## Foundation included -- BragStack dark brand theme with sky-blue and violet accents -- iOS and Android application identifiers -- Bottom-tab navigation -- Starter Home, Accomplishments, Add, Profile, and Settings screens -- Axios API client +- Official BragStack vector brandmark based on `frontend/public/brandmark.svg` +- Official authenticated-app palette from the BragStack Brand Guide: `#090909`, `#F7F4EE`, and `#FFB184` +- Home, Proof, Add, Profile, and Settings navigation +- Real sign-in through `/auth/login` - Encrypted access-token storage through Expo SecureStore +- Session restore through `/auth/me` +- Sign out that clears the local token +- Axios API client for the existing FastAPI backend +- Interactive private-by-default Impact Receipt preview +- EAS preview and production build profiles - No unnecessary native permissions +## Auth behavior + +BragStack's backend requires verified email before login. The mobile client surfaces backend auth errors directly, stores successful JWT sessions securely, restores sessions on launch, and clears expired or invalid sessions. + +Registration, email verification, password reset, and account deletion UX remain tracked store-readiness work. The existing backend already exposes registration, verification, password-reset, profile, and session APIs. + ## Next implementation slices -1. Connect login/session bootstrap to the existing FastAPI auth endpoints. -2. Connect accomplishments and Impact Receipts to real API data. -3. Implement quick-add validation and editing. -4. Add profile controls and public-profile deep links. +1. Connect Impact Receipts to live API data. +2. Add registration, verification, and reset flows appropriate for mobile. +3. Implement quick-add persistence, validation, and editing. +4. Add public-profile controls and deep links. 5. Add accessibility, offline/error states, automated tests, and release QA. 6. Complete App Store / Google Play metadata, privacy disclosures, screenshots, signing, and internal testing. From 6bca12603211a3fbf50ffc3c0516ba6b2899697e Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:54:35 -0400 Subject: [PATCH 22/64] chore: include canonical BragStack brandmark asset in mobile --- mobile/assets/brandmark.svg | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 mobile/assets/brandmark.svg 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 @@ + + BragStack brandmark + A stylized B built from stacked proof cards with an upward career arrow. + + + + + + + + + + + + + + + + From 3b59b4ae3358fa73312904dd84a9c4c20372f5e1 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 15:56:07 -0400 Subject: [PATCH 23/64] chore: sync canonical changelog policy from main --- CHANGELOG.md | 176 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 155 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c7412..83712ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,157 @@ # Changelog -All notable BragStack product changes are recorded here. - -## Unreleased - -### Added -- Started the BragStack mobile initiative for iOS and Android under tracking issue #200. -- Added a React Native / Expo mobile scaffold for iOS and Android. -- Added starter Home, Proof, Add, Profile, and Settings navigation. -- Added a polished mobile product preview with Proof Pulse metrics, proof-status badges, recent proof cards, and mobile-first navigation. -- Added an interactive private-by-default Impact Receipt capture preview. -- Added real mobile sign-in against the existing `/auth/login` API, encrypted token storage, `/auth/me` session restore, and sign out. -- Added the canonical BragStack vector brandmark from `frontend/public/brandmark.svg` to the native mobile UI. -- Aligned mobile UI tokens with the official authenticated-app palette from the BragStack Brand Guide: near-black, warm ivory, and BragStack peach, while preserving blue/purple/cyan as brand identity accents. -- Added EAS preview/production build profiles and an example mobile API environment configuration. -- Added a mobile roadmap covering product parity, native features, store readiness, security, QA, and release. - -### Security / privacy -- Mobile foundation requests no unnecessary native permissions. -- Sensitive session tokens are stored with Expo SecureStore rather than plaintext application storage. -- Mobile product principles preserve BragStack's private-by-default handling of workplace evidence. -- Demo capture explicitly avoids inventing missing result data and keeps draft proof private by default. +All notable changes to BragStack are documented here. + +This changelog tracks **merged, shipped repository changes only**. Open or draft pull requests are not listed as released work. Entries are grouped by date and by impact using a Keep a Changelog-style structure. The exhaustive event-level history, including the direct-commit era before pull requests, is maintained in the internal **BragStack — Changelog** Google Sheet. + +## [Unreleased] + +No merged changes have been recorded here yet. + +## 2026-08-27 + +### Changed +- Made the globally required Frontend CI check report on every pull request while keeping the full browser/lint/build/bundle suite scoped to frontend-relevant changes, preventing non-frontend PRs from getting stuck behind a required check that never starts. (#199) + +### Fixed +- Stabilized Aisha interview audio, microphone permission, and avatar behavior. Microphone access is now primed directly from the user start gesture on supported browsers, temporary permission streams are released immediately, typed-answer fallbacks remain intact, and the interviewer image no longer uses fake zoom/pan/rotate motion that reduced sharpness. (#196) + +## 2026-08-26 + +### Added +- Added Career Intelligence v1 with deterministic skill evidence, quantified outcomes, evidence/confirmation signals, proof-gap analysis, recommended actions, authenticated intelligence endpoints, and a dedicated dashboard. The feature intentionally avoids opaque hiring or promotion-readiness scoring. (#177) +- Added an internal user accounts directory to the Ops Console with safe account search/filtering, plan and verification visibility, public profile links, audited resend-verification support actions, and no exposure of passwords, auth tokens, payment details, or private accomplishment content. (#173) +- Added persistent operational observability with sanitized Mongo-backed request traces, 14-day TTL retention, exception grouping, slow/failure views, and restricted Ops access. Request bodies, auth headers, query strings, tokens, exception messages, and raw user content are intentionally excluded. (#167) +- Added audited Ops team and role management with backend-enforced `support`, `ops`, `security`, and `admin` roles, last-admin protection, bootstrap-admin support, and persistent role-change audit records. (#165) +- Added the first usable BragStack Ops Console with backend-enforced RBAC, safe request telemetry, service/database diagnostics, bounded user lookup, and explicit redaction of sensitive authentication and career data. (#163) +- Added a reusable branded BragStack loading system with reduced-motion support and route/app initialization coverage. (#162) +- Added production bundle-budget regression checks so code-splitting and startup bundle size are continuously guarded in CI. (#157) +- Added production SEO/search quality gates covering sitemap consistency, canonical URLs, site navigation signals, metadata, private-route noindex behavior, and regression checks. (#155) + +### Changed +- Improved Aisha interview presentation and ATS Scan behavior: route-level speech teardown, responsive interviewer states, evidence-first resume parsing, a BragStack-specific ATS compatibility score with explainable breakdown, and stronger parser fallbacks. ATS Scan remains explicitly non-predictive. (#176) +- Finished the branded loading rollout across Pro Career, Appearance Settings, and Ops initial-load states while preserving action-specific progress states. (#175) +- Modernized public product/SEO landing pages with clearer hierarchy, responsive navigation, stronger internal linking, canonical metadata, and accessible navigation semantics. (#154) +- Expanded branded loading states across Dashboard, Accomplishments, Impact Receipts, Profile, Billing, Upgrade, and public receipt-verification flows. (#166, #168, #164) +- Improved login and account-flow cold-start handling so users remain on BragStack’s branded auth UI while the Render API wakes, with readiness checks shared across password, OAuth, email-verification, and reset flows. (#172) +- Improved authentication UX and transactional email branding for account verification, password reset, and Impact Receipt verification, including safer HTML escaping and clearer expiry/security copy. (#171) +- Moved product CSS and analytics work off the public critical startup path to improve first-load performance while keeping functionality intact. (#161, #159) +- Added route-level code splitting so authenticated/heavy product surfaces no longer inflate public-page startup unnecessarily. (#156) + +### Fixed +- Fixed Proof Profile accessibility semantics by adding an explicit accessible search label and current-page pagination state. (#185) +- Disabled legacy global public brag aggregation endpoints so public proof remains scoped to intentionally published user profile slugs. (#183) +- Fixed duplicate proof counting in Career Intelligence when an Impact Receipt enriches an accomplishment already linked by `source_entry_id`. (#179) +- Fixed the SEO landing-page navbar style regression by restoring the shared landing navigation styles. (#174) +- Fixed the Dashboard tag-summary React crash by normalizing current and legacy API response shapes at the frontend boundary. (#160) + +### Security +- Added production API rate limiting and abuse protection backed by MongoDB fixed windows, HMAC-hashed client-address bucket keys, generic 429 responses, targeted auth/OAuth/public policies, TTL cleanup, and fail-open storage behavior to avoid turning limiter storage failures into an auth outage. (#191) +- Made Stripe webhook processing idempotent with an atomic event ledger, safe retry/reclaim behavior, signature verification preservation, and event-order protection that prevents older billing events from regressing newer subscription state. (#189) +- Added a full real-browser click audit in Frontend CI covering public/authenticated routes, nested and revealed controls, navigation safety, browser errors, and unhandled promise rejections. (#192) + +## 2026-08-25 + +### Added +- Added cancellation-at-period-end billing controls, paid-through date visibility, resume-subscription support, and Stripe-aligned entitlement behavior that preserves Pro access through the already-paid billing period. (#144) +- Strengthened Google sitelink/search signals with crawlable public auth pages, route-aware robots metadata, structured navigation, sitemap alignment, and regression coverage. (#145, #148) + +### Changed +- Restored and iterated on the professional Aisha interviewer across mobile/tablet/desktop, improving audio, visual rendering, responsive layout, interview sequencing, evidence-anchored scoring, and interview feedback. (#133, #134, #135, #136, #139, #140, #143) +- Rebuilt Resume Builder around structured import, ATS-safe reconstruction, ATS coaching, editing, mobile support, and evidence-first analysis. (#123, #125, #127, #131, #138, #142) +- Improved customer Docs with visual product guidance and clearer troubleshooting/support separation. (#128, #130, #137) + +### Fixed +- Fixed dark-mode contrast and readability on Settings → Plan & billing. (#146) + +### Security +- Hardened BragStack application security with API security headers, HSTS on HTTPS, narrower CORS rules, password byte-length protection, JWT lifecycle claims and IDs, dependency vulnerability auditing, safer authenticated search handling, and dedicated Security CI. (#151) + +## 2026-08-24 + +### Added +- Added the Pro Practice Interviewer, a Mongo-backed career/question catalog, question rotation, and meaning-aware Career Intelligence coaching without making the core flow dependent on a paid model API. (#90, #91, #93, #100) +- Added the evidence-backed Pro Resume Builder MVP with job-description analysis, Impact Receipt matching, source-linked bullets, readiness/gap analysis, saved versions, and export paths. (#97) +- Added production health/readiness probes, tested MongoDB indexes, and restore-validation safeguards for operational readiness. (#88, #94, #95, #96) +- Added guided onboarding, Settings organization, profile appearance controls, and expanded Pro career-tool navigation. (#60, #61, #71, #72, #79) + +### Changed +- Hardened Resume Builder provenance and ATS claims so manual edits require source review and matching remains evidence-aware rather than overstating parser certainty. (#99) +- Iterated heavily on Aisha’s interview room, browser speech behavior, sequencing, device responsiveness, and catalog-backed question flow. (#104, #110, #111, #112, #114, #115, #116, #117, #118, #119, #120, #122, #124, #126, #129) +- Expanded BragStack customer documentation and searchability. (#121) + +### Security +- Hardened auth recovery against account-enumeration side channels. (#92) +- Hardened production OAuth callback URL handling behind Render/proxy infrastructure. (#106) + +## 2026-08-23 + +### Added +- Added Google and GitHub OAuth, Stripe Checkout/subscription lifecycle handling, Free/Pro server-side entitlements, password reset, and email verification for password signups. (#40, #41, #43, #44) +- Added customer-facing Privacy Policy, Terms, NDA/confidential-work guidance, Docs, SEO/search discovery assets, and expanded legal/product documentation. (#45, #46, #49, #55, #56) +- Added profile editing, saved profile images, and career-inspired Proof Profile themes with private appearance settings. (#57, #58, #60, #61) +- Added Google Analytics 4 and role-based BragStack contact routing. (#62, #65) + +### Changed +- Redesigned the authenticated dashboard, auth pages, landing experience, mobile navigation, branding, and public profile presentation. (#42, #50, #51, #52, #53, #54, #59, #63, #69, #75, #80) +- Added a one-hour correction window for newly created accomplishments. (#48) + +### Security +- Moved JWT secrets and production CORS/OAuth credentials into environment-managed configuration. (#39) + +## 2026-08-22 + +### Added +- Stabilized Impact Receipt core loop v2 with standalone receipt creation, measurable impact, multiple evidence items, skills, privacy controls, CRUD/reopen behavior, evidence-only performance-review output, job-targeted resume material, and beta feedback/pull metrics. (#38) + +## 2026-08-20 + +### Added +- Shipped Packet Platform v1.2 with accomplishment selection/pinning, selective sections, user-authored annotations, packet themes, branding controls, export audit metadata, secure private sharing, and verified-recognition semantics. (#37) + +## 2026-08-19 + +### Added +- Landed the V1.1 product foundation and public career analytics experience. (#32, #10) +- Added persistent app navigation, paginated accomplishment/receipt libraries, and the recruiter-facing Proof Profile. (#11) +- Added the Free/Pro entitlement foundation and premium marketing/pricing experience. (#12) +- Added Performance Review, Promotion, Interview, and Certification/Licensure packets with evidence-backed server-generated PDFs. (#19, #31, #33, #35) +- Added cross-career packet regression coverage to keep packet behavior career-neutral across professions. (#34) + +## 2026-08-17 + +### Changed +- Polished BragStack for the V1 release with portable/searchable reports, cleanup of duplicated models, V1 product documentation, and an explicit separation between shipped product and post-V1 roadmap work. (#9) + +## 2026-08-07 + +### Added +- Added Reports Hub v1, owner-controlled Impact Receipt visibility, public receipts with private evidence filtered out, profile persistence, and report output covering accomplishments, receipts, evidence, confirmations, skills, categories, trust signals, quantified results, highlights, and resume bullets. (#8) + +## 2026-08-06 + +### Added +- Added Impact Receipts v1 with structured accomplishment/contribution/result/evidence/skills/credit/confirmation fields, persistence, ownership checks, duplicate protection, pagination, dashboard cards, and public-profile improvements. (#7) + +## 2026-06-09 to 2026-06-30 + +### Added +- Added JWT authentication and private entry ownership, frontend login/register, protected dashboard behavior, and authenticated API access. (#1) +- Added public BragStack sharing APIs, slug-scoped public profiles, weekly/tag/category summaries, and frontend public-profile integration. (#2, #5, #6) +- Added the initial backend test suite and GitHub Actions CI workflow. (#3, #4) + +## 2026-05-26 to 2026-06-09 — repository foundation + +Before the pull-request workflow began, the repository established the original BragStack MVP through direct commits: entry update and weekly reporting, skill/category summaries, keyword search, pagination, resume-bullet generation, a React dashboard, entry edit/delete, public brag metadata/page support, and the first JWT/private-ownership and frontend-auth implementation. The repository root commit is dated **2026-05-26**. + +--- + +## Maintenance rules + +- Add an entry only after the underlying pull request has merged to `main`. +- Prefer user/product impact over commit-level implementation trivia. +- Include the pull request number for traceability. +- Use `Added`, `Changed`, `Fixed`, `Security`, `Deprecated`, or `Removed` when applicable. +- Do not claim an open PR, draft, planned roadmap item, or unverified deployment as shipped. +- Keep the Google Sheet as the exhaustive event-level ledger; keep this Markdown file focused on notable release/milestone history. +- For customer-facing release notes, summarize this canonical engineering changelog rather than copying internal/security implementation details blindly. From 287fdba8953e31d666077cfc18e26b29802f69e6 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 16:39:29 -0400 Subject: [PATCH 24/64] docs: add mobile architecture and release guide --- docs/MOBILE_APP.md | 100 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/MOBILE_APP.md 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 From 1349caf2e41a0842630cb5962ce13f989f45a1da Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 16:39:42 -0400 Subject: [PATCH 25/64] docs: add customer-facing mobile guide source --- docs/MOBILE_CUSTOMER_GUIDE.md | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/MOBILE_CUSTOMER_GUIDE.md 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. From 97f3426d11de0e2d2a9fab3e31c4529b2337a955 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 16:39:59 -0400 Subject: [PATCH 26/64] docs: refresh mobile roadmap and documentation gates --- docs/ROADMAP.md | 65 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d4c1c2a..8ae89b6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,22 +2,33 @@ ## Mobile initiative — iOS + Android -### Phase 1: Foundation — in progress +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 -- Shared BragStack theme tokens -- Navigation shell for Home, Accomplishments, Add, Profile, and Settings -- Secure on-device token storage -- Authenticated API client configuration -- Store-safe default permission posture - -### Phase 2: Core product parity -- Login, logout, and session restore +- 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 -- Quick-add accomplishment flow +- 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, offline, retry, and API error states +- 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 @@ -25,25 +36,39 @@ - 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 reasons review +- Apple privacy disclosures / required-reason review - Google Play Data safety disclosure - Account deletion flow validation -- Terms and Privacy Policy links +- 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 -- Device / OS compatibility matrix - Performance and crash-free-session targets -- Security review of token lifecycle, deep links, logs, and evidence handling +- 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. @@ -51,5 +76,13 @@ 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 From e00650f9be3ff6847b75e3c9993cb84465906bd1 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 16:40:12 -0400 Subject: [PATCH 27/64] docs: expand mobile readme with customer and release docs --- mobile/README.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 2ff2363..55448ca 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -2,6 +2,8 @@ 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 @@ -28,15 +30,27 @@ For a physical device, set `EXPO_PUBLIC_API_URL` to an address the device can re ## Auth behavior -BragStack's backend requires verified email before login. The mobile client surfaces backend auth errors directly, stores successful JWT sessions securely, restores sessions on launch, and clears expired or invalid sessions. +BragStack's backend requires verified email before password login. The mobile client surfaces backend auth errors directly, stores successful JWT sessions securely, restores sessions on launch, and clears expired or invalid sessions. + +Registration, email verification, password reset, recovery deep links, and account deletion UX remain required store-readiness work. The existing backend already exposes the relevant account/session APIs. + +## Data status -Registration, email verification, password reset, and account deletion UX remain tracked store-readiness work. The existing backend already exposes registration, verification, password-reset, profile, and session APIs. +Authentication is connected to the real backend. Some product screens still use preview proof data while live accomplishment and Impact Receipt reads/writes are completed. Customer-facing documentation must distinguish preview behavior from persisted production behavior. ## Next implementation slices -1. Connect Impact Receipts to live API data. -2. Add registration, verification, and reset flows appropriate for mobile. +1. Connect Impact Receipts and accomplishments to live API data. +2. Add registration, verification, reset, and recovery flows appropriate for mobile. 3. Implement quick-add persistence, validation, and editing. 4. Add public-profile controls and deep links. 5. Add accessibility, offline/error states, automated tests, and release QA. -6. Complete App Store / Google Play metadata, privacy disclosures, screenshots, signing, and internal testing. +6. Complete App Store / Google Play metadata, privacy disclosures, screenshots, signing, internal testing, and mobile CI. + +## 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 — initial mobile foundation From 4287d181f756d4ea15149c216beb1e2fb80b1c5a Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 16:51:49 -0400 Subject: [PATCH 28/64] test(mobile): add Jest coverage and Mobile CI --- .github/workflows/mobile-ci.yml | 36 +++++++++++++++++++++ mobile/__tests__/authApi.test.js | 45 +++++++++++++++++++++++++++ mobile/__tests__/authStorage.test.js | 32 +++++++++++++++++++ mobile/__tests__/receiptDraft.test.js | 23 ++++++++++++++ mobile/__tests__/theme.test.js | 15 +++++++++ mobile/package.json | 25 +++++++++++++-- mobile/src/receiptDraft.js | 13 ++++++++ 7 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/mobile-ci.yml create mode 100644 mobile/__tests__/authApi.test.js create mode 100644 mobile/__tests__/authStorage.test.js create mode 100644 mobile/__tests__/receiptDraft.test.js create mode 100644 mobile/__tests__/theme.test.js create mode 100644 mobile/src/receiptDraft.js 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/mobile/__tests__/authApi.test.js b/mobile/__tests__/authApi.test.js new file mode 100644 index 0000000..e47f5c8 --- /dev/null +++ b/mobile/__tests__/authApi.test.js @@ -0,0 +1,45 @@ +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 { getAuthErrorMessage, login, logout, restoreSession } from '../src/authApi'; + +describe('mobile auth API', () => { + beforeEach(() => jest.clearAllMocks()); + + it('normalizes email, stores the returned token, and returns the user', 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('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('clears local credentials on logout', async () => { + await logout(); + expect(clearAccessToken).toHaveBeenCalledTimes(1); + }); + + it('surfaces backend auth detail and a useful offline fallback', () => { + expect(getAuthErrorMessage({ response: { data: { detail: 'Email verification required.' } } })).toBe('Email verification required.'); + expect(getAuthErrorMessage(new Error('network'))).toMatch(/Could not reach BragStack/); + }); +}); 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__/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..d94288d --- /dev/null +++ b/mobile/__tests__/theme.test.js @@ -0,0 +1,15 @@ +import { colors, navigationTheme } from '../src/theme'; + +describe('BragStack mobile brand tokens', () => { + it('uses the canonical authenticated-app palette', () => { + expect(colors.background).toBe('#090909'); + expect(colors.text).toBe('#F7F4EE'); + expect(colors.primary).toBe('#FFB184'); + }); + + 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); + }); +}); diff --git a/mobile/package.json b/mobile/package.json index 422bcd1..6481b57 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -8,7 +8,10 @@ "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", - "doctor": "expo-doctor" + "doctor": "expo-doctor", + "test": "jest", + "test:watch": "jest --watch", + "test:ci": "jest --runInBand --coverage" }, "dependencies": { "@react-navigation/bottom-tabs": "^7.2.0", @@ -24,6 +27,24 @@ "react-native-svg": "^15.15.1" }, "devDependencies": { - "@babel/core": "^7.25.2" + "@babel/core": "^7.25.2", + "@testing-library/react-native": "^14.0.1", + "jest": "^30.4.2", + "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/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', + }; +} From 613131d185f429ba887b16301ed53b21538096e1 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 16:53:13 -0400 Subject: [PATCH 29/64] test(mobile): cover authenticated API client --- mobile/__tests__/api.test.js | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 mobile/__tests__/api.test.js diff --git a/mobile/__tests__/api.test.js b/mobile/__tests__/api.test.js new file mode 100644 index 0000000..20f8fd9 --- /dev/null +++ b/mobile/__tests__/api.test.js @@ -0,0 +1,40 @@ +const useRequestInterceptor = jest.fn(); +const create = jest.fn(() => ({ + interceptors: { request: { use: useRequestInterceptor } }, +})); + +jest.mock('axios', () => ({ create })); +jest.mock('../src/authStorage', () => ({ getAccessToken: jest.fn() })); + +import { getAccessToken } from '../src/authStorage'; + +describe('authenticated API client', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + useRequestInterceptor.mockClear(); + create.mockClear(); + }); + + it('uses the configured API URL and a bounded timeout', () => { + process.env.EXPO_PUBLIC_API_URL = 'https://api.example.test'; + require('../src/api'); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + baseURL: 'https://api.example.test', + timeout: 15000, + })); + }); + + it('adds a bearer token when one exists and leaves anonymous requests alone', async () => { + require('../src/api'); + const interceptor = useRequestInterceptor.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(); + }); +}); From d64be5c3d2dc9381ed8bee6154820ca2c744e229 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 16:53:47 -0400 Subject: [PATCH 30/64] docs(mobile): add Codespaces and device testing checklist --- mobile/TESTING.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 mobile/TESTING.md diff --git a/mobile/TESTING.md b/mobile/TESTING.md new file mode 100644 index 0000000..ef319cb --- /dev/null +++ b/mobile/TESTING.md @@ -0,0 +1,57 @@ +# 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 + +Create `mobile/.env` from `.env.example` and set `EXPO_PUBLIC_API_URL` to a BragStack API URL reachable from the preview/device. Do not commit secrets. + +Then: + +```bash +npm start +``` + +For a fast browser smoke test, press `w` in Expo or run `npm run web`. Browser testing is useful for JavaScript/runtime/UI checks but does not validate native SecureStore, iOS/Android lifecycle, signing, or store behavior. + +## Manual acceptance checks + +- Official BragStack brandmark is visible and not replaced by a placeholder. +- Authenticated UI uses near-black, warm ivory, and BragStack peach. +- Login rejects empty credentials and surfaces backend errors. +- A valid verified BragStack account can sign in. +- Relaunch restores a valid session. +- 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. +- Quick Capture cannot preview an empty accomplishment. +- A missing result remains explicitly missing; BragStack does not invent an outcome. +- Draft/proof UI remains private by default. +- No unexpected device permission prompt appears during normal launch/navigation. +- Text remains readable at narrow/mobile widths. +- No passwords, access tokens, or confidential evidence appear in logs/errors. + +## Real-device checks required later + +Run preview/internal builds on at least one current iPhone and one current Android device. Verify SecureStore persistence, keyboard behavior, safe areas, gestures, app background/foreground lifecycle, network loss/retry behavior, deep links when implemented, accessibility/dynamic text, and account deletion/recovery flows. + +## Store gate + +Do not submit to App Store Connect or Google Play production until automated checks pass, 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. From 423a2c2c1cf7bb25ba7d7e60ab3f9031bf8ee0ba Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 18:35:50 -0400 Subject: [PATCH 31/64] fix: align React Native test dependencies for Expo 57 --- mobile/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/package.json b/mobile/package.json index 6481b57..45db726 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -21,7 +21,7 @@ "expo-secure-store": "~56.0.4", "expo-status-bar": "~57.0.1", "react": "19.2.3", - "react-native": "0.86.2", + "react-native": "0.86.3", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.26.0", "react-native-svg": "^15.15.1" From 69d21ad5c024eb87a89e3a36ed36f1eb51e59eb8 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 18:38:53 -0400 Subject: [PATCH 32/64] fix(mobile): align Jest with Expo SDK 57 --- mobile/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/package.json b/mobile/package.json index 45db726..47651d1 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -29,7 +29,7 @@ "devDependencies": { "@babel/core": "^7.25.2", "@testing-library/react-native": "^14.0.1", - "jest": "^30.4.2", + "jest": "^29.7.0", "jest-expo": "~57.0.5" }, "jest": { From 07a75340292588a85611aa2967ea26c8f77d3ee0 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 18:40:19 -0400 Subject: [PATCH 33/64] test: fix Jest axios mock hoisting --- mobile/__tests__/api.test.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/__tests__/api.test.js b/mobile/__tests__/api.test.js index 20f8fd9..e9d846f 100644 --- a/mobile/__tests__/api.test.js +++ b/mobile/__tests__/api.test.js @@ -1,9 +1,9 @@ -const useRequestInterceptor = jest.fn(); -const create = jest.fn(() => ({ - interceptors: { request: { use: useRequestInterceptor } }, +const mockUseRequestInterceptor = jest.fn(); +const mockCreate = jest.fn(() => ({ + interceptors: { request: { use: mockUseRequestInterceptor } }, })); -jest.mock('axios', () => ({ create })); +jest.mock('axios', () => ({ create: mockCreate })); jest.mock('../src/authStorage', () => ({ getAccessToken: jest.fn() })); import { getAccessToken } from '../src/authStorage'; @@ -12,14 +12,14 @@ describe('authenticated API client', () => { beforeEach(() => { jest.resetModules(); jest.clearAllMocks(); - useRequestInterceptor.mockClear(); - create.mockClear(); + mockUseRequestInterceptor.mockClear(); + mockCreate.mockClear(); }); it('uses the configured API URL and a bounded timeout', () => { process.env.EXPO_PUBLIC_API_URL = 'https://api.example.test'; require('../src/api'); - expect(create).toHaveBeenCalledWith(expect.objectContaining({ + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ baseURL: 'https://api.example.test', timeout: 15000, })); @@ -27,7 +27,7 @@ describe('authenticated API client', () => { it('adds a bearer token when one exists and leaves anonymous requests alone', async () => { require('../src/api'); - const interceptor = useRequestInterceptor.mock.calls[0][0]; + const interceptor = mockUseRequestInterceptor.mock.calls[0][0]; getAccessToken.mockResolvedValueOnce('abc123'); const signed = await interceptor({ headers: {} }); From 281f3a4a82639ad5cbfcdf5d858ae847498fe601 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 18:43:50 -0400 Subject: [PATCH 34/64] test(mobile): fix API interceptor mock isolation --- mobile/__tests__/api.test.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/mobile/__tests__/api.test.js b/mobile/__tests__/api.test.js index e9d846f..799aa1a 100644 --- a/mobile/__tests__/api.test.js +++ b/mobile/__tests__/api.test.js @@ -6,11 +6,8 @@ const mockCreate = jest.fn(() => ({ jest.mock('axios', () => ({ create: mockCreate })); jest.mock('../src/authStorage', () => ({ getAccessToken: jest.fn() })); -import { getAccessToken } from '../src/authStorage'; - describe('authenticated API client', () => { beforeEach(() => { - jest.resetModules(); jest.clearAllMocks(); mockUseRequestInterceptor.mockClear(); mockCreate.mockClear(); @@ -18,7 +15,9 @@ describe('authenticated API client', () => { it('uses the configured API URL and a bounded timeout', () => { process.env.EXPO_PUBLIC_API_URL = 'https://api.example.test'; - require('../src/api'); + jest.isolateModules(() => { + require('../src/api'); + }); expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ baseURL: 'https://api.example.test', timeout: 15000, @@ -26,7 +25,10 @@ describe('authenticated API client', () => { }); it('adds a bearer token when one exists and leaves anonymous requests alone', async () => { - require('../src/api'); + const { getAccessToken } = require('../src/authStorage'); + jest.isolateModules(() => { + require('../src/api'); + }); const interceptor = mockUseRequestInterceptor.mock.calls[0][0]; getAccessToken.mockResolvedValueOnce('abc123'); From 453af3bc18ae14eb20a999736c3d1111bad90385 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 18:47:25 -0400 Subject: [PATCH 35/64] fix: add expo doctor dev dependency --- mobile/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/package.json b/mobile/package.json index 47651d1..16415c1 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -29,6 +29,7 @@ "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" }, From b935ea058cd24fbd115d812392e2dd117cd4fb04 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 18:48:18 -0400 Subject: [PATCH 36/64] fix: align mobile dependencies with Expo SDK 57 --- mobile/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/package.json b/mobile/package.json index 16415c1..04443e3 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -18,13 +18,13 @@ "@react-navigation/native": "^7.1.0", "axios": "^1.7.9", "expo": "~57.0.9", - "expo-secure-store": "~56.0.4", + "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.1" + "react-native-svg": "15.15.4" }, "devDependencies": { "@babel/core": "^7.25.2", From 8b9de80cd838bafecc83306b740b23a34a87a391 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 18:56:16 -0400 Subject: [PATCH 37/64] fix: wrap mobile app in SafeAreaProvider --- mobile/App.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mobile/App.js b/mobile/App.js index 269bb52..29e461d 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -3,7 +3,7 @@ import { NavigationContainer } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { StatusBar } from 'expo-status-bar'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import Brandmark from './src/Brandmark'; import { getAuthErrorMessage, login, logout, restoreSession } from './src/authApi'; import { colors, navigationTheme, radius, spacing } from './src/theme'; @@ -59,8 +59,12 @@ export default function App() { const [user, setUser] = useState(null); const [booting, setBooting] = useState(true); useEffect(() => { let live = true; restoreSession().then(u => live && setUser(u)).catch(() => {}).finally(() => live && setBooting(false)); return () => { live = false; }; }, []); const signOut = async () => { await logout(); setUser(null); }; - if (booting) return Opening your BragStack…; - return user ? : ; + const content = booting + ? Opening your BragStack… + : user + ? + : ; + return {content}; } const styles = StyleSheet.create({ From c82ac88e7011fcd76875d0972aea0b46d08c6746 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:00:55 -0400 Subject: [PATCH 38/64] fix: align mobile colors with BragStack brand palette --- mobile/src/theme.js | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/mobile/src/theme.js b/mobile/src/theme.js index d38b5ae..fa1bc65 100644 --- a/mobile/src/theme.js +++ b/mobile/src/theme.js @@ -1,19 +1,20 @@ export const colors = { - // Authenticated app/admin palette — BragStack Brand Guide - background: '#090909', - sidebar: '#0B0B0B', - surface: '#111111', - surfaceElevated: '#121212', - border: 'rgba(247, 244, 238, 0.12)', - text: '#F7F4EE', - muted: '#AAA39A', - mutedStrong: '#817A73', - primary: '#FFB184', - primarySoft: '#FFD2B8', + // 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 brand/marketing accents retained for the official logo and - // occasional identity moments, not as the authenticated app's main UI color. + // Canonical identity aliases retained for shared components. brandBackground: '#070B14', brandSurface: '#0D1526', brandSurfaceLight: '#131E33', @@ -49,6 +50,6 @@ export const navigationTheme = { card: colors.sidebar, text: colors.text, border: colors.border, - notification: colors.primary, + notification: colors.secondary, }, }; From 16a9881ad083bb39386e512a1c431e7402e6567a Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:01:41 -0400 Subject: [PATCH 39/64] fix: restore BragStack mobile auth branding and social sign-in --- mobile/App.js | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/mobile/App.js b/mobile/App.js index 29e461d..ae90354 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -2,14 +2,24 @@ import React, { useEffect, useState } from 'react'; import { NavigationContainer } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { StatusBar } from 'expo-status-bar'; -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { ActivityIndicator, Linking, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, 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 { getAuthErrorMessage, login, logout, restoreSession } from './src/authApi'; -import { colors, navigationTheme, radius, spacing } from './src/theme'; +import { colors, navigationTheme, radius } from './src/theme'; const Tab = createBottomTabNavigator(); const icons = { Home: '⌂', Proof: '✓', Add: '+', Profile: '◉', Settings: '⚙' }; +const oauthBaseUrl = (process.env.EXPO_PUBLIC_API_URL || 'http://localhost:8000').replace(/\/$/, ''); + +function GoogleMark() { + return ; +} + +function GitHubMark() { + return ; +} function Brand({ small = false }) { return BragStack{!small && Proof of the impact you create.}; @@ -27,7 +37,12 @@ function Login({ onSuccess }) { catch (e) { setError(getAuthErrorMessage(e)); } finally { setBusy(false); } }; - return WELCOME BACKYour proof is waiting.Sign in with your existing BragStack account.EMAILPASSWORD{error ? {error} : null}{busy ? : Sign in}🔒 Session tokens are kept in encrypted device storage.; + const startOAuth = async (provider) => { + const url = `${oauthBaseUrl}/auth/${provider}/login`; + if (Platform.OS === 'web' && typeof window !== 'undefined') window.location.assign(url); + else await Linking.openURL(url); + }; + return WELCOME BACKYour proof is waiting.Sign in with your existing BragStack account.EMAILPASSWORD{error ? {error} : null}{busy ? : Sign in}OR CONTINUE WITH startOAuth('google')} style={[styles.socialButton, styles.googleButton]}>Continue with Google startOAuth('github')} style={[styles.socialButton, styles.githubButton]}>Continue with GitHub🔒 Session tokens are kept in encrypted device storage.; } function Page({ kicker, title, children }) { @@ -49,7 +64,7 @@ function Add() { } function Profile({ user }) { return {user?.name || 'BragStack Member'}{user?.headline || 'Your evidence-backed professional story'}{user?.public_slug ? `Public profile: /${user.public_slug}` : 'Public profile ready when you choose to share.'}; } -function Settings({ user, onSignOut }) { return Signed in{user?.email}Official app themeNear-black • warm ivory • BragStack peachSign out; } +function Settings({ user, onSignOut }) { return Signed in{user?.email}Official app themeDeep navy • crisp white • BragStack blue + purpleSign out; } function Tabs({ user, onSignOut }) { return ({ headerShown: false, tabBarActiveTintColor: colors.primary, tabBarInactiveTintColor: colors.mutedStrong, tabBarStyle: styles.tabBar, tabBarLabelStyle: styles.tabLabel, tabBarIcon: ({ color }) => {icons[route.name]} })}>{p => }{p => }{p => }; @@ -68,5 +83,5 @@ export default function App() { } const styles = StyleSheet.create({ - safe: { flex: 1, backgroundColor: colors.background }, boot: { flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', gap: 20 }, loginPage: { flexGrow: 1, justifyContent: 'center', padding: 24, gap: 30 }, page: { paddingHorizontal: 24, paddingTop: 16, paddingBottom: 110, gap: 16 }, brand: { flexDirection: 'row', alignItems: 'center', gap: 14 }, brandName: { color: colors.text, fontSize: 28, fontWeight: '900' }, brandSmall: { fontSize: 20 }, kicker: { color: colors.primary, fontSize: 11, fontWeight: '900', letterSpacing: 2 }, title: { color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '900' }, loginTitle: { color: colors.text, fontSize: 31, fontWeight: '900' }, muted: { color: colors.muted, fontSize: 14, lineHeight: 21 }, card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 20, gap: 12 }, label: { color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.5, marginTop: 5 }, input: { minHeight: 52, backgroundColor: colors.surfaceElevated, borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, color: colors.text, padding: 14, fontSize: 15 }, tall: { minHeight: 85, textAlignVertical: 'top' }, button: { minHeight: 52, borderRadius: radius.pill, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center' }, disabled: { opacity: 0.4 }, buttonText: { color: colors.background, fontWeight: '900' }, note: { color: colors.mutedStrong, fontSize: 11, textAlign: 'center' }, error: { color: colors.danger, fontSize: 13 }, hero: { backgroundColor: colors.surface, borderWidth: 1, borderColor: 'rgba(255,177,132,0.28)', borderRadius: radius.lg, padding: 20, gap: 10 }, heroLabel: { color: colors.primary, fontWeight: '900', fontSize: 10, letterSpacing: 1.8 }, heroTitle: { color: colors.text, fontSize: 24, lineHeight: 29, fontWeight: '900' }, metrics: { flexDirection: 'row', gap: 8, marginTop: 8 }, metric: { flex: 1, backgroundColor: colors.surfaceElevated, borderRadius: radius.md, padding: 10 }, metricNum: { color: colors.primary, fontSize: 22, fontWeight: '900' }, metricText: { color: colors.muted, fontSize: 10 }, action: { flexDirection: 'row', alignItems: 'center', gap: 14, backgroundColor: colors.surfaceElevated, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, padding: 16 }, plus: { width: 42, height: 42, borderRadius: 21, backgroundColor: colors.primary, color: colors.background, textAlign: 'center', textAlignVertical: 'center', fontSize: 28 }, arrow: { color: colors.primary, fontSize: 30 }, cardTitle: { color: colors.text, fontSize: 17, fontWeight: '900' }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, pill: { borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(255,177,132,0.28)', backgroundColor: 'rgba(255,177,132,0.10)', paddingHorizontal: 10, paddingVertical: 6 }, pillText: { color: colors.text, fontSize: 10, fontWeight: '900' }, private: { color: colors.mutedStrong, fontSize: 11 }, profileName: { color: colors.text, fontSize: 20, fontWeight: '900' }, signout: { minHeight: 52, borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(255,176,176,0.3)', alignItems: 'center', justifyContent: 'center' }, signoutText: { color: colors.danger, fontWeight: '900' }, tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 78, paddingTop: 8, paddingBottom: 10 }, tabLabel: { fontSize: 10, fontWeight: '800' }, tabIcon: { fontSize: 18, fontWeight: '800' } + safe: { flex: 1, backgroundColor: colors.background }, boot: { flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', gap: 20 }, loginPage: { flexGrow: 1, justifyContent: 'center', padding: 24, gap: 30 }, page: { paddingHorizontal: 24, paddingTop: 16, paddingBottom: 110, gap: 16 }, brand: { flexDirection: 'row', alignItems: 'center', gap: 14 }, brandName: { color: colors.text, fontSize: 28, fontWeight: '900' }, brandSmall: { fontSize: 20 }, kicker: { color: colors.primary, fontSize: 11, fontWeight: '900', letterSpacing: 2 }, title: { color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '900' }, loginTitle: { color: colors.text, fontSize: 31, fontWeight: '900' }, muted: { color: colors.muted, fontSize: 14, lineHeight: 21 }, card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 20, gap: 12 }, label: { color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.5, marginTop: 5 }, input: { minHeight: 52, backgroundColor: colors.surfaceElevated, borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, color: colors.text, padding: 14, fontSize: 15 }, tall: { minHeight: 85, textAlignVertical: 'top' }, button: { minHeight: 52, borderRadius: radius.pill, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center' }, disabled: { opacity: 0.4 }, buttonText: { color: colors.background, fontWeight: '900' }, note: { color: colors.mutedStrong, fontSize: 11, textAlign: 'center' }, error: { color: colors.danger, fontSize: 13 }, divider: { flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 2 }, dividerLine: { flex: 1, height: 1, backgroundColor: colors.border }, dividerText: { color: colors.mutedStrong, fontSize: 9, fontWeight: '900', letterSpacing: 1.2 }, socialStack: { gap: 10 }, socialButton: { minHeight: 50, borderRadius: radius.pill, paddingHorizontal: 18, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 }, googleButton: { backgroundColor: '#FFFFFF', borderWidth: 1, borderColor: '#DADCE0' }, googleText: { color: '#202124', fontWeight: '800' }, githubButton: { backgroundColor: '#24292F', borderWidth: 1, borderColor: '#57606A' }, githubText: { color: '#FFFFFF', fontWeight: '800' }, hero: { backgroundColor: colors.surface, borderWidth: 1, borderColor: 'rgba(166,220,255,0.28)', borderRadius: radius.lg, padding: 20, gap: 10 }, heroLabel: { color: colors.primary, fontWeight: '900', fontSize: 10, letterSpacing: 1.8 }, heroTitle: { color: colors.text, fontSize: 24, lineHeight: 29, fontWeight: '900' }, metrics: { flexDirection: 'row', gap: 8, marginTop: 8 }, metric: { flex: 1, backgroundColor: colors.surfaceElevated, borderRadius: radius.md, padding: 10 }, metricNum: { color: colors.primary, fontSize: 22, fontWeight: '900' }, metricText: { color: colors.muted, fontSize: 10 }, action: { flexDirection: 'row', alignItems: 'center', gap: 14, backgroundColor: colors.surfaceElevated, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, padding: 16 }, plus: { width: 42, height: 42, borderRadius: 21, backgroundColor: colors.primary, color: colors.background, textAlign: 'center', textAlignVertical: 'center', fontSize: 28 }, arrow: { color: colors.primary, fontSize: 30 }, cardTitle: { color: colors.text, fontSize: 17, fontWeight: '900' }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, 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 }, profileName: { color: colors.text, fontSize: 20, fontWeight: '900' }, signout: { minHeight: 52, borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(255,176,176,0.3)', alignItems: 'center', justifyContent: 'center' }, signoutText: { color: colors.danger, fontWeight: '900' }, tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 78, paddingTop: 8, paddingBottom: 10 }, tabLabel: { fontSize: 10, fontWeight: '800' }, tabIcon: { fontSize: 18, fontWeight: '800' } }); From 6adceda8faca503bec12e8410bbcb4325a840a9b Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:10:13 -0400 Subject: [PATCH 40/64] fix: resolve mobile API URL in Codespaces --- mobile/src/api.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/mobile/src/api.js b/mobile/src/api.js index 425aca8..6b64ab4 100644 --- a/mobile/src/api.js +++ b/mobile/src/api.js @@ -1,10 +1,25 @@ import axios from 'axios'; import { getAccessToken } from './authStorage'; -const baseURL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:8000'; +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, + baseURL: apiBaseURL, timeout: 15000, headers: { 'Content-Type': 'application/json' }, }); From 368bfbb2d09ec8e4a62191718a6200e361240373 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:11:19 -0400 Subject: [PATCH 41/64] feat: modernize mobile sign-in experience --- mobile/App.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/mobile/App.js b/mobile/App.js index ae90354..b2b024a 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -6,12 +6,12 @@ import { ActivityIndicator, Linking, Platform, Pressable, ScrollView, StyleSheet 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, restoreSession } from './src/authApi'; import { colors, navigationTheme, radius } from './src/theme'; const Tab = createBottomTabNavigator(); const icons = { Home: '⌂', Proof: '✓', Add: '+', Profile: '◉', Settings: '⚙' }; -const oauthBaseUrl = (process.env.EXPO_PUBLIC_API_URL || 'http://localhost:8000').replace(/\/$/, ''); function GoogleMark() { return ; @@ -38,11 +38,14 @@ function Login({ onSuccess }) { finally { setBusy(false); } }; const startOAuth = async (provider) => { - const url = `${oauthBaseUrl}/auth/${provider}/login`; + const returnTo = Platform.OS === 'web' && typeof window !== 'undefined' + ? window.location.origin + : 'bragstack://oauth'; + const url = `${apiBaseURL}/auth/${provider}/login?return_to=${encodeURIComponent(returnTo)}`; if (Platform.OS === 'web' && typeof window !== 'undefined') window.location.assign(url); else await Linking.openURL(url); }; - return WELCOME BACKYour proof is waiting.Sign in with your existing BragStack account.EMAILPASSWORD{error ? {error} : null}{busy ? : Sign in}OR CONTINUE WITH startOAuth('google')} style={[styles.socialButton, styles.googleButton]}>Continue with Google startOAuth('github')} style={[styles.socialButton, styles.githubButton]}>Continue with GitHub🔒 Session tokens are kept in encrypted device storage.; + return PRIVATE CAREER PROOFWELCOME BACKYour proof is ready when you are.Open your private workspace and keep building evidence that travels with your career.EMAILPASSWORD{error ? {error} : null}{busy ? : Sign in to BragStack}OR CONTINUE WITH startOAuth('google')} style={[styles.socialButton, styles.googleButton]}>Continue with Google startOAuth('github')} style={[styles.socialButton, styles.githubButton]}>Continue with GitHubYour session stays on this device.; } function Page({ kicker, title, children }) { @@ -83,5 +86,63 @@ export default function App() { } const styles = StyleSheet.create({ - safe: { flex: 1, backgroundColor: colors.background }, boot: { flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', gap: 20 }, loginPage: { flexGrow: 1, justifyContent: 'center', padding: 24, gap: 30 }, page: { paddingHorizontal: 24, paddingTop: 16, paddingBottom: 110, gap: 16 }, brand: { flexDirection: 'row', alignItems: 'center', gap: 14 }, brandName: { color: colors.text, fontSize: 28, fontWeight: '900' }, brandSmall: { fontSize: 20 }, kicker: { color: colors.primary, fontSize: 11, fontWeight: '900', letterSpacing: 2 }, title: { color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '900' }, loginTitle: { color: colors.text, fontSize: 31, fontWeight: '900' }, muted: { color: colors.muted, fontSize: 14, lineHeight: 21 }, card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 20, gap: 12 }, label: { color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.5, marginTop: 5 }, input: { minHeight: 52, backgroundColor: colors.surfaceElevated, borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, color: colors.text, padding: 14, fontSize: 15 }, tall: { minHeight: 85, textAlignVertical: 'top' }, button: { minHeight: 52, borderRadius: radius.pill, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center' }, disabled: { opacity: 0.4 }, buttonText: { color: colors.background, fontWeight: '900' }, note: { color: colors.mutedStrong, fontSize: 11, textAlign: 'center' }, error: { color: colors.danger, fontSize: 13 }, divider: { flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 2 }, dividerLine: { flex: 1, height: 1, backgroundColor: colors.border }, dividerText: { color: colors.mutedStrong, fontSize: 9, fontWeight: '900', letterSpacing: 1.2 }, socialStack: { gap: 10 }, socialButton: { minHeight: 50, borderRadius: radius.pill, paddingHorizontal: 18, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 }, googleButton: { backgroundColor: '#FFFFFF', borderWidth: 1, borderColor: '#DADCE0' }, googleText: { color: '#202124', fontWeight: '800' }, githubButton: { backgroundColor: '#24292F', borderWidth: 1, borderColor: '#57606A' }, githubText: { color: '#FFFFFF', fontWeight: '800' }, hero: { backgroundColor: colors.surface, borderWidth: 1, borderColor: 'rgba(166,220,255,0.28)', borderRadius: radius.lg, padding: 20, gap: 10 }, heroLabel: { color: colors.primary, fontWeight: '900', fontSize: 10, letterSpacing: 1.8 }, heroTitle: { color: colors.text, fontSize: 24, lineHeight: 29, fontWeight: '900' }, metrics: { flexDirection: 'row', gap: 8, marginTop: 8 }, metric: { flex: 1, backgroundColor: colors.surfaceElevated, borderRadius: radius.md, padding: 10 }, metricNum: { color: colors.primary, fontSize: 22, fontWeight: '900' }, metricText: { color: colors.muted, fontSize: 10 }, action: { flexDirection: 'row', alignItems: 'center', gap: 14, backgroundColor: colors.surfaceElevated, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, padding: 16 }, plus: { width: 42, height: 42, borderRadius: 21, backgroundColor: colors.primary, color: colors.background, textAlign: 'center', textAlignVertical: 'center', fontSize: 28 }, arrow: { color: colors.primary, fontSize: 30 }, cardTitle: { color: colors.text, fontSize: 17, fontWeight: '900' }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, 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 }, profileName: { color: colors.text, fontSize: 20, fontWeight: '900' }, signout: { minHeight: 52, borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(255,176,176,0.3)', alignItems: 'center', justifyContent: 'center' }, signoutText: { color: colors.danger, fontWeight: '900' }, tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 78, paddingTop: 8, paddingBottom: 10 }, tabLabel: { fontSize: 10, fontWeight: '800' }, tabIcon: { fontSize: 18, fontWeight: '800' } + safe: { flex: 1, backgroundColor: colors.background }, + boot: { flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', gap: 20 }, + loginPage: { flexGrow: 1, justifyContent: 'center', paddingHorizontal: 20, paddingVertical: 30, gap: 24 }, + brandHeader: { gap: 14, zIndex: 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 }, + page: { paddingHorizontal: 24, paddingTop: 16, paddingBottom: 110, gap: 16 }, + brand: { flexDirection: 'row', alignItems: 'center', gap: 14 }, + brandName: { color: colors.text, fontSize: 28, fontWeight: '900' }, + brandSmall: { fontSize: 20 }, + kicker: { color: colors.primary, fontSize: 11, fontWeight: '900', letterSpacing: 2 }, + title: { color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '900' }, + loginTitle: { color: colors.text, fontSize: 32, lineHeight: 37, fontWeight: '900', letterSpacing: -0.8 }, + muted: { color: colors.muted, fontSize: 14, lineHeight: 21 }, + card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 20, gap: 12 }, + loginCard: { backgroundColor: 'rgba(13,21,38,0.94)', borderColor: 'rgba(173,145,255,0.26)', padding: 22, gap: 13, shadowColor: '#000000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.28, shadowRadius: 30, elevation: 12 }, + label: { color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.5, marginTop: 5 }, + input: { minHeight: 54, backgroundColor: 'rgba(19,30,51,0.92)', borderWidth: 1, borderColor: 'rgba(166,220,255,0.14)', borderRadius: 16, color: colors.text, paddingHorizontal: 16, paddingVertical: 14, fontSize: 15 }, + tall: { minHeight: 85, textAlignVertical: 'top' }, + button: { minHeight: 54, borderRadius: radius.pill, backgroundColor: colors.primary, borderWidth: 1, borderColor: 'rgba(255,255,255,0.22)', alignItems: 'center', justifyContent: 'center', shadowColor: colors.primary, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.18, shadowRadius: 16, elevation: 5 }, + disabled: { opacity: 0.4 }, + buttonText: { color: colors.background, fontWeight: '900', letterSpacing: 0.1 }, + note: { color: colors.mutedStrong, fontSize: 11, textAlign: 'center' }, + securityRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 2 }, + securityIcon: { color: colors.cyan, fontSize: 15 }, + error: { color: colors.danger, fontSize: 13 }, + divider: { flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 3 }, + dividerLine: { flex: 1, height: 1, backgroundColor: colors.border }, + dividerText: { color: colors.mutedStrong, fontSize: 9, fontWeight: '900', letterSpacing: 1.2 }, + socialStack: { gap: 10 }, + socialButton: { minHeight: 52, borderRadius: 16, paddingHorizontal: 18, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 }, + googleButton: { backgroundColor: '#FFFFFF', borderWidth: 1, borderColor: '#DADCE0' }, + googleText: { color: '#202124', fontWeight: '800' }, + githubButton: { backgroundColor: '#24292F', borderWidth: 1, borderColor: '#57606A' }, + githubText: { color: '#FFFFFF', fontWeight: '800' }, + hero: { backgroundColor: colors.surface, borderWidth: 1, borderColor: 'rgba(166,220,255,0.28)', borderRadius: radius.lg, padding: 20, gap: 10 }, + heroLabel: { color: colors.primary, fontWeight: '900', fontSize: 10, letterSpacing: 1.8 }, + heroTitle: { color: colors.text, fontSize: 24, lineHeight: 29, fontWeight: '900' }, + metrics: { flexDirection: 'row', gap: 8, marginTop: 8 }, + metric: { flex: 1, backgroundColor: colors.surfaceElevated, borderRadius: radius.md, padding: 10 }, + metricNum: { color: colors.primary, fontSize: 22, fontWeight: '900' }, + metricText: { color: colors.muted, fontSize: 10 }, + action: { flexDirection: 'row', alignItems: 'center', gap: 14, backgroundColor: colors.surfaceElevated, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, padding: 16 }, + plus: { width: 42, height: 42, borderRadius: 21, backgroundColor: colors.primary, color: colors.background, textAlign: 'center', textAlignVertical: 'center', fontSize: 28 }, + arrow: { color: colors.primary, fontSize: 30 }, + cardTitle: { color: colors.text, fontSize: 17, fontWeight: '900' }, + row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + 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 }, + profileName: { color: colors.text, fontSize: 20, fontWeight: '900' }, + signout: { minHeight: 52, borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(255,176,176,0.3)', alignItems: 'center', justifyContent: 'center' }, + signoutText: { color: colors.danger, fontWeight: '900' }, + tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 78, paddingTop: 8, paddingBottom: 10 }, + tabLabel: { fontSize: 10, fontWeight: '800' }, + tabIcon: { fontSize: 18, fontWeight: '800' } }); From aaabf7f9e86576e12e5b47202ef753d40424b82b Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:15:50 -0400 Subject: [PATCH 42/64] fix: make mobile sign-in responsive and scroll-safe --- mobile/App.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/mobile/App.js b/mobile/App.js index b2b024a..ba468be 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -2,7 +2,7 @@ import React, { useEffect, 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, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { ActivityIndicator, Linking, Platform, Pressable, 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'; @@ -26,6 +26,8 @@ function Brand({ small = false }) { } function Login({ onSuccess }) { + const { width, height } = useWindowDimensions(); + const compact = height < 760 || width < 390; const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); @@ -45,7 +47,7 @@ function Login({ onSuccess }) { if (Platform.OS === 'web' && typeof window !== 'undefined') window.location.assign(url); else await Linking.openURL(url); }; - return PRIVATE CAREER PROOFWELCOME BACKYour proof is ready when you are.Open your private workspace and keep building evidence that travels with your career.EMAILPASSWORD{error ? {error} : null}{busy ? : Sign in to BragStack}OR CONTINUE WITH startOAuth('google')} style={[styles.socialButton, styles.googleButton]}>Continue with Google startOAuth('github')} style={[styles.socialButton, styles.githubButton]}>Continue with GitHubYour session stays on this device.; + return PRIVATE CAREER PROOFWELCOME BACKYour proof is ready when you are.Open your private workspace and keep building evidence that travels with your career.EMAILPASSWORD{error ? {error} : null}{busy ? : Sign in to BragStack}OR CONTINUE WITH startOAuth('google')} style={[styles.socialButton, styles.googleButton, compact && styles.controlCompact]}>Continue with Google startOAuth('github')} style={[styles.socialButton, styles.githubButton, compact && styles.controlCompact]}>Continue with GitHubYour session stays on this device.; } function Page({ kicker, title, children }) { @@ -88,7 +90,11 @@ export default function App() { const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: colors.background }, boot: { flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', gap: 20 }, - loginPage: { flexGrow: 1, justifyContent: 'center', paddingHorizontal: 20, paddingVertical: 30, gap: 24 }, + loginScroll: { flex: 1 }, + loginPage: { flexGrow: 1, alignItems: 'center', paddingHorizontal: 20, paddingTop: 44, paddingBottom: 48 }, + loginPageCompact: { paddingHorizontal: 14, paddingTop: 18, paddingBottom: 32 }, + loginShell: { width: '100%', maxWidth: 480, gap: 24 }, + loginShellCompact: { gap: 16 }, brandHeader: { gap: 14, zIndex: 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 }, @@ -102,13 +108,17 @@ const styles = StyleSheet.create({ kicker: { color: colors.primary, fontSize: 11, fontWeight: '900', letterSpacing: 2 }, title: { color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '900' }, loginTitle: { color: colors.text, fontSize: 32, lineHeight: 37, fontWeight: '900', letterSpacing: -0.8 }, + loginTitleCompact: { fontSize: 28, lineHeight: 32 }, muted: { color: colors.muted, fontSize: 14, lineHeight: 21 }, card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 20, gap: 12 }, loginCard: { backgroundColor: 'rgba(13,21,38,0.94)', borderColor: 'rgba(173,145,255,0.26)', padding: 22, gap: 13, shadowColor: '#000000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.28, shadowRadius: 30, elevation: 12 }, + loginCardCompact: { padding: 17, gap: 10, borderRadius: 22 }, label: { color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.5, marginTop: 5 }, input: { minHeight: 54, backgroundColor: 'rgba(19,30,51,0.92)', borderWidth: 1, borderColor: 'rgba(166,220,255,0.14)', borderRadius: 16, color: colors.text, paddingHorizontal: 16, paddingVertical: 14, fontSize: 15 }, + inputCompact: { minHeight: 48, paddingVertical: 11 }, tall: { minHeight: 85, textAlignVertical: 'top' }, button: { minHeight: 54, borderRadius: radius.pill, backgroundColor: colors.primary, borderWidth: 1, borderColor: 'rgba(255,255,255,0.22)', alignItems: 'center', justifyContent: 'center', shadowColor: colors.primary, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.18, shadowRadius: 16, elevation: 5 }, + controlCompact: { minHeight: 48 }, disabled: { opacity: 0.4 }, buttonText: { color: colors.background, fontWeight: '900', letterSpacing: 0.1 }, note: { color: colors.mutedStrong, fontSize: 11, textAlign: 'center' }, @@ -145,4 +155,4 @@ const styles = StyleSheet.create({ tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 78, paddingTop: 8, paddingBottom: 10 }, tabLabel: { fontSize: 10, fontWeight: '800' }, tabIcon: { fontSize: 18, fontWeight: '800' } -}); +}); \ No newline at end of file From 1d7c506b3a2a599ac9fc91661b4070130b1e7962 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:20:47 -0400 Subject: [PATCH 43/64] fix: make mobile layouts responsive across phones and tablets --- mobile/App.js | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/mobile/App.js b/mobile/App.js index ba468be..7ef2148 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -13,6 +13,11 @@ import { colors, navigationTheme, radius } from './src/theme'; const Tab = createBottomTabNavigator(); const icons = { Home: '⌂', Proof: '✓', Add: '+', Profile: '◉', Settings: '⚙' }; +function responsiveWidth(width, maxWidth) { + const gutter = width < 390 ? 14 : width < 768 ? 20 : 32; + return Math.max(280, Math.min(width - (gutter * 2), maxWidth)); +} + function GoogleMark() { return ; } @@ -22,12 +27,14 @@ function GitHubMark() { } function Brand({ small = false }) { - return BragStack{!small && Proof of the impact you create.}; + return BragStack{!small && Proof of the impact you create.}; } function Login({ onSuccess }) { const { width, height } = useWindowDimensions(); const compact = height < 760 || width < 390; + const tablet = width >= 768; + const shellWidth = responsiveWidth(width, tablet ? 560 : 480); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); @@ -47,11 +54,13 @@ function Login({ onSuccess }) { if (Platform.OS === 'web' && typeof window !== 'undefined') window.location.assign(url); else await Linking.openURL(url); }; - return PRIVATE CAREER PROOFWELCOME BACKYour proof is ready when you are.Open your private workspace and keep building evidence that travels with your career.EMAILPASSWORD{error ? {error} : null}{busy ? : Sign in to BragStack}OR CONTINUE WITH startOAuth('google')} style={[styles.socialButton, styles.googleButton, compact && styles.controlCompact]}>Continue with Google startOAuth('github')} style={[styles.socialButton, styles.githubButton, compact && styles.controlCompact]}>Continue with GitHubYour session stays on this device.; + return PRIVATE CAREER PROOFWELCOME BACKYour proof is ready when you are.Open your private workspace and keep building evidence that travels with your career.EMAILPASSWORD{error ? {error} : null}{busy ? : Sign in to BragStack}OR CONTINUE WITH startOAuth('google')} style={[styles.socialButton, styles.googleButton, compact && styles.controlCompact]}>Continue with Google startOAuth('github')} style={[styles.socialButton, styles.githubButton, compact && styles.controlCompact]}>Continue with GitHubYour session stays on this device.; } function Page({ kicker, title, children }) { - return {kicker}{title}{children}; + const { width } = useWindowDimensions(); + const pageWidth = responsiveWidth(width, width >= 768 ? 760 : 640); + return {kicker}{title}{children}; } function Pill({ children }) { return {children}; } @@ -91,9 +100,10 @@ const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: colors.background }, boot: { flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', gap: 20 }, loginScroll: { flex: 1 }, - loginPage: { flexGrow: 1, alignItems: 'center', paddingHorizontal: 20, paddingTop: 44, paddingBottom: 48 }, - loginPageCompact: { paddingHorizontal: 14, paddingTop: 18, paddingBottom: 32 }, - loginShell: { width: '100%', maxWidth: 480, gap: 24 }, + loginPage: { flexGrow: 1, alignItems: 'center', paddingTop: 44, paddingBottom: 48 }, + loginPageCompact: { paddingTop: 18, paddingBottom: 32 }, + loginPageTablet: { justifyContent: 'center', paddingTop: 56, paddingBottom: 56 }, + loginShell: { gap: 24, alignSelf: 'center' }, loginShellCompact: { gap: 16 }, brandHeader: { gap: 14, zIndex: 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)' }, @@ -101,8 +111,10 @@ const styles = StyleSheet.create({ 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 }, - page: { paddingHorizontal: 24, paddingTop: 16, paddingBottom: 110, gap: 16 }, - brand: { flexDirection: 'row', alignItems: 'center', gap: 14 }, + pageFrame: { flexGrow: 1, alignItems: 'center', paddingBottom: 110 }, + page: { paddingTop: 16, gap: 16 }, + brand: { flexDirection: 'row', alignItems: 'center', gap: 14, minWidth: 0 }, + brandCopy: { flexShrink: 1, minWidth: 0 }, brandName: { color: colors.text, fontSize: 28, fontWeight: '900' }, brandSmall: { fontSize: 20 }, kicker: { color: colors.primary, fontSize: 11, fontWeight: '900', letterSpacing: 2 }, @@ -113,6 +125,7 @@ const styles = StyleSheet.create({ card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 20, gap: 12 }, loginCard: { backgroundColor: 'rgba(13,21,38,0.94)', borderColor: 'rgba(173,145,255,0.26)', padding: 22, gap: 13, shadowColor: '#000000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.28, shadowRadius: 30, elevation: 12 }, loginCardCompact: { padding: 17, gap: 10, borderRadius: 22 }, + loginCardTablet: { padding: 28, gap: 15 }, label: { color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.5, marginTop: 5 }, input: { minHeight: 54, backgroundColor: 'rgba(19,30,51,0.92)', borderWidth: 1, borderColor: 'rgba(166,220,255,0.14)', borderRadius: 16, color: colors.text, paddingHorizontal: 16, paddingVertical: 14, fontSize: 15 }, inputCompact: { minHeight: 48, paddingVertical: 11 }, @@ -155,4 +168,4 @@ const styles = StyleSheet.create({ tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 78, paddingTop: 8, paddingBottom: 10 }, tabLabel: { fontSize: 10, fontWeight: '800' }, tabIcon: { fontSize: 18, fontWeight: '800' } -}); \ No newline at end of file +}); From 1564bcfcc0e904296c6eb523343cba82bc506890 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:25:19 -0400 Subject: [PATCH 44/64] fix: make mobile root fill all device viewports --- mobile/App.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mobile/App.js b/mobile/App.js index 7ef2148..1ce5360 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -93,13 +93,14 @@ export default function App() { : user ? : ; - return {content}; + return {content}; } const styles = StyleSheet.create({ - safe: { flex: 1, backgroundColor: colors.background }, + appRoot: { flex: 1, width: '100%', minWidth: '100%', minHeight: '100%', alignSelf: 'stretch', backgroundColor: colors.background }, + safe: { flex: 1, width: '100%', alignSelf: 'stretch', backgroundColor: colors.background }, boot: { flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', gap: 20 }, - loginScroll: { flex: 1 }, + loginScroll: { flex: 1, width: '100%' }, loginPage: { flexGrow: 1, alignItems: 'center', paddingTop: 44, paddingBottom: 48 }, loginPageCompact: { paddingTop: 18, paddingBottom: 32 }, loginPageTablet: { justifyContent: 'center', paddingTop: 56, paddingBottom: 56 }, @@ -168,4 +169,4 @@ const styles = StyleSheet.create({ tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 78, paddingTop: 8, paddingBottom: 10 }, tabLabel: { fontSize: 10, fontWeight: '800' }, tabIcon: { fontSize: 18, fontWeight: '800' } -}); +}); \ No newline at end of file From 74e95d7657568672105d280bd7a4af7f8e7d1102 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:27:43 -0400 Subject: [PATCH 45/64] fix: make mobile layout truly responsive across device sizes --- mobile/App.js | 134 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 87 insertions(+), 47 deletions(-) diff --git a/mobile/App.js b/mobile/App.js index 1ce5360..b216707 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -13,11 +13,6 @@ import { colors, navigationTheme, radius } from './src/theme'; const Tab = createBottomTabNavigator(); const icons = { Home: '⌂', Proof: '✓', Add: '+', Profile: '◉', Settings: '⚙' }; -function responsiveWidth(width, maxWidth) { - const gutter = width < 390 ? 14 : width < 768 ? 20 : 32; - return Math.max(280, Math.min(width - (gutter * 2), maxWidth)); -} - function GoogleMark() { return ; } @@ -34,11 +29,11 @@ function Login({ onSuccess }) { const { width, height } = useWindowDimensions(); const compact = height < 760 || width < 390; const tablet = width >= 768; - const shellWidth = responsiveWidth(width, tablet ? 560 : 480); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); + const submit = async () => { if (!email.trim() || !password || busy) return; setBusy(true); setError(''); @@ -46,21 +41,46 @@ function Login({ onSuccess }) { catch (e) { setError(getAuthErrorMessage(e)); } finally { setBusy(false); } }; + const startOAuth = async (provider) => { - const returnTo = Platform.OS === 'web' && typeof window !== 'undefined' - ? window.location.origin - : 'bragstack://oauth'; + const returnTo = Platform.OS === 'web' && typeof window !== 'undefined' ? window.location.origin : 'bragstack://oauth'; const url = `${apiBaseURL}/auth/${provider}/login?return_to=${encodeURIComponent(returnTo)}`; if (Platform.OS === 'web' && typeof window !== 'undefined') window.location.assign(url); else await Linking.openURL(url); }; - return PRIVATE CAREER PROOFWELCOME BACKYour proof is ready when you are.Open your private workspace and keep building evidence that travels with your career.EMAILPASSWORD{error ? {error} : null}{busy ? : Sign in to BragStack}OR CONTINUE WITH startOAuth('google')} style={[styles.socialButton, styles.googleButton, compact && styles.controlCompact]}>Continue with Google startOAuth('github')} style={[styles.socialButton, styles.githubButton, compact && styles.controlCompact]}>Continue with GitHubYour session stays on this device.; + + return + + + + + + PRIVATE CAREER PROOF + + WELCOME BACK + Your proof is ready when you are. + Open your private workspace and keep building evidence that travels with your career. + EMAIL + + PASSWORD + + {error ? {error} : null} + {busy ? : Sign in to BragStack} + OR CONTINUE WITH + + startOAuth('google')} style={[styles.socialButton, styles.googleButton, compact && styles.controlCompact]}>Continue with Google + startOAuth('github')} style={[styles.socialButton, styles.githubButton, compact && styles.controlCompact]}>Continue with GitHub + + Your session stays on this device. + + + + + ; } function Page({ kicker, title, children }) { - const { width } = useWindowDimensions(); - const pageWidth = responsiveWidth(width, width >= 768 ? 760 : 640); - return {kicker}{title}{children}; + return {kicker}{title}{children}; } function Pill({ children }) { return {children}; } @@ -77,7 +97,7 @@ function Add() { return WHAT HAPPENED?WHAT CHANGED?🔒 Drafts stay private by default. setDraft({ win: win.trim(), result: result.trim() })} style={[styles.button, !win.trim() && styles.disabled]}>Preview Impact Receipt{draft && }; } -function Profile({ user }) { return {user?.name || 'BragStack Member'}{user?.headline || 'Your evidence-backed professional story'}{user?.public_slug ? `Public profile: /${user.public_slug}` : 'Public profile ready when you choose to share.'}; } +function Profile({ user }) { return {user?.name || 'BragStack Member'}{user?.headline || 'Your evidence-backed professional story'}{user?.public_slug ? `Public profile: /${user.public_slug}` : 'Public profile ready when you choose to share.'}; } function Settings({ user, onSignOut }) { return Signed in{user?.email}Official app themeDeep navy • crisp white • BragStack blue + purpleSign out; } function Tabs({ user, onSignOut }) { @@ -86,84 +106,104 @@ function Tabs({ user, onSignOut }) { 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 html = document.documentElement; + const body = document.body; + const root = document.getElementById('root'); + const nodes = [html, body, root].filter(Boolean); + nodes.forEach((node) => { + node.style.width = '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'; } + body.style.overflow = 'hidden'; + return undefined; + }, []); + useEffect(() => { let live = true; restoreSession().then(u => live && setUser(u)).catch(() => {}).finally(() => live && setBooting(false)); return () => { live = false; }; }, []); const signOut = async () => { await logout(); setUser(null); }; - const content = booting - ? Opening your BragStack… - : user - ? - : ; + const content = booting ? Opening your BragStack… : user ? : ; return {content}; } const styles = StyleSheet.create({ - appRoot: { flex: 1, width: '100%', minWidth: '100%', minHeight: '100%', alignSelf: 'stretch', backgroundColor: colors.background }, - safe: { flex: 1, width: '100%', alignSelf: 'stretch', backgroundColor: colors.background }, + 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 }, - loginScroll: { flex: 1, width: '100%' }, - loginPage: { flexGrow: 1, alignItems: 'center', paddingTop: 44, paddingBottom: 48 }, + loginScroll: { flex: 1, minWidth: 0 }, + loginPage: { flexGrow: 1, alignItems: 'stretch', paddingTop: 44, paddingBottom: 48 }, loginPageCompact: { paddingTop: 18, paddingBottom: 32 }, loginPageTablet: { justifyContent: 'center', paddingTop: 56, paddingBottom: 56 }, - loginShell: { gap: 24, alignSelf: 'center' }, + loginFrame: { alignSelf: 'stretch', alignItems: 'center' }, + loginFramePhone: { paddingHorizontal: 18 }, + loginFrameTablet: { paddingHorizontal: 32 }, + loginShell: { alignSelf: 'stretch', width: '100%', maxWidth: 560, gap: 24 }, loginShellCompact: { gap: 16 }, - brandHeader: { gap: 14, zIndex: 2 }, + brandHeader: { gap: 14, zIndex: 2, minWidth: 0 }, 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 }, - page: { paddingTop: 16, gap: 16 }, + pageFrame: { flexGrow: 1, alignItems: 'stretch', paddingBottom: 110, paddingHorizontal: 18 }, + pageShell: { width: '100%', maxWidth: 760, alignSelf: 'center' }, + page: { paddingTop: 16, gap: 16, minWidth: 0 }, brand: { flexDirection: 'row', alignItems: 'center', gap: 14, minWidth: 0 }, brandCopy: { flexShrink: 1, minWidth: 0 }, - brandName: { color: colors.text, fontSize: 28, fontWeight: '900' }, + brandName: { color: colors.text, fontSize: 28, fontWeight: '900', flexShrink: 1 }, brandSmall: { fontSize: 20 }, kicker: { color: colors.primary, fontSize: 11, fontWeight: '900', letterSpacing: 2 }, - title: { color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '900' }, - loginTitle: { color: colors.text, fontSize: 32, lineHeight: 37, fontWeight: '900', letterSpacing: -0.8 }, + title: { color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '900', flexShrink: 1 }, + loginTitle: { color: colors.text, fontSize: 32, lineHeight: 37, fontWeight: '900', letterSpacing: -0.8, flexShrink: 1 }, loginTitleCompact: { fontSize: 28, lineHeight: 32 }, - muted: { color: colors.muted, fontSize: 14, lineHeight: 21 }, - card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 20, gap: 12 }, + muted: { color: colors.muted, fontSize: 14, lineHeight: 21, flexShrink: 1 }, + card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 20, gap: 12, minWidth: 0 }, loginCard: { backgroundColor: 'rgba(13,21,38,0.94)', borderColor: 'rgba(173,145,255,0.26)', padding: 22, gap: 13, shadowColor: '#000000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.28, shadowRadius: 30, elevation: 12 }, loginCardCompact: { padding: 17, gap: 10, borderRadius: 22 }, loginCardTablet: { padding: 28, gap: 15 }, label: { color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.5, marginTop: 5 }, - input: { minHeight: 54, backgroundColor: 'rgba(19,30,51,0.92)', borderWidth: 1, borderColor: 'rgba(166,220,255,0.14)', borderRadius: 16, color: colors.text, paddingHorizontal: 16, paddingVertical: 14, fontSize: 15 }, + input: { minHeight: 54, width: '100%', backgroundColor: 'rgba(19,30,51,0.92)', borderWidth: 1, borderColor: 'rgba(166,220,255,0.14)', borderRadius: 16, color: colors.text, paddingHorizontal: 16, paddingVertical: 14, fontSize: 15 }, inputCompact: { minHeight: 48, paddingVertical: 11 }, tall: { minHeight: 85, textAlignVertical: 'top' }, - button: { minHeight: 54, borderRadius: radius.pill, backgroundColor: colors.primary, borderWidth: 1, borderColor: 'rgba(255,255,255,0.22)', alignItems: 'center', justifyContent: 'center', shadowColor: colors.primary, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.18, shadowRadius: 16, elevation: 5 }, + button: { minHeight: 54, width: '100%', borderRadius: radius.pill, backgroundColor: colors.primary, borderWidth: 1, borderColor: 'rgba(255,255,255,0.22)', alignItems: 'center', justifyContent: 'center', shadowColor: colors.primary, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.18, shadowRadius: 16, elevation: 5 }, controlCompact: { minHeight: 48 }, disabled: { opacity: 0.4 }, buttonText: { color: colors.background, fontWeight: '900', letterSpacing: 0.1 }, - note: { color: colors.mutedStrong, fontSize: 11, textAlign: 'center' }, - securityRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 2 }, + note: { color: colors.mutedStrong, fontSize: 11, textAlign: 'center', flexShrink: 1 }, + securityRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 2, flexWrap: 'wrap' }, securityIcon: { color: colors.cyan, fontSize: 15 }, error: { color: colors.danger, fontSize: 13 }, - divider: { flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 3 }, + divider: { flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 3, minWidth: 0 }, dividerLine: { flex: 1, height: 1, backgroundColor: colors.border }, - dividerText: { color: colors.mutedStrong, fontSize: 9, fontWeight: '900', letterSpacing: 1.2 }, + dividerText: { color: colors.mutedStrong, fontSize: 9, fontWeight: '900', letterSpacing: 1.2, flexShrink: 1 }, socialStack: { gap: 10 }, - socialButton: { minHeight: 52, borderRadius: 16, paddingHorizontal: 18, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 }, + socialButton: { minHeight: 52, width: '100%', borderRadius: 16, paddingHorizontal: 18, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 }, googleButton: { backgroundColor: '#FFFFFF', borderWidth: 1, borderColor: '#DADCE0' }, - googleText: { color: '#202124', fontWeight: '800' }, + googleText: { color: '#202124', fontWeight: '800', flexShrink: 1 }, githubButton: { backgroundColor: '#24292F', borderWidth: 1, borderColor: '#57606A' }, - githubText: { color: '#FFFFFF', fontWeight: '800' }, + githubText: { color: '#FFFFFF', fontWeight: '800', flexShrink: 1 }, hero: { backgroundColor: colors.surface, borderWidth: 1, borderColor: 'rgba(166,220,255,0.28)', borderRadius: radius.lg, padding: 20, gap: 10 }, heroLabel: { color: colors.primary, fontWeight: '900', fontSize: 10, letterSpacing: 1.8 }, heroTitle: { color: colors.text, fontSize: 24, lineHeight: 29, fontWeight: '900' }, - metrics: { flexDirection: 'row', gap: 8, marginTop: 8 }, - metric: { flex: 1, backgroundColor: colors.surfaceElevated, borderRadius: radius.md, padding: 10 }, + metrics: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 }, + metric: { flexGrow: 1, flexBasis: 90, backgroundColor: colors.surfaceElevated, borderRadius: radius.md, padding: 10 }, metricNum: { color: colors.primary, fontSize: 22, fontWeight: '900' }, metricText: { color: colors.muted, fontSize: 10 }, - action: { flexDirection: 'row', alignItems: 'center', gap: 14, backgroundColor: colors.surfaceElevated, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, padding: 16 }, + action: { flexDirection: 'row', alignItems: 'center', gap: 14, backgroundColor: colors.surfaceElevated, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, padding: 16, minWidth: 0 }, plus: { width: 42, height: 42, borderRadius: 21, backgroundColor: colors.primary, color: colors.background, textAlign: 'center', textAlignVertical: 'center', fontSize: 28 }, arrow: { color: colors.primary, fontSize: 30 }, - cardTitle: { color: colors.text, fontSize: 17, fontWeight: '900' }, - row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + 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 }, - profileName: { color: colors.text, fontSize: 20, fontWeight: '900' }, + profileName: { color: colors.text, fontSize: 20, fontWeight: '900', flexShrink: 1 }, signout: { minHeight: 52, borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(255,176,176,0.3)', alignItems: 'center', justifyContent: 'center' }, signoutText: { color: colors.danger, fontWeight: '900' }, tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 78, paddingTop: 8, paddingBottom: 10 }, From 2000e7760cb893e0e5d2e7ceea05a378d52f6d42 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:29:55 -0400 Subject: [PATCH 46/64] test: align mobile theme expectations with current brand palette --- mobile/__tests__/theme.test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/mobile/__tests__/theme.test.js b/mobile/__tests__/theme.test.js index d94288d..698c7d3 100644 --- a/mobile/__tests__/theme.test.js +++ b/mobile/__tests__/theme.test.js @@ -1,15 +1,18 @@ import { colors, navigationTheme } from '../src/theme'; describe('BragStack mobile brand tokens', () => { - it('uses the canonical authenticated-app palette', () => { - expect(colors.background).toBe('#090909'); - expect(colors.text).toBe('#F7F4EE'); - expect(colors.primary).toBe('#FFB184'); + 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); }); }); From 00f0d7c1162ed1d3df3ce70331da861694025c4e Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:31:20 -0400 Subject: [PATCH 47/64] fix: initialize mobile web viewport before React Native --- mobile/index.js | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 mobile/index.js 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); From 0965af3f7715cb740c89ff4706e4a384c7d8eb27 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:31:31 -0400 Subject: [PATCH 48/64] fix: use custom mobile entrypoint for responsive web viewport --- mobile/package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mobile/package.json b/mobile/package.json index 04443e3..1c292ba 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -2,7 +2,7 @@ "name": "bragstack-mobile", "version": "0.1.0", "private": true, - "main": "node_modules/expo/AppEntry.js", + "main": "index.js", "scripts": { "start": "expo start", "android": "expo start --android", @@ -24,7 +24,9 @@ "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-native-svg": "15.15.4", + "react-dom": "19.2.3", + "react-native-web": "^0.21.2" }, "devDependencies": { "@babel/core": "^7.25.2", From b32d8d4a325a130a384fd6644d757b2cb3176a16 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:39:05 -0400 Subject: [PATCH 49/64] feat(mobile): complete password auth flows --- mobile/src/authApi.js | 46 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/mobile/src/authApi.js b/mobile/src/authApi.js index 2a5accf..cb85647 100644 --- a/mobile/src/authApi.js +++ b/mobile/src/authApi.js @@ -1,9 +1,13 @@ 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', email.trim().toLowerCase()); + form.append('username', normalizeEmail(email)); form.append('password', password); const response = await api.post('/auth/login', form.toString(), { @@ -14,6 +18,40 @@ export async function login(email, password) { 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'); @@ -31,6 +69,8 @@ export async function logout() { export function getAuthErrorMessage(error) { const detail = error?.response?.data?.detail; if (typeof detail === 'string') return detail; - if (!error?.response) return 'Could not reach BragStack. Check your connection and API configuration.'; - return 'Sign in failed. Please try again.'; + 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.'; } From ff61da624fa98a03577b15b882bee3d54ce907fa Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:39:17 -0400 Subject: [PATCH 50/64] feat(mobile): wire live proof and profile APIs --- mobile/src/productApi.js | 89 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 mobile/src/productApi.js 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; +} From ecc2a29fc57fcc10b76d75cd28b775998424550f Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:41:18 -0400 Subject: [PATCH 51/64] feat(mobile): rebuild responsive auth and live product screens --- mobile/App.js | 725 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 611 insertions(+), 114 deletions(-) diff --git a/mobile/App.js b/mobile/App.js index b216707..ef6fa21 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -1,134 +1,623 @@ -import React, { useEffect, useState } from 'react'; +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, ScrollView, StyleSheet, Text, TextInput, useWindowDimensions, View } from 'react-native'; +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, restoreSession } from './src/authApi'; +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 GoogleMark() { - return ; + return ( + + + + + + + ); } function GitHubMark() { - return ; + return ( + + + + ); } function Brand({ small = false }) { - return BragStack{!small && Proof of the impact you create.}; + return ( + + + + BragStack + {!small && Proof of the impact you create.} + + + ); } -function Login({ onSuccess }) { +function AuthScreen({ onSuccess }) { const { width, height } = useWindowDimensions(); const compact = height < 760 || width < 390; - const tablet = width >= 768; + const contentWidth = Math.max(280, Math.min(width - (width >= 768 ? 64 : 24), 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 (!email.trim() || !password || busy) return; - setBusy(true); setError(''); - try { onSuccess(await login(email, password)); } - catch (e) { setError(getAuthErrorMessage(e)); } - finally { setBusy(false); } + 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) => { - const returnTo = Platform.OS === 'web' && typeof window !== 'undefined' ? window.location.origin : 'bragstack://oauth'; - const url = `${apiBaseURL}/auth/${provider}/login?return_to=${encodeURIComponent(returnTo)}`; - if (Platform.OS === 'web' && typeof window !== 'undefined') window.location.assign(url); - else await Linking.openURL(url); + if (Platform.OS !== 'web') return; + await Linking.openURL(`${apiBaseURL}/auth/${provider}/login`); }; - return - - - - - - PRIVATE CAREER PROOF - - WELCOME BACK - Your proof is ready when you are. - Open your private workspace and keep building evidence that travels with your career. + 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 + + + + + {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 + + + )} + EMAIL - - PASSWORD - + + + {mode !== 'reset' && ( + <> + PASSWORD + + + )} + {error ? {error} : null} - {busy ? : Sign in to BragStack} - OR CONTINUE WITH - - startOAuth('google')} style={[styles.socialButton, styles.googleButton, compact && styles.controlCompact]}>Continue with Google - startOAuth('github')} style={[styles.socialButton, styles.githubButton, compact && styles.controlCompact]}>Continue with GitHub + {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? + + )} + + {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 - Your session stays on this device. + + + ); +} + +function Page({ kicker, title, children, refreshing = false, onRefresh }) { + const { width } = useWindowDimensions(); + const contentWidth = Math.max(280, Math.min(width - 28, 760)); + 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 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 Proof({ overview, loading, error, refreshing, onRefresh }) { + return ( + + {loading ? : null} + {error ? {error} : null} + + {!loading && overview.receipts.length > 0 ? IMPACT RECEIPTS : null} + {overview.receipts.map((receipt) => { + const confirmed = (receipt.confirmations || []).some((confirmation) => confirmation.status === 'confirmed'); + const evidenceCount = (receipt.evidence || []).length; + return ( + + ); + })} + + {!loading && overview.entries.length > 0 ? ACCOMPLISHMENTS : null} + {overview.entries.map((entry) => ( + + ))} + + {!loading && !error && overview.entries.length === 0 && overview.receipts.length === 0 ? ( + No proof yet.Your mobile library is connected to production data. Add a win to start it. + ) : null} + + ); } -function Page({ kicker, title, children }) { - return {kicker}{title}{children}; +function Field({ label, value, onChangeText, placeholder, multiline = false, autoCapitalize = 'sentences' }) { + return ( + + {label} + + + ); } -function Pill({ children }) { return {children}; } -function ProofCard({ title, body, verified }) { return {verified ? 'Verified' : 'Private draft'}◌ Private{title}{body}; } +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); + } + }; -function Home({ navigation, user }) { - return PROOF PULSETurn fresh wins into durable career proof.Capture the work, result, evidence, skills, and credit while the details are fresh.12Receipts4Verified3With proof navigation.navigate('Add')}>+Capture a winStart private. Add evidence when you have it.; + 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 Proof() { return ; } +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]); -function Add() { - const [win, setWin] = useState(''); const [result, setResult] = useState(''); const [draft, setDraft] = useState(null); - return WHAT HAPPENED?WHAT CHANGED?🔒 Drafts stay private by default. setDraft({ win: win.trim(), result: result.trim() })} style={[styles.button, !win.trim() && styles.disabled]}>Preview Impact Receipt{draft && }; + 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 Profile({ user }) { return {user?.name || 'BragStack Member'}{user?.headline || 'Your evidence-backed professional story'}{user?.public_slug ? `Public profile: /${user.public_slug}` : 'Public profile ready when you choose to share.'}; } -function Settings({ user, onSignOut }) { return Signed in{user?.email}Official app themeDeep navy • crisp white • BragStack blue + purpleSign out; } +function Settings({ user, onSignOut }) { + 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, onSignOut }) { - return ({ headerShown: false, tabBarActiveTintColor: colors.primary, tabBarInactiveTintColor: colors.mutedStrong, tabBarStyle: styles.tabBar, tabBarLabelStyle: styles.tabLabel, tabBarIcon: ({ color }) => {icons[route.name]} })}>{p => }{p => }{p => }; +function Tabs({ user, onUserChange, onSignOut }) { + 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, + tabBarStyle: styles.tabBar, + tabBarLabelStyle: styles.tabLabel, + 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); + const [user, setUser] = useState(null); + const [booting, setBooting] = useState(true); useEffect(() => { if (Platform.OS !== 'web' || typeof document === 'undefined') return undefined; - const html = document.documentElement; - const body = document.body; const root = document.getElementById('root'); - const nodes = [html, body, root].filter(Boolean); + 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'; } - body.style.overflow = 'hidden'; + 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(u => live && setUser(u)).catch(() => {}).finally(() => live && setBooting(false)); return () => { live = false; }; }, []); - const signOut = async () => { await logout(); setUser(null); }; - const content = booting ? Opening your BragStack… : user ? : ; + 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}; } @@ -136,77 +625,85 @@ 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 }, - loginScroll: { flex: 1, minWidth: 0 }, - loginPage: { flexGrow: 1, alignItems: 'stretch', paddingTop: 44, paddingBottom: 48 }, - loginPageCompact: { paddingTop: 18, paddingBottom: 32 }, - loginPageTablet: { justifyContent: 'center', paddingTop: 56, paddingBottom: 56 }, - loginFrame: { alignSelf: 'stretch', alignItems: 'center' }, - loginFramePhone: { paddingHorizontal: 18 }, - loginFrameTablet: { paddingHorizontal: 32 }, - loginShell: { alignSelf: 'stretch', width: '100%', maxWidth: 560, gap: 24 }, - loginShellCompact: { gap: 16 }, - brandHeader: { gap: 14, zIndex: 2, minWidth: 0 }, + 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: 18 }, + loginShell: { alignSelf: 'center', gap: 20, minWidth: 0, maxWidth: '100%' }, + brandHeader: { gap: 12, minWidth: 0 }, + brandHeaderCompact: { gap: 9 }, + 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: 'stretch', paddingBottom: 110, paddingHorizontal: 18 }, - pageShell: { width: '100%', maxWidth: 760, alignSelf: 'center' }, - page: { paddingTop: 16, gap: 16, minWidth: 0 }, - brand: { flexDirection: 'row', alignItems: 'center', gap: 14, minWidth: 0 }, - brandCopy: { flexShrink: 1, minWidth: 0 }, - brandName: { color: colors.text, fontSize: 28, fontWeight: '900', flexShrink: 1 }, - brandSmall: { fontSize: 20 }, - kicker: { color: colors.primary, fontSize: 11, fontWeight: '900', letterSpacing: 2 }, - title: { color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '900', flexShrink: 1 }, - loginTitle: { color: colors.text, fontSize: 32, lineHeight: 37, fontWeight: '900', letterSpacing: -0.8, flexShrink: 1 }, - loginTitleCompact: { fontSize: 28, lineHeight: 32 }, + pageFrame: { flexGrow: 1, alignItems: 'center', paddingBottom: 110, paddingTop: 10, width: '100%', minWidth: 0 }, + 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 }, + 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 }, - card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 20, gap: 12, minWidth: 0 }, - loginCard: { backgroundColor: 'rgba(13,21,38,0.94)', borderColor: 'rgba(173,145,255,0.26)', padding: 22, gap: 13, shadowColor: '#000000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.28, shadowRadius: 30, elevation: 12 }, - loginCardCompact: { padding: 17, gap: 10, borderRadius: 22 }, - loginCardTablet: { padding: 28, gap: 15 }, - label: { color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.5, marginTop: 5 }, - input: { minHeight: 54, width: '100%', backgroundColor: 'rgba(19,30,51,0.92)', borderWidth: 1, borderColor: 'rgba(166,220,255,0.14)', borderRadius: 16, color: colors.text, paddingHorizontal: 16, paddingVertical: 14, fontSize: 15 }, - inputCompact: { minHeight: 48, paddingVertical: 11 }, - tall: { minHeight: 85, textAlignVertical: 'top' }, - button: { minHeight: 54, width: '100%', borderRadius: radius.pill, backgroundColor: colors.primary, borderWidth: 1, borderColor: 'rgba(255,255,255,0.22)', alignItems: 'center', justifyContent: 'center', shadowColor: colors.primary, shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.18, shadowRadius: 16, elevation: 5 }, - controlCompact: { minHeight: 48 }, - disabled: { opacity: 0.4 }, - buttonText: { color: colors.background, fontWeight: '900', letterSpacing: 0.1 }, note: { color: colors.mutedStrong, fontSize: 11, textAlign: 'center', flexShrink: 1 }, - securityRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 2, flexWrap: 'wrap' }, - securityIcon: { color: colors.cyan, fontSize: 15 }, - error: { color: colors.danger, fontSize: 13 }, - divider: { flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 3, minWidth: 0 }, + 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 }, + 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.2, flexShrink: 1 }, - socialStack: { gap: 10 }, - socialButton: { minHeight: 52, width: '100%', borderRadius: 16, paddingHorizontal: 18, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 }, + 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 }, - hero: { backgroundColor: colors.surface, borderWidth: 1, borderColor: 'rgba(166,220,255,0.28)', borderRadius: radius.lg, padding: 20, gap: 10 }, + securityRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 2, flexWrap: 'wrap' }, + securityIcon: { color: colors.cyan, fontSize: 15 }, + 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: 24, lineHeight: 29, fontWeight: '900' }, - metrics: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 }, - metric: { flexGrow: 1, flexBasis: 90, backgroundColor: colors.surfaceElevated, borderRadius: radius.md, padding: 10 }, - metricNum: { color: colors.primary, fontSize: 22, fontWeight: '900' }, + heroTitle: { color: colors.text, fontSize: 23, lineHeight: 28, fontWeight: '900' }, + 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: 14, backgroundColor: colors.surfaceElevated, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, padding: 16, minWidth: 0 }, - plus: { width: 42, height: 42, borderRadius: 21, backgroundColor: colors.primary, color: colors.background, textAlign: 'center', textAlignVertical: 'center', fontSize: 28 }, - arrow: { color: colors.primary, fontSize: 30 }, + 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 }, + profileHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0, marginBottom: 2 }, profileName: { color: colors.text, fontSize: 20, fontWeight: '900', flexShrink: 1 }, - signout: { minHeight: 52, borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(255,176,176,0.3)', alignItems: 'center', justifyContent: 'center' }, + 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' }, - tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 78, paddingTop: 8, paddingBottom: 10 }, + tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 76, paddingTop: 7, paddingBottom: 9 }, tabLabel: { fontSize: 10, fontWeight: '800' }, - tabIcon: { fontSize: 18, fontWeight: '800' } -}); \ No newline at end of file + tabIcon: { fontSize: 18, fontWeight: '800' }, +}); From 0bb11a7b46d5dd8764a013ae8965aab6ffe69c19 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:42:31 -0400 Subject: [PATCH 52/64] test(mobile): cover complete auth lifecycle --- mobile/__tests__/authApi.test.js | 65 ++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/mobile/__tests__/authApi.test.js b/mobile/__tests__/authApi.test.js index e47f5c8..ca2acf7 100644 --- a/mobile/__tests__/authApi.test.js +++ b/mobile/__tests__/authApi.test.js @@ -6,12 +6,22 @@ jest.mock('../src/authStorage', () => ({ import { api } from '../src/api'; import { clearAccessToken, setAccessToken } from '../src/authStorage'; -import { getAuthErrorMessage, login, logout, restoreSession } from '../src/authApi'; +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', async () => { + 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' }); @@ -21,6 +31,43 @@ describe('mobile auth API', () => { 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' }); @@ -33,13 +80,23 @@ describe('mobile auth API', () => { 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('surfaces backend auth detail and a useful offline fallback', () => { - expect(getAuthErrorMessage({ response: { data: { detail: 'Email verification required.' } } })).toBe('Email verification required.'); + 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); }); }); From 74d67d5e11ff4a8210ac9dd8031314817c8a8cff Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:42:51 -0400 Subject: [PATCH 53/64] test(mobile): cover live product data flows --- mobile/__tests__/productApi.test.js | 149 ++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 mobile/__tests__/productApi.test.js 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'); + }); +}); From 281cd7e4ac445468ec243aea4af331a298a8202f Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:42:56 -0400 Subject: [PATCH 54/64] chore(mobile): configure production API for release builds --- mobile/eas.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mobile/eas.json b/mobile/eas.json index 9a814a0..72c05b8 100644 --- a/mobile/eas.json +++ b/mobile/eas.json @@ -4,10 +4,16 @@ }, "build": { "preview": { - "distribution": "internal" + "distribution": "internal", + "env": { + "EXPO_PUBLIC_API_URL": "https://bragstack-api-bxf3.onrender.com" + } }, "production": { - "autoIncrement": true + "autoIncrement": true, + "env": { + "EXPO_PUBLIC_API_URL": "https://bragstack-api-bxf3.onrender.com" + } } }, "submit": { From 8722326abb65d315f7083842208d8756b6c0c8f3 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 19:43:24 -0400 Subject: [PATCH 55/64] docs(mobile): document live app and store blockers --- mobile/README.md | 58 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 55448ca..63bcf9e 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -14,38 +14,58 @@ 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`. -## Foundation included +Preview and production EAS profiles are configured to use the BragStack production API at `https://bragstack-api-bxf3.onrender.com`. -- Official BragStack vector brandmark based on `frontend/public/brandmark.svg` -- Official authenticated-app palette from the BragStack Brand Guide: `#090909`, `#F7F4EE`, and `#FFB184` +## 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 sign-in through `/auth/login` +- 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 -- Axios API client for the existing FastAPI backend -- Interactive private-by-default Impact Receipt preview +- 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 -## Auth behavior +## 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. -BragStack's backend requires verified email before password login. The mobile client surfaces backend auth errors directly, stores successful JWT sessions securely, restores sessions on launch, and clears expired or invalid sessions. +## Data behavior -Registration, email verification, password reset, recovery deep links, and account deletion UX remain required store-readiness work. The existing backend already exposes the relevant account/session APIs. +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. -## Data status +Impact Receipts are read live. Full receipt creation/editing, evidence attachment, and verification workflows remain later mobile slices. -Authentication is connected to the real backend. Some product screens still use preview proof data while live accomplishment and Impact Receipt reads/writes are completed. Customer-facing documentation must distinguish preview behavior from persisted production behavior. +## Public-store blockers -## Next implementation slices +Do not call the app store-ready until these gates are complete: -1. Connect Impact Receipts and accomplishments to live API data. -2. Add registration, verification, reset, and recovery flows appropriate for mobile. -3. Implement quick-add persistence, validation, and editing. -4. Add public-profile controls and deep links. -5. Add accessibility, offline/error states, automated tests, and release QA. -6. Complete App Store / Google Play metadata, privacy disclosures, screenshots, signing, internal testing, and mobile CI. +- 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 @@ -53,4 +73,4 @@ Authentication is connected to the real backend. Some product screens still use - `../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 — initial mobile foundation +- PR #201 — mobile foundation and store-readiness work From 81ffe00a6d867c2f7d445e6f53c9893959b1ad15 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 20:01:12 -0400 Subject: [PATCH 56/64] feat(mobile): enable phone and tablet rotation --- mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/app.json b/mobile/app.json index 205a2a9..6444697 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -3,7 +3,7 @@ "name": "BragStack", "slug": "bragstack", "version": "0.1.0", - "orientation": "portrait", + "orientation": "default", "userInterfaceStyle": "dark", "scheme": "bragstack", "ios": { From fb81bb6c99cb0726fa8e5d5d71b83740836a95d5 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 20:01:36 -0400 Subject: [PATCH 57/64] docs(mobile): add Codespaces phone and tablet QA matrix --- mobile/TESTING.md | 84 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 69 insertions(+), 15 deletions(-) diff --git a/mobile/TESTING.md b/mobile/TESTING.md index ef319cb..68fec3a 100644 --- a/mobile/TESTING.md +++ b/mobile/TESTING.md @@ -21,37 +21,91 @@ Expected result: Expo Doctor passes, the Jest suites pass with coverage threshol ## Interactive Codespaces preview -Create `mobile/.env` from `.env.example` and set `EXPO_PUBLIC_API_URL` to a BragStack API URL reachable from the preview/device. Do not commit secrets. - -Then: +For the quickest end-to-end UI test in Codespaces, use the deployed BragStack API rather than `localhost`: ```bash -npm start +cd mobile +EXPO_PUBLIC_API_URL=https://bragstack-api-bxf3.onrender.com npm run web ``` -For a fast browser smoke test, press `w` in Expo or run `npm run web`. Browser testing is useful for JavaScript/runtime/UI checks but does not validate native SecureStore, iOS/Android lifecycle, signing, or store behavior. +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. -## Manual acceptance 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 near-black, warm ivory, and BragStack peach. +- 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. -- Relaunch restores a valid session. +- 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. -- Quick Capture cannot preview an empty accomplishment. -- A missing result remains explicitly missing; BragStack does not invent an outcome. -- Draft/proof UI remains private by default. +- 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. -- Text remains readable at narrow/mobile widths. - No passwords, access tokens, or confidential evidence appear in logs/errors. -## Real-device checks required later +## 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 -Run preview/internal builds on at least one current iPhone and one current Android device. Verify SecureStore persistence, keyboard behavior, safe areas, gestures, app background/foreground lifecycle, network loss/retry behavior, deep links when implemented, accessibility/dynamic text, and account deletion/recovery flows. +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, 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. +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. From dbaa97b026b1939d7767519b3219573d0c9fc592 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 20:03:48 -0400 Subject: [PATCH 58/64] feat(mobile): add responsive landscape and tablet layouts --- mobile/App.js | 255 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 181 insertions(+), 74 deletions(-) diff --git a/mobile/App.js b/mobile/App.js index ef6fa21..2827b0c 100644 --- a/mobile/App.js +++ b/mobile/App.js @@ -40,6 +40,16 @@ 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 ( @@ -72,9 +82,13 @@ function Brand({ small = false }) { } function AuthScreen({ onSuccess }) { - const { width, height } = useWindowDimensions(); - const compact = height < 760 || width < 390; - const contentWidth = Math.max(280, Math.min(width - (width >= 768 ? 64 : 24), 560)); + 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(''); @@ -150,7 +164,7 @@ function AuthScreen({ onSuccess }) { : 'Your proof is ready when you are.'; return ( - + @@ -160,16 +174,22 @@ function AuthScreen({ onSuccess }) { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > - - + + 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} @@ -180,12 +200,12 @@ function AuthScreen({ onSuccess }) { : 'Open your private workspace and keep building evidence while the details are still fresh.'} - {mode === 'register' && ( + {mode === 'register' ? ( <> NAME - )} + ) : null} EMAIL - {mode !== 'reset' && ( + {mode !== 'reset' ? ( <> PASSWORD - )} + ) : null} {error ? {error} : null} {message ? {message} : null} @@ -229,11 +249,11 @@ function AuthScreen({ onSuccess }) { ) : null} - {mode === 'login' && ( + {mode === 'login' ? ( changeMode('reset')} style={styles.linkButton}> Forgot password? - )} + ) : null} {Platform.OS === 'web' && mode === 'login' ? ( <> @@ -276,21 +296,27 @@ function AuthScreen({ onSuccess }) { ); } -function Page({ kicker, title, children, refreshing = false, onRefresh }) { - const { width } = useWindowDimensions(); - const contentWidth = Math.max(280, Math.min(width - 28, 760)); +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} + {title} {children} @@ -321,6 +347,8 @@ function LoadingCard({ label = 'Loading your proof…' }) { } 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; @@ -328,65 +356,92 @@ function Home({ navigation, user, overview, loading, error, refreshing, onRefres 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 + + + + 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. + + 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} - - - - {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 && overview.receipts.length > 0 ? IMPACT RECEIPTS : null} - {overview.receipts.map((receipt) => { - const confirmed = (receipt.confirmations || []).some((confirmation) => confirmation.status === 'confirmed'); - const evidenceCount = (receipt.evidence || []).length; - return ( - - ); - })} + {!loading && receiptItems.length > 0 ? IMPACT RECEIPTS : null} + { + const confirmed = (receipt.confirmations || []).some((confirmation) => confirmation.status === 'confirmed'); + const evidenceCount = (receipt.evidence || []).length; + return ( + + ); + }} + /> - {!loading && overview.entries.length > 0 ? ACCOMPLISHMENTS : null} - {overview.entries.map((entry) => ( - - ))} + {!loading && entryItems.length > 0 ? ACCOMPLISHMENTS : null} + ( + + )} + /> - {!loading && !error && overview.entries.length === 0 && overview.receipts.length === 0 ? ( + {!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} @@ -437,7 +492,7 @@ function Add({ navigation, onCreated }) { }; return ( - + This saves a real accomplishment to your BragStack account. The four proof fields are required so the app never invents missing career evidence. @@ -484,7 +539,7 @@ function Profile({ user, onUserChange }) { }; return ( - + @@ -508,11 +563,19 @@ function Profile({ user, onUserChange }) { } 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} + + + + Signed in{user?.email} + + + Production API{apiBaseURL} + + Privacy & support open('https://usebragstack.com/privacy')} style={styles.settingsLink}>Privacy policy @@ -525,6 +588,8 @@ function Settings({ user, onSignOut }) { } 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); @@ -546,7 +611,10 @@ function Tabs({ user, onUserChange, onSignOut }) { useEffect(() => { void refresh(false); }, [refresh]); - const shared = useMemo(() => ({ overview, loading, error, refreshing, onRefresh: () => refresh(true) }), [overview, loading, error, refreshing, refresh]); + const shared = useMemo( + () => ({ overview, loading, error, refreshing, onRefresh: () => refresh(true) }), + [overview, loading, error, refreshing, refresh], + ); return ( @@ -557,8 +625,12 @@ function Tabs({ user, onUserChange, onSignOut }) { tabBarActiveTintColor: colors.primary, tabBarInactiveTintColor: colors.mutedStrong, tabBarHideOnKeyboard: true, - tabBarStyle: styles.tabBar, - tabBarLabelStyle: styles.tabLabel, + 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]}, })} > @@ -626,12 +698,17 @@ const styles = StyleSheet.create({ 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: 18 }, + 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 }, @@ -642,18 +719,25 @@ const styles = StyleSheet.create({ 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 }, @@ -679,9 +763,17 @@ const styles = StyleSheet.create({ 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' }, @@ -698,12 +790,27 @@ const styles = StyleSheet.create({ 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' }, - tabBar: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 76, paddingTop: 7, paddingBottom: 9 }, - tabLabel: { fontSize: 10, fontWeight: '800' }, + + 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' }, }); From a8c78725535cf062b56aacf8b008f36e7d672801 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 20:04:26 -0400 Subject: [PATCH 59/64] chore(mobile): add Codespaces preview command --- mobile/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/package.json b/mobile/package.json index 1c292ba..b1485f0 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -8,6 +8,7 @@ "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", + "web:codespaces": "EXPO_PUBLIC_API_URL=https://bragstack-api-bxf3.onrender.com expo start --web", "doctor": "expo-doctor", "test": "jest", "test:watch": "jest --watch", From 5091239201e9cd76314154a9e571d87506c58a56 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 20:10:51 -0400 Subject: [PATCH 60/64] chore(mobile): ignore Expo local state --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) 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 From 0ffb6180136f16b9d69e3d067b0491cea5acaa95 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 20:15:58 -0400 Subject: [PATCH 61/64] fix(mobile): use api.usebragstack.com in Codespaces --- mobile/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/package.json b/mobile/package.json index b1485f0..521beea 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -8,7 +8,7 @@ "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", - "web:codespaces": "EXPO_PUBLIC_API_URL=https://bragstack-api-bxf3.onrender.com 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", From e95cbf83492ba648b8d2e0bb2254604db74bb520 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 20:16:05 -0400 Subject: [PATCH 62/64] fix(mobile): point EAS builds at api.usebragstack.com --- mobile/eas.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/eas.json b/mobile/eas.json index 72c05b8..3e7a1a8 100644 --- a/mobile/eas.json +++ b/mobile/eas.json @@ -6,13 +6,13 @@ "preview": { "distribution": "internal", "env": { - "EXPO_PUBLIC_API_URL": "https://bragstack-api-bxf3.onrender.com" + "EXPO_PUBLIC_API_URL": "https://api.usebragstack.com" } }, "production": { "autoIncrement": true, "env": { - "EXPO_PUBLIC_API_URL": "https://bragstack-api-bxf3.onrender.com" + "EXPO_PUBLIC_API_URL": "https://api.usebragstack.com" } } }, From 19055fe228da2c5fa11ab09b3e75e245a9226f8a Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 20:16:17 -0400 Subject: [PATCH 63/64] docs(mobile): use canonical BragStack API hostname --- mobile/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/README.md b/mobile/README.md index 63bcf9e..904decd 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -14,7 +14,7 @@ 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 BragStack production API at `https://bragstack-api-bxf3.onrender.com`. +Preview and production EAS profiles are configured to use the canonical BragStack production API at `https://api.usebragstack.com`. ## Current implementation From fde99f96bb73dd1041a74582c947eb7a784ecd36 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 27 Aug 2026 20:16:33 -0400 Subject: [PATCH 64/64] docs(mobile): correct Codespaces production API hostname --- mobile/TESTING.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mobile/TESTING.md b/mobile/TESTING.md index 68fec3a..bb02ce8 100644 --- a/mobile/TESTING.md +++ b/mobile/TESTING.md @@ -21,11 +21,17 @@ Expected result: Expo Doctor passes, the Jest suites pass with coverage threshol ## Interactive Codespaces preview -For the quickest end-to-end UI test in Codespaces, use the deployed BragStack API rather than `localhost`: +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://bragstack-api-bxf3.onrender.com npm run web +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.