slim down CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-08 17:58:13 +03:00
parent 53d9db174c
commit d9ebb6348b
+95 -443
View File
@@ -82,7 +82,7 @@ should go in `/screens` (not `/view/screens`) to encourage better organization
and separation from legacy code. and separation from legacy code.
For complex screens that have specific components or data needs that _are not For complex screens that have specific components or data needs that _are not
shared by other screens_, we encourage subdirectoreis within `/screens/<name>` shared by other screens_, we encourage subdirectories within `/screens/<name>`
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
`/screens/ProfileScreen/components/`. `/screens/ProfileScreen/components/`.
@@ -126,44 +126,14 @@ eventually.
Typically JS style for variables, functions, etc. We use ProudCamelCase for Typically JS style for variables, functions, etc. We use ProudCamelCase for
components, and camelCase directories and files. components, and camelCase directories and files.
When organizing new code, consider if it fits into a single file, or if it For "macro" cases in `/features`, `/screens`, or `/components`, co-locate related
should be broken down into multiple files. For "macro" component cases, or code in a directory with an `index.tsx` main component plus sibling
things that live in `/features` or `/screens`, we often follow a pattern of components/hooks/utils (e.g. `screens/ProfileScreen/index.tsx` +
having an `index.tsx` for the main component, and then co-locating related `screens/ProfileScreen/components/`). Keep related code together so it lives where
components, hooks, and utilities in the same directory. For example: someone would look for it. Don't overdo it: a component that fits in one file
should just be `Component.tsx`, not `Component/index.tsx`.
``` Platform-specific files are covered under "Platform-Specific Code" below.
src
├── screens/
│ ├── ProfileScreen/
│ │ ├── index.tsx # Main screen component
│ │ ├── components/ # Sub-components used only by this screen
```
Similar patterns can be found in `/features` and `/components`. The idea here is
to keep related code together and make it easier to navigate.
You should ask yourself: if someone new was looking for the code related to this
feature or screen, where would they expect to find it? Organizing code in a way
that matches developer expectations can make the codebase much more
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
easier to understand than having it scattered across multiple directories.
No need to go overboard with this. If a component or feature fits into a single
file, there's no reason to have a `/Component/index.tsx` file when it could just
be `/Component.tsx`. Use your judgment based on the complexity and amount of
related code.
#### Platform Specific Files
We have conflicting patterns in the app for this. The preferred approach is to
group platform-specific files into a directory as much as possible. For example,
rather than having `Component.tsx`, `Component.web.tsx`, and
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
related code together and gives us a better visual cue that there are probably
other files contained within this "macro" feature, whereas `Component.tsx` on
its own looks more like a single component file.
### Comments ### Comments
@@ -204,88 +174,40 @@ const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
### Documentation and Tests Within Features ### Documentation and Tests Within Features
For larger features or components, it's helpful to include a README.md file For larger features or components, co-locate documentation and tests with the
within the directory that explains the purpose of the feature, how it works, and code. A `README.md` in the directory (the `/Component/index.tsx` pattern lends
any important implementation details. The `/Component/index.tsx` pattern lends itself well to this) can document the whole feature, and feature-specific tests
itself well to this, since the `index.tsx` can be the main component file, and belong alongside it as `Component.test.tsx` or in a `__tests__/` subdirectory.
the `README.md` can provide documentation for the whole feature. This is Both are optional.
optional, but can be a nice way to keep documentation close to the code it
describes.
Similarly, if there are tests that are specific to a component or feature, it
can be helpful to include them in the same directory, either as
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
related to the component or feature in one place and makes it easier to find and
maintain tests.
## Styling System (ALF) ## Styling System (ALF)
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens. ALF is the custom design system. Tailwind-inspired naming with underscores
instead of hyphens. Static atoms (`atoms as a`) are theme-independent; theme
atoms/palette come from `useTheme()` (`t.atoms.bg`, `t.palette.primary_500`).
Style props take an array of atoms + theme atoms + raw styles.
### Basic Usage Order atoms by: flexbox (`a.flex_row`), spacing (`a.px_md`), text (`a.font_bold`),
themes (`t.atoms.text`), then raw styles (`{backgroundColor: t.palette.primary_500}`).
Generally, order atoms by:
- Flexbox configuration, e.g., `a.flex_row`
- Spacing, e.g., `a.px_md`
- Text styles, e.g., `a.font_bold`
- Themes, e.g., `t.atoms.text`,
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
```tsx ```tsx
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
function MyComponent() { const t = useTheme()
const t = useTheme() <View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]} />
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
</View>
)
}
``` ```
### Key Concepts ### Key Concepts
**Static Atoms**  Theme-independent styles imported from `atoms`: Static atoms live in `a.*` (e.g. `a.flex_row`, `a.p_md`, `a.rounded_md`,
`a.text_lg`). Theme atoms/palette come from `useTheme()` (`t.atoms.bg`,
`t.atoms.text`, `t.atoms.border_contrast_low`, `t.palette.primary_500`).
```tsx **Platform utilities** (`import {web, native, ios, android, platform} from '#/alf'`)
import {atoms as a} from '#/alf' return conditional styles inline in a style array: `web({cursor: 'pointer'})`,
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc. `native({paddingBottom: 20})`, `platform({ios: {...}, android: {...}, web: {...}})`.
```
**Theme Atoms**  Theme-dependent colors from `useTheme()`: **Breakpoints:** `const {gtPhone, gtMobile, gtTablet} = useBreakpoints()` from `#/alf`.
```tsx
const t = useTheme()
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
// t.palette.primary_500, t.palette.negative_400, etc.
```
**Platform Utilities**  For platform-specific styles:
```tsx
import {web, native, ios, android, platform} from '#/alf'
const styles = [
a.p_md,
web({cursor: 'pointer'}),
native({paddingBottom: 20}),
platform({ios: {...}, android: {...}, web: {...}}),
]
```
**Breakpoints**  Responsive design:
```tsx
import {useBreakpoints} from '#/alf'
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
if (gtMobile) {
// Tablet or desktop layout
}
```
### Naming Conventions ### Naming Conventions
@@ -306,176 +228,67 @@ if (gtMobile) {
```tsx ```tsx
import {Fragment} from 'react' import {Fragment} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
function MyComponent({foo = []}: {foo?: string[]}) { function MyComponent({items = []}: {items?: string[]}) {
const {t: l} = useLingui()
return ( return (
<> <>
<View><Text><Trans>Example</Trans><Text></View>
<View> <View>
{foo.map((foo, index) => ( <Text>
<Fragment key={foo}> <Trans>Example</Trans>
</Text>
</View>
<View>
{items.map((item, index) => (
<Fragment key={item}>
<Text>{index}</Text> <Text>{index}</Text>
<Text>{foo}</Text> <Text>{item}</Text>
</Fragment> </Fragment>
))} ))}
</View> </View>
</> </>
); )
} }
``` ```
### Dialog Component ### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state. Lives in `#/components/Dialog`. Bottom sheet on native, modal on web. Manage
state with `useDialogControl()`. `Dialog.Handle` renders native-only, `Dialog.Close`
```tsx web-only. CRITICAL: run any post-close action inside the `control.close(() => ...)`
import * as Dialog from '#/components/Dialog' callback (see Footguns). Compound-component usage; canonical example in any dialog
under `#/components`.
function MyFeature() {
const control = Dialog.useDialogControl()
return (
<>
<Button label="Open" onPress={control.open}>
<ButtonText>Open Dialog</ButtonText>
</Button>
<Dialog.Outer control={control}>
{/* Typically the inner part is in its own component */}
<DialogInner />
</Dialog.Outer>
</>
)
}
function DialogInner() {
return (
<>
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={l`My Dialog`}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
</>
)
}
```
### Menu Component ### Menu Component
Menus render as a dropdown on web and a bottom sheet dialog on native. Lives in `#/components/Menu`. Dropdown on web, bottom sheet dialog on native.
`Menu.Divider` is web-only, `Menu.ContainerItem` native-only. Compound API
```tsx (`Menu.Root` / `Menu.Trigger` / `Menu.Outer` / `Menu.Group` / `Menu.Item`); grep
import * as Menu from '#/components/Menu' existing usages across the app for a canonical example.
function MyMenu() {
return (
<Menu.Root>
<Menu.Trigger label="Open menu">
{({props}) => (
<Button {...props} label="Menu">
<ButtonIcon icon={DotsHorizontal} />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item label="Edit" onPress={handleEdit}>
<Menu.ItemIcon icon={Pencil} />
<Menu.ItemText>Edit</Menu.ItemText>
</Menu.Item>
<Menu.Item label="Delete" onPress={handleDelete}>
<Menu.ItemIcon icon={Trash} />
<Menu.ItemText>Delete</Menu.ItemText>
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
```
### Button Component ### Button Component
```tsx `import {Button, ButtonText, ButtonIcon} from '#/components/Button'`. Props:
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
// Solid primary button (most common)
<Button label="Save" onPress={handleSave} color="primary" size="large">
<ButtonText>Save</ButtonText>
</Button>
// With icon
<Button label="Share" onPress={handleShare} color="secondary" size="small">
<ButtonIcon icon={Share} />
<ButtonText>Share</ButtonText>
</Button>
// Icon-only button
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
<ButtonIcon icon={XIcon} />
</Button>
// Ghost variant (deprecated - use color prop)
<Button label="Cancel" variant="ghost" color="secondary" size="small">
<ButtonText>Cancel</ButtonText>
</Button>
```
**Button Props:**
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'` - `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
- `size`: `'tiny'` | `'small'` | `'large'` - `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'` - `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`) - `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, prefer `color`)
### Typography
```tsx
import {Text, H1, H2, P} from '#/components/Typography'
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
// will almost certainly contain emoji, so only omit it when the text is static and
// does not contain an emoji
<Text emoji>Hello! 👋</Text>
```
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
### TextField ### TextField
```tsx Compound component at `#/components/forms/TextField` (`TextField.LabelText`,
import * as TextField from '#/components/forms/TextField' `TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over
`value` (see Footguns).
<TextField.LabelText>Email</TextField.LabelText> ### Typography
<TextField.Root>
<TextField.Icon icon={AtSign} /> `import {Text, H1, H2, P} from '#/components/Typography'`. The `Text` default style
<TextField.Input is `[a.text_sm, a.leading_snug, t.atoms.text]`. Pass the `emoji` prop to any `Text`
label="Email address" that may contain emoji - user-generated text (display names etc.) almost always
placeholder="you@example.com" does, so only omit it for static, emoji-free strings: `<Text emoji>Hello!</Text>`.
defaultValue={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</TextField.Root>
```
## Internationalization (i18n) ## Internationalization (i18n)
@@ -549,201 +362,49 @@ pnpm intl:compile # Compile translations for runtime
### TanStack Query (Data Fetching) ### TanStack Query (Data Fetching)
```tsx Follow the established pattern in `src/state/queries/`; `src/state/queries/feed.ts`
// src/state/queries/profile.ts is a good canonical reference (it uses `createQueryKey`, matching key roots,
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' `useInfiniteQuery`, and `persistedVersion`).
import {createQueryKey} from '#/state/queries/util' - Build query keys with `createQueryKey(root, args)` (from `#/state/queries/util`)
using an object for `args`. The key root variable should match the hook name.
/* - Naming conventions: `use[Name]Query` for queries, `use[Name]Mutation` for
* Query key name should match the query hook name for consistency mutations, `use[Name]CacheMutation` for helpers that mutate cached data directly.
*/ - Stale times come from `STALE` in `src/state/queries/index.ts`: `STALE.SECONDS.FIFTEEN`,
const profileQueryKeyRoot = 'profile' `STALE.MINUTES.ONE`, `STALE.MINUTES.FIVE`, `STALE.HOURS.ONE`, `STALE.INFINITY`.
- Paginated atproto APIs (those returning a `cursor`) use `useInfiniteQuery` with
/* `getNextPageParam: page => page.cursor`; flatten results with
* Use object params and createQueryKey helper for better readability and to `data?.pages.flatMap(page => page.items) ?? []`.
* avoid bugs with parameter order or types. - Persist a query across restarts by passing options:
*/ `createQueryKey(root, args, {persistedVersion: n})`. Bumping `n` clears the old
export const createProfileQueryKey = (args: {did: string}) => persisted data and refetches - do this whenever the data shape changes.
createQueryKey(profileQueryKeyRoot, args) - Error handling in mutations: don't log network errors (just inform the user),
handle typed XRPC errors specifically (e.g. `err instanceof SomeNsid.SomeError`),
/* and send unexpected errors to `logger.error('...', {safeMessage: error})`.
* Query hook should be named use[Name]Query, where [Name] describes the data
* being fetched. This is not a strict requirement, but it's a helpful
* convention for discoverability
*/
export function useProfileQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery({
queryKey: createProfileQueryKey({did}),
queryFn: async () => {
const res = await agent.getProfile({actor: did})
return res.data
},
staleTime: STALE.MINUTES.FIVE,
enabled: !!did,
})
}
/*
* Mutation hook should match the name of the query hook, but with "Mutation"
* suffix. This is not a strict requirement, but it's a helpful convention for
* discoverability and consistency.
*/
export function useProfileMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async data => {
// Update logic
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: createProfileQueryKey({did: variables.did}),
})
},
onError: error => {
if (isNetworkError(error)) {
// don't log, but inform user
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
// XRPC APIs often have typed errors, allows nicer handling
} else {
// Log unexpected errors to Sentry
logger.error('Error updating profile', {safeMessage: error})
}
},
})
}
/*
* If cache mutation is needed, include specific interfaces for the specific
* mutations you require adjacent to the source queries. Naming should be
* descriptive of the mutation's purpose, e.g. use[Name]CacheMutation. This is
* not a strict requirement, but it's a helpful convention for discoverability
* and consistency.
*/
export function useProfileCacheMutation() {
const queryClient = useQueryClient()
return (data: Partial<Profile>) => {
queryClient.setQueryData(
createProfileQueryKey({did: data.did}),
oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
},
)
}
}
```
**Stale Time Constants** (from `src/state/queries/index.ts`):
```tsx
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
```
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
```tsx
export function useDraftsQuery() {
const agent = useAgent()
return useInfiniteQuery({
queryKey: createQueryKey('drafts'),
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
return res.data
},
initialPageParam: undefined as string | undefined,
getNextPageParam: page => page.cursor,
})
}
```
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
**Persisted Queries**
To persist query data across app restarts, `createQueryKey` supports a third
parameter called `options`, which has a `persistedVersion` property. When this
property is set to a number, the query will be persisted.
When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid.
```tsx
export const createProfileQueryKey = (args: {did: string}) =>
createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1})
```
### Preferences (React Context) ### Preferences (React Context)
```tsx Boolean/simple UI preferences are exposed as paired hooks from `#/state/preferences`,
// Simple boolean preference pattern e.g. `useAutoplayDisabled()` / `useSetAutoplayDisabled()`.
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
}
```
### Session State ### Session State
```tsx `import {useSession, useAgent} from '#/state/session'`. `useSession()` gives
import {useSession, useAgent} from '#/state/session' `hasSession` and `currentAccount`; `useAgent()` gives the atproto agent for API calls.
function MyComponent() {
const {hasSession, currentAccount} = useSession()
const agent = useAgent()
if (!hasSession) {
return <LoginPrompt />
}
// Use agent for API calls
const response = await agent.getProfile({actor: currentAccount.did})
}
```
## Navigation ## Navigation
Navigation uses React Navigation with type-safe route parameters. React Navigation with type-safe route params. Type a screen with
`NativeStackScreenProps<CommonNavigatorParams, 'X'>` (`route`/`navigation` come
```tsx from props; params via `route.params`). Navigate programmatically with
// Screen component `useNavigation()`, or the `navigate` helper from `#/Navigation`. Config lives in
import {type NativeStackScreenProps} from '@react-navigation/native-stack' `src/Navigation.tsx`, routes in `src/routes.ts`, types in `src/lib/routes/types.ts`.
import {type CommonNavigatorParams} from '#/lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen({route, navigation}: Props) {
const {name} = route.params // Type-safe params
return <Layout.Screen>{/* Screen content */}</Layout.Screen>
}
// Programmatic navigation
import {useNavigation} from '@react-navigation/native'
const navigation = useNavigation()
navigation.navigate('Profile', {name: 'alice.bsky.social'})
// Or use the navigate helper
import {navigate} from '#/Navigation'
navigate('Profile', {name: 'alice.bsky.social'})
```
## Platform-Specific Code ## Platform-Specific Code
Use file extensions for platform-specific implementations: Use file extensions for platform-specific implementations. The bundler resolves
them automatically - just import the base path normally, never a conditional
`require()`.
``` ```
Component.tsx # Shared/default Component.tsx # Shared/default
@@ -753,12 +414,11 @@ Component.ios.tsx # iOS-only
Component.android.tsx # Android-only Component.android.tsx # Android-only
``` ```
Example from Dialog: Prefer grouping variants into a `Component/` directory (`index.tsx`,
`index.web.tsx`, `index.native.tsx`) rather than sibling `Component.web.tsx` files,
- `src/components/Dialog/index.tsx`  Native (uses BottomSheet) so the shared surface reads as one "macro" module (e.g. `src/components/Dialog/index.tsx`
- `src/components/Dialog/index.web.tsx Web (uses modal with Radix primitives) native vs `index.web.tsx` web). The app has both patterns; the directory form is
preferred for new code.
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
```tsx ```tsx
// CORRECT - bundler picks storage.ts or storage.web.ts automatically // CORRECT - bundler picks storage.ts or storage.web.ts automatically
@@ -770,15 +430,7 @@ const storage = IS_NATIVE
: require('#/state/drafts/storage.web') : require('#/state/drafts/storage.web')
``` ```
Platform detection (for runtime logic, not imports): Runtime platform detection (not for imports): `import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'`.
```tsx
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
if (IS_NATIVE) {
// Native-specific logic
}
```
## Import Aliases ## Import Aliases