From d9ebb6348bcde9ac95fc84e6718b6820a604be3a Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 8 Jul 2026 17:58:13 +0300 Subject: [PATCH] slim down CLAUDE.md Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 538 ++++++++++-------------------------------------------- 1 file changed, 95 insertions(+), 443 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3a2c589dfc..e26e9e3f86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ should go in `/screens` (not `/view/screens`) to encourage better organization and separation from legacy code. For complex screens that have specific components or data needs that _are not -shared by other screens_, we encourage subdirectoreis within `/screens/` +shared by other screens_, we encourage subdirectories within `/screens/` e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and `/screens/ProfileScreen/components/`. @@ -126,44 +126,14 @@ eventually. Typically JS style for variables, functions, etc. We use ProudCamelCase for components, and camelCase directories and files. -When organizing new code, consider if it fits into a single file, or if it -should be broken down into multiple files. For "macro" component cases, or -things that live in `/features` or `/screens`, we often follow a pattern of -having an `index.tsx` for the main component, and then co-locating related -components, hooks, and utilities in the same directory. For example: +For "macro" cases in `/features`, `/screens`, or `/components`, co-locate related +code in a directory with an `index.tsx` main component plus sibling +components/hooks/utils (e.g. `screens/ProfileScreen/index.tsx` + +`screens/ProfileScreen/components/`). Keep related code together so it lives where +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`. -``` -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. +Platform-specific files are covered under "Platform-Specific Code" below. ### Comments @@ -204,88 +174,40 @@ const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today ### Documentation and Tests Within Features -For larger features or components, it's helpful to include a README.md file -within the directory that explains the purpose of the feature, how it works, and -any important implementation details. The `/Component/index.tsx` pattern lends -itself well to this, since the `index.tsx` can be the main component file, and -the `README.md` can provide documentation for the whole feature. This is -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. +For larger features or components, co-locate documentation and tests with the +code. A `README.md` in the directory (the `/Component/index.tsx` pattern lends +itself well to this) can document the whole feature, and feature-specific tests +belong alongside it as `Component.test.tsx` or in a `__tests__/` subdirectory. +Both are optional. ## 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 - -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}` +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}`). ```tsx import {atoms as a, useTheme} from '#/alf' -function MyComponent() { - const t = useTheme() - - return ( - - Hello - - ) -} +const t = useTheme() + ``` ### 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 -import {atoms as a} from '#/alf' -// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc. -``` +**Platform utilities** (`import {web, native, ios, android, platform} from '#/alf'`) +return conditional styles inline in a style array: `web({cursor: 'pointer'})`, +`native({paddingBottom: 20})`, `platform({ios: {...}, android: {...}, web: {...}})`. -**Theme Atoms** – Theme-dependent colors from `useTheme()`: - -```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 -} -``` +**Breakpoints:** `const {gtPhone, gtMobile, gtTablet} = useBreakpoints()` from `#/alf`. ### Naming Conventions @@ -306,176 +228,67 @@ if (gtMobile) { ```tsx import {Fragment} from 'react' import {View} from 'react-native' -import {Trans, useLingui} from '@lingui/react/macro' +import {Trans} from '@lingui/react/macro' import {Text} from '#/components/Typography' -function MyComponent({foo = []}: {foo?: string[]}) { - const {t: l} = useLingui() - +function MyComponent({items = []}: {items?: string[]}) { return ( <> - Example - {foo.map((foo, index) => ( - + + Example + + + + {items.map((item, index) => ( + {index} - {foo} + {item} ))} - ); + ) } ``` ### Dialog Component -Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state. - -```tsx -import * as Dialog from '#/components/Dialog' - -function MyFeature() { - const control = Dialog.useDialogControl() - - return ( - <> - - - - {/* Typically the inner part is in its own component */} - - - - ) -} - -function DialogInner() { - return ( - <> - {/* Native-only drag handle */} - - - Title - - Dialog content here - - {/* Web-only X button in top left */} - - - ) -} -``` +Lives in `#/components/Dialog`. Bottom sheet on native, modal on web. Manage +state with `useDialogControl()`. `Dialog.Handle` renders native-only, `Dialog.Close` +web-only. CRITICAL: run any post-close action inside the `control.close(() => ...)` +callback (see Footguns). Compound-component usage; canonical example in any dialog +under `#/components`. ### Menu Component -Menus render as a dropdown on web and a bottom sheet dialog on native. - -```tsx -import * as Menu from '#/components/Menu' - -function MyMenu() { - return ( - - - {({props}) => ( - - )} - - - - - - - Edit - - - - Delete - - - - - ) -} -``` +Lives in `#/components/Menu`. Dropdown on web, bottom sheet dialog on native. +`Menu.Divider` is web-only, `Menu.ContainerItem` native-only. Compound API +(`Menu.Root` / `Menu.Trigger` / `Menu.Outer` / `Menu.Group` / `Menu.Item`); grep +existing usages across the app for a canonical example. ### Button Component -```tsx -import {Button, ButtonText, ButtonIcon} from '#/components/Button' - -// Solid primary button (most common) - - -// With icon - - -// Icon-only button - - -// Ghost variant (deprecated - use color prop) - -``` - -**Button Props:** +`import {Button, ButtonText, ButtonIcon} from '#/components/Button'`. Props: - `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'` - `size`: `'tiny'` | `'small'` | `'large'` - `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'` -- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`) - -### Typography - -```tsx -import {Text, H1, H2, P} from '#/components/Typography' - -

Heading

-

Paragraph text with default styling.

-Custom 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 -Hello! 👋 -``` - -The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`. +- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, prefer `color`) ### TextField -```tsx -import * as TextField from '#/components/forms/TextField' +Compound component at `#/components/forms/TextField` (`TextField.LabelText`, +`TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over +`value` (see Footguns). -Email - - - - -``` +### Typography + +`import {Text, H1, H2, P} from '#/components/Typography'`. The `Text` default style +is `[a.text_sm, a.leading_snug, t.atoms.text]`. Pass the `emoji` prop to any `Text` +that may contain emoji - user-generated text (display names etc.) almost always +does, so only omit it for static, emoji-free strings: `Hello!`. ## Internationalization (i18n) @@ -549,201 +362,49 @@ pnpm intl:compile # Compile translations for runtime ### TanStack Query (Data Fetching) -```tsx -// src/state/queries/profile.ts -import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' +Follow the established pattern in `src/state/queries/`; `src/state/queries/feed.ts` +is a good canonical reference (it uses `createQueryKey`, matching key roots, +`useInfiniteQuery`, and `persistedVersion`). -import {createQueryKey} from '#/state/queries/util' - -/* - * Query key name should match the query hook name for consistency - */ -const profileQueryKeyRoot = 'profile' - -/* - * Use object params and createQueryKey helper for better readability and to - * avoid bugs with parameter order or types. - */ -export const createProfileQueryKey = (args: {did: string}) => - createQueryKey(profileQueryKeyRoot, args) - -/* - * 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) => { - 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}) -``` +- 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 + 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`, + `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 + `data?.pages.flatMap(page => page.items) ?? []`. +- Persist a query across restarts by passing options: + `createQueryKey(root, args, {persistedVersion: n})`. Bumping `n` clears the old + persisted data and refetches - do this whenever the data shape changes. +- 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})`. ### Preferences (React Context) -```tsx -// Simple boolean preference pattern -import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' - -function SettingsScreen() { - const autoplayDisabled = useAutoplayDisabled() - const setAutoplayDisabled = useSetAutoplayDisabled() - - return -} -``` +Boolean/simple UI preferences are exposed as paired hooks from `#/state/preferences`, +e.g. `useAutoplayDisabled()` / `useSetAutoplayDisabled()`. ### Session State -```tsx -import {useSession, useAgent} from '#/state/session' - -function MyComponent() { - const {hasSession, currentAccount} = useSession() - const agent = useAgent() - - if (!hasSession) { - return - } - - // Use agent for API calls - const response = await agent.getProfile({actor: currentAccount.did}) -} -``` +`import {useSession, useAgent} from '#/state/session'`. `useSession()` gives +`hasSession` and `currentAccount`; `useAgent()` gives the atproto agent for API calls. ## Navigation -Navigation uses React Navigation with type-safe route parameters. - -```tsx -// Screen component -import {type NativeStackScreenProps} from '@react-navigation/native-stack' -import {type CommonNavigatorParams} from '#/lib/routes/types' - -type Props = NativeStackScreenProps - -export function ProfileScreen({route, navigation}: Props) { - const {name} = route.params // Type-safe params - - return {/* Screen content */} -} - -// 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'}) -``` +React Navigation with type-safe route params. Type a screen with +`NativeStackScreenProps` (`route`/`navigation` come +from props; params via `route.params`). Navigate programmatically with +`useNavigation()`, or the `navigate` helper from `#/Navigation`. Config lives in +`src/Navigation.tsx`, routes in `src/routes.ts`, types in `src/lib/routes/types.ts`. ## 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 @@ -753,12 +414,11 @@ Component.ios.tsx # iOS-only Component.android.tsx # Android-only ``` -Example from Dialog: - -- `src/components/Dialog/index.tsx` – Native (uses BottomSheet) -- `src/components/Dialog/index.web.tsx` – Web (uses modal with Radix primitives) - -**Important:** The bundler automatically resolves platform-specific files. Just import normally: +Prefer grouping variants into a `Component/` directory (`index.tsx`, +`index.web.tsx`, `index.native.tsx`) rather than sibling `Component.web.tsx` files, +so the shared surface reads as one "macro" module (e.g. `src/components/Dialog/index.tsx` +native vs `index.web.tsx` web). The app has both patterns; the directory form is +preferred for new code. ```tsx // CORRECT - bundler picks storage.ts or storage.web.ts automatically @@ -770,15 +430,7 @@ const storage = IS_NATIVE : require('#/state/drafts/storage.web') ``` -Platform detection (for runtime logic, not imports): - -```tsx -import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env' - -if (IS_NATIVE) { - // Native-specific logic -} -``` +Runtime platform detection (not for imports): `import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'`. ## Import Aliases