assistant-ucd-mobile
Coding standards, component defaults, and best practices for the UCD Mobile app (Expo, React Native, NativeWind, Expo Router).
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Coding standards, component defaults, and best practices for the UCD Mobile app (Expo, React Native, NativeWind, Expo Router).
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Architecture decisions: layering, design patterns, microservices trade-offs, and domain-driven design
Python architecture: PEP-8, type hints, clean layering, dependency injection, and testing strategy
General code review process: priority ordering, what to block on, how to give actionable feedback
Python data science: notebook structure, data validation, reproducibility, and model documentation
Git workflow: branch naming, conventional commits, PR conventions, and merge strategies
TypeScript refactoring: SOLID principles, DRY, type safety improvements, and eliminating code smells
| name | assistant-ucd-mobile |
| description | Coding standards, component defaults, and best practices for the UCD Mobile app (Expo, React Native, NativeWind, Expo Router). |
Authoritative 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 functions
Rules:
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 backgrounds
When 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-right
Required 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.