Coding standards, component defaults, and best practices for the UCD Mobile app (Expo, React Native, NativeWind, Expo Router).
46
48%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Passed
No known issues
Optimize this skill with Tessl
npx tessl skill review --optimize ./skills/assistant-ucd-mobile/SKILL.mdAuthoritative reference for building features in this project. Apply these rules whenever writing or reviewing code in apps/mobile/.
| Concern | Tool |
|---|---|
| Framework | Expo (managed) + React Native 0.81 |
| Language | TypeScript (strict) |
| Navigation | Expo Router (file-based) |
| Styling | NativeWind 4 (Tailwind) + StyleSheet (hybrid) |
| Server state | TanStack Query v5 |
| Global state | React Context |
| Local persistence | Drizzle ORM + expo-sqlite |
| Auth | Azure AD via expo-auth-session |
| Icons | @hugeicons/react-native |
| Lists | @shopify/flash-list |
| Bottom sheets | @gorhom/bottom-sheet |
| Validation | Zod |
Import alias: use ~/ for src/ — e.g. import { AGGIE_BLUE } from '~/constants/colors'.
src/
├── app/ # Expo Router screens (file = route)
├── components/
│ ├── shared/ # Generic, reusable UI atoms
│ └── features/ # Feature-scoped components
├── constants/ # Colors, API endpoints, config
├── contexts/ # React Context providers + hooks
├── db/ # Drizzle schema
├── hooks/ # Custom hooks (data + UI logic)
├── services/ # API calls, DB operations, business logic
├── types/ # TypeScript types + Zod schemas
└── utils/ # Pure utility functionsRules:
app/. Do not put business logic in screen files — extract to hooks or services.services/ pure (no JSX, no hooks). They return data or throw.types/zod/ holds runtime validation schemas; types/*.ts holds static TypeScript-only types.Every component must be typed and follow this template:
import { View, Pressable, Text } from 'react-native';
import type { ComponentProps } from 'react';
type Props = {
label: string;
onPress: () => void;
disabled?: boolean;
} & Pick<ComponentProps<typeof Pressable>, 'testID'>;
export function ActionButton({ label, onPress, disabled = false, testID }: Props) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
testID={testID}
accessible
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled }}
hitSlop={8}
className="h-11 min-w-11 items-center justify-center rounded-lg bg-aggie-blue px-4 active:opacity-70"
>
<Text className="text-base font-semibold text-white">{label}</Text>
</Pressable>
);
}Mandatory defaults on every component:
| Default | Reason |
|---|---|
accessible on interactive containers | Screen reader groups children |
accessibilityRole | Tells VoiceOver/TalkBack what the element is |
accessibilityLabel | Human-readable name for the element |
hitSlop={8} on small targets | Ensures ≥44 pt tap area per Apple HIG |
disabled prop with accessibilityState={{ disabled }} | State reflected to assistive tech |
active:opacity-70 on pressables | Visible press feedback, both platforms |
className) for:text-sm, font-bold, etc.)StyleSheet.create() for:shadowColor vs Android elevation)style prop for:className: <View className="flex-1" style={{ backgroundColor: brandColor }}>// tailwind.config.js theme extension — use as className
'aggie-blue' // #022851 — primary brand, headers, key actions
'ucd-blue' // #5D94FA — active states, links, tab icons
'sub-blue' // #AAB3BC — secondary text, muted elements
'ui-grey' // #F5F5F5 — screen backgrounds
'light-grey' // #F8F8F8 — card backgroundsWhen a color is needed in a StyleSheet or dynamic context, import from ~/constants/colors.
const cardShadow = StyleSheet.create({
shadow: {
// iOS
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
// Android
elevation: 3,
backgroundColor: '#fff', // elevation requires a background color on Android
},
});Always use Pressable — TouchableOpacity is legacy.
// Correct
<Pressable
onPress={fn}
className="active:opacity-70"
hitSlop={8}
accessible
accessibilityRole="button"
accessibilityLabel="Open settings"
>
...
</Pressable>
// Wrong — do not use
<TouchableOpacity onPress={fn}>...</TouchableOpacity>For icon-only buttons, always pair with an accessibilityLabel (the icon itself conveys no meaning to screen readers):
<Pressable
onPress={goBack}
hitSlop={12}
accessible
accessibilityRole="button"
accessibilityLabel="Go back"
className="p-2"
>
<HugeiconsIcon icon={ArrowLeft01Icon} size={24} color={AGGIE_BLUE} />
</Pressable>Use the shared text components from ~/components/shared/text/ — never raw <Text> for content:
| Component | When to use |
|---|---|
<HeadingText> | Screen or section titles |
<SubHeadingText> | Section subtitles, card titles |
<CaptionText> | Labels, metadata, helper text |
When you must use raw <Text>, apply allowFontScaling explicitly if the size is constrained:
// Fixed-size UI (e.g., tab bar label) — opt out of scaling
<Text allowFontScaling={false} className="text-xs">TAB</Text>
// Content text — always allow scaling (default is true, but be explicit)
<Text allowFontScaling className="text-base">Body content</Text>Always use FlashList from @shopify/flash-list, not FlatList or ScrollView for data lists.
import { FlashList } from '@shopify/flash-list';
<FlashList
data={items}
renderItem={({ item }) => <ItemRow item={item} />}
estimatedItemSize={72} // Required — measure your row height
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
ListEmptyComponent={<EmptyState />}
/>Inside bottom sheets, use BottomSheetFlatList or BottomSheetScrollView from @gorhom/bottom-sheet.
File-based routing — the file path is the route. Never use navigation.navigate() — use the typed router:
import { router } from 'expo-router';
import { Link } from 'expo-router';
// Programmatic
router.push('/Maps/Library/room-123/book');
router.back();
// Declarative
<Link href="/More" asChild>
<Pressable ...>...</Pressable>
</Link>Screen layout template:
import { View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Stack } from 'expo-router';
export default function MyScreen() {
const insets = useSafeAreaInsets();
return (
<>
<Stack.Screen options={{ title: 'Screen Title', headerShown: true }} />
<View
className="flex-1 bg-ui-grey"
style={{ paddingBottom: insets.bottom }}
>
{/* content */}
</View>
</>
);
}useSafeAreaInsets() for bottom padding on scrollable screens.Stack.Screen options inline — do not hard-code navigation headers in the layout file.Use useQuery / useMutation from TanStack Query for all API calls:
import { useQuery } from '@tanstack/react-query';
import { fetchLibraryRooms } from '~/services/library';
export function useLibraryRooms() {
return useQuery({
queryKey: ['library', 'rooms'],
queryFn: fetchLibraryRooms,
staleTime: 5 * 60 * 1000, // 5 min — adjust per data freshness needs
retry: 2,
});
}Rules:
queryFn calls go through ~/services/ — never inline fetch() in components.~/hooks/ or ~/hooks/<Feature>/).useIsFocused() to gate expensive queries to active screens:const isFocused = useIsFocused();
const { data } = useQuery({ ..., enabled: isFocused });All Context providers follow this shape — always throw if hook is used outside provider:
type MyContextType = {
value: string;
setValue: (v: string) => void;
};
const MyContext = createContext<MyContextType | null>(null);
export function MyProvider({ children }: { children: React.ReactNode }) {
const [value, setValue] = useState('');
return (
<MyContext.Provider value={{ value, setValue }}>
{children}
</MyContext.Provider>
);
}
export function useMyContext(): MyContextType {
const ctx = useContext(MyContext);
if (!ctx) throw new Error('useMyContext must be used within MyProvider');
return ctx;
}Add new providers to app/_layout.tsx — never nest providers inside feature screens.
Use Platform.select() for values that must differ, not Platform.OS === 'ios' ternaries scattered across JSX:
import { Platform } from 'react-native';
const hitSlop = Platform.select({ ios: 8, android: 12, default: 8 });
const headerStyle = Platform.select({
ios: { shadowOpacity: 0.1, shadowRadius: 4 },
android: { elevation: 4 },
default: {},
});Standard platform defaults to always apply:
| Property | iOS | Android |
|---|---|---|
| Shadow | shadowColor/Offset/Opacity/Radius | elevation + backgroundColor |
| Status bar style | <StatusBar style="dark" /> | <StatusBar backgroundColor="transparent" translucent /> |
| Keyboard avoid | KeyboardAvoidingView behavior="padding" | behavior="height" |
| Font rendering | System font (SF Pro) via fontFamily: undefined | Roboto via fontFamily: undefined |
| Tap feedback | Pressable active opacity | Pressable + optional android_ripple |
| Bottom safe area | insets.bottom from useSafeAreaInsets | Same |
Ripple effect on Android (add to card/list-item Pressables):
<Pressable
android_ripple={{ color: 'rgba(0,0,0,0.08)', borderless: false }}
className="active:opacity-90"
...
>KeyboardAvoidingView template:
import { KeyboardAvoidingView, Platform } from 'react-native';
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
{/* form content */}
</KeyboardAvoidingView>Every screen and interactive component must meet these minimums:
Tap targets ≥ 44×44 pts (use hitSlop to pad small icons)
Color contrast ≥ 4.5:1 text, ≥ 3:1 UI components
Focus order logical top-to-bottom, left-to-rightRequired props checklist for interactive elements:
accessible // groups children for screen reader
accessibilityRole="button|link|header|image|none|..."
accessibilityLabel="Descriptive name" // required if no visible text
accessibilityHint="What happens" // optional but recommended for ambiguous actions
accessibilityState={{ disabled, selected, checked, expanded, busy }}For images:
// Decorative — hide from screen readers
<Image accessible={false} ... />
// Meaningful
<Image
accessible
accessibilityRole="image"
accessibilityLabel="Campus map showing Shields Library"
...
/>For loading states:
<ActivityIndicator
accessible
accessibilityRole="progressbar"
accessibilityLabel="Loading bus routes"
/>See .claude/skills/accessibility-react-native/SKILL.md for full a11y audit workflow.
any, no @ts-ignore without a comment explaining why.type over interface for props and data shapes.~/types/zod/ for all data crossing API boundaries. Parse, don't cast:// Correct
const data = MySchema.parse(rawApiResponse);
// Wrong
const data = rawApiResponse as MyType;<ComponentName>Props.ComponentProps<typeof X> to extend native component props:type ButtonProps = {
label: string;
} & Pick<ComponentProps<typeof Pressable>, 'onPress' | 'disabled' | 'testID'>;| Thing | Convention | Example |
|---|---|---|
| Components | PascalCase file, named export | BackButton.tsx → export function BackButton |
| Screens | PascalCase file (Expo Router) | app/Maps/index.tsx |
| Hooks | camelCase, use prefix | useLibraryRooms.ts |
| Services | camelCase | library.ts |
| Constants | camelCase file, UPPER_SNAKE values | colors.ts → AGGIE_BLUE |
| Types | PascalCase for types/interfaces | MapTypes.ts |
| Zod schemas | camelCase + Schema suffix | libraryRoomSchema |
useCallback for event handlers passed as props to child components.useMemo only when the computation is genuinely expensive — not as a default.renderItem — define the renderer outside the JSX tree.React.memo on list item components.estimatedItemSize on every FlashList.useEffect for derived state — compute inline or with useMemo.InteractionManager.runAfterInteractions() for heavy work triggered by navigation.All bottom sheets use @gorhom/bottom-sheet. Standard setup:
import BottomSheet, { BottomSheetView, BottomSheetFlatList } from '@gorhom/bottom-sheet';
import { useRef, useMemo } from 'react';
export function MySheet() {
const sheetRef = useRef<BottomSheet>(null);
const snapPoints = useMemo(() => ['40%', '85%'], []);
return (
<BottomSheet
ref={sheetRef}
index={0}
snapPoints={snapPoints}
enablePanDownToClose
backgroundStyle={{ borderRadius: 16 }}
handleIndicatorStyle={{ backgroundColor: '#ccc', width: 40 }}
>
<BottomSheetView className="flex-1 px-4">
{/* content */}
</BottomSheetView>
</BottomSheet>
);
}BottomSheetFlatList / BottomSheetScrollView inside sheets — never FlatList or ScrollView.<BottomSheetModalProvider> (already done in app/_layout.tsx).Use @hugeicons/react-native exclusively:
import { HugeiconsIcon } from '@hugeicons/react-native';
import { SearchIcon } from '@hugeicons/core-free-icons';
<HugeiconsIcon
icon={SearchIcon}
size={24} // Default: 24. Use 20 for dense UI, 28 for prominent actions
color={AGGIE_BLUE}
strokeWidth={1.5} // Default stroke — do not change without design approval
/>HugeiconsIcon.accessibilityLabel.The app has four strict layers: DB → Service → API → UI. Each layer has its own result container. Never mix them.
DB (Drizzle) → DbResult<T>
API → ApiResponse<T>
Service → ServiceResult<T>
UI → consumes ServiceResult<T> only// DB layer — wraps Drizzle query results
class DbResult<T> {
static success<T>(data?: T): DbResult<T>
static error(error: string, errorCode?: number): DbResult<any>
constructor(
public success: boolean,
public data?: T,
public error?: string,
public errorCode?: number,
) {}
}
// API layer — wraps fetch/HTTP results
class ApiResponse<T> {
static success<T>(statusCode: number, data?: T, message?: string): ApiResponse<T>
static error(statusCode: number, error: string, message?: string): ApiResponse<any>
constructor(
public success: boolean,
public data?: T,
public error?: string,
public message: string = '',
public statusCode?: number,
) {}
}
// Service layer — what UI consumes
class ServiceResult<T> {
static success<T>(data?: T): ServiceResult<T>
static successFrom<T>(dbResult: DbResult<T>): ServiceResult<T>
static error(error: string, errorCode?: number): ServiceResult<any>
static errorFrom<T>(dbResult: DbResult<T>): ServiceResult<T>
constructor(
public success: boolean,
public data?: T,
public error?: string,
public errorCode?: number,
) {}
}DB Layer (~/services/db/)
DbResult<T>.export async function getUser(id: string): Promise<DbResult<User>> {
try {
const user = await db.query.users.findFirst({ where: eq(users.id, id) });
if (!user) return DbResult.error('User not found', 404);
return DbResult.success(user);
} catch (e: unknown) {
return DbResult.error((e as Error)?.message ?? 'SQLite error', 500);
}
}API Layer (~/services/)
ApiResponse<T>.fetch exceptions into standardized error responses.export async function fetchUserFromApi(id: string): Promise<ApiResponse<User>> {
try {
const res = await fetch(`${API_BASE}/users/${id}`);
const json = await res.json();
if (!res.ok) return ApiResponse.error(res.status, json.error ?? 'Request failed');
return ApiResponse.success(res.status, json);
} catch (e: unknown) {
return ApiResponse.error(503, (e as Error)?.message ?? 'Network error');
}
}Service Layer (~/services/)
DbResult / ApiResponse → ServiceResult.export async function loadUserProfile(id: string): Promise<ServiceResult<User>> {
const local = await getUser(id);
if (local.success && local.data) return ServiceResult.successFrom(local);
if (!local.success && local.errorCode !== 404) return ServiceResult.errorFrom(local);
// Not in DB — fetch from API
const remote = await fetchUserFromApi(id);
if (!remote.success) return ServiceResult.error(remote.error!, remote.statusCode);
await saveUser(remote.data!);
return ServiceResult.success(remote.data);
}UI Layer (screens and hooks)
ServiceResult<T>..success flag.export function ProfileScreen() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>();
const [user, setUser] = useState<User | undefined>();
const { id } = useLocalSearchParams<{ id: string }>();
const load = async () => {
setLoading(true);
const res = await loadUserProfile(id);
if (res.success) setUser(res.data);
else setError(res.error);
setLoading(false);
};
useEffect(() => { load(); }, []);
if (loading) return <Spinner />;
if (error) return <ErrorView message={error} onRetry={load} />;
if (!user) return <EmptyState title="No user" />;
return <ProfileView user={user} />;
}| Code | UI Behavior |
|---|---|
| 200 | Success toast or silent proceed |
| 201 | Success toast or navigate to confirmation |
| 202 | Loading indicator / "Processing..." |
| 204 | Refresh UI silently |
| 400 | Inline form error or alert modal with retry |
| 401 | Redirect to login — "Session expired. Please log in again." |
| 403 | "Access denied" modal; disable restricted actions |
| 404 | "Not Found" toast; allow retry or go back |
| 408 | "Request timed out" toast with retry button |
| 409 | Explain conflict (e.g. "Already exists"); allow retry or update |
| 410 | "Resource unavailable"; navigate to safe screen |
| 422 | Validation messages inline on form inputs |
| 429 | Rate-limit warning toast; disable submit temporarily |
| 500 | Error modal/toast with retry; optionally log |
| 503 | Maintenance / "Try again later" screen |
| 504 | Timeout toast with manual retry option |
DbResult or ApiResponse escapes to UI.DbResult.error(...).ApiResponse.error(...).FlatList, TouchableOpacity, TouchableHighlight — use FlashList and Pressable.AsyncStorage for sensitive data — use expo-secure-store.fetch() in components or hooks — go through ~/services/.console.log in committed code.as to work around type errors — fix the types.~/services/db/connection.ts.SafeAreaView if the layout already uses useSafeAreaInsets() — double-nesting causes incorrect padding.margin on list items to achieve spacing — use contentContainerStyle gap or ItemSeparatorComponent.c0b2e4b
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.